uat→main (PROD): GRO-2234 portal session fix + validated batch #191
@@ -284,6 +284,8 @@ This means:
|
||||
| TC-API-8.14 | Portal pet update — non-owner blocked (GRO-2187) | `PATCH /api/portal/pets/{petId}` for a pet owned by a different client, using another client's portal session | 403 Forbidden (or 404 if pet id is unknown); no mutation persisted |
|
||||
| TC-API-8.15 | Portal pet update — invalid enum rejected (GRO-2187) | `PATCH /api/portal/pets/{petId}` with `coatType: "fluffy"` or `petSizeCategory: "gigantic"` | 422 Unprocessable Entity; pet unchanged |
|
||||
| TC-API-8.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted |
|
||||
| TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. |
|
||||
| TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) |
|
||||
|
||||
### 4.9 Waitlist
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,32 @@ export interface PortalEnv {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle lifetime of an SSO-bridge portal impersonation session. Each authenticated
|
||||
* portal request slides `expiresAt` forward to `now + IDLE_TTL`, so an actively-used
|
||||
* session (e.g. a customer working through the multi-step Book New wizard) never
|
||||
* lapses mid-flow. Matches the staff-console impersonation idle window
|
||||
* (SESSION_TIMEOUT_MINUTES in routes/impersonation.ts). (GRO-2234)
|
||||
*/
|
||||
export const PORTAL_SESSION_IDLE_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Absolute cap on a single SSO-bridge portal session's lifetime, measured from
|
||||
* `startedAt`. Sliding can never extend a session beyond this bound, keeping the
|
||||
* impersonation model bounded regardless of how long a customer keeps the tab
|
||||
* active. Deliberately tighter than the previous static 24h mint. (GRO-2234)
|
||||
*/
|
||||
export const PORTAL_SESSION_MAX_LIFETIME_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Minimum extension before we issue a sliding-expiration write. Avoids a DB write
|
||||
* on every rapid successive request — at most one slide per minute per session.
|
||||
*/
|
||||
const PORTAL_SESSION_SLIDE_THRESHOLD_MS = 60 * 1000;
|
||||
|
||||
/** Reason marker for sessions minted by the Better Auth -> portal bridge. */
|
||||
const SSO_BRIDGE_REASON = "sso-bridge";
|
||||
|
||||
/**
|
||||
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
||||
* Must be applied to all portal routes.
|
||||
@@ -16,6 +42,12 @@ export interface PortalEnv {
|
||||
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
||||
* Returns 401 if session is invalid/missing/expired.
|
||||
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
||||
*
|
||||
* Sliding expiration (GRO-2234): for SSO-bridge sessions, each successful request
|
||||
* extends `expiresAt` to `now + PORTAL_SESSION_IDLE_TTL_MS`, bounded by
|
||||
* `startedAt + PORTAL_SESSION_MAX_LIFETIME_MS`. Staff-initiated impersonation
|
||||
* sessions (any other `reason`) are left untouched, preserving their existing
|
||||
* console-enforced timeout behavior.
|
||||
*/
|
||||
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
@@ -24,16 +56,29 @@ export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, nex
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
if (!session || session.expiresAt <= now) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// Sliding expiration for SSO-bridge portal sessions only (GRO-2234).
|
||||
if (session.reason === SSO_BRIDGE_REASON) {
|
||||
const maxExpiry = session.startedAt.getTime() + PORTAL_SESSION_MAX_LIFETIME_MS;
|
||||
const slidExpiry = Math.min(now.getTime() + PORTAL_SESSION_IDLE_TTL_MS, maxExpiry);
|
||||
if (slidExpiry - session.expiresAt.getTime() >= PORTAL_SESSION_SLIDE_THRESHOLD_MS) {
|
||||
await db
|
||||
.update(impersonationSessions)
|
||||
.set({ expiresAt: new Date(slidExpiry) })
|
||||
.where(eq(impersonationSessions.id, session.id));
|
||||
}
|
||||
}
|
||||
|
||||
c.set("portalClientId", session.clientId);
|
||||
c.set("portalSessionId", session.id);
|
||||
await next();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, inArray } from "@groombook/db";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||
import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js";
|
||||
import { portalAudit } from "../middleware/portalAudit.js";
|
||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||
|
||||
@@ -129,7 +129,7 @@ portalRouter.post("/session-from-auth", async (c) => {
|
||||
staffId,
|
||||
clientId: client.id,
|
||||
reason: "sso-bridge",
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
expiresAt: new Date(Date.now() + PORTAL_SESSION_IDLE_TTL_MS),
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user