Files
Aether/scripts/kalender-teilen-selbsttest.py
T
marcandClaude Opus 5 662c0ebdc1 security(rescan): Notbremse vor dem harten Löschen
internal/rescan ist das einzige Stück Aether, das hart löscht: DELETE FROM
files, kein Papierkorb. Entfernt wird, was beim Durchgang über die Platte nicht
gesehen wurde.

Der Ernstfall ist nicht ausgedacht. LocalFS.Walk gibt für ein nicht vorhandenes
Verzeichnis nil zurück – kein Fehler, keine Einträge. Genau der Fingerabdruck
eines Bind-Mounts, der nach einem Neustart nicht hochkam, oder eines
Tippfehlers in DATA_PATH. Dann gilt jede Datei als verwaist, und ein einziger
Aufruf mit ?anwenden=true löscht den gesamten Bestand aus der Datenbank. Die
Bytes lägen noch da, aber niemand wüsste mehr, dass es sie gibt – und der GC
räumte sie 24 Stunden später ab.

Der GC hat diese Bremse seit dem 12.09. Beide gleichen Platte und Datenbank ab,
nur aus entgegengesetzter Richtung, und beide haben denselben Fehlerfall: Eine
Seite antwortet fälschlich mit „leer". Der GC löscht dann Blobs – die Datenbank
weiss hinterher noch, was fehlt. Hier fällt die Zeile selbst. Die gefährlichere
Seite war die ungesicherte.

Zwei Bremsen: Die Notbremse (nichts gefunden, Datenbank kennt Dateien) lässt
sich nicht übersteuern. Die Mengenbremse (mehr als die Hälfte) verlangt
&bestaetigt=true. Die leere Installation ist ausgenommen.

Dazu umgebaut: erst sammeln, dann entscheiden, dann löschen. Vorher wurde
mitten in der Schleife gelöscht; die Gesamtzahl kennt man dort erst, wenn die
Hälfte schon weg ist.

Der Bericht nennt den Grund und ob er bestätigbar ist – die Oberfläche soll
keinen Knopf anbieten, der nichts bewirkt. Ein abgebrochener Lauf steht rot in
der Karte und im Audit-Log; gerade der, denn er ist der Hinweis darauf, dass
mit dem Datenordner etwas nicht stimmt.

Fünf Tests, unter Mutation nachgewiesen. Der Ernstfall läuft am echten Gerät:
fünf Zeilen in der Datenbank, ein Wurzelverzeichnis ohne users/ – nach Apply
stehen alle fünf noch, auch mit Bestätigung.

audit und thumbnails bekommen bewusst keine Go-Tests; die Begründung steht in
BEFUNDE.md statt als ewig offener Punkt.

Nebenbefund behoben: kalender-teilen-selbsttest.py leckte je Lauf ein
App-Passwort des Besitzers. Nach achtzehn Läufen war maxAppPasswords = 20
erreicht, und zwei Suiten scheiterten mit 409, ohne dass am Code etwas kaputt
war.

Geprüft: go build/vet sauber, alle Go-Pakete grün, 243 Prüfungen der sieben
Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 15:26:17 +02:00

337 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 = ('<?xml version="1.0" encoding="utf-8"?>'
'<d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>')
st, text = dav(basic, "PROPFIND", f"/caldav/calendars/{username}/", body,
{"Depth": "1", "Content-Type": "application/xml"})
return st, re.findall(r"<href[^>]*>([^<]+)</href>", 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": [], "apppw": []}
def aufraeumen():
for pid in angelegt["apppw"]:
ruf(besitzer, "DELETE", f"/api/v1/app-passwords/{pid}", None, BES_CSRF)
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}"
# **Merken, sonst leckt jeder Lauf eines.** Das des Empfaengers geht
# mit dessen Konto weg; das des Besitzers bleibt liegen. Nach 20 ist
# die Obergrenze erreicht (auth.maxAppPasswords) und der naechste Lauf
# scheitert mit 409 – genau das ist am 26.09.2026 passiert, nach
# achtzehn Laeufen.
angelegt["apppw"].append(d["id"])
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 = ('<?xml version="1.0" encoding="utf-8"?>'
'<d:sync-collection xmlns:d="DAV:"><d:sync-token/>'
'<d:sync-level>1</d:sync-level><d:prop><d:getetag/></d:prop></d:sync-collection>')
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)