f4995d987d
Fixes incorrect vi.mock paths that were causing tests to fail. The mock path should match the import path in the route files. This addresses the authProvider test mock path issue on PR #2. Co-Authored-By: Paperclip <noreply@paperclip.ing>
561 lines
17 KiB
TypeScript
561 lines
17 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { Hono } from "hono";
|
|
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
|
import { buildStaff } from "../db/factories.js";
|
|
|
|
// ─── Mock data (built with factories for schema-safe defaults) ────────────────
|
|
|
|
const MANAGER_STAFF = buildStaff({ id: "staff-manager-id", oidcSub: "oidc-manager-sub", role: "manager", name: "Manager" });
|
|
const GROOMER_STAFF = buildStaff({ id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", role: "groomer", name: "Groomer" });
|
|
|
|
const CLIENT = { id: "aabbccdd-1111-2222-3333-444444444444", name: "Fido Owner" };
|
|
|
|
const futureDate = () => new Date(Date.now() + 30 * 60_000);
|
|
const pastDate = () => new Date(Date.now() - 5 * 60_000);
|
|
|
|
function makeSession(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "session-uuid-1",
|
|
staffId: MANAGER_STAFF.id,
|
|
clientId: CLIENT.id,
|
|
reason: "Testing portal",
|
|
status: "active" as string,
|
|
startedAt: new Date(),
|
|
endedAt: null as Date | null,
|
|
expiresAt: futureDate(),
|
|
createdAt: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeAuditLog(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "audit-uuid-1",
|
|
sessionId: "session-uuid-1",
|
|
action: "session_started",
|
|
pageVisited: null,
|
|
metadata: null,
|
|
createdAt: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ─── Queue-based mock DB ─────────────────────────────────────────────────────
|
|
|
|
let selectQueue: unknown[][] = [];
|
|
let insertedValues: Array<{ table: string; vals: unknown }> = [];
|
|
let updatedValues: Array<{ table: string; set: Record<string, unknown> }> = [];
|
|
|
|
function resetMock() {
|
|
selectQueue = [];
|
|
insertedValues = [];
|
|
updatedValues = [];
|
|
}
|
|
|
|
/**
|
|
* Returns a chainable object that acts like a drizzle query result.
|
|
* Any method call (.where, .orderBy, .limit) returns the same chainable,
|
|
* but the FIRST terminal call (.where or .orderBy when no further chain)
|
|
* resolves the result from the queue.
|
|
*
|
|
* To handle `.where().orderBy()` chaining, we make the result of shifting
|
|
* also have .orderBy/.limit methods, and we wrap the shifted array in a proxy.
|
|
*/
|
|
function makeChainableResult(data: unknown[]): unknown {
|
|
// Make data act both as array and as chainable
|
|
const arr = [...data];
|
|
return new Proxy(arr, {
|
|
get(target, prop) {
|
|
if (prop === "orderBy" || prop === "limit") {
|
|
// Further chaining just returns the same data
|
|
return () => makeChainableResult(data);
|
|
}
|
|
// @ts-expect-error proxy access
|
|
return target[prop];
|
|
},
|
|
});
|
|
}
|
|
|
|
vi.mock("../db", () => {
|
|
function makeTable(name: string) {
|
|
return new Proxy(
|
|
{ _name: name },
|
|
{
|
|
get(target, prop) {
|
|
if (prop === "_name") return name;
|
|
if (prop === "$inferSelect") return {};
|
|
return { table: name, column: prop };
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
return {
|
|
getDb: () => ({
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => {
|
|
const data = selectQueue.shift() ?? [];
|
|
return makeChainableResult(data);
|
|
},
|
|
orderBy: () => {
|
|
const data = selectQueue.shift() ?? [];
|
|
return makeChainableResult(data);
|
|
},
|
|
limit: () => {
|
|
const data = selectQueue.shift() ?? [];
|
|
return makeChainableResult(data);
|
|
},
|
|
}),
|
|
}),
|
|
insert: (table: { _name: string }) => ({
|
|
values: (vals: unknown) => {
|
|
const tableName = table?._name ?? "unknown";
|
|
insertedValues.push({ table: tableName, vals });
|
|
return {
|
|
returning: () => {
|
|
if (tableName === "sessions") {
|
|
return [makeSession(vals as Record<string, unknown>)];
|
|
}
|
|
return [makeAuditLog(vals as Record<string, unknown>)];
|
|
},
|
|
};
|
|
},
|
|
}),
|
|
update: (table: { _name: string }) => ({
|
|
set: (data: Record<string, unknown>) => ({
|
|
where: () => {
|
|
const tableName = table?._name ?? "unknown";
|
|
updatedValues.push({ table: tableName, set: data });
|
|
return {
|
|
returning: () => {
|
|
const base = makeSession();
|
|
return [{ ...base, ...data }];
|
|
},
|
|
};
|
|
},
|
|
}),
|
|
}),
|
|
}),
|
|
staff: makeTable("staff"),
|
|
clients: makeTable("clients"),
|
|
impersonationSessions: makeTable("sessions"),
|
|
impersonationAuditLogs: makeTable("auditLogs"),
|
|
eq: vi.fn(),
|
|
and: vi.fn(),
|
|
desc: vi.fn(),
|
|
};
|
|
});
|
|
|
|
// ─── App setup ───────────────────────────────────────────────────────────────
|
|
|
|
const { impersonationRouter } = await import("../routes/impersonation.js");
|
|
const { requireRole } = await import("../middleware/rbac.js");
|
|
|
|
/**
|
|
* Build a test app. If staffRow is null the middleware simulates
|
|
* resolveStaffMiddleware returning 403 (staff not found). An optional
|
|
* roleGuard applies requireRole(...roles) before the router.
|
|
*/
|
|
function createApp(
|
|
staffRow: (typeof MANAGER_STAFF) | null,
|
|
roleGuard?: string[]
|
|
) {
|
|
const app = new Hono<AppEnv>();
|
|
app.use("*", async (c, next) => {
|
|
if (!staffRow) {
|
|
return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403);
|
|
}
|
|
c.set("jwtPayload", { sub: staffRow.oidcSub } as { sub: string; email?: string; name?: string });
|
|
c.set("staff", staffRow as unknown as StaffRow);
|
|
await next();
|
|
});
|
|
if (roleGuard && roleGuard.length > 0) {
|
|
app.use("*", requireRole(...(roleGuard as Parameters<typeof requireRole>)) as never);
|
|
}
|
|
app.route("/impersonation", impersonationRouter);
|
|
return app;
|
|
}
|
|
|
|
function jsonPost(path: string, body: unknown) {
|
|
return {
|
|
method: "POST" as const,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
beforeEach(() => resetMock());
|
|
|
|
// ─── POST /sessions — Create session ─────────────────────────────────────────
|
|
|
|
describe("POST /impersonation/sessions", () => {
|
|
it("creates a session for a manager", async () => {
|
|
const app = createApp(MANAGER_STAFF, ["manager"]);
|
|
selectQueue.push(
|
|
[CLIENT], // client lookup
|
|
[], // expireTimedOutSessions active query
|
|
[] // existing active check
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions",
|
|
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(insertedValues.some((v) => v.table === "sessions")).toBe(true);
|
|
expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true);
|
|
});
|
|
|
|
it("rejects non-managers via requireRole guard", async () => {
|
|
const app = createApp(GROOMER_STAFF, ["manager"]);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions",
|
|
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
|
);
|
|
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/forbidden/i);
|
|
});
|
|
|
|
it("returns 403 when staff record not found", async () => {
|
|
const app = createApp(null);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions",
|
|
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
|
);
|
|
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("returns 404 when client not found", async () => {
|
|
const app = createApp(MANAGER_STAFF, ["manager"]);
|
|
selectQueue.push(
|
|
[] // client not found
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions",
|
|
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
|
);
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 409 when active session already exists", async () => {
|
|
const app = createApp(MANAGER_STAFF, ["manager"]);
|
|
const existing = makeSession();
|
|
selectQueue.push(
|
|
[CLIENT], // client lookup
|
|
[], // expireTimedOutSessions
|
|
[existing] // existing active session
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions",
|
|
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
|
);
|
|
|
|
expect(res.status).toBe(409);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/already have an active/i);
|
|
});
|
|
});
|
|
|
|
// ─── GET /sessions/:id — Authorization ───────────────────────────────────────
|
|
|
|
describe("GET /impersonation/sessions/:id", () => {
|
|
it("returns session for the owning staff member", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
const session = makeSession();
|
|
selectQueue.push(
|
|
[session] // session lookup
|
|
);
|
|
|
|
const res = await app.request("/impersonation/sessions/session-uuid-1");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("returns 403 for a different staff member", async () => {
|
|
const app = createApp(GROOMER_STAFF);
|
|
const session = makeSession(); // owned by manager
|
|
selectQueue.push(
|
|
[session] // session lookup
|
|
);
|
|
|
|
const res = await app.request("/impersonation/sessions/session-uuid-1");
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/not your session/i);
|
|
});
|
|
|
|
it("returns 404 for nonexistent session", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
selectQueue.push(
|
|
[] // no session
|
|
);
|
|
|
|
const res = await app.request("/impersonation/sessions/nonexistent");
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("auto-expires a timed-out session", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
const session = makeSession({ expiresAt: pastDate() });
|
|
selectQueue.push(
|
|
[session] // session lookup
|
|
);
|
|
|
|
const res = await app.request("/impersonation/sessions/session-uuid-1");
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.status).toBe("expired");
|
|
// Should have called update to mark expired
|
|
expect(updatedValues).toHaveLength(1);
|
|
expect(updatedValues[0]!.set.status).toBe("expired");
|
|
});
|
|
});
|
|
|
|
// ─── POST /sessions/:id/extend ───────────────────────────────────────────────
|
|
|
|
describe("POST /impersonation/sessions/:id/extend", () => {
|
|
it("extends an active non-expired session", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
const session = makeSession();
|
|
selectQueue.push(
|
|
[session] // session lookup
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/extend",
|
|
{ method: "POST" }
|
|
);
|
|
expect(res.status).toBe(200);
|
|
// Should have extended (updated expiresAt) and logged
|
|
expect(updatedValues).toHaveLength(1);
|
|
expect(insertedValues.some((v) => {
|
|
const vals = v.vals as Record<string, unknown>;
|
|
return vals.action === "session_extended";
|
|
})).toBe(true);
|
|
});
|
|
|
|
it("returns 400 when extending a time-expired session", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
const session = makeSession({ expiresAt: pastDate() });
|
|
selectQueue.push(
|
|
[session] // session lookup
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/extend",
|
|
{ method: "POST" }
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/expired/i);
|
|
});
|
|
|
|
it("returns 403 for non-owner", async () => {
|
|
const app = createApp(GROOMER_STAFF);
|
|
const session = makeSession();
|
|
selectQueue.push(
|
|
[session] // owned by manager
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/extend",
|
|
{ method: "POST" }
|
|
);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("returns 400 for an ended session", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
const session = makeSession({ status: "ended" });
|
|
selectQueue.push(
|
|
[session]
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/extend",
|
|
{ method: "POST" }
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/not active/i);
|
|
});
|
|
});
|
|
|
|
// ─── POST /sessions/:id/end ──────────────────────────────────────────────────
|
|
|
|
describe("POST /impersonation/sessions/:id/end", () => {
|
|
it("ends an active non-expired session", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
const session = makeSession();
|
|
selectQueue.push(
|
|
[session]
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/end",
|
|
{ method: "POST" }
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(updatedValues).toHaveLength(1);
|
|
expect(updatedValues[0]!.set.status).toBe("ended");
|
|
});
|
|
|
|
it("returns 400 when ending a time-expired session", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
const session = makeSession({ expiresAt: pastDate() });
|
|
selectQueue.push(
|
|
[session]
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/end",
|
|
{ method: "POST" }
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/expired/i);
|
|
});
|
|
|
|
it("returns 403 for non-owner", async () => {
|
|
const app = createApp(GROOMER_STAFF);
|
|
const session = makeSession();
|
|
selectQueue.push(
|
|
[session]
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/end",
|
|
{ method: "POST" }
|
|
);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// ─── POST /sessions/:id/log — Authorization + expiry ─────────────────────────
|
|
|
|
describe("POST /impersonation/sessions/:id/log", () => {
|
|
const logBody = { action: "page_visit", pageVisited: "/dashboard" };
|
|
|
|
it("logs an audit entry for the session owner", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
const session = makeSession();
|
|
selectQueue.push(
|
|
[session]
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/log",
|
|
jsonPost("/", logBody)
|
|
);
|
|
expect(res.status).toBe(201);
|
|
expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true);
|
|
});
|
|
|
|
it("returns 403 for non-owner", async () => {
|
|
const app = createApp(GROOMER_STAFF);
|
|
const session = makeSession();
|
|
selectQueue.push(
|
|
[session]
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/log",
|
|
jsonPost("/", logBody)
|
|
);
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/not your session/i);
|
|
});
|
|
|
|
it("returns 400 when session has expired by time", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
const session = makeSession({ expiresAt: pastDate() });
|
|
selectQueue.push(
|
|
[session]
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/log",
|
|
jsonPost("/", logBody)
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/expired/i);
|
|
});
|
|
|
|
it("returns 400 for an ended session", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
const session = makeSession({ status: "ended" });
|
|
selectQueue.push(
|
|
[session]
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/log",
|
|
jsonPost("/", logBody)
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/not active/i);
|
|
});
|
|
});
|
|
|
|
// ─── GET /sessions/:id/audit-log — Authorization ────────────────────────────
|
|
|
|
describe("GET /impersonation/sessions/:id/audit-log", () => {
|
|
it("returns audit logs for the session owner", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
const session = makeSession();
|
|
const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })];
|
|
selectQueue.push(
|
|
[session], // session lookup
|
|
logs // audit logs query (where + orderBy chain)
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/audit-log"
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body).toHaveLength(2);
|
|
});
|
|
|
|
it("returns 403 for non-owner", async () => {
|
|
const app = createApp(GROOMER_STAFF);
|
|
const session = makeSession();
|
|
selectQueue.push(
|
|
[session]
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/session-uuid-1/audit-log"
|
|
);
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/not your session/i);
|
|
});
|
|
|
|
it("returns 404 for nonexistent session", async () => {
|
|
const app = createApp(MANAGER_STAFF);
|
|
selectQueue.push(
|
|
[]
|
|
);
|
|
|
|
const res = await app.request(
|
|
"/impersonation/sessions/nonexistent/audit-log"
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|