import { describe, expect, test } from "bun:test"; import { ecrireStatut } from "./extension/caldav.js"; import { URL_COLLECTION } from "./extension/nextcloud.js"; const UID = "db2d146b-8e18-4df6-929f-a651d04245df"; const HREF = "/remote.php/dav/calendars/Pierre/latelier-du-huit-sbastien_shared_by_admin/00D03211-DA2E-4D06-AAAE-6E8C2355BA2B.ics"; const URL_RESSOURCE = "https://atelier-huit.frama.space" + HREF; const ETAG = '"8ee26a8f6124c1833c48f319868ce10a"'; const ICS = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//IDN nextcloud.com//Calendar app 5.2.2//EN", "BEGIN:VEVENT", `UID:${UID}`, "DTSTAMP:20250828T095756Z", "DTSTART;TZID=Europe/Paris:20260907T090000", "SUMMARY:Forum des associations", "CATEGORIES:Concert", "END:VEVENT", "END:VCALENDAR", "", ].join("\r\n"); const ajouterTag = (categories: string[]) => [...categories, "mairie:ignoré"]; type Appel = { url: string; options: any }; // Faux transport : enregistre chaque appel, répond selon la méthode. function transport({ jeton = { status: 200, corps: '{"token":"jeton-csrf"}' }, report = { status: 207, corps: "" }, put = { status: 204, corps: "" }, levee = null as null | string, }) { const appels: Appel[] = []; const fetchImpl = async (url: string, options: any = {}) => { appels.push({ url, options }); const methode = options.method ?? "GET"; if (levee === methode) throw new TypeError("Failed to fetch"); const reponse = methode === "REPORT" ? report : methode === "PUT" ? put : jeton; return new Response(reponse.corps, { status: reponse.status }); }; return { appels, fetchImpl, parMethode: (methode: string) => appels.filter((a) => (a.options.method ?? "GET") === methode), }; } const uneReponse = () => [{ href: HREF, etag: ETAG, ics: ICS }]; function ecrire(transportUtilise: ReturnType, options: any = {}) { return ecrireStatut({ fetchImpl: transportUtilise.fetchImpl, extraireReponsesImpl: options.extraireReponsesImpl ?? uneReponse, uid: options.uid ?? UID, transformer: options.transformer ?? ajouterTag, }); } describe("requête REPORT", () => { test("méthode, Depth, Content-Type, credentials et UID dans le corps", async () => { const t = transport({}); await ecrire(t); const [report] = t.parMethode("REPORT"); expect(report.url).toBe(URL_COLLECTION); expect(report.options.headers.Depth).toBe("1"); expect(report.options.headers["Content-Type"]).toBe("application/xml; charset=utf-8"); expect(report.options.credentials).toBe("include"); expect(report.options.body).toContain(UID); }); test("UID échappé en XML (jamais interpolé brut)", async () => { const t = transport({}); await ecrire(t, { uid: "a&bd" }); const [report] = t.parMethode("REPORT"); expect(report.options.body).toContain("a&b<c>d"); expect(report.options.body).not.toContain("a&bd"); }); }); describe("jeton CSRF", () => { test("jeton obtenu → en-tête requesttoken sur REPORT et PUT", async () => { const t = transport({}); await ecrire(t); expect(t.parMethode("REPORT")[0].options.headers.requesttoken).toBe("jeton-csrf"); expect(t.parMethode("PUT")[0].options.headers.requesttoken).toBe("jeton-csrf"); }); test("/csrftoken en 404 → on poursuit sans en-tête", async () => { const t = transport({ jeton: { status: 404, corps: "" } }); const res = await ecrire(t); expect(res.ok).toBe(true); expect(t.parMethode("PUT")[0].options.headers.requesttoken).toBeUndefined(); }); test("/csrftoken qui lève → pas d'exception, écriture tentée quand même", async () => { const t = transport({ levee: "GET" }); const res = await ecrire(t); expect(res.ok).toBe(true); expect(t.parMethode("PUT")).toHaveLength(1); }); }); describe("choix de la ressource", () => { test("multistatus vide → introuvable, aucun PUT", async () => { const t = transport({}); const res = await ecrire(t, { extraireReponsesImpl: () => [] }); expect(res).toEqual({ ok: false, erreur: "introuvable" }); expect(t.parMethode("PUT")).toHaveLength(0); }); test("href pointant hors de la collection → ambigu, aucun PUT", async () => { const t = transport({}); const res = await ecrire(t, { extraireReponsesImpl: () => [ { href: "https://ailleurs.example/vol.ics", etag: ETAG, ics: ICS }, ], }); expect(res).toEqual({ ok: false, erreur: "ambigu" }); expect(t.parMethode("PUT")).toHaveLength(0); }); test("deux href distincts → ambigu, aucun PUT", async () => { const t = transport({}); const res = await ecrire(t, { extraireReponsesImpl: () => [ { href: HREF, etag: ETAG, ics: ICS }, { href: HREF + "-bis", etag: ETAG, ics: ICS }, ], }); expect(res).toEqual({ ok: false, erreur: "ambigu" }); expect(t.parMethode("PUT")).toHaveLength(0); }); }); describe("requête PUT", () => { test("href résolu en absolu, If-Match verbatim, corps = ICS modifié", async () => { const t = transport({}); const res = await ecrire(t); const [put] = t.parMethode("PUT"); expect(put.url).toBe(URL_RESSOURCE); expect(put.options.headers["If-Match"]).toBe(ETAG); expect(put.options.headers["Content-Type"]).toBe("text/calendar; charset=utf-8"); expect(put.options.credentials).toBe("include"); expect(put.options.body).toContain("mairie:ignoré"); expect(put.options.body).toContain("Concert"); expect(res).toEqual({ ok: true, categories: ["Concert", "mairie:ignoré"] }); }); test("les catégories transformées sont celles LUES dans la ressource", async () => { const t = transport({}); let vues: string[] = []; await ecrire(t, { transformer: (categories: string[]) => { vues = categories; return categories; }, }); expect(vues).toEqual(["Concert"]); }); }); describe("garde-fou de réécriture", () => { test("ICS illisible → erreur remontée, aucun PUT", async () => { const t = transport({}); const res = await ecrire(t, { extraireReponsesImpl: () => [{ href: HREF, etag: ETAG, ics: "n'importe quoi" }], }); expect(res).toEqual({ ok: false, erreur: "ics-illisible" }); expect(t.parMethode("PUT")).toHaveLength(0); }); test("calendar-data réduit à des blancs → ics-illisible, aucun PUT", async () => { const t = transport({}); const res = await ecrire(t, { extraireReponsesImpl: () => [{ href: HREF, etag: ETAG, ics: " \r\n " }], }); expect(res).toEqual({ ok: false, erreur: "ics-illisible" }); expect(t.parMethode("PUT")).toHaveLength(0); }); test("href absolu malformé → ambigu, aucun PUT (new URL lève)", async () => { const t = transport({}); const res = await ecrire(t, { extraireReponsesImpl: () => [{ href: "http://[", etag: ETAG, ics: ICS }], }); expect(res).toEqual({ ok: false, erreur: "ambigu" }); expect(t.parMethode("PUT")).toHaveLength(0); }); test("UID absent de la ressource → vevent-introuvable, aucun PUT", async () => { const t = transport({}); const res = await ecrire(t, { uid: "uid-absent" }); expect(res).toEqual({ ok: false, erreur: "vevent-introuvable" }); expect(t.parMethode("PUT")).toHaveLength(0); }); }); describe("mapping des statuts", () => { const cas: [number, string][] = [ [412, "conflit"], [403, "lecture-seule"], [404, "introuvable"], [500, "serveur"], ]; for (const [status, erreur] of cas) { test(`PUT ${status} → ${erreur}`, async () => { const t = transport({ put: { status, corps: "" } }); expect(await ecrire(t)).toEqual({ ok: false, erreur }); }); test(`REPORT ${status} → ${erreur}, aucun PUT`, async () => { const t = transport({ report: { status, corps: "" } }); expect(await ecrire(t)).toEqual({ ok: false, erreur }); expect(t.parMethode("PUT")).toHaveLength(0); }); } test("PUT 201 → succès", async () => { const t = transport({ put: { status: 201, corps: "" } }); expect((await ecrire(t)).ok).toBe(true); }); test("401 avec corps mentionnant CSRF → csrf", async () => { const t = transport({ put: { status: 401, corps: '{"message":"CSRF check not passed."}' }, }); expect(await ecrire(t)).toEqual({ ok: false, erreur: "csrf" }); }); test("401 sans mention CSRF → non-connecte", async () => { const t = transport({ put: { status: 401, corps: "Unauthorized" } }); expect(await ecrire(t)).toEqual({ ok: false, erreur: "non-connecte" }); }); test("REPORT qui lève → reseau, aucun PUT", async () => { const t = transport({ levee: "REPORT" }); expect(await ecrire(t)).toEqual({ ok: false, erreur: "reseau" }); expect(t.parMethode("PUT")).toHaveLength(0); }); test("PUT qui lève → reseau", async () => { const t = transport({ levee: "PUT" }); expect(await ecrire(t)).toEqual({ ok: false, erreur: "reseau" }); }); // La connexion peut tomber APRÈS les en-têtes : c'est `text()` qui rejette, // pas `fetch`. Sans cela, l'exception remonterait jusqu'au clic. test("corps illisible (text() qui rejette) → reseau, jamais d'exception", async () => { const appels: string[] = []; const fetchImpl = async (_url: string, options: any = {}) => { const methode = options.method ?? "GET"; appels.push(methode); if (methode === "GET") return new Response('{"token":"t"}'); return { ok: true, status: 207, text: () => Promise.reject(new TypeError("Network error while reading body")), }; }; const res = await ecrireStatut({ fetchImpl, extraireReponsesImpl: uneReponse, uid: UID, transformer: ajouterTag, }); expect(res).toEqual({ ok: false, erreur: "reseau" }); expect(appels).not.toContain("PUT"); }); });