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>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user