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>
337 lines
17 KiB
Python
337 lines
17 KiB
Python
#!/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)
|