import { describe, it, expect, beforeEach, vi } from "vitest"; import { Hono } from "hono"; import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS, PORTAL_SESSION_MAX_LIFETIME_MS, type PortalEnv, } from "../middleware/portalSession.js"; const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; // Mutable test state driven per-case. let selectSessionRow: Record | null = null; let sessionUpdates: Record[] = []; function resetMock() { selectSessionRow = null; sessionUpdates = []; } vi.mock("@groombook/db", () => { function makeChainable(data: unknown[]): unknown { const arr = [...data]; const chain = new Proxy(arr, { get(target, prop) { if (prop === "where" || prop === "orderBy" || prop === "limit") { return () => chain; } // @ts-expect-error proxy index return target[prop]; }, }); return chain; } const impersonationSessions = new Proxy( { _name: "impersonationSessions" }, { get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) } ); return { getDb: () => ({ select: () => ({ from: (table: { _name: string }) => { if (table._name === "impersonationSessions") { return makeChainable(selectSessionRow ? [selectSessionRow] : []); } return makeChainable([]); }, }), update: () => ({ set: (vals: Record) => { sessionUpdates.push(vals); return { where: () => Promise.resolve(undefined) }; }, }), }), impersonationSessions, eq: vi.fn(), and: vi.fn(), }; }); const app = new Hono(); app.use("/portal/*", validatePortalSession); app.get("/portal/ping", (c) => c.json({ ok: true, clientId: c.get("portalClientId") })); function ping(headers?: Record) { return app.request("/portal/ping", { method: "GET", headers }); } beforeEach(() => resetMock()); describe("validatePortalSession — sliding expiration (GRO-2234)", () => { it("extends an sso-bridge session's expiresAt on each authenticated request", async () => { const now = Date.now(); // Session minted ~28 min ago, originally a 30-min idle window: it is still // valid (2 min left) but a slow wizard would otherwise let it lapse. selectSessionRow = { id: SESSION_ID, clientId: CLIENT_ID, status: "active", reason: "sso-bridge", startedAt: new Date(now - 28 * 60 * 1000), expiresAt: new Date(now + 2 * 60 * 1000), }; const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); expect(res.status).toBe(200); expect(sessionUpdates).toHaveLength(1); const newExpiry = sessionUpdates[0]!.expiresAt as Date; // Slid forward to ~now + 30 min (well past the original 2-min-left window). expect(newExpiry.getTime()).toBeGreaterThan(now + PORTAL_SESSION_IDLE_TTL_MS - 5_000); expect(newExpiry.getTime()).toBeLessThanOrEqual(now + PORTAL_SESSION_IDLE_TTL_MS + 5_000); }); it("keeps a slow-wizard customer authorized past the original mint TTL", async () => { const now = Date.now(); // Original mint window has fully elapsed in wall-clock terms, but the session // was slid forward on the previous request, so it is still valid now. selectSessionRow = { id: SESSION_ID, clientId: CLIENT_ID, status: "active", reason: "sso-bridge", startedAt: new Date(now - 35 * 60 * 1000), expiresAt: new Date(now + 10 * 60 * 1000), // previously slid }; const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); expect(res.status).toBe(200); const body = await res.json(); expect(body.clientId).toBe(CLIENT_ID); }); it("never extends beyond startedAt + MAX_LIFETIME (bounded)", async () => { const now = Date.now(); // Session started right at the absolute cap boundary minus a hair. const startedAt = now - (PORTAL_SESSION_MAX_LIFETIME_MS - 5 * 60 * 1000); selectSessionRow = { id: SESSION_ID, clientId: CLIENT_ID, status: "active", reason: "sso-bridge", startedAt: new Date(startedAt), expiresAt: new Date(now + 60 * 1000), }; const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); expect(res.status).toBe(200); expect(sessionUpdates).toHaveLength(1); const newExpiry = (sessionUpdates[0]!.expiresAt as Date).getTime(); // Capped at startedAt + MAX_LIFETIME, NOT now + IDLE_TTL. expect(newExpiry).toBeLessThanOrEqual(startedAt + PORTAL_SESSION_MAX_LIFETIME_MS + 1_000); expect(newExpiry).toBeGreaterThan(now); // still extends at least a little }); it("does NOT slide a staff-initiated impersonation session (no regression)", async () => { const now = Date.now(); selectSessionRow = { id: SESSION_ID, clientId: CLIENT_ID, status: "active", reason: "manager reviewing booking", // staff-console reason, free text startedAt: new Date(now - 5 * 60 * 1000), expiresAt: new Date(now + 20 * 60 * 1000), }; const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); expect(res.status).toBe(200); expect(sessionUpdates).toHaveLength(0); }); it("still rejects an already-expired session (no resurrection)", async () => { const now = Date.now(); selectSessionRow = { id: SESSION_ID, clientId: CLIENT_ID, status: "active", reason: "sso-bridge", startedAt: new Date(now - 40 * 60 * 1000), expiresAt: new Date(now - 60 * 1000), // already lapsed }; const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); expect(res.status).toBe(401); expect(sessionUpdates).toHaveLength(0); }); it("skips the write when the extension is below the slide threshold", async () => { const now = Date.now(); // Already slid this minute: expiresAt is essentially now + IDLE_TTL already. selectSessionRow = { id: SESSION_ID, clientId: CLIENT_ID, status: "active", reason: "sso-bridge", startedAt: new Date(now - 2 * 60 * 1000), expiresAt: new Date(now + PORTAL_SESSION_IDLE_TTL_MS - 2_000), }; const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); expect(res.status).toBe(200); expect(sessionUpdates).toHaveLength(0); }); });