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>
67 lines
2.6 KiB
Python
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
|