From 5373189061f754c25dad6f03e1ba5f6988874402 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 10 Sep 2026 19:14:21 +0400 Subject: [PATCH] test: cover calendar subscriptions end to end in Docker --- .devcontainer/devcontainer.json | 1 + Dockerfile | 25 ++++ compose.yaml | 26 ++++ tests/e2e.py | 249 ++++++++++++++++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 Dockerfile create mode 100644 tests/e2e.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index da0bc13..df79031 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,6 +2,7 @@ "name": "vacationplanner2ics", "dockerComposeFile": "../compose.yaml", "service": "dev", + "runServices": ["dev"], "workspaceFolder": "/workspace", "shutdownAction": "stopCompose", "customizations": { diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bf553cb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1 +FROM rust:1.98.1-bookworm AS build +WORKDIR /build +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY src ./src +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/build/target \ + cargo build --release --locked && \ + cp target/release/vacationplanner2ics /usr/local/bin/vacationplanner2ics + +FROM python:3.14-slim-bookworm AS test +RUN pip install --no-cache-dir icalendar==7.3.0 +COPY --from=build /usr/local/bin/vacationplanner2ics /usr/local/bin/ +COPY tests /tests +ENV PYTHONDONTWRITEBYTECODE=1 +USER 65532:65532 +CMD ["python", "/tests/e2e.py"] + +FROM debian:bookworm-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /usr/local/bin/vacationplanner2ics /usr/local/bin/ +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["vacationplanner2ics"] diff --git a/compose.yaml b/compose.yaml index bd744ba..4a1e068 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,5 +1,21 @@ services: + app: + build: + context: . + target: runtime + environment: + API_URL: ${API_URL:-https://api.vacationplanner.ru} + UPSTREAM_TIMEOUT_SECONDS: ${UPSTREAM_TIMEOUT_SECONDS:-15} + ports: + - "127.0.0.1:8080:8080" + restart: unless-stopped + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + stop_grace_period: 65s + dev: + profiles: [dev] image: rust:1.98.1-bookworm working_dir: /workspace command: sleep infinity @@ -11,6 +27,16 @@ services: ports: - "127.0.0.1:8080:8080" + test: + profiles: [test] + build: + context: . + target: test + network_mode: none + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + volumes: cargo-registry: cargo-target: diff --git a/tests/e2e.py b/tests/e2e.py new file mode 100644 index 0000000..9aa89f2 --- /dev/null +++ b/tests/e2e.py @@ -0,0 +1,249 @@ +"""Four HTTP end-to-end scenarios; all credentials and vacation records are fake.""" + +import copy +import datetime as dt +import http.server +import json +import os +import subprocess +import threading +import time +import unittest +import urllib.error +import urllib.parse +import urllib.request + +from icalendar import Calendar + +TOKEN = "e2e-only-token+with/reserved=characters" +BASE = "http://127.0.0.1:8080" +WORKER = "Иван, Петров; \\" + "Длинное имя " * 12 +COMMENT = "First line\r\nSecond; line, with \\ slash\rBEGIN:VEVENT\r\nSUMMARY:injection" + + +def vacation(identifier=1, start="2026-12-30", end="2027-01-03", **extra): + return dict( + id=identifier, + workerName=WORKER, + dateStart=start, + dateEnd=end, + departmentName="Engineering", + comment=COMMENT, + moderationStatus="approved", + **extra, + ) + + +def request(path="/calendar.ics", parameters=None, method="GET"): + url = BASE + path + if parameters is not None: + url += "?" + urllib.parse.urlencode(parameters) + try: + response = urllib.request.urlopen(urllib.request.Request(url, method=method), timeout=5) + except urllib.error.HTTPError as error: + response = error + with response: + return response.status, response.headers, response.read() + + +class Upstream(http.server.BaseHTTPRequestHandler): + calls = [] + responses = {} + + def log_message(self, *_): + pass + + def do_GET(self): + self.calls.append((self.command, self.path, self.headers.get("Authorization"))) + response = self.responses.get(self.path, []) + if callable(response): + response = response(self) + status, headers, payload = response if isinstance(response, tuple) else (200, {}, response) + body = payload if isinstance(payload, bytes) else json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + for name, value in headers.items(): + self.send_header(name, value) + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + +class CalendarE2E(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.upstream = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Upstream) + cls.thread = threading.Thread(target=cls.upstream.serve_forever, daemon=True) + cls.thread.start() + cls.origin = f"http://127.0.0.1:{cls.upstream.server_port}" + + @classmethod + def tearDownClass(cls): + cls.upstream.shutdown() + cls.upstream.server_close() + cls.thread.join() + + def setUp(self): + Upstream.calls.clear() + Upstream.responses.clear() + self.process = subprocess.Popen( + ["vacationplanner2ics"], + env={**os.environ, "API_URL": self.origin, "UPSTREAM_TIMEOUT_SECONDS": "1"}, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + self.addCleanup(self.stop_service) + for _ in range(100): + if self.process.poll() is not None: + self.fail("service exited before becoming ready") + try: + if request("/healthz")[0] == 200: + return + except urllib.error.URLError: + pass + time.sleep(0.02) + self.fail("service did not become ready") + + def stop_service(self): + self.process.terminate() + try: + output, _ = self.process.communicate(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.communicate() + self.fail("service did not shut down gracefully") + self.assertEqual(self.process.returncode, 0) + self.assertNotIn(TOKEN.encode(), output) + self.assertNotIn(urllib.parse.quote_plus(TOKEN).encode(), output) + self.assertNotIn(WORKER.encode(), output) + + def get_calendar(self, **parameters): + status, headers, body = request(parameters={"token": TOKEN, **parameters}) + self.assertEqual(status, 200) + self.assertEqual(headers["Content-Type"], "text/calendar; charset=utf-8") + self.assertIn("no-store", headers["Cache-Control"]) + self.assertIn("vacations.ics", headers["Content-Disposition"]) + self.assertNotIn(TOKEN.encode(), body) + self.assertTrue(body.endswith(b"END:VCALENDAR\r\n")) + self.assertNotIn(b"\n", body.replace(b"\r\n", b"")) + for line in body.split(b"\r\n"): + self.assertLessEqual(len(line), 75) + line.decode("utf-8") + calendar = Calendar.from_ical(body) + self.assertEqual(str(calendar["VERSION"]), "2.0") + self.assertIn("PRODID", calendar) + for component in calendar.walk(): + self.assertFalse(component.errors) + return calendar.walk("VEVENT") + + def test_default_years_and_icalendar_compatibility(self): + year = dt.datetime.now(dt.UTC).year + first = vacation(start=f"{year}-12-30", end=f"{year + 1}-01-03") + pending = vacation(2, "2024-02-29", "2024-02-29") + pending.update(moderationStatus="on_moderation", comment=None, departmentName=None) + rejected = vacation(3) + rejected["moderationStatus"] = "rejected" + Upstream.responses = { + f"/v1/vacations/year/{year}": [first, pending, rejected], + f"/v1/vacations/year/{year + 1}": [first], + } + events = self.get_calendar() + self.assertEqual(len(events), 3) + self.assertEqual(events[0].decoded("DTSTART"), dt.date(year, 12, 30)) + self.assertEqual(events[0].decoded("DTEND"), dt.date(year + 1, 1, 4)) + self.assertEqual(events[1].decoded("DTEND"), dt.date(2024, 3, 1)) + self.assertEqual(str(events[0]["SUMMARY"]), WORKER + " — Vacation") + self.assertIn(COMMENT.replace("\r\n", "\n").replace("\r", "\n"), str(events[0]["DESCRIPTION"])) + self.assertEqual([str(event["STATUS"]) for event in events], ["CONFIRMED", "TENTATIVE", "CANCELLED"]) + self.assertTrue(all(event["DTSTART"].params["VALUE"] == "DATE" for event in events)) + self.assertTrue(all(event["DTSTAMP"].dt.utcoffset() == dt.timedelta() for event in events)) + self.assertEqual(Upstream.calls, [ + ("GET", f"/v1/vacations/year/{value}", "Bearer " + TOKEN) for value in (year, year + 1) + ]) + + def test_explicit_years_and_request_validation(self): + for years, expected in [("2026", [2026]), ("2030,2026,2028,2027,2029,2026", list(range(2026, 2031)))]: + with self.subTest(years=years): + Upstream.calls.clear() + self.assertEqual(self.get_calendar(years=years), []) + self.assertEqual([call[1] for call in Upstream.calls], [f"/v1/vacations/year/{year}" for year in expected]) + Upstream.calls.clear() + cases = [ + ({}, 401), ({"token": ""}, 401), ({"token": "bad\r\nheader"}, 401), + ({"token": TOKEN, "years": ""}, 400), + *[({"token": TOKEN, "years": value}, 400) for value in ["2026,nope", "2026,", "0", "9999", "2026-2027"]], + ({"token": TOKEN, "year": "2026"}, 400), + ({"token": TOKEN, "url": "https://example.invalid"}, 400), + ([("token", TOKEN), ("token", TOKEN)], 400), + ] + for parameters, expected in cases: + with self.subTest(parameters=parameters): + status, headers, body = request(parameters=parameters) + self.assertEqual(status, expected) + self.assertIn("no-store", headers["Cache-Control"]) + self.assertNotIn(TOKEN.encode(), body) + self.assertEqual(request(parameters={"token": TOKEN}, method="POST")[0], 405) + self.assertEqual(request("/v1/workers")[0], 404) + self.assertEqual(request("/healthz")[2], b"ok") + self.assertEqual(Upstream.calls, []) + + def test_upstream_failures_never_return_partial_calendars(self): + invalid_date = vacation() + invalid_date["dateEnd"] = "not-a-date" + reversed_dates = vacation(start="2026-02-10", end="2026-02-01") + + def slow(_): + time.sleep(1.5) + return [] + + cases = [ + ((401, {}, TOKEN.encode()), 401), + ((403, {}, TOKEN.encode()), 403), + ((429, {}, TOKEN.encode()), 503), + ((500, {}, TOKEN.encode()), 502), + ((302, {"Location": self.origin + "/should-not-be-called"}, b""), 502), + ((200, {}, b"not json " + TOKEN.encode()), 502), + ((200, {}, {"result": "error", "message": TOKEN}), 502), + ((200, {"Content-Length": str(10 * 1024 * 1024 + 1)}, b""), 502), + ([invalid_date], 502), ([reversed_dates], 502), ([{"id": 1}], 502), + (slow, 504), + ] + for response, expected in cases: + with self.subTest(expected=expected, case=cases.index((response, expected))): + Upstream.calls.clear() + Upstream.responses = { + "/v1/vacations/year/2026": [vacation()], + "/v1/vacations/year/2027": response, + } + status, headers, body = request(parameters={"token": TOKEN, "years": "2026,2027"}) + self.assertEqual(status, expected) + self.assertIn("no-store", headers["Cache-Control"]) + self.assertNotIn(b"BEGIN:VCALENDAR", body) + self.assertNotIn(TOKEN.encode(), body) + self.assertEqual(len(Upstream.calls), 2) + + def test_subscription_refresh_and_token_isolation(self): + original = vacation() + Upstream.responses = {"/v1/vacations/year/2026": [original]} + first = self.get_calendar(years="2026")[0] + changed = copy.deepcopy(original) + changed.update(workerName="New name", dateEnd="2027-01-05", moderationStatus="on_moderation") + Upstream.responses["/v1/vacations/year/2026"] = [changed] + updated = self.get_calendar(years="2026")[0] + self.assertEqual(first["UID"], updated["UID"]) + self.assertNotEqual(first["SUMMARY"], updated["SUMMARY"]) + self.assertEqual(updated.decoded("DTEND"), dt.date(2027, 1, 6)) + self.assertEqual(str(updated["STATUS"]), "TENTATIVE") + Upstream.responses["/v1/vacations/year/2026"] = ( + lambda handler: [changed] if handler.headers["Authorization"] == "Bearer " + TOKEN else [] + ) + self.assertEqual(self.get_calendar(token="another-fake-token", years="2026"), []) + self.assertEqual(self.get_calendar(years="2026")[0]["UID"], first["UID"]) + Upstream.responses["/v1/vacations/year/2026"] = [] + self.assertEqual(self.get_calendar(years="2026"), []) + + +if __name__ == "__main__": + unittest.main(verbosity=2)