Files
nebula/e2e/api.spec.ts
T
florian.wildeandClaude Opus 5 a122187feb
CI / pruefen (push) Canceled after 0s
feat(auth): Durchgang durch Anmeldung und Sitzungen nach OWASP, v1.8.0
Geprueft gegen ASVS und die Cheat Sheets zu Authentication, Session
Management und Password Storage. Das meiste stand schon gut da – Token mit
256 Bit und nur als Hash in der Datenbank, serverseitige Pruefung bei jeder
Anfrage, Sitzungen weg beim Passwortwechsel, TOTP mit Replay-Schutz. Fuenf
Punkte blieben.

**Aufwand beim Hashen.** N war 2^15 bei p=1. Das Password Storage Cheat
Sheet nennt 2^17/p=1 als Minimum und listet als gleichwertig unter anderem
2^16/p=2 und 2^15/p=3 – mit p=1 lag 2^15 unter *allen* zulaessigen
Kombinationen und war rund dreimal billiger zu knacken als vorgesehen. Jetzt
2^17/p=1, gemessen 0,5 s und 128 MiB je Pruefung. Der Speicher ist gedeckelt:
Node rechnet scrypt im libuv-Threadpool, also hoechstens vier parallel.

**Neuhashen bei der Anmeldung.** Ohne das blieben bestehende Passwoerter fuer
immer auf den alten Parametern – anheben laesst er sich nur dort, wo das
Klartextpasswort vorliegt. needsRehash vergleicht den Gesamtaufwand N*r*p,
nicht Feld fuer Feld, damit gleichwertige Kombinationen nicht grundlos neu
gehasht werden.

**Aufzaehlbarkeit.** Drei Zweige beantworteten sich *vor* der Passwort-
pruefung und verrieten damit die Existenz eines Kontos: gesperrt (423),
deaktiviert (403) und "kein Passwort hinterlegt, nimm einen Passkey" (401).
Der einheitliche Text GENERIC war damit wirkungslos. Jetzt wird zuerst das
Passwort geprueft; Klartext gibt es erst danach. Reine Passkey-Konten
verbrennen dieselbe Rechenzeit, damit sich der Fall auch nicht an der Dauer
zeigt. Der Preis ist der verlorene Hinweis "nimm den Passkey" – bewusst
hingenommen.

**Leerlauffrist.** lastSeenAt wurde gefuehrt, aber nie ausgewertet: ein
abgegriffener Cookie galt bis zu 30 Tage, egal ob das Konto benutzt wurde.
ASVS 3.3.2 will beide Grenzen. Acht Stunden, ueber SESSION_IDLE_HOURS
einstellbar. Das Schreibintervall fuer lastSeenAt haengt jetzt an der Frist –
bei fester Stunde haette eine kuerzere Frist sich selbst ausgehebelt.

**Cookies.** SameSite Strict statt Lax, und bei https der Praefix "__Host-".
Der laesst den Browser erzwingen, dass kein Domain-Attribut gesetzt ist –
eine andere Subdomain kann den Cookie dann nicht ueberschreiben. Beides
kostet einmalig eine neue Anmeldung.

Dazu die Passwortpruefung: Wiederholungen, durchlaufende Zeichenfolgen und
Passwoerter, die den Benutzernamen enthalten, werden abgelehnt.
Zusammensetzungsregeln bleiben bewusst aus – ASVS 2.1.9 raet davon ab.

Geprueft: 232 Unit-Tests (davon 12 neue zu Aufwand, Neuhashen und Mustern)
und 30 E2E-Tests, darunter einer, der die Antwort auf ein stillgelegtes
Konto gegen die auf ein erfundenes haelt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:38:05 +02:00

777 lines
30 KiB
TypeScript
Raw Permalink 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.
import { expect, test, type APIRequestContext } from "@playwright/test";
/**
* Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware,
* Authentifizierung, Validierung und Datenbank hindurch. Genau die Dinge,
* die Unit-Tests nicht erreichen.
*
* Die Tests bauen aufeinander auf und laufen in dieser Reihenfolge, weil sie
* sich eine Datenbank teilen (siehe playwright.config.ts, workers: 1).
*/
const ADMIN = { username: "chefin", password: "ein-langes-testpasswort" };
const ZWEITER = { username: "zweiter", password: "noch-ein-langes-passwort" };
async function login(request: APIRequestContext, user: { username: string; password: string }) {
const response = await request.post("/api/auth/login", { data: user });
expect(response.ok(), `Anmeldung als ${user.username}`).toBeTruthy();
}
test.describe.configure({ mode: "serial" });
test("Einrichtung legt das erste Konto an und sperrt sich danach", async ({ request }) => {
const response = await request.post("/api/auth/setup", {
data: { ...ADMIN, mode: "MULTI_USER" },
});
expect(response.status()).toBe(200);
expect((await response.json()).user.role).toBe("ADMIN");
// Ein zweiter Aufruf darf kein weiteres Adminkonto erzeugen.
const wieder = await request.post("/api/auth/setup", {
data: { username: "eindringling", password: "ein-langes-testpasswort" },
});
expect(wieder.status()).toBe(409);
});
test("ohne Anmeldung ist nichts zu holen", async ({ playwright }) => {
const anonym = await playwright.request.newContext({ baseURL: test.info().project.use.baseURL });
for (const pfad of ["/api/projects", "/api/connections", "/api/admin/users"]) {
expect((await anonym.get(pfad)).status(), pfad).toBe(401);
}
await anonym.dispose();
});
test("Fremdes Origin wird abgewiesen", async ({ request }) => {
await login(request, ADMIN);
const response = await request.post("/api/projects", {
data: { name: "Von woanders" },
headers: { Origin: "https://boese.example" },
});
expect(response.status()).toBe(403);
});
test("Content-Type wird erzwungen", async ({ request }) => {
await login(request, ADMIN);
const response = await request.post("/api/projects", {
headers: { "Content-Type": "text/plain" },
data: JSON.stringify({ name: "Umgehung" }),
});
expect(response.status()).toBe(415);
});
test("SSRF: interne Adressen werden geblockt", async ({ request }) => {
await login(request, ADMIN);
for (const ziel of ["http://127.0.0.1:5432", "http://169.254.169.254", "http://0.0.0.0"]) {
const response = await request.post("/api/connections", {
data: { name: `ssrf-${ziel}`, provider: "GITEA", baseUrl: ziel },
});
expect(response.status(), ziel).toBe(422);
}
});
test("Datentrennung zwischen zwei Konten", async ({ request, playwright }) => {
await login(request, ADMIN);
const angelegt = await request.post("/api/projects", {
data: { name: "Geheimprojekt", status: "IN_DEVELOPMENT" },
});
expect(angelegt.status()).toBe(201);
const projektId = (await angelegt.json()).project.id;
const aufgabe = await request.post(`/api/projects/${projektId}/tasks`, {
data: { title: "Nicht fuer fremde Augen" },
});
expect(aufgabe.status()).toBe(201);
const aufgabenId = (await aufgabe.json()).task.id;
// Zweites Konto anlegen und in einem eigenen Kontext anmelden.
const neu = await request.post("/api/admin/users", {
data: { ...ZWEITER, role: "USER" },
});
expect(neu.status()).toBe(201);
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await (await fremd.get("/api/projects")).json()).projects).toHaveLength(0);
// Fremde IDs muessen 404 liefern, nicht 403 – sonst waere die Existenz
// des Datensatzes bestaetigt.
expect((await fremd.get(`/api/projects/${projektId}`)).status()).toBe(404);
expect(
(await fremd.patch(`/api/projects/${projektId}`, { data: { name: "uebernommen" } })).status(),
).toBe(404);
expect((await fremd.delete(`/api/projects/${projektId}`)).status()).toBe(404);
expect((await fremd.get(`/api/projects/${projektId}/tasks`)).status()).toBe(404);
expect((await fremd.patch(`/api/tasks/${aufgabenId}`, { data: { title: "x" } })).status()).toBe(
404,
);
// Adminwege bleiben zu.
expect((await fremd.get("/api/admin/users")).status()).toBe(403);
expect(
(await fremd.patch("/api/admin/settings", { data: { mode: "SINGLE_USER" } })).status(),
).toBe(403);
// Und das Original ist unveraendert.
const danach = await (await request.get(`/api/projects/${projektId}`)).json();
expect(danach.project.name).toBe("Geheimprojekt");
expect(danach.project.tasks).toHaveLength(1);
await fremd.dispose();
});
test("Aufgaben: Reihenfolge, abgeleiteter Fortschritt, fremde IDs", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Aufgabenprojekt" } })).json()
).project;
const titel = ["eins", "zwei", "drei"];
const ids: string[] = [];
for (const title of titel) {
const response = await request.post(`/api/projects/${projekt.id}/tasks`, {
data: { title, status: "TODO" },
});
ids.push((await response.json()).task.id);
}
// Neue Aufgaben haengen sich hinten an.
const angelegt = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(angelegt.map((task: { position: number }) => task.position)).toEqual([0, 1, 2]);
// Umdrehen.
const gedreht = [...ids].reverse();
const reorder = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { TODO: gedreht } },
});
expect(reorder.ok()).toBeTruthy();
const nachher = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(nachher.map((task: { title: string }) => task.title)).toEqual(["drei", "zwei", "eins"]);
// Eine nach DONE – Fortschritt folgt, sobald eingeschaltet.
await request.patch(`/api/projects/${projekt.id}`, { data: { progressFromTasks: true } });
await request.patch(`/api/tasks/${ids[0]}`, { data: { status: "DONE" } });
const mitFortschritt = (
await (await request.get(`/api/projects/${projekt.id}`)).json()
).project;
expect(mitFortschritt.progress).toBe(33);
// Eine ID aus einem anderen Projekt darf nicht verschoben werden.
const anderes = (
await (await request.post("/api/projects", { data: { name: "Anderes" } })).json()
).project;
const fremdeAufgabe = (
await (
await request.post(`/api/projects/${anderes.id}/tasks`, { data: { title: "fremd" } })
).json()
).task;
const untergeschoben = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { DOING: [fremdeAufgabe.id] } },
});
const ergebnis = await untergeschoben.json();
expect(ergebnis.updated).toBe(0);
expect(ergebnis.ignored).toBe(1);
const unveraendert = (
await (await request.get(`/api/projects/${anderes.id}/tasks`)).json()
).tasks[0];
expect(unveraendert.status).toBe("TODO");
});
test("Issues als Aufgaben uebernehmen, ohne Dubletten", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Issue-Projekt" } })).json()
).project;
// 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("Die Anmeldung verraet nicht, welche Konten es gibt", async ({ request, playwright }) => {
await login(request, ADMIN);
// Ein Konto anlegen und stilllegen.
const angelegt = await request.post("/api/admin/users", {
data: {
username: "stillgelegt",
password: "ein-ausreichend-langes-passwort",
role: "USER",
},
});
expect(angelegt.status()).toBe(201);
const id = (await angelegt.json()).user.id;
expect((await request.patch(`/api/admin/users/${id}`, { data: { isActive: false } })).ok())
.toBeTruthy();
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Falsches Passwort auf dem stillgelegten Konto ...
const stillgelegt = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "falsches-passwort-hier" },
});
// ... und auf einem Konto, das es gar nicht gibt.
const erfunden = await anonym.post("/api/auth/login", {
data: { username: "gibtesnicht", password: "falsches-passwort-hier" },
});
// Beides muss ununterscheidbar sein – sonst laesst sich durchprobieren,
// welche Benutzernamen vergeben sind. Frueher kam hier 403 gegen 401.
expect(stillgelegt.status()).toBe(erfunden.status());
expect(stillgelegt.status()).toBe(401);
expect(await stillgelegt.json()).toEqual(await erfunden.json());
// Mit dem richtigen Passwort darf die Anwendung dann Klartext reden.
const mitPasswort = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "ein-ausreichend-langes-passwort" },
});
expect(mitPasswort.status()).toBe(403);
expect((await mitPasswort.json()).error).toMatch(/deaktiviert/);
await anonym.dispose();
await request.delete(`/api/admin/users/${id}`);
});
test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => {
await login(request, ADMIN);
const projekt = (
await (
await request.post("/api/projects", {
data: { name: "Hakenprojekt", repoUrl: "https://github.com/vercel/swr" },
})
).json()
).project;
const repoId = projekt.repositories[0].id;
// Ohne eingerichteten Webhook ist der Endpunkt stumm – und zwar mit 404,
// damit die ID nicht bestaetigt wird.
const ohne = await request.post(`/api/webhooks/${repoId}`, {
headers: { "Content-Type": "application/json" },
data: { action: "closed", issue: { number: 1 } },
});
expect(ohne.status()).toBe(404);
// Einrichten liefert das Geheimnis genau einmal zurueck.
const eingerichtet = await request.post(`/api/repositories/${repoId}/webhook`);
expect(eingerichtet.status()).toBe(200);
const { secret, url } = await eingerichtet.json();
expect(typeof secret).toBe("string");
expect(secret.length).toBeGreaterThanOrEqual(64);
expect(url).toContain(`/api/webhooks/${repoId}`);
// Und danach nirgends mehr: weder im Projekt noch am Repository.
const geladen = await (await request.get(`/api/projects/${projekt.id}`)).json();
expect(JSON.stringify(geladen)).not.toContain(secret);
expect(geladen.project.repositories[0].webhookSecret).toBeUndefined();
// Eine Aufgabe, die an einem Issue haengt – von Hand gesetzt gibt es
// nicht, also ueber die Uebernahme aus dem Snapshot. Stattdessen prueft
// dieser Test die Grenze selbst: ohne gueltige Signatur passiert nichts.
const rumpf = JSON.stringify({ action: "closed", issue: { number: 4242 } });
const falsch = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": "sha256=00",
},
data: rumpf,
});
expect(falsch.status()).toBe(401);
const gar_keine = await request.post(`/api/webhooks/${repoId}`, {
headers: { "Content-Type": "application/json", "X-GitHub-Event": "issues" },
data: rumpf,
});
expect(gar_keine.status()).toBe(401);
// Mit richtiger Signatur wird der Aufruf angenommen. Zu Issue 4242 gibt
// es keine Aufgabe – erwartet ist also "angenommen, nichts zugeordnet".
const { createHmac } = await import("node:crypto");
const signatur = createHmac("sha256", secret).update(rumpf, "utf8").digest("hex");
const richtig = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": `sha256=${signatur}`,
},
data: rumpf,
});
expect(richtig.status()).toBe(200);
const antwort = await richtig.json();
expect(antwort).toMatchObject({ ok: true, handled: "issue", matched: false });
// Dieselbe Signatur zu einem anderen Rumpf gilt nicht.
const veraendert = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": `sha256=${signatur}`,
},
data: JSON.stringify({ action: "closed", issue: { number: 9999 } }),
});
expect(veraendert.status()).toBe(401);
// Fremde Konten koennen den Haken weder einrichten noch zuruecknehmen.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await fremd.post(`/api/repositories/${repoId}/webhook`)).status()).toBe(404);
expect((await fremd.delete(`/api/repositories/${repoId}/webhook`)).status()).toBe(404);
await fremd.dispose();
// Zuruecknehmen schliesst den Endpunkt wieder.
expect((await request.delete(`/api/repositories/${repoId}/webhook`)).ok()).toBeTruthy();
const danach = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": `sha256=${signatur}`,
},
data: rumpf,
});
expect(danach.status()).toBe(404);
});
test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => {
await login(request, ADMIN);
const angelegt = [];
for (const name of ["Brett A", "Brett B", "Brett C"]) {
const response = await request.post("/api/projects", { data: { name, status: "IDEA" } });
angelegt.push((await response.json()).project);
}
// Neue Projekte haengen sich hinten an ihre Spalte an – fortlaufend, nicht
// alle auf denselben Platz. Absolut zaehlen laesst sich nicht: in der
// Spalte stehen schon Projekte aus frueheren Tests.
const [a, b, c] = angelegt.map((p) => p.position);
expect([b, c]).toEqual([a + 1, a + 2]);
const ids = angelegt.map((p) => p.id);
const vorher = angelegt[0].updatedAt;
// Umdrehen und dabei eines in eine andere Spalte schieben.
const gedreht = [ids[2], ids[1]];
const reorder = await request.patch("/api/projects/reorder", {
data: { columns: { IDEA: gedreht, IN_DEVELOPMENT: [ids[0]] } },
});
expect(reorder.ok()).toBeTruthy();
const alle = (await (await request.get("/api/projects")).json()).projects;
const finde = (id: string) => alle.find((p: { id: string }) => p.id === id);
expect(finde(ids[2]).position).toBe(0);
expect(finde(ids[1]).position).toBe(1);
expect(finde(ids[0]).status).toBe("IN_DEVELOPMENT");
// Reines Umsortieren darf das Aenderungsdatum nicht anfassen – sonst
// schoebe ein Aufraeumen im Brett alles in "Zuletzt geaendert" nach oben.
expect(new Date(finde(ids[1]).updatedAt).getTime()).toBe(
new Date(angelegt[1].updatedAt).getTime(),
);
// Das verschobene dagegen hat sich wirklich geaendert.
expect(new Date(finde(ids[0]).updatedAt).getTime()).toBeGreaterThan(
new Date(vorher).getTime(),
);
// Mehrfachauswahl: Tags ergaenzen und wieder entfernen.
const ergaenzt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["Sammel", "test"], tagMode: "add" },
});
expect((await ergaenzt.json()).changed).toBe(3);
const mitTags = (await (await request.get("/api/projects")).json()).projects;
// Kleingeschrieben abgelegt, wie ueberall.
expect(mitTags.find((p: { id: string }) => p.id === ids[0]).tags).toContain("sammel");
const entfernt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["sammel"], tagMode: "remove" },
});
expect((await entfernt.json()).changed).toBe(3);
// Status fuer mehrere: die Verschobenen landen hintereinander, nicht
// uebereinander auf derselben Position.
const verschoben = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await verschoben.json()).changed).toBe(3);
const geplant = (await (await request.get("/api/projects?status=PLANNING")).json()).projects;
const positionen = geplant.map((p: { position: number }) => p.position).sort();
expect(new Set(positionen).size).toBe(positionen.length);
// Wer schon so steht, wird nicht noch einmal angefasst.
const nochmal = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await nochmal.json()).changed).toBe(0);
// Fremde IDs fallen still unter den Tisch, statt etwas anzurichten.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
const untergeschoben = await fremd.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await untergeschoben.json()).changed).toBe(0);
const uebrig = (await (await request.get("/api/projects")).json()).projects;
expect(uebrig.filter((p: { id: string }) => ids.includes(p.id))).toHaveLength(3);
const fremdSortieren = await fremd.patch("/api/projects/reorder", {
data: { columns: { DONE: ids } },
});
expect((await fremdSortieren.json()).updated).toBe(0);
await fremd.dispose();
// Und zum Schluss die eigenen wirklich loeschen.
const geloescht = await request.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await geloescht.json()).changed).toBe(3);
});
test("Wiederkehrende Aufgaben legen die naechste Fassung an", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Wiederkehrend" } })).json()
).project;
const woechentlich = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, {
data: {
title: "Abhaengigkeiten pruefen",
dueDate: "2026-07-06T12:00:00",
recurrence: "WEEKLY",
labels: ["wartung"],
},
})
).json()
).task;
// Ein Zwischenschritt loest noch nichts aus.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { status: "DOING" } });
let alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(1);
// Erst der Uebergang nach ERLEDIGT.
const erledigt = await request.patch(`/api/tasks/${woechentlich.id}`, {
data: { status: "DONE" },
});
expect((await erledigt.json()).next).not.toBeNull();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
const folge = alle.find((task: { id: string }) => task.id !== woechentlich.id);
expect(folge.status).toBe("TODO");
expect(folge.title).toBe("Abhaengigkeiten pruefen");
expect(folge.labels).toEqual(["wartung"]);
expect(folge.recurrence).toBe("WEEKLY");
expect(folge.recurredFrom).toBe(woechentlich.id);
// Der Termin liegt in der Zukunft und auf demselben Wochentag.
expect(new Date(folge.dueDate).getTime()).toBeGreaterThan(Date.now());
expect(new Date(folge.dueDate).getUTCDay()).toBe(1);
// Erneut speichern erzeugt keine dritte.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { title: "Umbenannt" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
// Der zweite Weg: durch Ziehen in die Spalte ERLEDIGT.
const gezogen = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { DONE: [folge.id] } },
});
expect(gezogen.ok()).toBeTruthy();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(3);
expect(alle.filter((task: { status: string }) => task.status === "TODO")).toHaveLength(1);
// Eine einmalige Aufgabe bleibt einmalig.
const einmalig = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, { data: { title: "Nur einmal" } })
).json()
).task;
await request.patch(`/api/tasks/${einmalig.id}`, { data: { status: "DONE" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(4);
});
test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, playwright }) => {
await login(request, ADMIN);
// Ein Projekt herrichten, das als Vorlage taugt.
const quelle = (
await (
await request.post("/api/projects", {
data: { name: "Vorlagenquelle", status: "PLANNING", priority: 3, tags: ["web", "docker"] },
})
).json()
).project;
await request.post(`/api/projects/${quelle.id}/notes`, {
data: { content: "Checkliste vor dem Start." },
});
const erledigt = (
await (
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Schon fertig" } })
).json()
).task;
await request.patch(`/api/tasks/${erledigt.id}`, { data: { status: "DONE" } });
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Repo anlegen" } });
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "CI einrichten" } });
const gesichert = await request.post("/api/templates/from-project", {
data: { projectId: quelle.id, name: "Web-App", description: "Mit Docker und CI" },
});
expect(gesichert.status()).toBe(201);
const vorlage = (await gesichert.json()).template;
expect(vorlage.tags).toEqual(["web", "docker"]);
expect(vorlage.status).toBe("PLANNING");
expect(vorlage.notes).toHaveLength(1);
// Erledigte Aufgaben kommen nicht mit: eine Vorlage beschreibt, was zu tun ist.
expect(vorlage.tasks).toHaveLength(2);
expect(vorlage.tasks.map((task: { title: string }) => task.title)).not.toContain("Schon fertig");
// Derselbe Name ein zweites Mal wird abgewiesen.
const doppelt = await request.post("/api/templates/from-project", {
data: { projectId: quelle.id, name: "Web-App" },
});
expect(doppelt.status()).toBe(409);
// Anwenden: Notizen und Aufgaben entstehen mit dem Projekt.
const neu = (
await (
await request.post("/api/projects", {
data: { name: "Aus Vorlage", status: "IDEA", templateId: vorlage.id },
})
).json()
).project;
const geladen = (await (await request.get(`/api/projects/${neu.id}`)).json()).project;
expect(geladen.notes).toHaveLength(1);
expect(geladen.tasks).toHaveLength(2);
// Die Reihenfolge der Vorlage bleibt erhalten.
expect(geladen.tasks.map((task: { title: string }) => task.title)).toEqual([
"Repo anlegen",
"CI einrichten",
]);
// Der ausdrueckliche Status gewinnt gegen den der Vorlage.
expect(geladen.status).toBe("IDEA");
// Der Verlauf haelt die Herkunft fest.
const verlauf = (await (await request.get(`/api/projects/${neu.id}/activity`)).json()).activities;
expect(verlauf.some((entry: { summary: string }) => entry.summary.includes("Web-App"))).toBe(true);
// Eine fremde Vorlage laesst sich nicht unterschieben.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await (await fremd.get("/api/templates")).json()).templates).toHaveLength(0);
const untergeschoben = await fremd.post("/api/projects", {
data: { name: "Klau", templateId: vorlage.id },
});
expect(untergeschoben.status()).toBe(404);
expect((await fremd.delete(`/api/templates/${vorlage.id}`)).status()).toBe(404);
await fremd.dispose();
// Und die Vorlage ueberlebt das Loeschen ihres Ursprungsprojekts.
await request.delete(`/api/projects/${quelle.id}`);
const nochDa = (await (await request.get("/api/templates")).json()).templates;
expect(nochDa).toHaveLength(1);
expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy();
});
test("Der Bildoptimierer ist zu", async ({ playwright }) => {
// /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer
// das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image
// nirgends – der Endpunkt darf ohne Anmeldung nicht antworten.
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
maxRedirects: 0,
});
const antwort = await anonym.get("/_next/image?url=%2Ficon-192.png&w=64&q=75");
expect(antwort.status(), "kein 200 ohne Anmeldung").not.toBe(200);
await anonym.dispose();
});
test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => {
await login(request, ADMIN);
await request.post("/api/connections", {
data: {
name: "Mit Token",
provider: "GITEA",
baseUrl: "https://gitea.example.com",
token: "streng-geheimes-token",
},
});
const roh = await (await request.get("/api/projects/export")).text();
expect(roh).not.toContain("streng-geheimes-token");
expect(roh).not.toContain("tokenCipher");
expect(roh).not.toContain("ownerId");
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 },
});
expect((await uebersprungen.json()).summary.skipped).toBe(vorher);
// Zusaetzlich anlegen verdoppelt.
const zusaetzlich = await request.post("/api/projects/import", {
data: { data: datei, strategy: "rename", dryRun: false },
});
expect((await zusaetzlich.json()).summary.renamed).toBe(vorher);
const jetzt = (await (await request.get("/api/projects")).json()).projects;
expect(jetzt).toHaveLength(vorher * 2);
// Muell wird abgewiesen.
const muell = await request.post("/api/projects/import", {
data: { data: { irgendwas: true }, strategy: "skip", dryRun: true },
});
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);
});