CI / pruefen (push) Canceled after 0s
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>
777 lines
30 KiB
TypeScript
777 lines
30 KiB
TypeScript
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);
|
||
});
|