From 5253b0a317dc000eae572f3584e9a20d4af19174 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 13:24:08 +0200 Subject: [PATCH] test: mehrere Repositories abdecken, Doku, Version 1.0.0 Drei neue Ende-zu-Ende-Tests: das Verwalten mehrerer Repositories samt Datentrennung, das Lesen von Exportdateien der Version 1, und die Uebernahme ohne verknuepftes Repository. Dabei fiel auf, dass der Uebernahme-Endpunkt ohne Repository mit 404 antwortete, obwohl gar keine Repository-ID angefragt war. Wer nichts angibt, hat schlicht noch keines verknuepft - das ist kein "nicht gefunden", sondern 409 mit dem Hinweis, eines zu hinterlegen. Version 1.0.0: der Funktionsumfang ist beisammen. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 33 ++++++++++++- FEATURES.md | 49 ++++++++++++++++--- README.md | 9 +++- ROADMAP.md | 14 +----- e2e/api.spec.ts | 122 +++++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- 6 files changed, 206 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 35a158b..cf44f35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,15 +69,31 @@ SSRF-Schutz (`lib/security/ssrf.ts`). `prisma migrate dev` ist hier nicht benutzbar (interaktiv). Stattdessen: ```bash -npx prisma migrate diff --from-schema-datasource prisma/schema.prisma \ - --to-schema-datamodel prisma/schema.prisma --script --output prisma/migrations/_/migration.sql +docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_shadow" +npx prisma migrate diff --from-migrations prisma/migrations \ + --to-schema-datamodel prisma/schema.prisma \ + --shadow-database-url postgresql://nebula:nebula@localhost:5432/nebula_shadow \ + --script --output prisma/migrations/_/migration.sql npx prisma migrate deploy ``` +**`--from-migrations`, nicht `--from-schema-datasource`.** Letzteres +vergleicht mit der *laufenden* Datenbank. Ist eine eben erzeugte Migration +dort noch nicht eingespielt, steht ihr Inhalt in der nächsten gleich noch +einmal. + **Die erzeugte SQL vor dem Anwenden lesen.** Der Volltextindex `Note_fulltext_idx` ist ein Ausdrucksindex, den Prisma nicht kennt (er wird bislang in Ruhe gelassen, aber das ist nicht garantiert). +**Datenmigrationen von Hand schreiben.** `migrate diff` kennt nur den +Zielzustand: Spalten, die woandershin wandern, würde es ersatzlos löschen. +Vorgehen wie in `20260726230000_repositories`: neue Tabelle anlegen, Bestand +per `INSERT … SELECT` übernehmen, dann erst die alten Spalten entfernen. +Danach `migrate diff` erneut laufen lassen — kommt „empty migration“ heraus, +stimmt das Ergebnis mit dem Schema überein. Und gegen eine Kopie mit echten +Daten prüfen, nicht nur gegen eine leere Datenbank. + ## Prüfen, nicht behaupten Vor „fertig": `npx tsc --noEmit`, `npm test`, `npm run build`. @@ -86,6 +102,19 @@ Bei Änderungen an der Oberfläche **hinsehen**. Chrome ist installiert, ein Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots. Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen. +Wer den Standalone-Bau von Hand startet (`node .next/standalone/server.js`), +muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein +JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden +sich nativ ab. Das sieht aus wie ein Anwendungsfehler, ist aber keiner: + +```bash +cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/ +``` + +`pkill` gibt es in dieser Git-Bash nicht. Prozesse über PowerShell beenden +(`Get-CimInstance Win32_Process … | Stop-Process`) — sonst läuft der alte +Server weiter, hält die Prisma-DLL und blockiert den nächsten Bau. + **Niemals gegen die echte Datenbank testen.** Eine Wegwerf-Datenbank anlegen und danach löschen: diff --git a/FEATURES.md b/FEATURES.md index c3dfd96..398819d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -27,6 +27,14 @@ sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft ge - **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig. Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar. +- **Zwei-Faktor per TOTP.** Zeitbasiertes Einmalkennwort zusätzlich zum Passwort, für jede + gängige Authenticator-App. Einrichtung über QR-Code oder abgetippten Schlüssel; scharf + wird er erst nach einem bestätigten Code, damit sich niemand aussperrt, der den Scan + nicht hinbekommt. Dazu zehn **Wiederherstellungscodes**, jeder genau einmal gültig — + sie werden nur bei der Ausgabe angezeigt. Abschalten und Neuerzeugen verlangen das + Passwort, nicht bloß eine offene Sitzung. +- Die Passkey-Anmeldung fragt **nicht** nach dem zweiten Faktor: der Passkey ist bereits an + ein Gerät gebunden und damit selbst der zweite Faktor. - Ein Konto kann **ausschließlich** per Passkey nutzbar sein. Der letzte Passkey lässt sich dann nicht entfernen, solange kein Passwort gesetzt ist. - Fehlversuche werden gezählt; nach acht sperrt sich das Konto für 15 Minuten. @@ -35,9 +43,9 @@ sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft ge ### 0.3 Mein Konto -Anzeigename und E-Mail ändern · Passwort setzen oder wechseln · Passkeys verwalten · -aktive Sitzungen einsehen (Browser, Betriebssystem, IP, letzte Aktivität) · überall -abmelden, wobei das eigene Gerät angemeldet bleibt. +Anzeigename und E-Mail ändern · Passwort setzen oder wechseln · Zwei-Faktor einrichten und +abschalten · Passkeys verwalten · aktive Sitzungen einsehen (Browser, Betriebssystem, IP, +letzte Aktivität) · überall abmelden, wobei das eigene Gerät angemeldet bleibt. ### 0.4 Benutzerverwaltung (Administratoren) @@ -215,6 +223,31 @@ statt verschoben. ## 4. Git-Anbindung +### 4.0 Mehrere Repositories je Projekt + +Ein Vorhaben besteht oft aus mehreren Repositories — Frontend und Backend etwa, oder +App und Infrastruktur. Sie gehören trotzdem in **eine** Karte. + +- Im Git-Panel steht eine Leiste mit allen Repositories des Projekts, sobald es mehr als + eines gibt. Jedes hat eigene Kennzahlen, Listen, CI und eigenen Abgleich; „Alle" gleicht + sie nacheinander ab — nicht gleichzeitig, denn ein Schwung Anfragen an denselben Anbieter + ist genau das, worauf Rate-Limits reagieren. +- Eine frei wählbare **Bezeichnung** („Frontend") tritt an die Stelle von `owner/name`. +- Das erste Repository ist das **führende**. Wo nur eines dargestellt werden kann — auf der + Karte im Dashboard, im Kanban —, ist es dieses. Die Karte zeigt daneben `+2 Repos`. + Ein Abgleichsfehler an *irgendeinem* Repository erscheint auf der Karte, sonst bliebe er + unbemerkt. +- **Stillstand** bemisst sich am jüngsten Commit über alle Repositories: an einem Vorhaben + wird gearbeitet, sobald an einem seiner Teile gearbeitet wird. +- Benachrichtigungen merken sich den gemeldeten Zustand **je Repository**. Sonst verdeckte + ein rotes Frontend die rote Pipeline des Backends. Der Betreff nennt das Repository nur, + wenn es nicht ohnehin so heißt wie das Projekt. +- Übernommene Issues merken sich, aus welchem Repository sie stammen — Frontend und Backend + haben beide ein Issue Nummer 5, und das sind zwei Aufgaben. + +Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das +Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste. + ### 4.1 Unterstützte Systeme | System | API | Authentifizierung | Besonderheit | @@ -441,13 +474,17 @@ letzten Zustellung stehen an der Karte. | `Session` | Serverseitige Sitzungen (nur der Token-Hash) | | `Credential` | Passkeys inkl. öffentlichem Schlüssel und Signaturzähler | | `WebAuthnChallenge` | Kurzlebige, einmalig verwendbare Challenges | +| `RecoveryCode` | Wiederherstellungscodes für die Zwei-Faktor-Anmeldung (nur der Hash) | +| `PendingLogin` | Zwischenzustand zwischen Passwort und zweitem Faktor, fünf Minuten gültig | | `AppSettings` | Betriebsart und Registrierung (eine Zeile) | -| `Project` | Projektstammdaten, Status, Fortschritt, Repo-Verknüpfung, Besitzer | -| `Task` | Aufgaben mit Spalte, Position, Fälligkeit und Labels | +| `Project` | Projektstammdaten, Status, Fortschritt, Besitzer | +| `Repository` | Repositories eines Projekts inkl. Abgleichstand (n:1 zu Projekt) | +| `Task` | Aufgaben mit Spalte, Position, Fälligkeit, Labels und Issue-Herkunft | +| `Activity` | Verlauf je Projekt, neueste zuerst, auf 200 Einträge begrenzt | | `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis | | `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) | | `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer | -| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Projekt) | +| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Repository) | `Project.ownerId` und `GitConnection.ownerId` sind bewusst optional: so überleben Daten aus der Zeit vor der Benutzerverwaltung die Migration und werden beim Anlegen des ersten Kontos diff --git a/README.md b/README.md index 4845193..1653160 100644 --- a/README.md +++ b/README.md @@ -324,12 +324,15 @@ ausschließlich Daten des angemeldeten Kontos. | `GET` | `/api/health` | Status inkl. Datenbankverbindung (Docker-Healthcheck) | | `POST` | `/api/auth/setup` | Administratorkonto anlegen (nur beim ersten Mal) | | `POST` | `/api/auth/login` `/api/auth/logout` `/api/auth/register` | Anmeldung | +| `POST` | `/api/auth/mfa` | Zweiter Faktor: Einmalkennwort oder Wiederherstellungscode | | `GET` | `/api/auth/session` | Aktuelle Sitzung und Instanzzustand | | `POST` | `/api/auth/passkey/{login,register}/{options,verify}` | WebAuthn | | `PATCH` | `/api/account/profile` | Eigenes Profil | | `POST` | `/api/account/password` | Eigenes Passwort setzen | | `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden | | `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen | +| `GET` `POST` `PUT` `DELETE` | `/api/account/totp` | Zwei-Faktor: Zustand, einrichten, aktivieren, abschalten | +| `POST` | `/api/account/totp/recovery` | Neue Wiederherstellungscodes | | `GET` `PATCH` | `/api/admin/settings` | Betriebsart, Registrierung, Abgleich (Admin) | | `POST` | `/api/admin/sync/run` | Abgleich sofort anstoßen (Admin) | | `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) | @@ -344,7 +347,11 @@ ausschließlich Daten des angemeldeten Kontos. | `PATCH` `DELETE` | `/api/notifications/:id` | Ziel ändern / entfernen | | `POST` | `/api/notifications/:id/test` | Testnachricht senden | | `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen | -| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen | +| `POST` | `/api/projects/:id/sync` | Alle Repositories des Projekts abgleichen | +| `GET` | `/api/projects/:id/activity` | Verlauf des Projekts (`?limit=`) | +| `GET` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts | +| `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen | +| `POST` | `/api/repositories/:id/sync` | Ein einzelnes Repository abgleichen | | `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts | | `PATCH` | `/api/projects/:id/tasks/reorder` | Neue Reihenfolge nach dem Ziehen | | `POST` | `/api/projects/:id/tasks/from-issues` | Offene Issues als Aufgaben übernehmen | diff --git a/ROADMAP.md b/ROADMAP.md index 7819129..88a1ee4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,17 +37,7 @@ Frei sortieren hieße, eine Position je Projekt zu speichern. ## 3 · Größere Brocken -### 3.1 Mehrere Repositories je Projekt `M` - -Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischen­tabelle -und Anpassungen in der Anzeige. - -### 3.2 Zwei-Faktor per TOTP `M` - -Als Alternative zum Passkey — nützlich, wenn die Instanz aus dem Internet -erreichbar ist und nicht jedes Gerät Passkeys kann. - -### 3.3 Passwort-Zurücksetzen per E-Mail `M` +### 3.1 Passwort-Zurücksetzen per E-Mail `M` Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb. @@ -65,7 +55,6 @@ Voraussetzung, falls du das Projekt veröffentlichen willst. | --- | --- | --- | | Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste | | Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal | -| Aktivitätsverlauf je Projekt | `S` | Wer hat wann was geändert | | Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" | | Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen | | Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel | @@ -111,6 +100,7 @@ Damit klar ist, was **nicht** kommt und warum: | **v0.7.0** Benachrichtigungen über ntfy, Webhook und E-Mail | ✓ | | **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ | | **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ | +| **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ | Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 77880e5..598b4c6 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -191,11 +191,92 @@ test("Issues als Aufgaben uebernehmen, ohne Dubletten", async ({ request }) => { await (await request.post("/api/projects", { data: { name: "Issue-Projekt" } })).json() ).project; - // Ohne Repo-Stand gibt es nichts zu uebernehmen – und das sagt der Server auch. + // Ohne verknuepftes Repository gibt es nichts zu uebernehmen. + const ohneRepo = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, { + data: {}, + }); + expect(ohneRepo.status()).toBe(409); + + // Mit Repository, aber ohne geholten Stand ebenfalls nicht – der Server + // laedt hier bewusst nichts nach. + await request.patch(`/api/projects/${projekt.id}`, { + data: { repoUrl: "https://github.com/vercel/swr" }, + }); const ohneStand = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, { data: {}, }); expect(ohneStand.status()).toBe(409); + + // Nach einem bestimmten, das es nicht gibt, ist dagegen 404 richtig. + const fremdesRepo = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, { + data: { repositoryId: "gibt-es-nicht" }, + }); + expect(fremdesRepo.status()).toBe(404); +}); + +test("Mehrere Repositories je Projekt", async ({ request, playwright }) => { + await login(request, ADMIN); + + // Beim Anlegen setzen die Git-Felder das fuehrende Repository. + const projekt = ( + await ( + await request.post("/api/projects", { + data: { name: "Zwei Teile", repoUrl: "https://github.com/vercel/swr" }, + }) + ).json() + ).project; + + expect(projekt.repositories).toHaveLength(1); + expect(projekt.repositories[0].repoOwner).toBe("vercel"); + expect(projekt.repositories[0].repoName).toBe("swr"); + + // Ein zweites dazu. + const zweites = await request.post(`/api/projects/${projekt.id}/repositories`, { + data: { label: "Backend", repoUrl: "https://github.com/vercel/next.js" }, + }); + expect(zweites.status()).toBe(201); + const backendId = (await zweites.json()).repository.id; + + // Dasselbe Repo zweimal ist immer ein Versehen. + const doppelt = await request.post(`/api/projects/${projekt.id}/repositories`, { + data: { repoUrl: "https://github.com/vercel/next.js" }, + }); + expect(doppelt.status()).toBe(409); + + // Halbe Angaben ergeben kein Repository. + const leer = await request.post(`/api/projects/${projekt.id}/repositories`, { + data: { label: "Nur ein Name" }, + }); + expect(leer.status()).toBe(422); + + const geladen = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project; + expect(geladen.repositories).toHaveLength(2); + // Das fuehrende steht vorn. + expect(geladen.repositories[0].repoName).toBe("swr"); + expect(geladen.repositories[1].label).toBe("Backend"); + + // Der Projekt-PATCH bearbeitet weiterhin nur das fuehrende. + await request.patch(`/api/projects/${projekt.id}`, { data: { autoSync: false } }); + const danach = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project; + expect(danach.repositories[0].autoSync).toBe(false); + expect(danach.repositories[1].autoSync).toBe(true); + + // Fremde Konten kommen an kein Repository. + const fremd = await playwright.request.newContext({ + baseURL: test.info().project.use.baseURL, + }); + await login(fremd, ZWEITER); + expect((await fremd.get(`/api/projects/${projekt.id}/repositories`)).status()).toBe(404); + expect((await fremd.patch(`/api/repositories/${backendId}`, { data: {} })).status()).toBe(404); + expect((await fremd.delete(`/api/repositories/${backendId}`)).status()).toBe(404); + expect((await fremd.post(`/api/repositories/${backendId}/sync`)).status()).toBe(404); + await fremd.dispose(); + + // Entfernen laesst das andere stehen. + expect((await request.delete(`/api/repositories/${backendId}`)).ok()).toBeTruthy(); + const zumSchluss = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project; + expect(zumSchluss.repositories).toHaveLength(1); + expect(zumSchluss.repositories[0].repoName).toBe("swr"); }); test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => { @@ -218,6 +299,12 @@ test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ( const datei = JSON.parse(roh); const vorher = datei.projects.length; + // Version 2: die Repositories stehen als Liste in der Datei. + expect(datei.version).toBe(2); + const mitRepo = datei.projects.find((entry: { name: string }) => entry.name === "Zwei Teile"); + expect(mitRepo.repositories).toHaveLength(1); + expect(mitRepo.repositories[0].repoName).toBe("swr"); + // Ueberspringen aendert nichts. const uebersprungen = await request.post("/api/projects/import", { data: { data: datei, strategy: "skip", dryRun: false }, @@ -239,3 +326,36 @@ test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ( }); expect(muell.status()).toBe(422); }); + +test("Dateien der Version 1 bleiben lesbar", async ({ request }) => { + await login(request, ADMIN); + + // Damals stand genau ein Repository flach am Projekt. Wer eine solche + // Sicherung liegen hat, soll sie weiterhin einspielen koennen. + const alt = { + kind: "nebula.projects", + version: 1, + projects: [ + { + name: "Aus Version eins", + repoUrl: "https://github.com/vercel/swr", + repoOwner: "vercel", + repoName: "swr", + autoSync: false, + }, + ], + }; + + const gelesen = await request.post("/api/projects/import", { + data: { data: alt, strategy: "rename", dryRun: false }, + }); + expect(gelesen.ok()).toBeTruthy(); + + const projekte = (await (await request.get("/api/projects")).json()).projects; + const uebernommen = projekte.find( + (entry: { name: string }) => entry.name === "Aus Version eins", + ); + expect(uebernommen.repositories).toHaveLength(1); + expect(uebernommen.repositories[0].repoOwner).toBe("vercel"); + expect(uebernommen.repositories[0].autoSync).toBe(false); +}); diff --git a/package.json b/package.json index ec7229b..a7c81da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nebula", - "version": "0.9.0", + "version": "1.0.0", "private": true, "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "scripts": {