feat: write the mairie status back to Nextcloud over CalDAV

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.
This commit is contained in:
Pierre Martin
2026-08-13 16:50:16 +02:00
parent 3248b28e1f
commit 058229dce3
4 changed files with 460 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
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&amp;b&lt;c&gt;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");
});
});