CI / pruefen (pull_request) Canceled after 0s
Zwei Luecken, die im Issue zusammen stehen. **Im Aufgabendashboard fehlte ein Suchfeld.** Es gab Filter nach Status und Projekt, aber keine Moeglichkeit, nach einem Wort zu suchen. Das Suchfeld deckt Titel, Beschreibung, Labels und Projektname ab. Gefiltert wird im Browser - die Aufgaben liegen dort ohnehin alle vor, es braucht also keine zusaetzliche Abfrage und es gibt keine Verzoegerung beim Tippen. Wer sucht, sucht dabei auch in Erledigtem: sonst faende man die Aufgabe von letzter Woche nicht wieder, obwohl man genau sie meint. Der Schalter "Erledigte einblenden" gilt nur ohne Suchbegriff. **Die Volltextsuche kannte nur Notizen.** Task ist jetzt ebenfalls dabei, nach demselben Muster: deutsche Textsuche, ts_headline fuer den Ausschnitt, Hervorhebung als [[…]] statt als HTML. Deutsche Stammformen greifen damit auch hier - "Rechnung" findet "Rechnungen". Dazu ein Ausdrucksindex Task_fulltext_idx wie Note_fulltext_idx. Der Ausdruck muss Zeichen fuer Zeichen dem in der Abfrage entsprechen, sonst greift er nicht; nachgeprueft mit EXPLAIN, der Plan zeigt einen Bitmap Index Scan darauf. "prisma migrate diff" laesst ihn in Ruhe - Gegenprobe kommt leer heraus. Das Antwortfeld heisst weiterhin "hits" und meint die Notizen. Umbenennen haette nur Aufrufer gebrochen, ohne etwas zu verbessern; Aufgaben kommen als eigenes Feld dazu. Beide Stellen, die Treffer anzeigen - Projektbrett und Schnellsuche -, zeigen sie jetzt. Geprueft: 261 Unit-Tests, 36 E2E-Tests. Die Volltextsuche zusaetzlich gegen die laufende Anwendung: Treffer in Titel und Beschreibung, Erledigtes inbegriffen, Stammformen greifen, fremde Konten finden nichts. Refs #4
965 lines
37 KiB
TypeScript
965 lines
37 KiB
TypeScript
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||
import { PrismaClient } from "@prisma/client";
|
||
|
||
/**
|
||
* Dieselbe Datenbank wie global-setup.ts. Gebraucht fuer den einen Fall, in
|
||
* dem ein Zustand herzustellen ist, den die API bewusst nicht anbietet.
|
||
*/
|
||
const DATABASE_URL =
|
||
process.env.E2E_DATABASE_URL ??
|
||
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
|
||
|
||
/**
|
||
* 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("Abgleich hakt Aufgaben ab, deren Issue geschlossen wurde", async ({ request }) => {
|
||
await login(request, ADMIN);
|
||
|
||
// Ein oeffentliches Repository mit sicher geschlossenen Issues. Der
|
||
// Abgleich laeuft anonym gegen die GitHub-API – kein Token noetig.
|
||
const projekt = (
|
||
await (
|
||
await request.post("/api/projects", {
|
||
data: { name: "Rueckweg", repoUrl: "https://github.com/vercel/swr" },
|
||
})
|
||
).json()
|
||
).project;
|
||
const repoId = projekt.repositories[0].id;
|
||
|
||
const angelegt = await request.post(`/api/projects/${projekt.id}/tasks`, {
|
||
data: { title: "Haengt an einem geschlossenen Issue" },
|
||
});
|
||
const taskId = (await angelegt.json()).task.id;
|
||
|
||
/**
|
||
* Die Verknuepfung direkt in der Datenbank setzen.
|
||
*
|
||
* Ueber die API gibt es dafuer keinen Weg, und das ist richtig so: "als
|
||
* Issue anlegen" wuerde ein neues Issue in einem fremden Repository
|
||
* erzeugen. Ein Endpunkt nur fuer Tests waere eine Hintertuer im
|
||
* Produktionscode – dafuer greift der Test lieber selbst zur Datenbank.
|
||
*
|
||
* Issue 1 von vercel/swr ist seit Jahren geschlossen.
|
||
*/
|
||
const prisma = new PrismaClient({
|
||
datasources: { db: { url: DATABASE_URL } },
|
||
});
|
||
try {
|
||
await prisma.task.update({
|
||
where: { id: taskId },
|
||
data: { sourceRepositoryId: repoId, sourceIssue: 1 },
|
||
});
|
||
} finally {
|
||
await prisma.$disconnect();
|
||
}
|
||
|
||
const vorher = await (await request.get(`/api/projects/${projekt.id}`)).json();
|
||
const alsAngelegt = vorher.project.tasks.find(
|
||
(t: { id: string }) => t.id === taskId,
|
||
);
|
||
expect(alsAngelegt.sourceIssue).toBe(1);
|
||
expect(alsAngelegt.status).toBe("TODO");
|
||
|
||
// Abgleich anstossen – er fragt den Zustand des Issues nach.
|
||
const sync = await request.post(`/api/repositories/${repoId}/sync`);
|
||
const syncBody = await sync.json();
|
||
|
||
/**
|
||
* Dieser Test greift als einziger wirklich nach draussen. Ist GitHub nicht
|
||
* erreichbar oder das anonyme Rate-Limit ausgeschoepft, sagt er das statt
|
||
* rot zu werden – ein fremder Dienst darf die Testreihe nicht bestimmen.
|
||
*/
|
||
if (!sync.ok()) {
|
||
test.skip(true, `GitHub nicht erreichbar: ${syncBody.message ?? sync.status()}`);
|
||
return;
|
||
}
|
||
|
||
const nachher = await (await request.get(`/api/projects/${projekt.id}`)).json();
|
||
const abgehakt = nachher.project.tasks.find((t: { id: string }) => t.id === taskId);
|
||
expect(abgehakt.status).toBe("DONE");
|
||
expect(abgehakt.doneAt).not.toBeNull();
|
||
|
||
// Und ein zweiter Lauf aendert nichts mehr – abgehakt bleibt abgehakt,
|
||
// ohne dass erneut nachgefragt wird.
|
||
const nochmal = await request.post(`/api/repositories/${repoId}/sync`);
|
||
expect((await nochmal.json()).message ?? "").not.toMatch(/abgehakt/);
|
||
});
|
||
|
||
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("Verlauf blaettert per Cursor, ohne Zeilen zu verlieren", async ({ request, playwright }) => {
|
||
await login(request, ADMIN);
|
||
|
||
// Die bisherigen Tests haben reichlich Verlauf erzeugt. Erst alles auf
|
||
// einmal holen, dann in kleinen Seiten – beides muss dieselbe Menge sein.
|
||
const alles = await (await request.get("/api/activity?limit=100")).json();
|
||
expect(alles.activities.length).toBeGreaterThan(5);
|
||
|
||
const ids: string[] = [];
|
||
let cursor: { vor: string; id: string } | null = null;
|
||
for (let seite = 0; seite < 60; seite += 1) {
|
||
const query = cursor
|
||
? `?limit=3&vor=${encodeURIComponent(cursor.vor)}&id=${encodeURIComponent(cursor.id)}`
|
||
: "?limit=3";
|
||
const antwort = await request.get(`/api/activity${query}`);
|
||
expect(antwort.ok()).toBeTruthy();
|
||
const seiteBody = await antwort.json();
|
||
ids.push(...seiteBody.activities.map((eintrag: { id: string }) => eintrag.id));
|
||
if (!seiteBody.weitere) break;
|
||
cursor = seiteBody.naechster;
|
||
expect(cursor).not.toBeNull();
|
||
}
|
||
|
||
// Der eigentliche Punkt: keine Dubletten und keine Luecken, auch wenn
|
||
// mehrere Eintraege denselben Zeitstempel tragen.
|
||
expect(new Set(ids).size).toBe(ids.length);
|
||
expect(ids.length).toBe(alles.activities.length);
|
||
|
||
// Absteigend sortiert.
|
||
const zeiten: number[] = alles.activities.map((eintrag: { createdAt: string }) =>
|
||
Date.parse(eintrag.createdAt),
|
||
);
|
||
for (let i = 1; i < zeiten.length; i += 1) {
|
||
expect(zeiten[i]).toBeLessThanOrEqual(zeiten[i - 1]);
|
||
}
|
||
|
||
// Unbrauchbare Cursor fangen von vorn an, statt zu scheitern.
|
||
const kaputt = await request.get("/api/activity?vor=keindatum&id=x");
|
||
expect(kaputt.ok()).toBeTruthy();
|
||
expect((await kaputt.json()).activities.length).toBeGreaterThan(0);
|
||
|
||
// Und ein fremdes Konto sieht ausschliesslich seinen eigenen Verlauf.
|
||
const fremd = await playwright.request.newContext({
|
||
baseURL: test.info().project.use.baseURL,
|
||
});
|
||
await login(fremd, ZWEITER);
|
||
const fremderVerlauf = await (await fremd.get("/api/activity")).json();
|
||
const eigeneIds = new Set(ids);
|
||
for (const eintrag of fremderVerlauf.activities) {
|
||
expect(eigeneIds.has(eintrag.id)).toBe(false);
|
||
}
|
||
await fremd.dispose();
|
||
});
|
||
|
||
test("Volltextsuche findet auch Aufgaben", async ({ request, playwright }) => {
|
||
await login(request, ADMIN);
|
||
|
||
const projekt = (
|
||
await (await request.post("/api/projects", { data: { name: "Suchprojekt" } })).json()
|
||
).project;
|
||
|
||
for (const task of [
|
||
{
|
||
title: "Dockerfile aufraeumen",
|
||
description: "Die Schichten im Container neu ordnen, damit der Bau schneller wird.",
|
||
},
|
||
{ title: "Rechnungen exportieren", description: "Monatlicher Export als CSV." },
|
||
{ title: "Erledigtes mit Container", description: "Auch das soll gefunden werden.", status: "DONE" },
|
||
{ title: "Ohne Bezug", description: "Voellig anderer Inhalt." },
|
||
]) {
|
||
expect((await request.post(`/api/projects/${projekt.id}/tasks`, { data: task })).status()).toBe(
|
||
201,
|
||
);
|
||
}
|
||
|
||
// Titel und Beschreibung werden durchsucht.
|
||
const container = await (await request.get("/api/search?q=Container")).json();
|
||
const titel = container.tasks.map((treffer: { title: string }) => treffer.title);
|
||
expect(titel).toContain("Dockerfile aufraeumen");
|
||
// Erledigtes bleibt drin – wer sucht, sucht oft gerade das Abgehakte.
|
||
expect(titel).toContain("Erledigtes mit Container");
|
||
expect(titel).not.toContain("Ohne Bezug");
|
||
|
||
// Deutsche Stammformen: "Rechnung" findet "Rechnungen".
|
||
const rechnung = await (await request.get("/api/search?q=Rechnung")).json();
|
||
expect(rechnung.tasks.map((t: { title: string }) => t.title)).toContain(
|
||
"Rechnungen exportieren",
|
||
);
|
||
|
||
// Das Projekt taucht in projectIds auf, damit das Brett es einblendet.
|
||
expect(container.projectIds).toContain(projekt.id);
|
||
|
||
// Zu kurze Eingaben liefern leere Listen statt Unsinn.
|
||
const kurz = await (await request.get("/api/search?q=a")).json();
|
||
expect(kurz.tasks).toEqual([]);
|
||
expect(kurz.hits).toEqual([]);
|
||
|
||
// Und ein fremdes Konto findet nichts davon.
|
||
const fremd = await playwright.request.newContext({
|
||
baseURL: test.info().project.use.baseURL,
|
||
});
|
||
await login(fremd, ZWEITER);
|
||
expect((await (await fremd.get("/api/search?q=Container")).json()).tasks).toHaveLength(0);
|
||
await fremd.dispose();
|
||
});
|
||
|
||
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);
|
||
});
|