('DELETE', `/acl/${entryId}`),
}
@@ -959,6 +962,20 @@ export interface CalendarItem {
timezone: string
ctag: number
created_at: string
+ /** Was man mit diesem Kalender darf. Bei eigenen immer 'write'. */
+ permission: 'read' | 'write'
+ /** Der Kalender gehört jemand anderem. */
+ shared: boolean
+ /** Wem – steht in der Liste neben dem Namen. Ein fremder Kalender ohne
+ * sichtbaren Absender ist ein Rätsel. */
+ owner_name?: string
+ /** Über welche Gruppe der Zugriff kommt, falls nicht persönlich. */
+ via_group?: string
+}
+
+/** Kurz: Darf ich diesen Kalender ändern? */
+export function canEditCalendar(c: CalendarItem): boolean {
+ return c.permission !== 'read'
}
/** Die Wiederholung, die das Formular anbietet: alle N Tage/Wochen/Monate/
diff --git a/frontend/src/components/ShareWithDialog.tsx b/frontend/src/components/ShareWithDialog.tsx
index 0d100b1..1032304 100644
--- a/frontend/src/components/ShareWithDialog.tsx
+++ b/frontend/src/components/ShareWithDialog.tsx
@@ -7,7 +7,7 @@ import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
interface Props {
/** Was freigegeben wird – Ordner oder einzelne Datei. */
- resource: { type: 'folder' | 'file'; id: string; name: string }
+ resource: { type: 'folder' | 'file' | 'calendar'; id: string; name: string }
onClose: () => void
}
@@ -35,8 +35,8 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
setLoading(true)
try {
const [list, people] = await Promise.all([
- resource.type === 'folder'
- ? aclApi.listForFolder(resource.id)
+ resource.type === 'folder' ? aclApi.listForFolder(resource.id)
+ : resource.type === 'calendar' ? aclApi.listForCalendar(resource.id)
: aclApi.listForFile(resource.id),
aclApi.recipients(),
])
@@ -55,7 +55,9 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
setSaving(true)
setError('')
try {
- if (resource.type === 'folder') {
+ if (resource.type === 'calendar') {
+ await aclApi.grantCalendar(resource.id, chosen.id, chosen.type, permission)
+ } else if (resource.type === 'folder') {
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
} else {
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
@@ -167,6 +169,13 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
+ {resource.type === 'calendar' && (
+
+ The calendar shows up in their app too — web and CalDAV alike.
+ With “can edit” they may add and change events; renaming or
+ deleting the calendar stays with you.
+
+ )}
{resource.type === 'folder' && (
Everything inside this folder is shared too, including anything added later.
diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx
index b377bc4..87f0067 100644
--- a/frontend/src/pages/CalendarPage.tsx
+++ b/frontend/src/pages/CalendarPage.tsx
@@ -3,7 +3,7 @@ import {
type CSSProperties, type MouseEvent, type PointerEvent as ReactPointerEvent,
} from 'react'
import {
- calendarApi, APIError,
+ calendarApi, APIError, canEditCalendar,
type CalendarItem, type CalendarEvent, type EventInput,
} from '../api/client'
import PageHeader from '../components/PageHeader'
@@ -16,7 +16,8 @@ import {
addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek,
} from '../components/calendar/dates'
import { useEventDrag } from '../components/calendar/useEventDrag'
-import { TrashIcon, RenameIcon, DownloadIcon } from '../components/icons'
+import { TrashIcon, RenameIcon, DownloadIcon, PeopleIcon } from '../components/icons'
+import ShareWithDialog from '../components/ShareWithDialog'
type ViewMode = 'month' | 'week' | 'list'
@@ -109,6 +110,8 @@ export default function CalendarPage() {
const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; start: Date } | null>(null)
const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null)
const [deleteCalendar, setDeleteCalendar] = useState(null)
+ /** Kalender, dessen Freigaben gerade verwaltet werden. */
+ const [shareCalendar, setShareCalendar] = useState(null)
const [deleteEvent, setDeleteEvent] = useState<
{ calId: string; uid: string; summary: string; recurring: boolean; occurrence: string } | null>(null)
@@ -182,6 +185,15 @@ export default function CalendarPage() {
const byDay = useMemo(() => groupByDay(allEvents), [allEvents])
+ /**
+ * Kalender, in die dieses Konto schreiben darf.
+ *
+ * Die Kalenderwahl im Termin-Dialog zeigt nur diese. Einen nur lesbaren
+ * Kalender dort anzubieten hieße, jemanden ein Formular ausfüllen zu lassen,
+ * das der Server danach ablehnt.
+ */
+ const writableCalendars = useMemo(() => calendars.filter(canEditCalendar), [calendars])
+
function toggleVisible(id: string) {
setHidden(prev => {
const next = new Set(prev)
@@ -412,7 +424,10 @@ export default function CalendarPage() {
const on = !hidden.has(cal.id)
// Ablegefläche fürs Ziehen. Der eigene Kalender des gezogenen
// Termins ist keins – dorthin gibt es nichts zu verschieben.
- const isTarget = !!drag && drag.ev.calendarId !== cal.id
+ // Ablegefläche nur, wo auch geschrieben werden darf. Ein Ziel
+ // anzubieten, das der Server danach ablehnt, ist schlechter als
+ // gar keines: Man hat den Termin schon losgelassen.
+ const isTarget = !!drag && drag.ev.calendarId !== cal.id && canEditCalendar(cal)
const isOver = isTarget && drag!.overId === cal.id
return (
{cal.name}
+ {/* Fremde Kalender tragen ihren Absender. Zwei „Privat"
+ nebeneinander wären sonst nicht zu unterscheiden. */}
+ {cal.shared && cal.owner_name && (
+
+ {' · '}{cal.owner_name}{cal.permission === 'read' ? ' (nur lesen)' : ''}
+
+ )}
setCalDialog({ calendar: cal })}
+ onShare={() => setShareCalendar(cal)}
onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')}
onImport={() => triggerImport(cal.id)}
onDelete={() => setDeleteCalendar(cal)}
@@ -485,7 +510,7 @@ export default function CalendarPage() {
background: 'var(--color-surface)', borderTop: '1px solid var(--color-border)',
boxShadow: '0 -4px 16px rgba(0,0,0,0.3)',
}}>
- {calendars.filter(c => c.id !== drag.ev.calendarId).map(cal => (
+ {calendars.filter(c => c.id !== drag.ev.calendarId && canEditCalendar(c)).map(cal => (
setDeleteEvent({
@@ -547,6 +572,13 @@ export default function CalendarPage() {
{calDialog && (
setCalDialog(null)} />
)}
+ {shareCalendar && (
+ setShareCalendar(null)}
+ />
+ )}
+
{deleteCalendar && (
void; onExport: () => void; onImport: () => void; onDelete: () => void
+/**
+ * Das Menü eines Kalenders.
+ *
+ * Bei einem **fremden** Kalender bleiben nur die Punkte stehen, die auch
+ * gehen: ansehen und ausgeben. Bearbeiten, Löschen, Freigeben gehören dem
+ * Eigentümer – ein Menüpunkt, der immer scheitert, ist schlimmer als keiner.
+ * Der Server lehnt sie ohnehin ab; hier geht es darum, gar nicht erst
+ * hinzuführen.
+ */
+function CalMenu({ onRename, onExport, onImport, onDelete, onShare, own, canWrite }: {
+ onRename: () => void; onExport: () => void; onImport: () => void
+ onDelete: () => void; onShare: () => void
+ own: boolean; canWrite: boolean
}) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
@@ -609,10 +652,11 @@ function CalMenu({ onRename, onExport, onImport, onDelete }: {
background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 8,
boxShadow: '0 4px 14px rgba(0,0,0,0.25)', overflow: 'hidden',
}}>
- } label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />
+ {own && } label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />}
+ {own && } label="Freigeben…" onClick={() => { setOpen(false); onShare() }} />}
} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
- { setOpen(false); onImport() }} />
- } label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />
+ {canWrite && { setOpen(false); onImport() }} />}
+ {own && } label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />}
)}
diff --git a/kalender-plan.md b/kalender-plan.md
index e8fddfa..30e7edb 100644
--- a/kalender-plan.md
+++ b/kalender-plan.md
@@ -86,7 +86,7 @@ Die abgeleiteten Spalten sind nur zum Vorsortieren da.
| **1** | CalDAV lesend und schreibend, ein Kalender, Anmeldung über App-Passwörter, Auffindpfade (`/.well-known/caldav`, `current-user-principal`, `calendar-home-set`). Ziel: DAVx5 gleicht ab. | ✅ |
| **2** | Mehrere Kalender, Farbe, Umbenennen, Löschen. Export als `.ics`, Import einer bestehenden Datei. | ✅ |
| **3** | Weboberfläche in Aether: Monat, Woche, Liste; Termin anlegen, ändern, löschen. Der Teil, der Spaß macht – und dessen Fehler man **sieht**. | ✅ |
-| **4** | `sync-collection` ✅, Aufgabenansicht, Teilen mit anderen Nutzern, öffentlicher Abo-Link, Kalender in der HoneyOS-Leiste. | läuft |
+| **4** | `sync-collection` ✅, Teilen mit anderen Nutzern ✅, Aufgabenansicht (vertagt), öffentlicher Abo-Link, Kalender in der HoneyOS-Leiste. | läuft |
| **5** | Kontakte (CardDAV). Dieselbe Maschinerie, dieselbe Bibliothek – und **einfacher**, weil es dort keine Wiederholungen gibt. | später entscheiden |
Über die Phasen hinaus dazugekommen, weil es sich beim Prüfen als nötig
@@ -162,10 +162,35 @@ Fall, auf den es ankommt: *ohne Änderung kommt nichts zurück*.
1. **Derselbe Kalender auf mehreren Geräten.** Das ist der Sinn der Sache und
ab Phase 1 da.
2. **Ein Kalender, den ein anderer Nutzer sieht** – lesend oder schreibend, wie
- das Teilen in Nextcloud. Geht, und es ist keine Protokollarbeit: Wir kennen
- unsere Nutzer und unsere Kalender, also ist es eine Tabelle
- (`kalender × nutzer × recht`) und eine Bedingung in `ListCalendars`. Der
- Client sieht danach schlicht einen Kalender mehr in seiner Liste. **Phase 4.**
+ das Teilen in Nextcloud. ✅ **12.09.2026.**
+
+ Gebaut wurde es **nicht** als eigene Tabelle, wie hier ursprünglich geplant.
+ `acl_entries` beantwortet für Ordner und Dateien längst genau diese Frage –
+ wer darf was, direkt oder über eine Gruppe. Ein zweites Rechtesystem daneben
+ hieße zwei Antworten auf dieselbe Frage, mit eigener Gruppenauflösung,
+ eigenem Widerruf, eigenem Prüfpfad; die Lücke dazwischen fällt erst auf,
+ wenn jemand sieht, was er nicht sehen soll. Migration 25 hängt deshalb nur
+ eine dritte Ressourcenspalte an (`resource_calendar_id`).
+
+ Der Gewinn dabei: **Freigeben an eine Gruppe kostete keine Zeile extra.**
+
+ Drei Entscheidungen, die nicht offensichtlich waren:
+
+ * **Ein fremder Kalender steht immer unter seiner UUID**, nie unter dem
+ Pfadsegment des Eigentümers. Das Segment hat sich der Eigentümer
+ ausgesucht („…/arbeit/"), und zwei Leute dürfen denselben Namen wählen –
+ im Kalenderbestand des Empfängers lägen dann zwei Kalender auf derselben
+ Adresse (`Access.Ref`).
+ * **Alle Wege laufen durch `RequireCalendar`.** CalDAV und die
+ Weboberfläche sind zwei getrennte Zugänge zu denselben Daten; ein Recht,
+ das nur einer von beiden prüft, ist kein Recht. Im CalDAV-Teil genügte
+ dafür, `resolveCalendar` umzustellen – dort kommt jede Operation vorbei.
+ * **Wer ein Recht geliehen hat, darf es nicht weiterverleihen.** Auch mit
+ Schreibrecht bleiben Umbenennen, Löschen und Freigeben beim Eigentümer.
+
+ Geprüft in `scripts/kalender-teilen-selbsttest.py` (41 Prüfungen), und zwar
+ jede über **beide** Zugänge. Der Test beginnt damit, dass der Empfänger
+ vorher nichts sieht – ohne diesen Anfang beweist der Rest nichts.
3. **Ein Kalender an der Adresse eines Links**, den jemand ohne Konto
abonnieren kann – nur lesend, wie ein Freigabelink für Dateien. Klein, und
Aether hat die Freigabelinks schon. **Phase 4.**
diff --git a/scripts/kalender-teilen-selbsttest.py b/scripts/kalender-teilen-selbsttest.py
new file mode 100644
index 0000000..37534c2
--- /dev/null
+++ b/scripts/kalender-teilen-selbsttest.py
@@ -0,0 +1,328 @@
+#!/usr/bin/env python3
+"""Selbsttest: Kalender an andere Konten freigeben.
+
+ python3 scripts/kalender-teilen-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
+
+Eine Rechtefunktion ist genau so viel wert wie ihre engste Stelle. Aether hat
+**zwei** Zugänge zu denselben Kalendern – die Weboberfläche (JSON) und CalDAV –,
+und beide prüfen getrennt. Ein Recht, das nur einer von beiden durchsetzt, ist
+kein Recht; deshalb geht hier jede Prüfung über **beide** Wege.
+
+Geprüft wird in dieser Reihenfolge, und die Reihenfolge ist der Punkt:
+
+ 1. **Vorher sieht der Empfänger nichts.** Ohne diesen Anfang beweist der
+ Rest nichts – ein Kalender, den ohnehin jeder sieht, ist auch nach dem
+ Freigeben sichtbar.
+ 2. Nur lesend: sehen ja, ändern nein.
+ 3. Schreibend: ändern ja.
+ 4. Was auch schreibend **nicht** geht: den fremden Kalender umbenennen,
+ löschen oder weiterverschenken.
+ 5. Über eine Gruppe – derselbe Weg, ohne eine Zeile eigenen Code.
+ 6. Widerruf: danach wieder unsichtbar, auf beiden Wegen.
+
+Der Test legt zwei Konten, eine Gruppe und einen Kalender an und räumt am
+Ende alles weg.
+"""
+import base64
+import datetime
+import http.cookiejar
+import json
+import re
+import sys
+import urllib.error
+import urllib.request
+import uuid
+
+BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090").rstrip("/")
+USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
+PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
+
+ok_count = 0
+fail = []
+
+
+def check(name, cond, detail=""):
+ global ok_count
+ if cond:
+ ok_count += 1
+ print(f" \033[32mok\033[0m {name}")
+ else:
+ fail.append(name)
+ print(f" \033[31mFEHL\033[0m {name} {detail}")
+
+
+def sitzung():
+ jar = http.cookiejar.CookieJar()
+ return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
+
+
+def ruf(opener, method, path, body=None, headers=None):
+ hdrs = dict(headers or {})
+ data = None
+ if body is not None:
+ data = json.dumps(body).encode()
+ hdrs["Content-Type"] = "application/json"
+ req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method)
+ try:
+ with opener.open(req) as r:
+ text = r.read().decode()
+ return r.status, (json.loads(text).get("data") if text else None)
+ except urllib.error.HTTPError as e:
+ return e.code, None
+
+
+def anmelden(user, passwort):
+ o = sitzung()
+ st, d = ruf(o, "POST", "/api/v1/auth/login", {"username": user, "password": passwort})
+ assert st == 200, f"Anmeldung {user} fehlgeschlagen: {st}"
+ return o, {"X-CSRF-Token": d["csrf_token"]}
+
+
+def dav(basic, method, path, body=None, headers=None):
+ hdrs = {"Authorization": basic}
+ hdrs.update(headers or {})
+ data = body.encode() if isinstance(body, str) else body
+ req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method)
+ try:
+ with urllib.request.urlopen(req) as r:
+ return r.status, r.read().decode(errors="replace")
+ except urllib.error.HTTPError as e:
+ return e.code, e.read().decode(errors="replace")
+
+
+def kalenderliste_dav(basic, username):
+ """Die Kalender, die dieser Zugang über CalDAV sieht."""
+ body = (''
+ '')
+ st, text = dav(basic, "PROPFIND", f"/caldav/calendars/{username}/", body,
+ {"Depth": "1", "Content-Type": "application/xml"})
+ return st, re.findall(r"]*>([^<]+)", text), text
+
+
+# Die Terminliste hat einen Standardzeitraum (ein Monat zurueck bis ein Jahr
+# voraus). Ein Testtermin mit festem Datum faellt irgendwann heraus -- und dann
+# besteht die Pruefung "nichts angekommen" **leer**, was schlimmer ist als ein
+# Fehlschlag. Deshalb relativ zum heutigen Tag, und der Zeitraum wird bei jeder
+# Abfrage ausdruecklich mitgegeben.
+_morgen = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1)
+START = _morgen.strftime("%Y%m%dT090000Z")
+ENDE = _morgen.strftime("%Y%m%dT100000Z")
+ZEITRAUM = ("?from=" + (_morgen - datetime.timedelta(days=2)).strftime("%Y-%m-%dT00:00:00Z")
+ + "&to=" + (_morgen + datetime.timedelta(days=2)).strftime("%Y-%m-%dT00:00:00Z"))
+
+ICS = ("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Selbsttest//DE\r\n"
+ "BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\n"
+ "DTSTART:{start}\r\nDTEND:{ende}\r\n"
+ "SUMMARY:{summary}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n")
+
+print(f"Kalender-Freigabe-Selbsttest gegen {BASE}\n")
+
+besitzer, BES_CSRF = anmelden(USER, PASSWORT)
+
+marke = uuid.uuid4().hex[:6]
+empf_name = f"teilen-empf-{marke}"
+empf_pw = "Empf!" + uuid.uuid4().hex[:12]
+angelegt = {"user": None, "group": None, "cal": None, "acl": []}
+
+
+def aufraeumen():
+ for eid in angelegt["acl"]:
+ ruf(besitzer, "DELETE", f"/api/v1/acl/{eid}", None, BES_CSRF)
+ if angelegt["cal"]:
+ ruf(besitzer, "DELETE", f"/api/v1/calendars/{angelegt['cal']}", None, BES_CSRF)
+ if angelegt["group"]:
+ ruf(besitzer, "DELETE", f"/api/v1/admin/groups/{angelegt['group']}", None, BES_CSRF)
+ if angelegt["user"]:
+ ruf(besitzer, "DELETE", f"/api/v1/admin/users/{angelegt['user']}", None, BES_CSRF)
+
+
+try:
+ st, empf = ruf(besitzer, "POST", "/api/v1/admin/users",
+ {"username": empf_name, "display_name": "Empfaenger",
+ "password": empf_pw, "is_admin": False, "quota_bytes": 0}, BES_CSRF)
+ assert st in (200, 201), f"Empfängerkonto anlegen fehlgeschlagen: {st}"
+ angelegt["user"] = empf["id"]
+ EMPF_ID = empf["id"]
+
+ empfaenger, EMPF_CSRF = anmelden(empf_name, empf_pw)
+
+ # App-Passwörter für den CalDAV-Weg – das Kontopasswort nimmt /caldav/ nicht.
+ def app_passwort(opener, csrf):
+ st, d = ruf(opener, "POST", "/api/v1/app-passwords",
+ {"name": "teilen-test-" + marke}, csrf)
+ assert st in (200, 201), f"App-Passwort fehlgeschlagen: {st}"
+ return d["password"]
+
+ BES_DAV = "Basic " + base64.b64encode(f"{USER}:{app_passwort(besitzer, BES_CSRF)}".encode()).decode()
+ EMPF_DAV = "Basic " + base64.b64encode(f"{empf_name}:{app_passwort(empfaenger, EMPF_CSRF)}".encode()).decode()
+
+ st, kal = ruf(besitzer, "POST", "/api/v1/calendars",
+ {"name": "Geteilt " + marke, "color": "#aa3366", "description": ""}, BES_CSRF)
+ assert st in (200, 201), f"Kalender anlegen fehlgeschlagen: {st}"
+ angelegt["cal"] = kal["id"]
+ CAL = kal["id"]
+
+ uid = "teilen-" + marke
+ st, _ = dav(BES_DAV, "PUT", f"/caldav/calendars/{USER}/{CAL}/{uid}.ics",
+ ICS.format(uid=uid, summary="Besprechung", start=START, ende=ENDE), {"Content-Type": "text/calendar"})
+ assert st in (201, 204), f"Termin anlegen fehlgeschlagen: {st}"
+
+ def sichtbar_json():
+ st, cals = ruf(empfaenger, "GET", "/api/v1/calendars", None, EMPF_CSRF)
+ return [c for c in (cals or []) if c["id"] == CAL]
+
+ def sichtbar_dav():
+ st, hrefs, _ = kalenderliste_dav(EMPF_DAV, empf_name)
+ return [h for h in hrefs if CAL in h]
+
+ # ── 1. Vorher ────────────────────────────────────────────────────────────
+ print("── Vorher: der Empfänger sieht nichts ──")
+ check("nicht in seiner Kalenderliste (Web)", sichtbar_json() == [], sichtbar_json())
+ check("nicht in seinem Kalenderbestand (CalDAV)", sichtbar_dav() == [], sichtbar_dav())
+ st, _ = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, EMPF_CSRF)
+ check("Termine direkt abrufen: 404", st == 404, st)
+ st, _ = dav(EMPF_DAV, "GET", f"/caldav/calendars/{empf_name}/{CAL}/{uid}.ics")
+ check("Termin über CalDAV: 404", st == 404, st)
+
+ # ── 2. Nur lesend ────────────────────────────────────────────────────────
+ print("\n── Nur lesend freigegeben ──")
+ st, eintrag = ruf(besitzer, "POST", f"/api/v1/calendars/{CAL}/acl",
+ {"subject_id": EMPF_ID, "subject_type": "user", "permission": "read"},
+ BES_CSRF)
+ check("Freigabe angelegt", st in (200, 201), st)
+ if eintrag:
+ angelegt["acl"].append(eintrag["id"])
+
+ treffer = sichtbar_json()
+ check("jetzt in der Kalenderliste (Web)", len(treffer) == 1, treffer)
+ if treffer:
+ check("als fremd gekennzeichnet", treffer[0].get("shared") is True, treffer[0])
+ check("mit Recht „read\"", treffer[0].get("permission") == "read", treffer[0].get("permission"))
+ check("mit Namen des Eigentümers", bool(treffer[0].get("owner_name")), treffer[0].get("owner_name"))
+ check("jetzt auch im Kalenderbestand (CalDAV)", len(sichtbar_dav()) >= 1, sichtbar_dav())
+
+ st, ev = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, EMPF_CSRF)
+ check("Termine lesbar (Web)", st == 200, st)
+ st, text = dav(EMPF_DAV, "GET", f"/caldav/calendars/{empf_name}/{CAL}/{uid}.ics")
+ check("Termin lesbar (CalDAV)", st == 200 and "Besprechung" in text, st)
+
+ print(" … und nichts davon lässt sich ändern:")
+ st, _ = ruf(empfaenger, "POST", f"/api/v1/calendars/{CAL}/events",
+ {"summary": "Eingeschmuggelt", "start": "2026-08-02T09:00:00Z",
+ "end": "2026-08-02T10:00:00Z", "all_day": False}, EMPF_CSRF)
+ check("Termin anlegen (Web) abgelehnt", st in (403, 404), st)
+ fremd_uid = "fremd-" + marke
+ st, _ = dav(EMPF_DAV, "PUT", f"/caldav/calendars/{empf_name}/{CAL}/{fremd_uid}.ics",
+ ICS.format(uid=fremd_uid, summary="Eingeschmuggelt", start=START, ende=ENDE),
+ {"Content-Type": "text/calendar"})
+ check("Termin anlegen (CalDAV) abgelehnt", st == 403, st)
+ st, _ = dav(EMPF_DAV, "DELETE", f"/caldav/calendars/{empf_name}/{CAL}/{uid}.ics")
+ check("Termin löschen (CalDAV) abgelehnt", st == 403, st)
+ st, _ = ruf(empfaenger, "POST", f"/api/v1/calendars/{CAL}/import", None, EMPF_CSRF)
+ check("Import abgelehnt", st in (400, 403, 404), st)
+
+ # Und der Termin steht wirklich noch da – nicht nur der Statuscode stimmt.
+ st, ev = ruf(besitzer, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, BES_CSRF)
+ namen = [e.get("summary") for e in (ev or [])]
+ # **Erst prüfen, dass überhaupt etwas in der Liste steht.** Sonst besteht
+ # die nächste Zeile auch dann, wenn die Abfrage schlicht nichts liefert –
+ # und eine Prüfung, die leer besteht, prüft nichts.
+ check("die Terminliste des Eigentümers ist nicht leer", "Besprechung" in namen, namen)
+ check("beim Eigentümer ist nichts angekommen", "Eingeschmuggelt" not in namen, namen)
+
+ # ── 3. Schreibend ────────────────────────────────────────────────────────
+ print("\n── Auf schreibend hochgestuft ──")
+ st, eintrag = ruf(besitzer, "POST", f"/api/v1/calendars/{CAL}/acl",
+ {"subject_id": EMPF_ID, "subject_type": "user", "permission": "write"},
+ BES_CSRF)
+ check("Recht geändert statt doppelt angelegt", st in (200, 201), st)
+
+ treffer = sichtbar_json()
+ check("Recht jetzt „write\"", treffer and treffer[0].get("permission") == "write",
+ treffer[0].get("permission") if treffer else None)
+ st, _ = dav(EMPF_DAV, "PUT", f"/caldav/calendars/{empf_name}/{CAL}/{fremd_uid}.ics",
+ ICS.format(uid=fremd_uid, summary="Vom Empfaenger", start=START, ende=ENDE),
+ {"Content-Type": "text/calendar"})
+ check("Termin anlegen (CalDAV) geht jetzt", st in (201, 204), st)
+ st, ev = ruf(besitzer, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, BES_CSRF)
+ namen = [e.get("summary") for e in (ev or [])]
+ check("und kommt beim Eigentümer an", "Vom Empfaenger" in namen, namen)
+
+ # ── 4. Was auch schreibend nicht geht ────────────────────────────────────
+ print("\n── Grenzen des Schreibrechts ──")
+ st, _ = ruf(empfaenger, "PATCH", f"/api/v1/calendars/{CAL}",
+ {"name": "Uebernommen"}, EMPF_CSRF)
+ check("fremden Kalender umbenennen: abgelehnt", st in (403, 404), st)
+ st, _ = ruf(empfaenger, "DELETE", f"/api/v1/calendars/{CAL}", None, EMPF_CSRF)
+ check("fremden Kalender löschen: abgelehnt", st in (403, 404), st)
+ st, _ = dav(EMPF_DAV, "DELETE", f"/caldav/calendars/{empf_name}/{CAL}/")
+ check("fremden Kalender löschen (CalDAV): abgelehnt", st not in (200, 204), st)
+ st, _ = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/acl", None, EMPF_CSRF)
+ check("fremde Freigabeliste sehen: abgelehnt", st == 404, st)
+ st, _ = ruf(empfaenger, "POST", f"/api/v1/calendars/{CAL}/acl",
+ {"subject_id": EMPF_ID, "subject_type": "user", "permission": "read"}, EMPF_CSRF)
+ check("fremden Kalender weiterverschenken: abgelehnt", st == 404, st)
+
+ # Der Kalender heißt noch, wie er hieß.
+ st, kals = ruf(besitzer, "GET", "/api/v1/calendars", None, BES_CSRF)
+ meiner = [c for c in (kals or []) if c["id"] == CAL]
+ check("Name unverändert", meiner and meiner[0]["name"] == "Geteilt " + marke,
+ meiner[0]["name"] if meiner else None)
+
+ # ── 5. Über eine Gruppe ──────────────────────────────────────────────────
+ print("\n── Freigabe über eine Gruppe ──")
+ for eid in angelegt["acl"]:
+ ruf(besitzer, "DELETE", f"/api/v1/acl/{eid}", None, BES_CSRF)
+ angelegt["acl"] = []
+ check("nach Widerruf wieder unsichtbar", sichtbar_json() == [], sichtbar_json())
+
+ st, grp = ruf(besitzer, "POST", "/api/v1/admin/groups",
+ {"name": "teilen-gruppe-" + marke, "quota_bytes": 0}, BES_CSRF)
+ check("Gruppe angelegt", st in (200, 201), st)
+ if grp:
+ angelegt["group"] = grp["id"]
+ st, _ = ruf(besitzer, "POST", f"/api/v1/admin/groups/{grp['id']}/members",
+ {"user_id": EMPF_ID}, BES_CSRF)
+ check("Empfänger ist Mitglied", st in (200, 201, 204), st)
+ st, eintrag = ruf(besitzer, "POST", f"/api/v1/calendars/{CAL}/acl",
+ {"subject_id": grp["id"], "subject_type": "group", "permission": "read"},
+ BES_CSRF)
+ check("an die Gruppe freigegeben", st in (200, 201), st)
+ if eintrag:
+ angelegt["acl"].append(eintrag["id"])
+ treffer = sichtbar_json()
+ check("über die Gruppe sichtbar (Web)", len(treffer) == 1, treffer)
+ check("über die Gruppe sichtbar (CalDAV)", len(sichtbar_dav()) >= 1, sichtbar_dav())
+ if treffer:
+ check("Herkunft wird genannt", bool(treffer[0].get("via_group")), treffer[0].get("via_group"))
+
+ # ── 6. Abgleich auf einem geteilten Kalender ─────────────────────────────
+ print("\n── sync-collection auf einem fremden Kalender ──")
+ body = (''
+ ''
+ '1')
+ st, text = dav(EMPF_DAV, "REPORT", f"/caldav/calendars/{empf_name}/{CAL}/", body,
+ {"Depth": "1", "Content-Type": "application/xml"})
+ check("Abgleich läuft (207)", st == 207, st)
+ check("die Adressen zeigen auf den Empfänger",
+ f"/caldav/calendars/{empf_name}/{CAL}/" in text, text[:200])
+
+ # ── 7. Widerruf ──────────────────────────────────────────────────────────
+ print("\n── Widerruf ──")
+ for eid in angelegt["acl"]:
+ st, _ = ruf(besitzer, "DELETE", f"/api/v1/acl/{eid}", None, BES_CSRF)
+ check("Freigabe widerrufen", st in (200, 204), st)
+ angelegt["acl"] = []
+ check("wieder unsichtbar (Web)", sichtbar_json() == [], sichtbar_json())
+ check("wieder unsichtbar (CalDAV)", sichtbar_dav() == [], sichtbar_dav())
+ st, _ = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, EMPF_CSRF)
+ check("Termine wieder 404", st == 404, st)
+
+finally:
+ aufraeumen()
+
+print()
+if fail:
+ print(f"\033[31m{len(fail)} fehlgeschlagen\033[0m: " + ", ".join(fail))
+print(f"{ok_count} bestanden, {len(fail)} fehlgeschlagen")
+sys.exit(1 if fail else 0)