REPORT by UID gives href + ETag + calendar-data, then PUT with If-Match: the resource URL is never guessed and a concurrent edit yields 412 instead of an overwrite. URL_COLLECTION is derived from URL_CALENDRIER.
292 lines
9.8 KiB
TypeScript
292 lines
9.8 KiB
TypeScript
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: "<multistatus/>" },
|
|
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<typeof transport>, 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&b<c>d" });
|
|
|
|
const [report] = t.parMethode("REPORT");
|
|
expect(report.options.body).toContain("a&b<c>d");
|
|
expect(report.options.body).not.toContain("a&b<c>d");
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|