Files
timemaster/backend/tests/test_ical.py
T
patrickandClaude Opus 4.8 d13350b38b feat(ical): abonnierbarer read-only Kalender-Feed pro Nutzer
Token-gescoper iCal-Feed (/absences/ical/<token>.ics), abonnierbar in
Outlook/Apple/Google. Anders als der CalDAV-Client (Push nach Nextcloud)
pollt der Kalender die URL selbst. Feed zeigt nur die eigenen bestätigten
Abwesenheiten des Token-Inhabers.

- users.ical_token_hash (SHA-256, rotierbar) + Migration 0041 (nur Spalte,
  keine RLS-Aenderung; users-Policy deckt neue nullable Spalte ab)
- Router ical.py: oeffentlicher Feed (kein JWT) + Token-Verwaltung
  POST/GET/DELETE /users/me/ical-token (authentifiziert)
- ProfilePage: Sektion "Kalender-Abo (iCal)" mit Erzeugen/Rotieren/
  Deaktivieren, URL-Anzeige einmalig + Kopieren
- test_ical.py: Token-Lifecycle + oeffentlicher Feed (3 Tests)

Deployed auf 137 (Migration 0041, 196/196 Tests gruen). 164 ausstehend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:12:05 +02:00

67 lines
2.6 KiB
Python

"""Tests für den iCal-Abo-Feed (router/ical.py)."""
import pytest
import pytest_asyncio
from httpx import AsyncClient
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def ical_headers(client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"company_name": "iCal GmbH",
"first_name": "Cal",
"last_name": "Endar",
"email": "admin@icalgmbh.de",
"password": "Secret123",
})
assert resp.status_code == 201, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
@pytest.mark.asyncio(loop_scope="session")
async def test_ical_token_disabled_by_default(client: AsyncClient, ical_headers):
r = await client.get("/api/v1/users/me/ical-token", headers=ical_headers)
assert r.status_code == 200
assert r.json()["enabled"] is False
@pytest.mark.asyncio(loop_scope="session")
async def test_ical_feed_lifecycle(client: AsyncClient, ical_headers):
# Aktivieren → URL zurück
r = await client.post("/api/v1/users/me/ical-token", json={}, headers=ical_headers)
assert r.status_code == 200, r.text
url = r.json()["url"]
assert url.endswith(".ics")
assert r.json()["enabled"] is True
# Öffentlicher Feed OHNE Auth erreichbar, liefert VCALENDAR
path = url.split("/api/v1", 1)[1]
feed = await client.get("/api/v1" + path) # kein Authorization-Header
assert feed.status_code == 200
assert "text/calendar" in feed.headers["content-type"]
assert feed.text.startswith("BEGIN:VCALENDAR")
# Status jetzt aktiv
st = await client.get("/api/v1/users/me/ical-token", headers=ical_headers)
assert st.json()["enabled"] is True
# Rotieren → altes Token wird ungültig
old_path = path
r2 = await client.post("/api/v1/users/me/ical-token", json={}, headers=ical_headers)
new_path = r2.json()["url"].split("/api/v1", 1)[1]
assert new_path != old_path
assert (await client.get("/api/v1" + old_path)).status_code == 404
assert (await client.get("/api/v1" + new_path)).status_code == 200
# Deaktivieren → Feed weg
d = await client.delete("/api/v1/users/me/ical-token", headers=ical_headers)
assert d.status_code == 204
assert (await client.get("/api/v1" + new_path)).status_code == 404
st2 = await client.get("/api/v1/users/me/ical-token", headers=ical_headers)
assert st2.json()["enabled"] is False
@pytest.mark.asyncio(loop_scope="session")
async def test_ical_feed_bad_token(client: AsyncClient):
r = await client.get("/api/v1/absences/ical/nonexistenttoken.ics")
assert r.status_code == 404