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:
+291
@@ -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&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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <parsererror>. 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 `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<c:calendar-query xmlns:d="${NS_DAV}" xmlns:c="${NS_CALDAV}">
|
||||||
|
<d:prop><d:getetag/><c:calendar-data/></d:prop>
|
||||||
|
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">
|
||||||
|
<c:prop-filter name="UID"><c:text-match collation="i;octet">${echapperXml(uid)}</c:text-match></c:prop-filter>
|
||||||
|
</c:comp-filter></c:comp-filter></c:filter>
|
||||||
|
</c:calendar-query>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function echapperXml(texte) {
|
||||||
|
return texte.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@
|
|||||||
export const URL_CALENDRIER =
|
export const URL_CALENDRIER =
|
||||||
"https://atelier-huit.frama.space/remote.php/dav/calendars/Pierre/latelier-du-huit-sbastien_shared_by_admin/?export";
|
"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é.
|
// fetchImpl est injecté (obligatoire) pour la testabilité.
|
||||||
// Résultat discriminé : { ok:true, flux } | { ok:false, erreur }.
|
// Résultat discriminé : { ok:true, flux } | { ok:false, erreur }.
|
||||||
export async function recupererFluxCalendrier(fetchImpl) {
|
export async function recupererFluxCalendrier(fetchImpl) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
URL_CALENDRIER,
|
URL_CALENDRIER,
|
||||||
|
URL_COLLECTION,
|
||||||
recupererFluxCalendrier,
|
recupererFluxCalendrier,
|
||||||
} from "./extension/nextcloud.js";
|
} from "./extension/nextcloud.js";
|
||||||
|
|
||||||
@@ -64,3 +65,11 @@ describe("recupererFluxCalendrier", () => {
|
|||||||
expect(optionsVues).toEqual({ credentials: "include" });
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user