Files
Aether/scripts/zeichnung-selbsttest.py
T
marcandClaude Opus 5 dd68bc594d feat(drawing): Vorschaubilder und ein Zeichenbrett, das zur Oberfläche passt
Vorschaubilder entstehen sonst serverseitig aus den hochgeladenen Bytes – bei
einer Zeichnung steht dort JSON, daraus wird ohne Browser kein Bild. Wer sie
gerade bearbeitet, hat aber genau den Browser, der sie ohnehin zeichnet.

Neu: POST /api/v1/files/{id}/thumbnail. Der Editor schickt nach dem Speichern
ein PNG hinterher.

Den Bytes wird dabei nicht geglaubt: Sie werden nicht abgelegt, sondern durch
denselben Weg geschickt wie jedes andere Vorschaubild – dekodieren,
verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
übliche Grösse; nichts anderes kann im thumbs-Bereich landen. Dazu Schreibrecht
auf die Datei: Wer sie nicht ändern darf, bestimmt auch nicht, wie sie in der
Liste aussieht.

Das Hochladen läuft nach dem Speichern und ohne darauf zu warten. Ein
fehlgeschlagenes Vorschaubild ist ein Schönheitsfehler, kein Grund, das
Speichern scheitern zu lassen – und bleibt deshalb stumm.

Politur am Zeichenbrett: dunkles Thema (ein weisses Brett in dunkler Oberfläche
war der auffälligste Bruch), „Unsaved" als Marke statt eines Punktes hinter dem
Namen, Strg+S, Dateisymbol und Name ohne Endung.

„Open" und „Save to…" sind aus. Beide gehen am Browser vorbei: „Open" ersetzte
den Inhalt dieser Zeichnung durch eine fremde Datei, „Save to…" schriebe eine
zweite Fassung in den Download-Ordner, die niemand wiederfindet.

Selbsttest 12 → 18 Prüfungen, darunter die beiden Ablehnungen (kein Bild → 400,
fremder Typ → 415).

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 20:13:04 +02:00

229 lines
9.9 KiB
Python
Executable File
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 der Zeichnungen (Excalidraw).
python3 scripts/zeichnung-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
Eine Zeichnung ist bei uns **eine Datei** – das ist die ganze Entscheidung
hinter der Umsetzung. Dieser Test prueft genau das nach: Wenn sie wirklich
eine Datei ist, muss sie alles koennen, was Dateien koennen – Versionen,
Freigabelinks, Gruppenordner, Papierkorb. Und das ist nicht selbstverstaendlich,
sondern der Gewinn, den man sonst nur behauptet.
Dazu die eine Stelle, die ohne gleichzeitiges Arbeiten wehtut: Zwei Leute
speichern nacheinander, und der Zweite darf den Ersten nicht spurlos
ueberschreiben.
"""
import hashlib
import json
import struct
import zlib
import sys
import urllib.error
import urllib.request
import uuid
import http.cookiejar
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, fail = 0, []
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():
return urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
def ruf(opener, method, path, body=None, headers=None, roh=False):
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 or urllib.request.build_opener()).open(req) as r:
payload = r.read()
if roh:
return r.status, payload
text = payload.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 hochladen(opener, csrf, name, inhalt: bytes, folder_id=None):
grenze = "----aether" + uuid.uuid4().hex
teile = []
if folder_id:
teile.append(f'--{grenze}\r\nContent-Disposition: form-data; name="folder_id"\r\n\r\n{folder_id}\r\n')
kopf = (f'--{grenze}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
f'Content-Type: application/json\r\n\r\n')
leib = b"".join(t.encode() for t in teile) + kopf.encode() + inhalt + f"\r\n--{grenze}--\r\n".encode()
req = urllib.request.Request(BASE + "/api/v1/files", data=leib, method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={grenze}", **csrf})
with opener.open(req) as r:
return json.loads(r.read().decode())["data"]
def zeichnung(elemente=0):
return json.dumps({
"type": "excalidraw", "version": 2, "source": "aether",
"elements": [{"id": f"e{i}", "type": "rectangle", "x": i * 10, "y": 0,
"width": 80, "height": 40} for i in range(elemente)],
"appState": {}, "files": {},
}).encode()
def winziges_png() -> bytes:
"""Ein gueltiges 1x1-PNG, von Hand gebaut – ohne Abhaengigkeit."""
def chunk(typ, daten):
c = typ + daten
return struct.pack(">I", len(daten)) + c + struct.pack(">I", zlib.crc32(c))
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", zlib.compress(b"\x00\xff\x00\x00")) + chunk(b"IEND", b""))
def roh_senden(opener, path, daten: bytes, typ: str, csrf):
req = urllib.request.Request(BASE + path, data=daten, method="POST",
headers={"Content-Type": typ, **csrf})
try:
with opener.open(req) as r:
return r.status
except urllib.error.HTTPError as e:
return e.code
print(f"Zeichnungs-Selbsttest gegen {BASE}\n")
admin, CSRF = anmelden(USER, PASSWORT)
_, ich = ruf(admin, "GET", "/api/v1/auth/me")
CSRF = {"X-CSRF-Token": ich["csrf_token"]}
name = f"selbsttest-{uuid.uuid4().hex[:6]}.excalidraw"
angelegt, links, gruppen = [], [], []
def aufraeumen():
for lid in links:
ruf(admin, "DELETE", f"/api/v1/shares/{lid}", None, CSRF)
for fid in angelegt:
ruf(admin, "DELETE", f"/api/v1/files/{fid}", None, CSRF)
_, muell = ruf(admin, "GET", "/api/v1/trash")
for f in muell or []:
if f.get("name", "").startswith("selbsttest-"):
ruf(admin, "DELETE", f"/api/v1/trash/{f['id']}", None, CSRF)
for gid in gruppen:
ruf(admin, "DELETE", f"/api/v1/admin/groups/{gid}", None, CSRF)
try:
print("── Als Datei anlegen ──")
inhalt = zeichnung(3)
datei = hochladen(admin, CSRF, name, inhalt)
angelegt.append(datei["id"])
check("angelegt", datei["size"] == len(inhalt), datei["size"])
check("Pruefsumme stimmt", datei["checksum"] == hashlib.sha256(inhalt).hexdigest())
check("als JSON erkannt", datei["mime_type"] == "application/json", datei["mime_type"])
st, roh = ruf(admin, "GET", f"/api/v1/files/{datei['id']}/download?inline=1", None, None, True)
check("Inhalt kommt unveraendert zurueck", st == 200 and roh == inhalt, st)
print("\n── Aendern erzeugt eine Version ──")
# Speichern heisst: gleicher Name, gleicher Ordner, neuer Upload.
zweite = hochladen(admin, CSRF, name, zeichnung(7))
check("gleiche Datei, neue Fassung", zweite["id"] == datei["id"] and zweite["version"] > datei["version"],
f"{datei['version']} -> {zweite['version']}")
st, versionen = ruf(admin, "GET", f"/api/v1/files/{datei['id']}/versions")
check("die alte Fassung liegt in der Historie", st == 200 and len(versionen or []) >= 1,
len(versionen or []))
print("\n── Ueberschreiben faellt auf ──")
# Ohne gleichzeitiges Arbeiten ist die Fassungsnummer der einzige Hinweis
# darauf, dass jemand anders gespeichert hat. Sie MUSS sich aendern.
dritte = hochladen(admin, CSRF, name, zeichnung(9))
check("jede Speicherung hebt die Fassung", dritte["version"] > zweite["version"],
f"{zweite['version']} -> {dritte['version']}")
_, liste = ruf(admin, "GET", "/api/v1/files")
inliste = next((f for f in (liste or {}).get("files", []) if f["id"] == datei["id"]), None)
check("und die Liste zeigt sie – darauf beruht der Abgleich im Editor",
inliste is not None and inliste["version"] == dritte["version"],
inliste and inliste["version"])
print("\n── Erbt alles, was Dateien koennen ──")
st, link = ruf(admin, "POST", f"/api/v1/files/{datei['id']}/share", {}, CSRF)
check("Freigabelink darauf (201)", st == 201, st)
if st == 201:
links.append(link["id"])
st, geholt = ruf(None, "POST", f"/share/{link['token']}/download", None, None, True)
check("oeffentlich abrufbar, Inhalt stimmt", st == 200 and geholt == zeichnung(9), st)
gname = "selbsttest-zeichnung-" + uuid.uuid4().hex[:6]
st, gruppe = ruf(admin, "POST", "/api/v1/admin/groups", {"name": gname, "description": ""}, CSRF)
if st in (200, 201):
gruppen.append(gruppe["id"])
ruf(admin, "POST", f"/api/v1/admin/groups/{gruppe['id']}/members",
{"user_id": ich["user"]["id"]}, CSRF)
_, wurzeln = ruf(admin, "GET", "/api/v1/files")
gordner = next((f for f in (wurzeln or {}).get("folders", []) if f.get("group_id")), None)
if gordner:
gz = hochladen(admin, CSRF, "selbsttest-gruppe.excalidraw", zeichnung(2), gordner["id"])
angelegt.append(gz["id"])
check("Zeichnung im Gruppenordner", gz["folder_id"] == gordner["id"])
print("\n── Vorschaubild ──")
# Der Server kann es nicht selbst rechnen: In der Datei steht JSON. Der
# Browser, der gerade zeichnet, kann es – deshalb darf er es schicken.
check("frische Zeichnung hat noch keines", not datei.get("has_thumbnail"))
st = roh_senden(admin, f"/api/v1/files/{datei['id']}/thumbnail", winziges_png(), "image/png", CSRF)
check("Vorschaubild setzen (204)", st == 204, st)
_, liste2 = ruf(admin, "GET", "/api/v1/files")
jetzt = next((f for f in (liste2 or {}).get("files", []) if f["id"] == datei["id"]), None)
check("und die Liste weiss davon", jetzt and jetzt.get("has_thumbnail"), jetzt)
st, _ = ruf(admin, "GET", f"/api/v1/files/{datei['id']}/thumbnail", None, None, True)
check("es laesst sich abrufen", st == 200, st)
# **Den Bytes wird nicht geglaubt.** Sie gehen durch denselben Weg wie
# jedes andere Vorschaubild – dekodieren, verkleinern, neu kodieren.
# Sonst liesse sich Beliebiges in den thumbs-Bereich legen.
st = roh_senden(admin, f"/api/v1/files/{datei['id']}/thumbnail", b"<html>kein bild</html>",
"image/png", CSRF)
check("was kein Bild ist, wird abgelehnt (400)", st == 400, st)
st = roh_senden(admin, f"/api/v1/files/{datei['id']}/thumbnail", winziges_png(),
"text/html", CSRF)
check("fremder Typ wird abgelehnt (415)", st == 415, st)
print("\n── Schriften werden selbst ausgeliefert ──")
# Ohne das laedt Excalidraw sie von aussen – und die Sicherheitsrichtlinie
# verbietet genau das. Man saehe eine Ersatzschrift und suchte den Grund
# im Zeichenbrett statt in der Richtlinie.
st, _ = ruf(None, "GET", "/excalidraw/fonts/Excalifont/"
"Excalifont-Regular-349fac6ca4700ffec595a7150a0d1e1d.woff2", None, None, True)
check("Excalifont liegt auf dem eigenen Server", st == 200, st)
finally:
aufraeumen()
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
if fail:
print("Fehlgeschlagen: " + ", ".join(fail))
sys.exit(1 if fail else 0)