Repository Public
1import { expect, test, type Page } from "@playwright/test";2import AxeBuilder from "@axe-core/playwright";3import { bookFirstSlot, expectAccessible, psql, signInAsOwner, waitForMail } from "./helpers";45test.describe.configure({ mode: "serial" });67let page: Page;8let csrf = "";9const ORIGIN = process.env.PW_BASE_URL ?? "http://localhost:8661";1011/** Admin API call with the session cookie of `page` and its CSRF token, as the interface does. */12async function admin(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", path: string, data?: unknown, headers: Record<string, string> = {}) {13 const res = await page.request.fetch(`/api/v1/admin/${path}`, { method, data: data === undefined ? undefined : JSON.stringify(data), headers: { "Content-Type": "application/json", Origin: ORIGIN, "X-CSRF-Token": csrf, ...headers } });14 return { status: res.status(),15 body: (await res.json().catch(() => null)) as any };16}17async function publicSubmit(slug: string, start: string, who: string) {18 const token = ((await (await page.request.get("/api/v1/public/form-token")).json()) as { formToken: string }).formToken;19 await page.waitForTimeout(2700);20 const res = await page.request.post("/api/v1/public/requests", { headers: { Origin: ORIGIN }, data: { slug, start, name: who, email: `${who.toLowerCase().replace(/\W+/g, ".")}@example.test`, reason: "Test", visitorTz: "America/Toronto", locale: "fr", idempotencyKey: crypto.randomUUID(), consent: true, formToken: token } });21 return { status: res.status(), body: (await res.json()) as { status?: string; manageToken?: string; error?: { code: string } } };22}23const slotsOf = async (slug: string, day: string) => ((await (await page.request.get(`/api/v1/public/event-types/${slug}/slots?from=${day}&to=${day}`)).json()) as { slots: string[] }).slots;24/** A Monday 3 to 9 days from now (calendar date); each project uses its own week, since they share one owner's agenda. */25function nextMonday(project: string): string {26 const d = new Date(Date.now() + (project === "mobile" ? 10 : 3) * 86_400_000);27 while (d.getUTCDay() !== 1) d.setUTCDate(d.getUTCDate() + 1);28 return d.toISOString().slice(0, 10);29}30const typeBody = (slug: string, over: Record<string, unknown> = {}) => ({ slug, title: `Type ${slug}`, duration_min: 30, location_kind: "video", location_detail: "https://visio.example/salle", min_notice_min: 0, require_reason: false, ...over });3132test.beforeAll(async ({ browser }, info) => {33 const ctx = await browser.newContext({ ...info.project.use, locale: "fr-CA", timezoneId: "America/Toronto", reducedMotion: "reduce" });34 page = await ctx.newPage();35 await signInAsOwner(page); // real WebAuthn registration with user verification (virtual authenticator)36 csrf = ((await (await page.request.get("/api/v1/auth/session")).json()) as { csrfToken: string }).csrfToken;37});38test.afterAll(async () => { await page.context().close(); });3940test("passkey enrolment opened a session; every admin screen renders, is accessible and fits the viewport", async () => {41 await expect(page.getByRole("heading", { level: 1 })).toContainText("Bonjour");42 const problems: string[] = [];43 for (const path of ["/admin", "/admin/requests", "/admin/agenda", "/admin/availability", "/admin/types", "/admin/contacts", "/admin/settings", "/admin/settings?tab=notifications", "/admin/settings?tab=integrations", "/admin/settings?tab=security", "/admin/settings?tab=audit"]) {44 await page.goto(path);45 await page.waitForLoadState("networkidle");46 await expect(page.locator("main [aria-busy='true']")).toHaveCount(0);47 await expect(page.getByRole("alert").filter({ hasText: /Impossible de charger/ })).toHaveCount(0);48 const r = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();49 for (const v of r.violations) problems.push(`${path} ${v.id}: ${v.nodes.map((n) => n.target.join(" ")).slice(0, 2).join(" | ")}`);50 const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);51 if (overflow > 1) problems.push(`${path} horizontal overflow ${overflow}px`);52 }53 expect(problems).toEqual([]);54});5556test("acceptance 1–2: Monday 9–12 with 10–11 blocked; a confirmed appointment removes its slot at once", async ({}, info) => {57 const slug = `a1-${info.project.name}`;58 const day = nextMonday(info.project.name);59 const created = await admin("POST", "event-types", typeBody(slug, { custom_availability: true }));60 expect(created.status).toBe(200);61 const id = (created.body as { id: string }).id;62 expect((await admin("PUT", "availability/rules", { eventTypeId: id, rules: [{ weekday: 1, startMinute: 540, endMinute: 720 }] })).status).toBe(200);63 const local = (iso: string) => new Intl.DateTimeFormat("en-GB", { timeZone: "America/Toronto", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(iso));64 expect((await slotsOf(slug, day)).map(local)).toEqual(["09:00", "09:30", "10:00", "10:30", "11:00", "11:30"]);6566 // block 10–11 as a wall-clock closure for this type67 const ex = await admin("POST", "availability/exceptions", { mode: "range", kind: "closed", eventTypeId: id, startsLocal: `${day}T10:00`, endsLocal: `${day}T11:00` });68 expect(ex.status).toBe(200);69 const open = await slotsOf(slug, day);70 expect(open.map(local)).toEqual(["09:00", "09:30", "11:00", "11:30"]);71 expect((await publicSubmit(slug, new Date(new Date(open[1]).getTime() + 3_600_000).toISOString(), "Hors Plage")).body.error?.code).toBe("slot_unavailable"); // 10:30, inside the block7273 // the owner books 9:30 for someone → gone immediately74 const b = await admin("POST", "bookings", { eventTypeId: id, start: open[1], name: "Client Direct", email: "client.direct@example.test", notify: false });75 expect(b.status).toBe(200);76 expect((await slotsOf(slug, day)).map(local)).toEqual(["09:00", "11:00", "11:30"]);77 expect((await publicSubmit(slug, open[1], "Trop Tard")).body.error?.code).toBe("slot_taken");78 await admin("DELETE", `availability/exceptions/${(ex.body as { id: string }).id}`);79});8081test("acceptance 4–5: the owner sees the request, approves it in one tap; booking, e-mail and invitation are real", async ({}, info) => {82 const email = `approuve-${info.project.name}@example.test`;83 const visitor = await page.context().newPage();84 const r = await bookFirstSlot(visitor, "appel-15", { name: "Dominique Approuvé", email });85 expect(r.status).toBe("pending");8687 await page.goto("/admin/requests");88 const card = page.locator("article, li, section, div").filter({ hasText: "Dominique Approuvé" }).filter({ has: page.getByRole("button", { name: "Approuver" }) }).last();89 await expect(card).toBeVisible();90 await expect(card.getByText("En attente").first()).toBeVisible();91 await card.getByRole("button", { name: "Approuver" }).click();92 await expect.poll(() => psql(`SELECT r.status || '/' || b.status FROM booking_requests r JOIN bookings b ON b.id = r.booking_id JOIN contacts c ON c.id = r.contact_id WHERE c.email = '${email}'`)).toBe("approved/confirmed");9394 const mail = await waitForMail((m) => m.to === email && m.subject.startsWith("Rendez-vous confirmé"));95 expect(mail.ics?.method).toBe("REQUEST");96 expect(mail.ics?.content).toMatch(/BEGIN:VEVENT[\s\S]*UID:[0-9a-f-]+@sp-calendar[\s\S]*SEQUENCE:0[\s\S]*DTSTART:\d{8}T\d{6}Z[\s\S]*DTEND:\d{8}T\d{6}Z[\s\S]*STATUS:CONFIRMED[\s\S]*END:VEVENT/);97 await visitor.goto(`/manage/${r.token}`);98 await expect(visitor.getByTestId("manage")).toHaveAttribute("data-state", "confirmed");99 await expect(visitor.getByRole("link", { name: /\.ics/ })).toBeVisible();100 const ics = await visitor.request.get(`/api/v1/public/manage/${r.token}/ics`);101 expect(ics.headers()["content-type"]).toContain("text/calendar");102103 // the agenda API and the dashboard show it104 await page.goto("/admin");105 await expect(page.getByText("Dominique Approuvé").first()).toBeVisible();106107 // acceptance 8: the visitor cancels; slot freed, cancellation invitation, owner told108 await visitor.getByRole("button", { name: "Annuler le rendez-vous" }).click();109 await visitor.getByRole("button", { name: "Confirmer l'annulation" }).click();110 await expect(visitor.getByTestId("manage")).toHaveAttribute("data-state", "cancelled");111 const cancel = await waitForMail((m) => m.to === email && m.subject.startsWith("Rendez-vous annulé"));112 expect(cancel.ics?.method).toBe("CANCEL");113 expect(cancel.ics?.content).toContain("SEQUENCE:1");114 await waitForMail((m) => m.to === "owner@example.test" && m.subject.startsWith("Annulation"));115 await visitor.close();116});117118test("acceptance 3: two visitors ask for the same time (no-hold policy): one approval only, the other is told to reschedule", async ({}, info) => {119 const slug = `a3-${info.project.name}`;120 const day = nextMonday(info.project.name);121 expect((await admin("POST", "event-types", typeBody(slug, { hold_policy: "none" }))).status).toBe(200);122 const [slot] = await slotsOf(slug, day);123 const a = await publicSubmit(slug, slot, "Premier Demandeur"), b = await publicSubmit(slug, slot, "Second Demandeur");124 expect([a.body.status, b.body.status]).toEqual(["pending", "pending"]);125 const open = (await admin("GET", "requests?filter=open")).body as { requests: { id: string; contact: { name: string }; conflict: string | null }[] };126 const ra = open.requests.find((r) => r.contact.name === "Premier Demandeur")!, rb = open.requests.find((r) => r.contact.name === "Second Demandeur")!;127 // approve both at the same instant: the database lets exactly one through128 const [x, y] = await Promise.all([admin("POST", `requests/${ra.id}/approve`, {}), admin("POST", `requests/${rb.id}/approve`, {})]);129 expect([x.status, y.status].sort()).toEqual([200, 409]);130 const loser = x.status === 409 ? ra : rb;131 expect(((x.status === 409 ? x : y).body as { error: { code: string } }).error.code).toBe("slot_taken");132 expect(psql(`SELECT count(*) FROM bookings b JOIN event_types t ON t.id = b.event_type_id WHERE t.slug = '${slug}' AND b.status = 'confirmed'`)).toBe("1");133 // the inbox flags the conflict on the remaining request instead of confirming anything134 const after = (await admin("GET", `requests/${loser.id}`)).body as { request: { status: string; conflict: string } };135 expect(after.request).toMatchObject({ status: "pending", conflict: "taken" });136 await page.goto("/admin/requests");137 await expect(page.getByText(/plus (libre|disponible)|déjà pris|conflit/i).first()).toBeVisible();138139 // acceptance 6: the owner proposes another time; the visitor accepts on the management page140 const typeId = psql(`SELECT id FROM event_types WHERE slug = '${slug}'`);141 const free = (await admin("GET", `slots?eventTypeId=${typeId}&from=${day}&to=${day}&ignoreRequestId=${loser.id}`)).body as { slots: string[] };142 expect(free.slots).not.toContain(slot);143 expect((await admin("POST", `requests/${loser.id}/propose`, { start: slot, message: "" })).status).toBe(409); // cannot propose taken time144 expect((await admin("POST", `requests/${loser.id}/propose`, { start: free.slots[2], message: "Plutôt à ce moment ?" })).status).toBe(200);145 // the proposal e-mail leaves while the proposal is open (a message about a superseded state is never sent)146 const proposal = await waitForMail((m) => m.subject.startsWith("Autre moment proposé"));147 expect(proposal.text).toContain("Rien n'est confirmé");148 expect(proposal.ics ?? null).toBeNull();149 const loserToken = (x.status === 409 ? a : b).body.manageToken!;150 const visitor = await page.context().newPage();151 await visitor.goto(`/manage/${loserToken}`);152 await expect(visitor.getByTestId("manage")).toHaveAttribute("data-state", "reschedule_requested");153 await expect(visitor.getByText("Plutôt à ce moment ?")).toBeVisible();154 await expectAccessible(visitor);155 await visitor.getByRole("button", { name: "Accepter ce moment" }).click();156 await expect(visitor.getByTestId("manage")).toHaveAttribute("data-state", "confirmed");157 expect(psql(`SELECT to_char(b.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS".000Z"') FROM bookings b JOIN booking_requests r ON r.booking_id = b.id WHERE r.id = '${loser.id}'`)).toBe(free.slots[2]);158 await visitor.close();159});160161test("acceptance 6: declining from the inbox notifies the visitor politely", async ({}, info) => {162 const email = `refuse-${info.project.name}@example.test`;163 const visitor = await page.context().newPage();164 const r = await bookFirstSlot(visitor, "appel-15", { name: "Camille Refusé", email });165 const id = psql(`SELECT r.id FROM booking_requests r JOIN contacts c ON c.id = r.contact_id WHERE c.email = '${email}'`);166 expect((await admin("POST", `requests/${id}/decline`, { message: "Je suis à l'extérieur cette semaine-là." })).status).toBe(200);167 expect((await admin("POST", `requests/${id}/approve`, {})).status).toBe(409); // a declined request cannot be approved168 await visitor.goto(`/manage/${r.token}`);169 await expect(visitor.getByTestId("manage")).toHaveAttribute("data-state", "declined");170 await expect(visitor.getByText("Je suis à l'extérieur cette semaine-là.")).toBeVisible();171 const mail = await waitForMail((m) => m.to === email && m.subject.startsWith("Demande non retenue"));172 expect(mail.text).not.toContain("visio.example");173 expect(mail.ics ?? null).toBeNull();174 // acceptance 8: a revoked link stops working175 expect(((await admin("POST", `requests/${id}/revoke-links`, {})).body as { revoked: number }).revoked).toBe(1);176 await visitor.goto(`/manage/${r.token}`);177 await expect(visitor.getByTestId("manage-error")).toHaveAttribute("data-code", "link_revoked");178 await visitor.close();179});180181test("acceptance 10: authentication, CSRF, cross-origin and object-level access", async ({ request }) => {182 // no session183 for (const p of ["me", "requests", "agenda?from=2026-01-01T00:00:00Z&to=2026-01-02T00:00:00Z", "contacts", "settings", "audit"]) expect((await request.get(`/api/v1/admin/${p}`)).status()).toBe(401);184 expect((await request.post("/api/v1/admin/requests/00000000-0000-4000-8000-000000000000/approve", { data: {} })).status()).toBe(401);185 // session but no CSRF token / wrong token / cross-site origin186 expect((await admin("POST", "busy-blocks", { start: "2030-01-01T10:00:00Z", end: "2030-01-01T11:00:00Z" }, { "X-CSRF-Token": "" })).status).toBe(403);187 expect((await admin("POST", "busy-blocks", { start: "2030-01-01T10:00:00Z", end: "2030-01-01T11:00:00Z" }, { "X-CSRF-Token": "forged" })).status).toBe(403);188 expect((await admin("POST", "busy-blocks", { start: "2030-01-01T10:00:00Z", end: "2030-01-01T11:00:00Z" }, { Origin: "https://evil.example" })).status).toBe(403);189 expect(psql("SELECT count(*) FROM busy_blocks WHERE starts_at = '2030-01-01T10:00:00Z'")).toBe("0");190 // unknown or malformed identifiers191 expect((await admin("GET", "requests/00000000-0000-4000-8000-000000000000")).status).toBe(404);192 expect((await admin("GET", "bookings/not-a-uuid")).status).toBe(400);193 expect((await admin("POST", "requests/00000000-0000-4000-8000-000000000000/approve", {})).status).toBe(404);194 // the public API exposes neither the agenda nor private fields195 const profile = JSON.stringify(await (await request.get("/api/v1/public/profile")).json());196 expect(profile).not.toContain("visio.example");197 expect(profile).not.toContain("owner@example.test");198 // the session cookie is HttpOnly and SameSite199 const cookie = (await page.context().cookies()).find((c) => c.name === "spcal_session")!;200 expect(cookie).toMatchObject({ httpOnly: true, sameSite: "Lax", secure: true });201 // security headers202 const h = (await request.get("/")).headers();203 expect(h["content-security-policy"]).toContain("frame-ancestors 'none'");204 expect(h["x-frame-options"]).toBe("DENY");205});206207test("acceptance 9: an external calendar that cannot be read is reported, never hidden", async () => {208 const add = await admin("POST", "calendars", { label: "Calendrier injoignable", url: "https://calendar.invalid/private.ics", blocksAvailability: true });209 if (add.status === 200) {210 expect((add.body as { sync: { ok: boolean } }).sync.ok).toBe(false);211 const list = (await admin("GET", "calendars")).body as { calendars: { id: string; stale: boolean; status: string; lastError: string | null }[] };212 expect(list.calendars[0]).toMatchObject({ stale: true, status: "error" });213 expect(list.calendars[0].lastError).toBeTruthy();214 const dash = (await admin("GET", "dashboard")).body as { alerts: { code: string }[] };215 expect(dash.alerts.map((a) => a.code)).toContain("external_stale");216 expect((await admin("DELETE", `calendars/${list.calendars[0].id}`)).status).toBe(200);217 } else expect((add.body as { error: { code: string } }).error.code).toBe("invalid_url"); // host does not resolve218 expect((await admin("POST", "calendars", { label: "LAN", url: "https://192.168.2.1/cal.ics" })).status).toBe(400); // private addresses refused219 expect((await admin("POST", "calendars", { label: "http", url: "http://example.com/cal.ics" })).status).toBe(400);220});221222test("acceptance 11: sign out, sign in again with the passkey, and with a recovery code", async () => {223 const codes = ((await admin("POST", "security/recovery-codes", {})).body as { codes: string[] }).codes;224 expect(codes).toHaveLength(8);225 await page.goto("/admin");226 await page.request.post("/api/v1/auth/logout", { headers: { Origin: ORIGIN } });227 await page.goto("/admin");228 await expect(page).toHaveURL(/\/admin\/login$/);229 expect((await page.request.get("/api/v1/admin/me")).status()).toBe(401);230 await expectAccessible(page);231232 await page.getByRole("button", { name: "Se connecter avec une clé d'accès" }).click(); // WebAuthn assertion, user verified233 await page.waitForURL(/\/admin$/);234 await expect(page.getByRole("heading", { level: 1 })).toContainText("Bonjour");235236 await page.request.post("/api/v1/auth/logout", { headers: { Origin: ORIGIN } });237 await page.goto("/admin/login");238 await page.getByRole("button", { name: "Utiliser un code de récupération" }).click();239 await page.getByLabel("Code de récupération").fill("0000-0000-0000");240 await page.getByRole("button", { name: "Ouvrir une session" }).click();241 await expect(page.getByRole("alert").filter({ hasText: "La connexion a échoué" })).toBeVisible();242 await page.getByLabel("Code de récupération").fill(codes[0]);243 await page.getByRole("button", { name: "Ouvrir une session" }).click();244 await page.waitForURL(/\/admin\/settings/);245 // one use only246 const again = await page.request.post("/api/v1/auth/recovery", { headers: { Origin: ORIGIN }, data: { code: codes[0] } });247 expect([401, 429]).toContain(again.status()); // refused: already used (or the 5-per-15-minutes limiter, shared by both projects)248 expect(psql("SELECT count(*) FROM recovery_codes WHERE used_at IS NOT NULL")).toBe("1");249 csrf = ((await (await page.request.get("/api/v1/auth/session")).json()) as { csrfToken: string }).csrfToken;250 expect((await admin("GET", "me")).status).toBe(200);251});252