From 1432aba561ed2eb5f69513a64670cac8336a19d9 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Tue, 30 Jun 2026 00:07:43 +0200 Subject: [PATCH] feat: read raw iCalendar feed via browser session Isolated CalDAV transport module that reuses the browser's Nextcloud session (cookies, no stored password). Returns a discriminated result and detects the HTTP-200 login-page redirect by sniffing the body for BEGIN:VCALENDAR. compterEvenements counts real VEVENT line starts as the T1 proof of life. Co-Authored-By: Claude Opus 4.8 --- extension/nextcloud.js | 38 +++++++++++++++++++ nextcloud.test.ts | 85 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 extension/nextcloud.js create mode 100644 nextcloud.test.ts diff --git a/extension/nextcloud.js b/extension/nextcloud.js new file mode 100644 index 0000000..37c5087 --- /dev/null +++ b/extension/nextcloud.js @@ -0,0 +1,38 @@ +// Transport CalDAV isolé : récupère le flux iCalendar brut du calendrier +// Nextcloud de l'Atelier du Huit en réutilisant la session du navigateur +// (cookies). Aucun mot de passe stocké. Le module reste pur de toute +// présentation : il renvoie un *type* d'erreur, pas un message. + +export const URL_CALENDRIER = + "https://atelier-huit.frama.space/remote.php/dav/calendars/Pierre/latelier-du-huit-sbastien_shared_by_admin/?export"; + +// fetchImpl est injecté (obligatoire) pour la testabilité. +// Résultat discriminé : { ok:true, flux } | { ok:false, erreur }. +export async function recupererFluxCalendrier(fetchImpl) { + let reponse; + try { + reponse = await fetchImpl(URL_CALENDRIER, { credentials: "include" }); + } catch { + return { ok: false, erreur: "reseau" }; + } + + if (reponse.status === 401 || reponse.status === 403) { + return { ok: false, erreur: "non-connecte" }; + } + if (!reponse.ok) { + return { ok: false, erreur: "serveur" }; + } + + const flux = await reponse.text(); + // Piège central : une redirection vers la page de login renvoie un 200 HTML. + if (!flux.trimStart().startsWith("BEGIN:VCALENDAR")) { + return { ok: false, erreur: "non-connecte" }; + } + return { ok: true, flux }; +} + +// Preuve de vie de T1 : compte les vrais débuts de VEVENT (ancrage ligne, pas +// une chaîne « BEGIN:VEVENT » apparaissant dans une DESCRIPTION). +export function compterEvenements(flux) { + return (flux.match(/^BEGIN:VEVENT\s*$/gm) ?? []).length; +} diff --git a/nextcloud.test.ts b/nextcloud.test.ts new file mode 100644 index 0000000..d952c61 --- /dev/null +++ b/nextcloud.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { + URL_CALENDRIER, + compterEvenements, + recupererFluxCalendrier, +} from "./extension/nextcloud.js"; + +const FLUX_VALIDE = + "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Atelier\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + +function fetchQuiRenvoie(corps: string, status = 200) { + return async () => new Response(corps, { status }); +} + +describe("recupererFluxCalendrier", () => { + test("succès : 200 + VCALENDAR → { ok:true, flux }", async () => { + const res = await recupererFluxCalendrier(fetchQuiRenvoie(FLUX_VALIDE)); + expect(res).toEqual({ ok: true, flux: FLUX_VALIDE }); + }); + + test("non-connecté : 200 + page de login HTML → non-connecte", async () => { + const res = await recupererFluxCalendrier( + fetchQuiRenvoie("Login"), + ); + expect(res).toEqual({ ok: false, erreur: "non-connecte" }); + }); + + test("non-connecté : status 401 → non-connecte", async () => { + const res = await recupererFluxCalendrier(fetchQuiRenvoie("", 401)); + expect(res).toEqual({ ok: false, erreur: "non-connecte" }); + }); + + test("non-connecté : status 403 → non-connecte", async () => { + const res = await recupererFluxCalendrier(fetchQuiRenvoie("", 403)); + expect(res).toEqual({ ok: false, erreur: "non-connecte" }); + }); + + test("serveur : status 500 → serveur", async () => { + const res = await recupererFluxCalendrier(fetchQuiRenvoie("", 500)); + expect(res).toEqual({ ok: false, erreur: "serveur" }); + }); + + test("réseau : fetch lève → reseau", async () => { + const res = await recupererFluxCalendrier(async () => { + throw new TypeError("Failed to fetch"); + }); + expect(res).toEqual({ ok: false, erreur: "reseau" }); + }); + + test("BOM / espaces en tête : reste un succès", async () => { + const corps = "\r\n" + FLUX_VALIDE; + const res = await recupererFluxCalendrier(fetchQuiRenvoie(corps)); + expect(res).toEqual({ ok: true, flux: corps }); + }); + + test("appelle fetchImpl avec URL_CALENDRIER et credentials:include", async () => { + let urlVue: unknown; + let optionsVues: unknown; + await recupererFluxCalendrier(async (url, options) => { + urlVue = url; + optionsVues = options; + return new Response(FLUX_VALIDE); + }); + expect(urlVue).toBe(URL_CALENDRIER); + expect(optionsVues).toEqual({ credentials: "include" }); + }); +}); + +describe("compterEvenements", () => { + test("compte 2 VEVENT", () => { + const flux = + "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + expect(compterEvenements(flux)).toBe(2); + }); + + test("compte 0 quand aucun VEVENT", () => { + expect(compterEvenements("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n")).toBe(0); + }); + + test("ignore un BEGIN:VEVENT au milieu d'une DESCRIPTION", () => { + const flux = + "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDESCRIPTION:texte BEGIN:VEVENT piège\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + expect(compterEvenements(flux)).toBe(1); + }); +});