From 058229dce352e78b322bf435ac56fe1f80c48fea Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Thu, 13 Aug 2026 16:10:03 +0200 Subject: [PATCH] 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. --- caldav.test.ts | 291 +++++++++++++++++++++++++++++++++++++++++ extension/caldav.js | 155 ++++++++++++++++++++++ extension/nextcloud.js | 5 + nextcloud.test.ts | 9 ++ 4 files changed, 460 insertions(+) create mode 100644 caldav.test.ts create mode 100644 extension/caldav.js 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"); + }); +});