Repository Public
1import { afterAll, beforeEach, describe, expect, it } from "vitest";2import { sql } from "@/lib/db";3import { encrypt } from "@/lib/crypto";4import { fold, invitation } from "@/lib/ics";5import { busyFromIcs, normalizeIcsUrl, syncCalendar } from "@/lib/services/external";6import { code, resetDb } from "./helpers";78const TZ = "America/Toronto";9const NOW = new Date("2026-11-02T12:00:00Z");10const cal = (body: string) => ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//test//EN", body.trim(), "END:VCALENDAR"].join("\r\n");11const ev = (lines: string[]) => ["BEGIN:VEVENT", "DTSTAMP:20261101T000000Z", ...lines, "END:VEVENT"].join("\r\n");12const iso = (b: { start: Date; end: Date }) => `${b.start.toISOString()}/${b.end.toISOString()}`;1314describe("ICS → busy periods", () => {15 it("UTC, TZID and floating-zone events become instants", () => {16 const busy = busyFromIcs(cal([17 ev(["UID:a", "DTSTART:20261116T150000Z", "DTEND:20261116T160000Z", "SUMMARY:Privé"]),18 ev(["UID:b", "DTSTART;TZID=Europe/Paris:20261117T100000", "DTEND;TZID=Europe/Paris:20261117T113000"]),19 ].join("\r\n")), TZ, NOW);20 expect(busy.map(iso).sort()).toEqual(["2026-11-16T15:00:00.000Z/2026-11-16T16:00:00.000Z", "2026-11-17T09:00:00.000Z/2026-11-17T10:30:00.000Z"]);21 });22 it("all-day events block whole days in the owner's zone", () => {23 const [b] = busyFromIcs(cal(ev(["UID:c", "DTSTART;VALUE=DATE:20261118", "DTEND;VALUE=DATE:20261120"])), TZ, NOW);24 expect(iso(b)).toBe("2026-11-18T05:00:00.000Z/2026-11-20T05:00:00.000Z");25 });26 it("weekly recurrence is expanded, EXDATE removed, wall-clock time kept across the DST change", () => {27 const busy = busyFromIcs(cal(ev(["UID:d", "DTSTART;TZID=America/Toronto:20261026T090000", "DTEND;TZID=America/Toronto:20261026T100000", "RRULE:FREQ=WEEKLY;COUNT=5", "EXDATE;TZID=America/Toronto:20261109T090000"])), TZ, NOW);28 // 26 Oct is before the window; 2 Nov (EST, after the 1 Nov change) 14:00Z; 9 Nov excluded; 16 and 23 Nov29 expect(busy.map((b) => b.start.toISOString())).toEqual(["2026-11-02T14:00:00.000Z", "2026-11-16T14:00:00.000Z", "2026-11-23T14:00:00.000Z"]);30 });31 it("cancelled and transparent (free) events do not block", () => {32 const busy = busyFromIcs(cal([33 ev(["UID:e", "DTSTART:20261116T150000Z", "DTEND:20261116T160000Z", "STATUS:CANCELLED"]),34 ev(["UID:f", "DTSTART:20261116T170000Z", "DTEND:20261116T180000Z", "TRANSP:TRANSPARENT"]),35 ].join("\r\n")), TZ, NOW);36 expect(busy).toEqual([]);37 });38});3940describe("ICS subscription sync", () => {41 let userId: string, calId: string;42 beforeEach(async () => {43 ({ userId } = await resetDb());44 process.env.SPCAL_ALLOW_LOCAL_ICS = "1";45 const [a] = await sql<{ id: string }[]>`INSERT INTO external_calendar_accounts (user_id, provider, label, secret_enc) VALUES (${userId}, 'ics', 'Perso', ${encrypt("external-calendar", "https://calendar.example.test/x.ics")}) RETURNING id`;46 const [c] = await sql<{ id: string }[]>`INSERT INTO external_calendars (account_id, user_id, name) VALUES (${a.id}, ${userId}, 'Perso') RETURNING id`;47 calId = c.id;48 });49 afterAll(async () => { await sql.end(); });50 const soon = new Date(Date.now() + 5 * 86_400_000).toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z";51 const later = new Date(Date.now() + 5 * 86_400_000 + 3_600_000).toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z";52 const okFetch = (async () => new Response(cal(ev(["UID:x", `DTSTART:${soon}`, `DTEND:${later}`])), { status: 200, headers: { etag: '"v1"' } })) as unknown as typeof fetch;5354 it("success stores busy periods, freshness and the ETag; the stored address is encrypted", async () => {55 expect(await syncCalendar(calId, okFetch)).toMatchObject({ ok: true, events: 1 });56 expect((await sql`SELECT 1 FROM busy_blocks WHERE external_calendar_id = ${calId} AND source = 'external'`).length).toBe(1);57 const [s] = await sql<{ last_success_at: Date; etag: string; last_error: string | null }[]>`SELECT last_success_at, etag, last_error FROM sync_state WHERE calendar_id = ${calId}`;58 expect(s.etag).toBe('"v1"'); expect(s.last_error).toBeNull(); expect(s.last_success_at).toBeInstanceOf(Date);59 const [a] = await sql<{ secret_enc: Buffer }[]>`SELECT secret_enc FROM external_calendar_accounts`;60 expect(a.secret_enc.toString("latin1")).not.toContain("calendar.example.test");61 });62 it("304 Not Modified keeps the data and refreshes freshness", async () => {63 await syncCalendar(calId, okFetch);64 const r = await syncCalendar(calId, (async (_u: unknown, init: RequestInit) => { expect((init.headers as Record<string, string>)["If-None-Match"]).toBe('"v1"'); return new Response(null, { status: 304 }); }) as unknown as typeof fetch);65 expect(r).toMatchObject({ ok: true, notModified: true });66 expect((await sql`SELECT 1 FROM busy_blocks WHERE external_calendar_id = ${calId}`).length).toBe(1);67 });68 it("failure (HTTP 503, rate limit, not a calendar) keeps the last known busy periods and records the error", async () => {69 await syncCalendar(calId, okFetch);70 for (const [res, msg] of [[new Response("down", { status: 503 }), "HTTP 503"], [new Response("", { status: 429 }), "rate limited"], [new Response("<html>login</html>", { status: 200 }), "did not return an iCalendar"]] as const) {71 const r = await syncCalendar(calId, (async () => res) as unknown as typeof fetch);72 expect(r.ok).toBe(false); expect(r.error).toContain(msg);73 }74 expect((await sql`SELECT 1 FROM busy_blocks WHERE external_calendar_id = ${calId}`).length).toBe(1);75 const [s] = await sql<{ consecutive_failures: number; status: string }[]>`SELECT s.consecutive_failures, a.status FROM sync_state s JOIN external_calendars c ON c.id = s.calendar_id JOIN external_calendar_accounts a ON a.id = c.account_id`;76 expect(s).toMatchObject({ consecutive_failures: 3, status: "error" });77 });78 it("only public https addresses are accepted", async () => {79 delete process.env.SPCAL_ALLOW_LOCAL_ICS;80 for (const u of ["http://example.com/a.ics", "https://127.0.0.1/a.ics", "https://192.168.2.72/a.ics", "https://10.67.0.1/a.ics", "https://[::1]/a.ics", "https://user:pw@example.com/a.ics", "file:///etc/passwd", "nonsense"]) expect(await code(normalizeIcsUrl(u))).toBe("invalid_url");81 expect(await normalizeIcsUrl("webcal://1.1.1.1/a.ics")).toBe("https://1.1.1.1/a.ics");82 });83});8485describe("ICS output", () => {86 it("folds at 75 octets without cutting a multi-byte character, and escapes text", () => {87 const line = "SUMMARY:" + "é".repeat(80);88 for (const part of fold(line).split("\r\n")) expect(Buffer.byteLength(part)).toBeLessThanOrEqual(75);89 expect(fold(line).replace(/\r\n /g, "")).toBe(line);90 const ics = invitation({ uid: "u@sp-calendar", sequence: 2, start: new Date("2026-11-16T14:00:00Z"), end: new Date("2026-11-16T14:30:00Z"), summary: "Rencontre; test, ligne\nsuivante", status: "CANCELLED", organizerName: "O", organizerEmail: "o@example.test" }, new Date("2026-11-01T00:00:00Z"));91 expect(ics).toContain("METHOD:CANCEL\r\n"); expect(ics).toContain("SEQUENCE:2\r\n"); expect(ics).toContain("SUMMARY:Rencontre\; test\\, ligne\\nsuivante\r\n");92 expect(ics.endsWith("END:VCALENDAR\r\n")).toBe(true);93 });94});95