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); });