diff --git a/caldav.test.ts b/caldav.test.ts
new file mode 100644
index 0000000..30c8b74
--- /dev/null
+++ b/caldav.test.ts
@@ -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: "" },
+ 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");
+ });
+});
diff --git a/extension/caldav.js b/extension/caldav.js
new file mode 100644
index 0000000..de39c58
--- /dev/null
+++ b/extension/caldav.js
@@ -0,0 +1,155 @@
+// Transport d'ÉCRITURE CalDAV : pose le statut mairie sur l'événement Nextcloud
+// sans jamais deviner l'URL de la ressource ni écraser le travail d'un·e autre.
+//
+// Séquence mesurée sur Framaspace (cf. docs/RECHERCHE.md) :
+// GET /csrftoken → REPORT (calendar-query par UID) → PUT If-Match.
+// Le REPORT rend href + ETag + calendar-data : on écrit dans la ressource que le
+// serveur a désignée, avec l'ETag qu'il vient de donner.
+//
+// IMPUR : `fetchImpl` et `extraireReponsesImpl` sont injectés (obligatoires),
+// même couture que agenda-mairie.js — la boucle est testée, le DOMParser non.
+// Le module rend un *type* d'erreur, jamais un message.
+
+import { URL_COLLECTION } from "./nextcloud.js";
+import { appliquerAuxCategories } from "./ics-categories.js";
+
+const URL_JETON = new URL("/csrftoken", URL_COLLECTION).href;
+const NS_DAV = "DAV:";
+const NS_CALDAV = "urn:ietf:params:xml:ns:caldav";
+
+export async function ecrireStatut({ fetchImpl, extraireReponsesImpl, uid, transformer }) {
+ const jeton = await recupererJetonCsrf(fetchImpl);
+
+ const report = await appeler(fetchImpl, URL_COLLECTION, {
+ method: "REPORT",
+ credentials: "include",
+ headers: enTetes(jeton, {
+ "Content-Type": "application/xml; charset=utf-8",
+ Depth: "1",
+ }),
+ body: requeteParUid(uid),
+ });
+ if (!report.ok) return report;
+
+ const reponses = extraireReponsesImpl(report.corps);
+ // Deviner est précisément ce que cette approche refuse : 0 ou plusieurs
+ // candidates → on n'écrit nulle part.
+ if (reponses.length === 0) return { ok: false, erreur: "introuvable" };
+ if (new Set(reponses.map((r) => r.href)).size > 1) return { ok: false, erreur: "ambigu" };
+
+ const { href, etag, ics } = reponses[0];
+ const reecriture = appliquerAuxCategories(ics, uid, transformer);
+ if (!reecriture.ok) return reecriture;
+
+ // L'href du multistatus est un chemin : résolu contre la collection. On refuse
+ // toute cible qui sortirait de CETTE collection : le serveur est de confiance,
+ // mais on n'écrit jamais dans une ressource qu'on n'a pas choisie.
+ const cible = resoudre(href);
+ if (!cible) return { ok: false, erreur: "ambigu" };
+
+ const put = await appeler(fetchImpl, cible, {
+ method: "PUT",
+ credentials: "include",
+ headers: enTetes(jeton, {
+ "Content-Type": "text/calendar; charset=utf-8",
+ "If-Match": etag,
+ }),
+ body: reecriture.ics,
+ });
+ if (!put.ok) return put;
+
+ return { ok: true, categories: reecriture.categories };
+}
+
+// Extraction DOM du multistatus (IMPUR : DOMParser, absent de Bun → non testé
+// unitairement, comme extraireCartes). Sélection PAR NAMESPACE : le préfixe est
+// libre côté serveur. Toute réponse incomplète est ignorée, jamais d'exception.
+export function extraireReponses(xml) {
+ const doc = new DOMParser().parseFromString(xml, "application/xml");
+ // DOMParser ne lève JAMAIS : il rend un document . Sans cette
+ // trace, une réponse qui n'est pas du multistatus (page de login HTML,
+ // portail captif) se lirait « événement introuvable » — message faux.
+ if (doc.getElementsByTagName("parsererror").length > 0) {
+ console.error("Écho du Huit : réponse CalDAV illisible (pas du XML).", xml.slice(0, 300));
+ return [];
+ }
+
+ const reponses = [];
+ for (const reponse of doc.getElementsByTagNameNS(NS_DAV, "response")) {
+ const href = reponse.getElementsByTagNameNS(NS_DAV, "href")[0]?.textContent;
+ const etag = reponse.getElementsByTagNameNS(NS_DAV, "getetag")[0]?.textContent;
+ const ics = reponse.getElementsByTagNameNS(NS_CALDAV, "calendar-data")[0]?.textContent;
+ // `trim()` : un `calendar-data` réduit à des blancs est *truthy* mais ne
+ // contient aucun événement — le laisser passer, c'est écrire dans le vide.
+ if (!href?.trim() || !etag?.trim() || !ics?.trim()) continue;
+ reponses.push({ href, etag, ics });
+ }
+ return reponses;
+}
+
+// Échec NON FATAL : le serveur tranchera. Mesuré : DAV accepte l'écriture même
+// sans jeton valide, mais on l'envoie quand il est disponible.
+async function recupererJetonCsrf(fetchImpl) {
+ try {
+ const reponse = await fetchImpl(URL_JETON, { credentials: "include" });
+ if (!reponse.ok) return null;
+ return JSON.parse(await reponse.text()).token ?? null;
+ } catch {
+ return null;
+ }
+}
+
+// `new URL` LÈVE sur un href absolu malformé : ici comme ailleurs, une réponse
+// serveur bizarre doit produire un refus, jamais une exception.
+function resoudre(href) {
+ try {
+ const cible = new URL(href, URL_COLLECTION).href;
+ return cible.startsWith(URL_COLLECTION) ? cible : null;
+ } catch {
+ return null;
+ }
+}
+
+function enTetes(jeton, entetes) {
+ return jeton ? { ...entetes, requesttoken: jeton } : entetes;
+}
+
+async function appeler(fetchImpl, url, options) {
+ let reponse;
+ let corps;
+ try {
+ reponse = await fetchImpl(url, options);
+ // La lecture du corps est DANS le try : une connexion qui tombe pendant le
+ // téléchargement fait rejeter `text()`, pas `fetch`.
+ corps = await reponse.text();
+ } catch {
+ return { ok: false, erreur: "reseau" };
+ }
+
+ if (reponse.ok) return { ok: true, corps };
+ return { ok: false, erreur: typeErreur(reponse.status, corps) };
+}
+
+function typeErreur(status, corps) {
+ if (status === 412) return "conflit";
+ if (status === 403) return "lecture-seule";
+ if (status === 404) return "introuvable";
+ if (status === 401) return /csrf/i.test(corps) ? "csrf" : "non-connecte";
+ return "serveur";
+}
+
+// Les UID du flux sont d'origines diverses : échappement systématique avant
+// interpolation dans le XML.
+function requeteParUid(uid) {
+ return `
+
+
+
+ ${echapperXml(uid)}
+
+`;
+}
+
+function echapperXml(texte) {
+ return texte.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
+}
diff --git a/extension/nextcloud.js b/extension/nextcloud.js
index a55a606..1d934f2 100644
--- a/extension/nextcloud.js
+++ b/extension/nextcloud.js
@@ -6,6 +6,11 @@
export const URL_CALENDRIER =
"https://atelier-huit.frama.space/remote.php/dav/calendars/Pierre/latelier-du-huit-sbastien_shared_by_admin/?export";
+// La collection CalDAV (sans `?export`) est la cible des requêtes REPORT/PUT.
+// DÉRIVÉE plutôt que recopiée : une seule URL de calendrier dans le repo, donc
+// aucune dérive possible entre lecture et écriture.
+export const URL_COLLECTION = URL_CALENDRIER.replace(/\?export$/, "");
+
// fetchImpl est injecté (obligatoire) pour la testabilité.
// Résultat discriminé : { ok:true, flux } | { ok:false, erreur }.
export async function recupererFluxCalendrier(fetchImpl) {
diff --git a/nextcloud.test.ts b/nextcloud.test.ts
index 697b255..e8637d9 100644
--- a/nextcloud.test.ts
+++ b/nextcloud.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
URL_CALENDRIER,
+ URL_COLLECTION,
recupererFluxCalendrier,
} from "./extension/nextcloud.js";
@@ -64,3 +65,11 @@ describe("recupererFluxCalendrier", () => {
expect(optionsVues).toEqual({ credentials: "include" });
});
});
+
+describe("URL_COLLECTION", () => {
+ test("= URL_CALENDRIER sans ?export, et se termine par /", () => {
+ expect(URL_COLLECTION).toBe(URL_CALENDRIER.replace("?export", ""));
+ expect(URL_COLLECTION.endsWith("/")).toBe(true);
+ expect(URL_COLLECTION).not.toContain("?export");
+ });
+});