# Aether – Test-Checkliste Manuelle Test-Checkliste für die fertigen Phasen. Abhaken beim Durchtesten. - ⚠️ = bekannte Lücke, muss *nicht* getestet werden - 🔎 = von Claude per `curl`/API gegen ein Wegwerf-Konto geprüft und grün. Bewusst **nicht** abgehakt: eine API-Prüfung ersetzt nicht den Test in der Oberfläche bzw. mit einem echten Client. Beim Durchgehen selbst abhaken. --- ## Kalender: Selbsttest per Skript Die Kalender-Schnittstelle hat einen automatischen Selbsttest – schneller und gründlicher als Klicken, und er prüft genau die Stellen, an denen schon einmal etwas schiefging: python3 scripts/kalender-selbsttest.py 36 Prüfungen: Ganztags-Datum (der 3. muss der 3. bleiben – der Fehler, bei dem ein Termin östlich von UTC einen Tag zurücksprang), mehrtägige Termine, Terminfarbe in beiden Schreibweisen, alle Wiederholungsmuster (wöchentlich an bestimmten Tagen, monatlich/jährlich am festen Tag bzw. am n-ten Wochentag), Bearbeiten einer Reihe, einen einzelnen Termin aus einer Reihe streichen (EXDATE), Umhängen in einen anderen Kalender (samt ctag auf **beiden** Seiten, sonst gleicht ein CalDAV-Client nur eine davon ab), und dass das Zeitfenster wirklich begrenzt. **Legt Termine an und löscht am Ende alle Termine im ersten Kalender des Kontos**; für den Umzugstest entsteht kurzzeitig ein zweiter Kalender „T zweitkalender", der danach wieder verschwindet. Also nur gegen eine Entwicklungsinstanz laufen lassen. ## CalDAV: Selbsttest per Skript Der Test oben prüft die JSON-Schnittstelle – also den Weg der Web-Oberfläche. Dieser hier geht den Weg eines echten Clients (DAVx5, Thunderbird, Apple Kalender). Beide führen auf dieselben Daten, aber über völlig verschiedenen Code: Ein grüner Lauf des einen sagt nichts über den anderen. python3 scripts/caldav-selbsttest.py 62 Prüfungen: Anmeldung (falsches Passwort und **Kontopasswort** werden abgewiesen – für CalDAV gelten nur App-Passwörter), Auffinden über `.well-known/caldav` samt der Umleitung, der Clients mit GET folgen, Kalenderliste mit Anzeigename, Komponenten, `getctag` und `calendar-color`, PUT/GET/DELETE eines Termins, `If-Match` (falsches ETag muss 412 geben), `calendar-query` mit Zeitfenster und `calendar-multiget`, beide Richtungen zwischen Web und CalDAV (Ganztagstermin als reines Datum mit exklusivem DTEND, Terminfarbe als CSS3-Name, RRULE, EXDATE), das Umhängen aus Client-Sicht (Datei verschwindet in der Quelle, taucht im Ziel auf, UID bleibt), Kalender vom Client aus verwalten (MKCALENDAR und der erweiterte MKCOL, PROPPATCH zum Umbenennen und Umfärben, DELETE der ganzen Sammlung) und dass ein fremder Benutzerpfad 404 gibt statt eines leeren 207. Legt sich ein eigenes App-Passwort und zwei eigene Kalender an und räumt beides am Ende weg; vorhandene Kalender werden nur gelesen. ### Kalender verwalten: Adresse wählt der Client Bei MKCALENDAR sucht **der Client** die Adresse aus (`…/calendars/max/arbeit/`). Deshalb hat `cal_calendars` seit Migration 23 eine Spalte `path_segment`: gesetzt = die gewählte Adresse, leer = es gilt weiterhin die UUID. Alle vor der Migration angelegten Kalender bleiben damit unter genau der Adresse erreichbar, unter der eingerichtete Geräte sie schon kennen – das ist der Punkt, den ein Test hier zuerst prüfen sollte. `go-webdav` kann davon nur MKCOL. MKCALENDAR kennt es nicht (405), PROPPATCH beantwortet es mit 501, und ein DELETE auf einen Kalender reicht es an das Löschen eines *Termins* weiter, was im 404 endet. Alle drei fängt Aether deshalb vorher ab (`backend/internal/calendar/collections.go`). ### Zwei Eigenschaften kommen nicht aus der Bibliothek `go-webdav` v0.7.0 beantwortet in PROPFIND einen festen Satz Eigenschaften und bietet keinen Haken, eigene dazuzulegen. `getctag` (Calendarserver) und `calendar-color` (Apple) ergänzt Aether deshalb selbst (`backend/internal/calendar/propfind.go`): Beide werden aus der Anfrage genommen, bevor go-webdav sie zu sehen bekommt, und der Antwort als eigener `propstat`-Block wieder angehängt. Ohne das Herausnehmen stünde dieselbe Eigenschaft zweimal in der Antwort – einmal mit 404, einmal mit 200. Weil das eigener Code ist und nicht Bibliothek, prüft das Skript ihn besonders: dass beide mit 200 kommen, die Farbe in Apple-Schreibweise (`#RRGGBBAA`), dass das ctag sich bei jeder Änderung **ändert** (ein festgefrorenes ctag ist schlimmer als gar keins – der Client fragt einmal und hält den Kalender danach für unverändert), dass eine Anfrage nach *nur* diesen Eigenschaften funktioniert (sonst bliebe für go-webdav ein leeres `` übrig, das es abweist), und dass ohne Nachfrage auch nichts davon in der Antwort steht. Dazu eine Ungenauigkeit ohne Folgen: Ein PUT, das einen vorhandenen Termin überschreibt, antwortet mit `201 Created` statt `204 No Content`. Das kommt aus go-webdav; Clients prüfen auf 2xx. ## Freigabelinks: Selbsttest per Skript Freigabelinks sind der **einzige** Teil von Aether, den man ohne Anmeldung erreicht. Ein Fehler darin ist kein Anzeigefehler, sondern eine offene Tür. python3 scripts/sharing-selbsttest.py 24 Prüfungen: anlegen nur angemeldet und nur für **eigene** Dateien (auch der Admin kommt nicht an fremde), Tokenlänge, öffentlicher Abruf ohne Anmeldung, Herunterladen byte-genau, Passwortschutz (ohne, falsch, richtig), Download-Zähler, Widerrufen (danach ist der Link tot), ein Link auf eine gelöschte Datei liefert nichts, und die Sperre nach wiederholten Fehlversuchen. Der Test legt einen Zweitnutzer an – ohne ihn lässt sich „fremde Dateien bleiben fremd" gar nicht prüfen – und räumt ihn samt Dateien wieder weg. **Nach einem Lauf ist die eigene IP 15 Minuten gesperrt**, für passwortgeschützte Freigabelinks (nicht fürs Anmelden). Das ist der Preis der letzten Prüfung, die genau diese Sperre nachweist. Ein zweiter Lauf innerhalb der Frist bricht früh ab und sagt es. Beim Entwickeln hilft ein `docker restart aether-app` – die Sperre liegt im Arbeitsspeicher. ### Was dieser Test beim ersten Lauf gefunden hat Drei Dinge, alle behoben – hier notiert, weil sie zeigen, wonach man in diesem Bereich sucht: - **Der Download-Zähler blieb auf 0.** Die Zählung lief in einer Goroutine mit dem Kontext der Anfrage – der wird abgebrochen, sobald die Antwort draußen ist, und die Zählung verlor das Rennen fast immer. - **`owner_id` stand in der öffentlichen Antwort.** Das ganze Datenbankobjekt durchzureichen war bequem, gab aber die Kennung des Besitzers an jeden heraus, der den Link hat: Zwei Links desselben Besitzers ließen sich einander zuordnen, ohne dass irgendwer angemeldet ist. - **Passwörter ließen sich unbegrenzt durchprobieren.** Der öffentliche Freigabepfad lag außerhalb jeder Ratenbegrenzung; jeder Versuch rechnet Argon2, war also zugleich ein billiger Weg, den Server zu beschäftigen. ## Abgleich Platte ↔ Datenbank: Selbsttest per Skript Der Abgleich (Admin → Audit → **Rescan storage**) übernimmt Dateien, die von außen dazugekommen sind – aus einer Sicherung zurückgeholt oder per SFTP abgelegt – und **entfernt Einträge**, deren Datei fort ist. Das zweite ist der Grund für diesen Test: Ein Fehler darin löscht Daten, die niemand vermisst, bis er sie braucht. python3 scripts/rescan-selbsttest.py [BASIS-URL] [DATENPFAD] Der Test schreibt selbst Dateien ins Datenverzeichnis – er muss also auf dem Rechner laufen, auf dem die Daten liegen (Vorgabe `~/aether-data`). 20 Prüfungen in fünf Gruppen: **Prüfen ändert nichts** (zweimal geprüft liefert dasselbe, nichts wird sichtbar), **Übernehmen** (Ordnerkette angelegt, Prüfsumme und Größe stimmen, Dateityp erkannt, Herunterladen liefert die Datei), **von außen ersetzt** (Größe und Prüfsumme werden berichtigt), **Datei ist fort** (verwaister Eintrag wird gefunden, bleibt beim Prüfen stehen, verschwindet erst beim Anwenden), **nicht anfassen** (Papierkorb und frühere Fassungen liegen im selben Baum und dürfen nicht übernommen werden – sonst tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu einer eigenen Datei), und **nur für Verwalter**. ### Warum es das überhaupt gibt Der GC räumt Dateien weg, zu denen es keinen Eintrag gibt. Für Reste gelöschter Dateien ist das richtig – für eine per SFTP abgelegte Datei ist es falsch: Sie ist in Aether unsichtbar und nach 24 Stunden weg. Der Abgleich ist die Gegenrichtung und macht aus solchen Dateien richtige Einträge. Zwei Fallen, die beim Bauen auffielen und die ein Test hier abdecken muss: - **Der Dateityp muss aus Endung oder Inhalt kommen.** Beim Hochladen schickt der Browser ihn mit; eine per SFTP abgelegte Datei bringt niemanden mit, der ihn kennt. Dazu kommt: Das Alpine-Abbild hat keine `/etc/mime.types`, und Gos eingebaute Liste kennt nicht einmal `.txt`. - **Prüfsumme nicht überspringen.** Sie kostet einen vollständigen Lesevorgang je Datei, aber ein Eintrag ohne sie ist ein Eintrag, dessen Beschädigung beim Herunterladen niemand bemerkt. ## Abgebrochener Upload Am Kreuz der Fortschrittsmeldung lässt sich ein laufender Upload fallen lassen. Die Zusage dahinter ist die eigentliche Frage: **Auf dem Server bleibt dabei nichts zurück.** python3 scripts/upload-abbruch-selbsttest.py Der Test kappt eine echte Verbindung mitten im Leib – auf einem rohen Socket, nicht über eine Bibliothek. Ein sauber gesendeter, aber zu kurzer Leib wäre etwas anderes: Den wiese der Server als Formatfehler ab, und über den Abbruch bewiese das nichts. 13 Prüfungen: **neue Datei** (kein Eintrag, nichts im Papierkorb), **Überschreiben mit Versionierung** – der gefährlichste Fall, weil der Server die alte Fassung dabei kurzzeitig zur Seite schiebt: Sie muss danach in Größe, Prüfsumme und Inhalt unversehrt sein –, **Verbrauch** und ein **Rescan**, der nachsieht, ob herrenlose Bytes liegengeblieben sind. ### Eine Falle für jeden, der die Schnittstelle skriptet `GET /api/v1/auth/me` **erneuert das CSRF-Token** und gibt das neue zurück. Wer den Wert von der Anmeldung festhält, bekommt beim nächsten schreibenden Ruf ein `403` und sucht die Ursache bei den Rechten. Die Weboberfläche zieht den neuen Wert an allen drei Stellen nach (`hooks/useAuth.ts`); ein Skript muss es auch. ## Kalender teilen python3 scripts/kalender-teilen-selbsttest.py Eine Rechtefunktion ist so viel wert wie ihre engste Stelle. Aether hat **zwei** Zugänge zu denselben Kalendern – die Weboberfläche und CalDAV –, und beide prüfen getrennt. Ein Recht, das nur einer von beiden durchsetzt, ist keines; deshalb geht hier jede Prüfung über beide Wege. 41 Prüfungen in sieben Gruppen, und die Reihenfolge ist der Punkt: **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), dann nur lesend (sehen ja, ändern nein – und beim Eigentümer kommt nachweislich nichts an), schreibend, die **Grenzen** des Schreibrechts (fremden Kalender umbenennen, löschen oder weiterverschenken geht auch dann nicht), Freigabe über eine **Gruppe**, `sync-collection` auf einem fremden Kalender, und der Widerruf. Der Test legt zwei Konten, eine Gruppe und einen Kalender an und räumt am Ende alles weg. ### Eine Falle, die der Test selbst hatte Die Terminliste hat einen Standardzeitraum (ein Monat zurück bis ein Jahr voraus). Mit einem festen Testdatum lieferte sie irgendwann eine leere Liste – und die Prüfung „beim Eigentümer ist nichts angekommen" **bestand dann leer**. Eine Prüfung, die ohne Daten besteht, prüft nichts. Jetzt liegen die Testtermine relativ zum heutigen Tag, der Zeitraum wird ausdrücklich mitgegeben, und eine Zeile davor stellt sicher, dass überhaupt etwas in der Liste steht. ## Go-Tests mit Datenbank Einige Go-Tests brauchen ein echtes Postgres – die Warteschlange der Texterkennung etwa lässt sich ohne nicht prüfen. Sie überspringen sich selbst, wenn `TEST_DSN` fehlt, damit `go test ./...` überall grün bleibt. **Der Preis dieser Freundlichkeit: Sie laufen dann nie.** Und ein Test, der nie läuft, ist Dekoration – genau wie `vitest` es war, bevor es auffiel. Deshalb: ./scripts/db-tests.sh # alle ./scripts/db-tests.sh ./internal/documents/ Das Skript baut eine Wegwerf-Datenbank, spielt alle Migrationen ein, lässt die Tests laufen und räumt sie wieder weg. Die Entwicklungsdatenbank bleibt unberührt. ### Texterkennung: die drei stillen Zustände `internal/documents` ist das Paket, dessen Versagen am teuersten ist, weil es **still** ist: Die Volltextsuche findet dann einfach nichts, und das sieht genauso aus wie „steht nicht drin". Geprüft werden deshalb nicht die Abfragen, sondern die drei Zustände, in denen die Schlange hängenbleiben kann, ohne dass es auffällt: 1. **Ein Auftrag, der nie aufgibt.** Ein kaputtes oder passwortgeschütztes PDF besetzte sonst für immer den Arbeiter, und die übrigen Dateien kämen nie dran – während nach außen „die Erkennung läuft". 2. **Ein Auftrag, der nach einem harten Abbruch auf `running` stehen bleibt.** Nähme `Claim` nur `pending`, wäre diese Datei für immer verloren, und die Zahl der offenen Aufträge sähe dabei gesund aus. 3. **Ein Text nach alter Regel, der nie erneuert wird.** Auffindbar, aber im Betrachter nicht auf der richtigen Seite – und nichts zeigt, dass diese Dateien anders sind. Dazu: gelöschte Dateien werden nicht mehr genommen, ein erneuter Upload gibt eine neue Chance, und `Complete` schreibt Text und Auftragsende zusammen. ### Etiketten und Absender: bleibt Fremdes fremd? ./scripts/db-tests.sh ./internal/tags/ Die Rechteprüfung steckt hier nicht in einer `if`-Zeile, sondern in WHERE-Klauseln. Ein Tippfehler darin fällt beim Lesen nicht auf: Die Abfrage läuft, sie liefert nur zu viel. Fünf Tests auf dieselbe Frage – Liste, Umbenennen, Löschen, Anhängen an die eigene Datei, und vor allem das **Auflisten der Dateien** unter einem Etikett. Der letzte Weg gibt Dateinamen, Ordner und Größen heraus. Dazu die Gruppen: Ein Mitglied sieht das Gruppen-Etikett, ein Nichtmitglied nicht, und wer die Gruppe verlässt, verliert den Zugriff im selben Moment. **Eine Absicherung, die wie eine Förmlichkeit aussieht:** Ein Gruppen-Etikett darf nicht an eine persönliche Datei. Der Grund steht nicht im Code, wo die Prüfung sitzt, sondern zwei Dateien weiter: `FilesWithTag` prüft, ob der Anfragende das *Etikett* sehen darf – **nicht**, ob er die *Datei* sehen darf. Harmlos ist das nur, weil ein Etikett stets denselben Prinzipal hat wie die Dateien darunter. Fiele die Prüfung weg, hinge an einem Gruppen-Etikett eine private Datei, und jedes Gruppenmitglied bekäme ihren Namen zu sehen. ### Was der Mutationsversuch gezeigt hat Die Tests wurden gegen **absichtlich kaputten Code** gegengeprüft – sonst weiß man nicht, ob sie überhaupt etwas fangen. * `'running'` aus `Claim` entfernt → zwei Tests wurden rot. Gut. * `maxAttempts` von 3 auf 9999 gesetzt → **alle blieben grün.** Sie schleifen über die Konstante, prüfen die Zahl also nicht, sondern setzen sie voraus. Auffällig war nur die Laufzeit: 27 statt 0,2 Sekunden. Genau so verschwindet eine Entscheidung unbemerkt aus einer Anwendung. Dagegen steht jetzt ein Test, der die Zahl selbst festhält und sagt, wo die Begründung hingehört, falls sie sich ändert. Bei den Etiketten dasselbe Vorgehen, mit klarerem Ausgang: Eigentümerprüfung aus `FilesWithTag` entfernt → *„Bert sieht 1 fremde Dateien: Gehaltsabrechnung.pdf"*. Prüfung in `SetFieldsForFile` ausgehebelt → zwei Tests rot. Beide Absicherungen sind also wirklich abgedeckt und nicht bloß mitgelaufen. ## Notfall: Migration hängt fest ("Dirty database version") Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und bestätigt (siehe kalender-plan.md, Abschnitt „Migrationen"): Scheitert eine Migration, bleibt `schema_migrations.dirty` auf `true` stehen, und **jeder** weitere Start von Aether bricht ab – die ganze Cloud, nicht nur der neue Teil. Kein Datenverlust dabei: Die Migration lief in einer Transaktion und wurde zurückgerollt, bevor der Fehler kam. `dirty=true` ist nur eine Sperre, keine Aussage über beschädigte Daten. Beheben: docker exec aether-db psql -U aether -d aether \ -c "UPDATE schema_migrations SET dirty=false;" Danach Aether neu starten. Lief die Migrationsdatei nur teilweise (z. B. zwei `CREATE TABLE` in einer Datei, das erste gelang), vorher prüfen, was bereits existiert, bevor man einen erneuten Lauf zulässt – sonst scheitert der zweite Versuch am selben Fehler wie der erste. --- ## Phase 3 – Admin, Gruppen, Theming, Profil ### Admin-Panel: Zugriff & Sicherheit - [X] Admin-Link erscheint **nur** für Admin-User in der Sidebar - [X] Normaler User sieht den Admin-Link **nicht** - [X] Normaler User, der `/api/v1/admin/users` direkt aufruft, bekommt `403` (serverseitiger Check) - [X] Nach Logout ist kein Admin-Endpunkt erreichbar (`401`) ### Admin: Benutzerverwaltung - [x] Benutzerliste zeigt alle User mit verwendetem Speicher + Quota + Rolle - [x] Neuen User anlegen (Username, Anzeigename, Passwort, Quota, Admin-Flag) - [x] Anlegen mit existierendem Username → Fehler „bereits vergeben" (`409`) - [x] User bearbeiten (Anzeigename, Quota, Admin-Flag ändern) - [x] **Selbst-Schutz:** eigenen Admin-Status kann man **nicht** entfernen (Fehlermeldung) - [x] *Selbst-Schutz:** eigenes Konto kann man **nicht** löschen - [x] Passwort-Reset generiert neues Passwort und zeigt es **einmalig** an - [x] Neuer User kann sich mit dem zurückgesetzten Passwort einloggen - [ ] User löschen entfernt den User (und seine Dateien) 🔎 ### Admin: Gruppen - [x] Gruppen-Tab lädt ohne schwarzen Bildschirm (war der Bug – jetzt gefixt) - [x] Gruppe anlegen (Name + Beschreibung) - [x] Gruppe aufklappen zeigt Mitglieder - [x] Mitglied über Dropdown hinzufügen → Zähler steigt - [x] Mitglied entfernen → Zähler sinkt - [x] Gruppe mit doppeltem Namen → Fehler (`409`) - [x] Gruppe löschen (auch mit Mitgliedern – Cascade) - [ ] ⚠️ *Gruppen haben noch keine Funktion (keine Gruppenordner) – Phase 4.5/C, offen* ### Admin: Theming - [x] Cloud-Name ändern → erscheint nach Reload überall (Login-Seite, Sidebar) - [x] Primärfarbe ändern → Buttons/Akzente übernehmen die Farbe sofort - [x] Akzentfarbe ändern → Vorschau aktualisiert live - [x] Nach Reload sind die Farben persistent - [ ] ⚠️ *Logo-Upload noch nicht vorhanden – Phase 4.5/D, offen* ### Profil-Seite (jeder User) - [x] Avatar/Name unten in der Sidebar öffnet die Profil-Seite - [x] Speicheranzeige (Balken) stimmt mit tatsächlicher Nutzung - [ ] Anzeigename ändern → wird gespeichert (✓-Bestätigung) 🔎 - [x] Passwort ändern: falsches aktuelles Passwort → Fehler - [x] Passwort ändern: neue Passwörter stimmen nicht überein → Fehler - [x] Passwort ändern: < 8 Zeichen → Fehler - [x] Passwort erfolgreich ändern → Logout → Login mit neuem Passwort klappt - [x] Persönliche Farben ändern → werden angewendet - [x] Logout-Button im Profil-Bereich funktioniert (ohne Profil zu öffnen) --- ## Phase 4 – WebDAV & OIDC ### WebDAV – Verbindung Verbinden mit `https:///dav/` oder lokal direkt mit `http://127.0.0.1:8090/dav/`. **Achtung Port:** die App lauscht *im Container* auf 8080, Compose bildet das auf dem Host aber auf **8090** ab (`127.0.0.1:8090->8080`). Vom Host aus gilt immer 8090 – und nur vom Host, die Bindung ist auf localhost. Anmeldung mit dem Benutzernamen und einem **App-Passwort** (Profil → App passwords). Das Kontopasswort wird hier **abgelehnt** – es ist zugleich der Admin-Zugang und hat auf Geräten nichts verloren. Clients: KDE/Dolphin, macOS Finder, Windows Explorer, `cadaver`, `rclone`, Cyberduck. > **Hintergrund zu Dolphin/KIO:** KIO schickt Zugangsdaten nie vorab, sondern > immer erst auf den `401` hin – und beginnt dabei sofort mit dem Rumpf, ohne > `Expect: 100-continue` abzuwarten. Große Uploads scheiterten deshalb mit > „unerwarteter Fehler 0", während kleine Dateien durchliefen. Behoben am > 22.08.2026: Der Server liest den Rumpf einer unauthentifizierten Anfrage weg, > bevor er die Anmeldeaufforderung schickt, damit die Verbindung stehen bleibt > und KIO die `401` überhaupt zu sehen bekommt. > Alle Punkte hier wurden am 19.08.2026 per `curl` gegen ein Wegwerf-Konto > geprüft und waren grün (🔎). Was das **nicht** abdeckt: ein echter Client > (Finder, Explorer, Cyberduck, rclone) verlangt andere PROPFIND-Properties und > nutzt Locking – deshalb stehen die Haken offen. - [x] Login mit Username + **App-Passwort** → Verbindung steht 🔎 - [x] Login mit falschem Passwort → abgelehnt (`401`) 🔎 - [x] Login mit dem **Kontopasswort** → abgelehnt (`401`), auch wenn es stimmt 🔎 - [x] Ordnerstruktur entspricht der in der Web-App (gleiche Ordner sichtbar) 🔎 ### WebDAV – Lesen - [x] Ordner auflisten (PROPFIND) zeigt Dateien + Unterordner 🔎 - [x] Datei herunterladen → Inhalt korrekt 🔎 - [x] Datei, die in der Web-App hochgeladen wurde, ist via WebDAV sichtbar 🔎 ### WebDAV – Schreiben - [x] Datei hochladen (PUT) → erscheint danach in der Web-App 🔎 - [x] Neuen Ordner anlegen (MKCOL) → erscheint in der Web-App 🔎 - [x] Datei umbenennen (MOVE im selben Ordner) 🔎 - [x] Datei in anderen Ordner verschieben (MOVE) 🔎 - [x] Ordner umbenennen 🔎 - [x] Datei löschen (DELETE) → landet im Papierkorb der Web-App (nicht hart gelöscht!) 🔎 - [x] Bestehende Datei überschreiben → bei **aktivierter** Versionierung landet die alte Fassung in der Historie; bei deaktivierter wird ersetzt (so gewollt, Versionierung ist pro Datei opt-in) 🔎 - [ ] Checksumme/Integrität: hochgeladene Datei wieder runterladen → identisch 🔎 - [x] **Große Datei (100 MB+) per Dolphin hochladen** – war der Fehler „unerwarteter Fehler 0" 🔎 - [ ] Quota wird bei WebDAV-Upload geprüft → `507 Insufficient Storage` 🔎 - [ ] Abgebrochener Upload (Kontingent voll oder Verbindung weg) hinterlässt **nichts** – keine abgeschnittene Datei, kein verwaistes Blob 🔎 - [ ] Rate-Limit/Lockout am WebDAV-Login (10 Fehlversuche / 15 min → `429`) 🔎 - [ ] App-Passwort ist der **einzige** WebDAV-Zugang, gilt nicht fürs Web-Login, und ist nach dem Widerruf sofort tot 🔎 ### App-Passwörter (Profil → App passwords) - [ ] App-Passwort anlegen → Klartext erscheint **einmalig**, „Copy" kopiert es - [ ] Damit einen WebDAV-Client verbinden (Username = normaler Benutzername) - [ ] Liste zeigt „Last used" nach der ersten Nutzung - [ ] Widerrufen → Client kommt sofort nicht mehr rein - [ ] Kontopasswort im WebDAV-Client → abgelehnt; der Anmeldedialog nennt den Grund im Realm-Text 🔎 - [ ] Kontopasswort funktioniert weiterhin fürs **Web-Login** 🔎 - [ ] App-Passwort funktioniert **nicht** fürs Web-Login 🔎 ### Audit-Log (Admin → Activity) - [ ] Tab zeigt Einträge, neueste zuerst 🔎 - [ ] Fehlgeschlagene Anmeldung erscheint rot als „Sign-in failed" 🔎 - [ ] Anmeldung, Passwortwechsel, User-/Gruppen-Änderungen, Freigaben und Löschungen tauchen auf 🔎 - [ ] Filter-Dropdown schränkt auf eine Aktion ein - [ ] Blättern funktioniert (ab 50 Einträgen) - [ ] Gelöschtes Konto: Eintrag bleibt lesbar, Name mit „(deleted account)" - [ ] Passwort-Reset steht im Protokoll – **ohne** das Passwort selbst - [ ] Normaler User bekommt auf `/api/v1/admin/audit` ein `403` ### Speicher-Bereinigung (Admin → Activity → „Run now") - [ ] „Run now" meldet „Nothing to clean up", solange alles in Ordnung ist 🔎 - [ ] Verwaiste Datei älter als 24 h wird entfernt, Ergebnis wird angezeigt 🔎 - [ ] Frisch angelegte verwaiste Datei bleibt liegen (Schonfrist) 🔎 - [ ] Echte Dateien bleiben nach dem Lauf unangetastet 🔎 - [ ] Der Lauf erscheint anschließend selbst im Audit-Log --- ## Phase 4.5/B – Interne Freigaben ### Freigeben (Rechtsklick → „Share with people…") - [ ] Ordner an eine **Person** freigeben, Recht „Can view" - [ ] Empfänger sieht ihn unter **Shared with me**, mit „View only" - [ ] Empfänger kann hineinsehen und herunterladen - [ ] Empfänger kann **nicht** umbenennen, löschen, hochladen (Meldung statt Fehler) - [ ] Recht auf „Can edit" ändern → wird ersetzt, **kein** zweiter Eintrag - [ ] Empfänger kann jetzt umbenennen und hochladen - [ ] Einzelne **Datei** freigeben → Empfänger sieht nur sie, nicht den Ordner drumherum - [ ] An eine **Gruppe** freigeben → alle Mitglieder sehen es, mit Hinweis „via " - [ ] Mitglied aus der Gruppe entfernen → Zugriff sofort weg - [ ] „Remove" im Dialog → Zugriff sofort weg ### Grenzen (das Wichtige) - [ ] Empfänger kann die Freigabe **nicht weitergeben** (nur der Eigentümer teilt) - [ ] Dateien bleiben beim Eigentümer: Speicherbalken des **Eigentümers** wächst, wenn der Empfänger etwas hochlädt – nicht der des Empfängers - [ ] Unterordner, den ein Empfänger anlegt, gehört dem **Eigentümer** - [ ] Empfänger kann eine geteilte Datei **nicht** in seinen eigenen Ordner verschieben - [ ] Ohne Freigabe liefert ein direkter Aufruf mit bekannter ID `404` (nicht `403` – sonst verriete die Antwort, dass die ID existiert) - [ ] Freigaben tauchen im Audit-Log als `share.grant` / `share.revoke` auf > Über WebDAV erscheinen Freigaben unter `/dav/Shared///` > – siehe eigener Abschnitt weiter unten. --- --- ## Phase 4.5/C – Gruppenordner ### Anlegen (Admin → Groups) - [ ] Gruppe wählen, Namen eingeben, „Create" → Ordner erscheint in der Liste - [ ] Zweiter Ordner für dieselbe Gruppe → Fehler „already has a folder" (`409`) 🔎 - [ ] Übersicht zeigt Gruppe, Mitgliederzahl und belegten Platz ### Beim Nutzer - [ ] **Kein** Mitglied: Ordner ist nicht sichtbar 🔎 - [ ] Mitglied hinzufügen → Ordner erscheint in dessen Wurzel, beschriftet als „Group folder" 🔎 - [ ] Zwei Mitglieder arbeiten darin: hochladen, herunterladen, umbenennen, Unterordner anlegen 🔎 - [ ] Mitglied entfernen → Ordner verschwindet sofort 🔎 ### Der Ordner selbst ist geschützt - [ ] Rechtsklick auf den Gruppenordner: **kein** Löschen, Umbenennen, Verschieben, Teilen – nur Öffnen und Favorit - [ ] Er lässt sich nicht per Drag & Drop verschieben - [ ] Direkter API-Aufruf (`DELETE /folders/{id}`) → `403` 🔎 - [ ] **Innerhalb** des Ordners ist alles normal löschbar 🔎 ### Speicher und Papierkorb - [ ] Dateien liegen unter `groups//home/…`, nicht bei einem Nutzer 🔎 - [ ] Kontingent zählt gegen die **Gruppe**, bei keinem Mitglied 🔎 - [ ] Gruppenkontingent überschritten → `413` 🔎 - [ ] Gelöschte Gruppendatei erscheint im Papierkorb **jedes Mitglieds** und lässt sich wiederherstellen 🔎 - [ ] Admin löscht den Gruppenordner → Daten und Verzeichnis restlos weg 🔎 ### Geteiltes und Gruppenordner über WebDAV Der Aufbau bildet die Weboberfläche nach: Gruppenordner liegen **in der Wurzel** neben den eigenen, Personen-Freigaben gebündelt unter `Shared/`. - [ ] Gruppenordner erscheint in der WebDAV-Wurzel, gleiche Liste wie in der Weboberfläche 🔎 - [ ] Hineinnavigieren, hochladen, Unterordner anlegen 🔎 - [ ] **Namensgleichheit:** eigener Ordner `Projekt` + Gruppenordner `Projekt` → eigener behält den Namen, Gruppenordner heißt `Projekt ()`, beide erreichbar, Schreibvorgänge landen jeweils richtig 🔎 - [ ] Nach Austritt aus der Gruppe verschwindet er aus der Wurzel 🔎 - [ ] `Shared` erscheint nur, wenn eine **Person** etwas geteilt hat 🔎 - [ ] `/Shared/` listet die Personen, `/Shared//` deren Freigaben 🔎 - [ ] Datei im geteilten Ordner lesen und schreiben (bei „Can edit") 🔎 - [ ] Einzeln geteilte Datei erscheint als **Datei**, nicht als Ordner 🔎 - [ ] Nur-Lesen: Schreiben und Löschen → `403` (nicht `404`/`405`) 🔎 - [ ] In `/Shared/` selbst lässt sich nichts anlegen oder löschen (`403`) 🔎 - [ ] Verschieben über Eigentumsgrenzen → `403` 🔎 - [ ] Nicht Vorhandenes bleibt `404` – verrät nichts 🔎 - [ ] Entzug der Freigabe / Austritt aus der Gruppe → sofort `404` 🔎 - [ ] Bens Upload landet im Bereich des **Eigentümers** und zählt gegen dessen Kontingent 🔎 - [ ] Prüfsumme über den geliehenen Pfad: Upload → WebDAV-GET → Web-Download, alles identisch 🔎 - [ ] Mit einem echten Client (Dolphin) durchgehen ### OIDC (nur falls konfiguriert) OIDC ist optional. Test nur, wenn `OIDC_ISSUER` etc. in `.env` gesetzt sind. **Wichtig:** `OIDC_REDIRECT_URL` muss auf `/api/v1/auth/oidc/callback` zeigen und exakt so beim IdP registriert sein. - [ ] Ohne OIDC-Config: **kein** SSO-Button auf der Login-Seite (Standardfall) - [ ] Mit OIDC-Config: SSO-Button erscheint - [ ] Klick auf SSO → Weiterleitung zum IdP - [ ] Nach erfolgreichem IdP-Login → zurück in Aether, eingeloggt - [ ] Beim ersten OIDC-Login wird automatisch ein User angelegt (aus `preferred_username`) - [ ] Zweiter OIDC-Login desselben Accounts → gleicher User (kein Duplikat) - [ ] Username-Kollision → automatischer Suffix (`name2`, `name3`, …) - [ ] Manipulierter/abgebrochener Callback → Redirect mit Fehler, kein Login - [ ] CSRF/State: Login mit falschem `state`-Cookie → abgelehnt --- ## Phase 4.5/D – Logo & App-Symbol ### Admin → Theming: Logo - [x] Logo hochladen → erscheint sofort in der Vorschau, Seitenleiste und auf der Anmeldeseite (ohne Speichern-Klick) 🔎 - [x] Logo austauschen → neues Bild sofort sichtbar, altes ist von der Platte verschwunden 🔎 - [ ] „Remove" → Standard-Wolke ist zurück 🔎 - [ ] SVG, Textdatei, getarnte EXE → abgelehnt (`415`) 🔎 - [ ] Datei über 2 MB → `413` mit klarer Meldung 🔎 - [ ] Normaler Nutzer kann weder hochladen noch löschen (`403`) 🔎 - [ ] Logo ist **ohne** Anmeldung abrufbar (die Anmeldeseite braucht es) 🔎 ### Admin → Theming: App-Symbol Ein Upload für beides – Tab-Symbol und installierte App. Der Server rechnet 64, 192, 512 und die maskable-Fassung daraus. - [ ] Quadratisches PNG (am besten 512×512) hochladen → Symbol im Tab wechselt ohne Neuladen 🔎 - [ ] Anderer, schon offener Tab zieht beim nächsten Laden nach - [ ] Austauschen → neues Symbol sofort da, altes Verzeichnis von der Platte verschwunden 🔎 - [ ] „Remove" → Standard-Symbol ist zurück, Verzeichnis gelöscht 🔎 - [ ] Vorlage kleiner als 192×192 → `422` mit klarer Meldung 🔎 - [ ] Vorlage mit 256×256 → es entsteht **kein** 512er, das Manifest nennt ihn auch nicht (nie hochskalieren) 🔎 - [ ] Nicht quadratisches Bild → wird gerahmt, **nicht** beschnitten 🔎 - [ ] SVG und ICO → abgelehnt (`415`); Datei über 2 MB → `413` 🔎 - [ ] Ohne Anmeldung abrufbar (der Browser fragt das Tab-Symbol vor dem Anmelden ab) 🔎 - [ ] Speicher-Bereinigung („Run now") löscht den Symbol-Satz **nicht** 🔎 ### Manifest (`/manifest.webmanifest`) - [ ] Direkt aufrufen → nennt Cloud-Namen und Primärfarbe aus dem Admin-Bereich, nicht die fest eingebauten Werte 🔎 - [ ] Ohne eigenes Symbol fällt es auf die mitgelieferten Bilder zurück 🔎 - [ ] Auf dem Handy „Zum Startbildschirm hinzufügen" → Name und Symbol stimmen - [ ] **Bereits installierte App:** Symbol wechselt erst nach ein, zwei Tagen – Chrome fragt das Manifest von selbst nach, aber nicht sofort. Sicher sofort nur nach Deinstallieren und neu Installieren. --- ## Vor und Zurück im Verlauf - [ ] In mehrere Ordner nacheinander wechseln → **Zurück** (Browser-Taste, Seitentaste der Maus oder Wischgeste) geht die Reihenfolge rückwärts, **Vor** wieder nach vorn - [ ] In der Werkzeugleiste stehen **keine** eigenen Vor/Zurück-Pfeile mehr – das macht allein der Browser - [ ] Über die Seitenleiste die Ansicht wechseln (Papierkorb, Freigaben, Admin, Profil) → Zurück führt zur vorigen Ansicht - [ ] Aus einer anderen Ansicht zurück zu „Files" → der zuletzt geöffnete Ordner ist wieder da, nicht die Wurzel - [ ] Über die Brotkrumen nach oben springen → zählt als eigener Schritt, Zurück führt wieder nach unten - [ ] Auf einen Favoriten in der Seitenleiste klicken → eigener Schritt im Verlauf - [ ] Zweimal denselben Ordner öffnen, in dem man schon steht → erzeugt **keinen** zweiten Schritt (Zurück springt nicht ins Leere) - [ ] Seite neu laden (`F5`) → man landet wieder im selben Ordner - [ ] Adresse zeigt die Ansicht (`/admin`, `/trash`); direkt eingegeben lädt sie auch. Der Ordner steht **nicht** in der Adresse – ein Link auf einen tiefen Ordner ist also nicht teilbar (bewusst, siehe Projektplan) --- ## Suche Feld in der Werkzeugleiste, rechts neben den Brotkrumen. Sucht in **Namen** von Dateien und Ordnern – und seit dem 23.08.2026 zusätzlich im **Inhalt** von PDFs und Bildern (siehe „Volltext über den Inhalt" weiter unten). - [ ] Ab zwei Zeichen erscheinen Treffer, ein Zeichen tut nichts - [ ] Unter jedem Treffer steht der Ordner, in dem er liegt - [ ] Ordner-Treffer anklicken → Ordner wird geöffnet - [ ] Datei-Treffer anklicken → springt **nur** in den Ordner. Es öffnet sich **keine** Vorschau 🔎 - [ ] Die gesuchte Datei ist dort kurz umrandet und wird in den sichtbaren Bereich geholt; die Markierung verschwindet nach knapp drei Sekunden von selbst 🔎 - [ ] Geöffnet wird weiterhin per **Doppelklick** auf die Datei - [ ] Das gilt in Kachel- **und** Listenansicht - [ ] Pfeiltasten wählen aus, Enter öffnet, Esc schließt - [ ] Schnell tippen → keine veralteten Treffer (jede Anfrage bricht die vorige ab) - [ ] Papierkorb bleibt außen vor: gelöschte Datei erscheint nicht 🔎 ### Volltext über den Inhalt (OCR) Der Punkt, für den ein Rechnungsarchiv überhaupt gebaut wird: Ein Beleg namens `scan_0042.pdf` muss über das zu finden sein, was **drin** steht. Wichtig zum Verständnis beim Prüfen: Die Erkennung läuft **im Hintergrund**. Direkt nach dem Hochladen findet die Suche den Inhalt noch nicht – bei einem PDF mit Textebene nach etwa einer Sekunde, bei einem Scan nach rund einer Minute je Seite. Der Stand steht in `GET /api/v1/admin/ocr` (noch ohne Oberfläche) und im Log (`docker compose logs app | grep component=ocr`). - [ ] PDF **mit** Textebene hochladen → nach ein paar Sekunden findet ein Wort aus dem Text die Datei; im Log steht `source=layer` 🔎 - [ ] Eingescannte oder abfotografierte Rechnung hochladen → nach etwa einer Minute je Seite findet ein Wort daraus die Datei; im Log `source=ocr` 🔎 - [ ] Unter dem Treffer steht die **Fundstelle** mit hervorgehobenem Suchwort, und der Name allein hätte nicht gepasst 🔎 - [ ] Die Fundstelle läuft über **zwei Zeilen** und bricht am Ende der zweiten mit „…" ab – nicht mitten in der ersten 🔎 - [ ] Links daneben eine dünne Linie in derselben Farbe wie das hervorgehobene Wort; sie trennt das Zitat vom Ordnernamen darüber 🔎 - [ ] Ein Dokument mit `