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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user