189 lines
6.4 KiB
TypeScript
189 lines
6.4 KiB
TypeScript
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<string, unknown> | null = null;
|
|
let sessionUpdates: Record<string, unknown>[] = [];
|
|
|
|
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<string, unknown>) => {
|
|
sessionUpdates.push(vals);
|
|
return { where: () => Promise.resolve(undefined) };
|
|
},
|
|
}),
|
|
}),
|
|
impersonationSessions,
|
|
eq: vi.fn(),
|
|
and: vi.fn(),
|
|
};
|
|
});
|
|
|
|
const app = new Hono<PortalEnv>();
|
|
app.use("/portal/*", validatePortalSession);
|
|
app.get("/portal/ping", (c) => c.json({ ok: true, clientId: c.get("portalClientId") }));
|
|
|
|
function ping(headers?: Record<string, string>) {
|
|
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);
|
|
});
|
|
});
|