Extract groombook/api from monorepo with CI workflow

- Add source code from apps/api
- Add packages/db and packages/types workspace dependencies
- Add GitHub Actions CI workflow (lint, typecheck, test, docker)
- Generate pnpm-lock.yaml
- Add .gitignore

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-11 01:26:56 +00:00
committed by Flea Flicker [agent]
parent 4d7baec939
commit abac9dfe6c
114 changed files with 31941 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mutable state to control mock behavior per test
let dbSelectResult: unknown[] = [];
const mockEq = vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val }));
const mockDecryptSecret = vi.fn((s: string) => `decrypted:${s}`);
vi.mock("@groombook/db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
get(target, prop) {
if (prop === "_name") return "auth_provider_config";
if (prop === "$inferSelect") return {};
return { table: "auth_provider_config", column: prop };
},
}
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => dbSelectResult,
[Symbol.iterator]: function* () {
for (const item of dbSelectResult) yield item;
},
0: dbSelectResult[0],
length: dbSelectResult.length,
}),
}),
}),
}),
authProviderConfig,
eq: mockEq,
decryptSecret: mockDecryptSecret,
};
});
async function reimportAuth() {
vi.resetModules();
vi.doMock("@groombook/db", () => ({
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => dbSelectResult,
[Symbol.iterator]: function* () {
for (const item of dbSelectResult) yield item;
},
0: dbSelectResult[0],
length: dbSelectResult.length,
}),
}),
}),
}),
authProviderConfig: {},
eq: mockEq,
decryptSecret: mockDecryptSecret,
}));
const mod = await import("../lib/auth.js");
return mod;
}
describe("auth init", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
dbSelectResult = [];
vi.clearAllMocks();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("falls back to env vars when DB returns empty", async () => {
process.env = {
...originalEnv,
OIDC_ISSUER: "https://issuer.example.com",
OIDC_CLIENT_ID: "test-client-id",
OIDC_CLIENT_SECRET: "test-client-secret",
BETTER_AUTH_SECRET: "test-secret",
BETTER_AUTH_URL: "http://localhost:3000",
NODE_ENV: "test",
};
const { initAuth, getAuth } = await reimportAuth();
await initAuth();
expect(getAuth()).toBeDefined();
});
it("uses DB config and decrypts clientSecret when DB has enabled provider", async () => {
const dbConfig = {
id: "config-id",
providerId: "okta",
displayName: "Okta",
issuerUrl: "https://okta.example.com",
internalBaseUrl: null,
clientId: "okta-client-id",
clientSecret: "encrypted:okta-secret",
scopes: "openid profile email",
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
dbSelectResult = [dbConfig];
process.env = {
...originalEnv,
BETTER_AUTH_SECRET: "test-secret",
BETTER_AUTH_URL: "http://localhost:3000",
NODE_ENV: "test",
};
const { initAuth, getAuth } = await reimportAuth();
await initAuth();
expect(getAuth()).toBeDefined();
expect(mockDecryptSecret).toHaveBeenCalledWith("encrypted:okta-secret");
});
it("throws when BETTER_AUTH_SECRET is missing and AUTH_DISABLED is not set", async () => {
process.env = {
...originalEnv,
OIDC_ISSUER: "",
OIDC_CLIENT_ID: "",
OIDC_CLIENT_SECRET: "",
NODE_ENV: "test",
};
delete process.env.BETTER_AUTH_SECRET;
delete process.env.AUTH_DISABLED;
const { initAuth } = await reimportAuth();
await expect(initAuth()).rejects.toThrow(
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
);
});
it("builds placeholder auth when AUTH_DISABLED=true without throwing", async () => {
process.env = {
...originalEnv,
AUTH_DISABLED: "true",
NODE_ENV: "test",
};
delete process.env.BETTER_AUTH_SECRET;
const { initAuth, getAuth } = await reimportAuth();
await expect(initAuth()).resolves.toBeUndefined();
expect(getAuth()).toBeDefined();
});
});
+273
View File
@@ -0,0 +1,273 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import { authProviderRouter } from "../routes/authProvider.js";
// ─── Mock auth module ─────────────────────────────────────────────────────────
vi.mock("../lib/auth.js", () => ({
reinitAuth: vi.fn().mockResolvedValue(undefined),
}));
// ─── Types ────────────────────────────────────────────────────────────────────
interface MockStaff {
id: string;
role: string;
isSuperUser: boolean;
}
// ─── Mock DB state ────────────────────────────────────────────────────────────
let dbRows: Record<string, unknown>[] = [];
let deletedRows: string[] = [];
let insertedRows: Record<string, unknown>[] = [];
let encryptCalls: string[] = [];
function resetMock() {
dbRows = [];
deletedRows = [];
insertedRows = [];
encryptCalls = [];
}
// ─── Mock staff context ───────────────────────────────────────────────────────
const mockSuperUser: MockStaff = { id: "staff-1", role: "manager", isSuperUser: true };
const mockManager: MockStaff = { id: "staff-2", role: "manager", isSuperUser: false };
const mockGroomer: MockStaff = { id: "staff-3", role: "groomer", isSuperUser: false };
// ─── Mock db module ───────────────────────────────────────────────────────────
vi.mock("@groombook/db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
get(_target, prop) {
if (prop === "_name") return "auth_provider_config";
if (prop === "$inferSelect") return {};
return { table: "auth_provider_config", column: prop };
},
}
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => [...dbRows],
[Symbol.iterator]: function* () {
for (const item of dbRows) yield item;
},
0: dbRows[0],
length: dbRows.length,
}),
}),
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
insertedRows.push(vals);
return {
returning: () => [{ ...vals, id: "new-id-1", createdAt: new Date(), updatedAt: new Date() }],
};
},
}),
delete: () => {
// Execute immediately - route doesn't chain .returning()
deletedRows.push("all");
return Promise.resolve([]);
},
transaction: <T>(fn: (tx: {
delete: () => Promise<unknown>;
insert: () => { values: (v: Record<string, unknown>) => { returning: () => T[] } };
}) => Promise<T>) => {
const tx = {
delete: () => { deletedRows.push("all"); return Promise.resolve([]); },
insert: () => ({
values: (vals: Record<string, unknown>) => ({
returning: () => [{ ...vals, id: "new-id-1", createdAt: new Date(), updatedAt: new Date() }] as T[],
}),
}),
};
return fn(tx);
},
}),
authProviderConfig,
eq: (_col: unknown, _val: unknown) => ({ col: _col, val: _val }),
encryptSecret: (val: string) => {
encryptCalls.push(val);
return `encrypted:${val}`;
},
};
});
// ─── Build test app ───────────────────────────────────────────────────────────
function makeApp(staff: MockStaff | null) {
const app = new Hono();
// Inject staff context + super user guard per route
// Must match both exact path and wildcard subpaths
app.use(
"/admin/auth-provider/*",
async (c, next) => {
if (!staff) {
return c.json({ error: "Forbidden: no staff record resolved" }, 403);
}
if (!staff.isSuperUser) {
return c.json({ error: "Forbidden: super user privileges required" }, 403);
}
(c as any).set("staff", staff);
await next();
}
);
app.route("/admin/auth-provider", authProviderRouter as unknown as Hono);
return app;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function get<T extends Hono = Hono>(app: T, path: string, staff: MockStaff | null) {
const res = await app.request(path, { method: "GET" }, { allCtx: { staff } as { staff: MockStaff } });
return { status: res.status, body: await res.json() };
}
async function put<T extends Hono = Hono>(app: T, path: string, body: unknown, staff: MockStaff | null) {
const res = await app.request(path, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}, { allCtx: { staff } as { staff: MockStaff } });
return { status: res.status, body: await res.json() };
}
async function post<T extends Hono = Hono>(app: T, path: string, body: unknown, staff: MockStaff | null) {
const res = await app.request(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}, { allCtx: { staff } as { staff: MockStaff } });
return { status: res.status, body: await res.json() };
}
async function del<T extends Hono = Hono>(app: T, path: string, staff: MockStaff | null) {
const res = await app.request(path, { method: "DELETE" }, { allCtx: { staff } as { staff: MockStaff } });
return { status: res.status, body: await res.json() };
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("GET /admin/auth-provider", () => {
beforeEach(resetMock);
it("returns 404 when no provider configured", async () => {
dbRows = [];
const app = makeApp(mockSuperUser);
const { status, body } = await get(app, "/admin/auth-provider", mockSuperUser);
expect(status).toBe(404);
expect(body.error).toBe("No auth provider configured");
});
it("returns config with secret redacted", async () => {
dbRows = [{
id: "prov-1",
providerId: "authentik",
displayName: "Authentik",
issuerUrl: "https://auth.example.com",
internalBaseUrl: null,
clientId: "client-123",
clientSecret: "encrypted:secret",
scopes: "openid profile email",
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
}];
const app = makeApp(mockSuperUser);
const { status, body } = await get(app, "/admin/auth-provider", mockSuperUser);
expect(status).toBe(200);
expect(body.clientSecret).toBe("••••••••");
expect(body.providerId).toBe("authentik");
});
it("returns 403 when not super user", async () => {
dbRows = [];
const app = makeApp(mockManager);
const { status } = await get(app, "/admin/auth-provider", mockManager);
expect(status).toBe(403);
});
});
describe("PUT /admin/auth-provider", () => {
beforeEach(resetMock);
it("stores encrypted secret", async () => {
const app = makeApp(mockSuperUser);
const { status, body } = await put(app, "/admin/auth-provider", {
providerId: "authentik",
displayName: "Authentik SSO",
issuerUrl: "https://auth.example.com",
clientId: "my-client",
clientSecret: "my-secret",
scopes: "openid profile email",
}, mockSuperUser);
expect(status).toBe(200);
expect(encryptCalls).toContain("my-secret");
expect(body.clientSecret).toBe("••••••••");
expect(body.providerId).toBe("authentik");
});
it("returns 400 for invalid schema", async () => {
const app = makeApp(mockSuperUser);
const { status } = await put(app, "/admin/auth-provider", {
providerId: "",
issuerUrl: "not-a-url",
}, mockSuperUser);
expect(status).toBe(400);
});
});
describe("POST /admin/auth-provider/test", () => {
beforeEach(resetMock);
it("returns ok=false for unreachable issuer", async () => {
const app = makeApp(mockSuperUser);
const { status, body } = await post(app, "/admin/auth-provider/test", {
providerId: "authentik",
displayName: "Authentik",
issuerUrl: "https://192.0.2.1/", // TEST-NET, never reachable
clientId: "client",
scopes: "openid profile email",
}, mockSuperUser);
expect(status).toBe(200);
expect(body.ok).toBe(false);
expect(body.error).toBeTruthy();
}, 15000); // timeout must exceed the 10s fetch timeout in the route handler
it("returns 400 for missing clientSecret (not required for test)", async () => {
const app = makeApp(mockSuperUser);
const { status } = await post(app, "/admin/auth-provider/test", {
providerId: "authentik",
displayName: "Authentik",
issuerUrl: "https://auth.example.com",
clientId: "client",
}, mockSuperUser);
expect(status).toBe(200); // clientSecret omitted intentionally for test
});
});
describe("DELETE /admin/auth-provider", () => {
beforeEach(resetMock);
it("deletes all config rows", async () => {
const app = makeApp(mockSuperUser);
const { status, body } = await del(app, "/admin/auth-provider", mockSuperUser);
expect(status).toBe(200);
expect(body.ok).toBe(true);
expect(deletedRows).toContain("all");
});
it("returns 403 when not super user", async () => {
const app = makeApp(mockGroomer);
const { status } = await del(app, "/admin/auth-provider", mockGroomer);
expect(status).toBe(403);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { generateIcalToken } from "../routes/calendar.js";
describe("generateIcalToken", () => {
it("generates a 64-character hex token", () => {
const token = generateIcalToken();
expect(token).toHaveLength(64);
expect(token).toMatch(/^[a-f0-9]+$/);
});
it("generates unique tokens", () => {
const token1 = generateIcalToken();
const token2 = generateIcalToken();
expect(token1).not.toBe(token2);
});
});
+294
View File
@@ -0,0 +1,294 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock data ────────────────────────────────────────────────────────────────
const ACTIVE_CLIENT = {
id: "client-uuid-1",
name: "Alice",
email: "alice@example.com",
phone: "555-1234",
address: "1 Main St",
notes: null,
status: "active",
disabledAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const DISABLED_CLIENT = {
...ACTIVE_CLIENT,
id: "client-uuid-2",
name: "Bob",
status: "disabled",
disabledAt: new Date(),
};
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
let selectRows: Record<string, unknown>[] = [];
let appointmentRows: Record<string, unknown>[] = [];
let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let deletedId: string | null = null;
function resetMock() {
selectRows = [];
appointmentRows = [];
insertedValues = [];
updatedValues = [];
deletedId = null;
}
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
return target[prop];
},
});
return chain;
}
const clients = new Proxy(
{ _name: "clients" },
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: unknown) => {
const tableName = (table as { _name?: string })._name;
const rows = tableName === "appointments" ? appointmentRows : selectRows;
return makeChainable(rows);
},
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
insertedValues.push(vals);
return {
returning: () => [{ ...ACTIVE_CLIENT, ...vals, id: "client-uuid-new" }],
};
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
updatedValues.push(vals);
return {
returning: () =>
selectRows.length > 0
? [{ ...selectRows[0], ...vals }]
: [],
};
},
}),
}),
delete: () => ({
where: () => {
deletedId = "client-uuid-1";
return {
returning: () =>
selectRows.length > 0 ? [selectRows[0]] : [],
};
},
}),
}),
clients,
appointments,
eq: vi.fn(),
and: vi.fn(),
or: vi.fn(),
};
});
// ─── App setup ────────────────────────────────────────────────────────────────
const { clientsRouter } = await import("../routes/clients.js");
const app = new Hono();
app.route("/clients", clientsRouter);
function jsonRequest(method: string, path: string, body?: unknown) {
return app.request(path, {
method,
headers: { "Content-Type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => resetMock());
// ─── GET / ────────────────────────────────────────────────────────────────────
describe("GET /clients", () => {
it("returns active clients", async () => {
selectRows = [ACTIVE_CLIENT];
const res = await app.request("/clients");
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(1);
});
it("returns all clients when includeDisabled=true", async () => {
selectRows = [ACTIVE_CLIENT, DISABLED_CLIENT];
const res = await app.request("/clients?includeDisabled=true");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(2);
});
it("returns empty array when no clients exist", async () => {
selectRows = [];
const res = await app.request("/clients");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
// ─── GET /:id ─────────────────────────────────────────────────────────────────
describe("GET /clients/:id", () => {
it("returns a single client", async () => {
selectRows = [ACTIVE_CLIENT];
const res = await app.request("/clients/client-uuid-1");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe("client-uuid-1");
expect(body.name).toBe("Alice");
});
it("returns 404 for a nonexistent client", async () => {
selectRows = [];
const res = await app.request("/clients/nonexistent");
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toMatch(/not found/i);
});
});
// ─── POST / ───────────────────────────────────────────────────────────────────
describe("POST /clients", () => {
it("creates a client with valid data", async () => {
const res = await jsonRequest("POST", "/clients", {
name: "Charlie",
email: "charlie@example.com",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.name).toBe("Charlie");
expect(insertedValues).toHaveLength(1);
expect(insertedValues[0]!.name).toBe("Charlie");
});
it("creates a client with name and email", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
expect(res.status).toBe(201);
expect(insertedValues[0]!.name).toBe("Dana");
expect(insertedValues[0]!.email).toBe("dana@example.com");
});
it("rejects empty name", async () => {
const res = await jsonRequest("POST", "/clients", { name: "" });
expect(res.status).toBe(400);
});
it("rejects invalid email format", async () => {
const res = await jsonRequest("POST", "/clients", {
name: "Eve",
email: "not-an-email",
});
expect(res.status).toBe(400);
});
it("rejects missing body", async () => {
const res = await app.request("/clients", { method: "POST" });
expect(res.status).toBe(400);
});
});
// ─── PATCH /:id ───────────────────────────────────────────────────────────────
describe("PATCH /clients/:id", () => {
it("updates client fields", async () => {
selectRows = [ACTIVE_CLIENT];
const res = await jsonRequest("PATCH", "/clients/client-uuid-1", {
name: "Alice Updated",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.name).toBe("Alice Updated");
expect(updatedValues[0]!.name).toBe("Alice Updated");
});
it("sets disabledAt when status is set to disabled", async () => {
selectRows = [ACTIVE_CLIENT];
await jsonRequest("PATCH", "/clients/client-uuid-1", {
status: "disabled",
});
expect(updatedValues[0]!.status).toBe("disabled");
expect(updatedValues[0]!.disabledAt).toBeDefined();
});
it("clears disabledAt when re-enabling", async () => {
selectRows = [DISABLED_CLIENT];
await jsonRequest("PATCH", "/clients/client-uuid-2", {
status: "active",
});
expect(updatedValues[0]!.disabledAt).toBeNull();
});
it("returns 404 when client not found", async () => {
selectRows = [];
const res = await jsonRequest("PATCH", "/clients/nonexistent", {
name: "Ghost",
});
expect(res.status).toBe(404);
});
});
// ─── DELETE /:id ──────────────────────────────────────────────────────────────
describe("DELETE /clients/:id", () => {
it("requires ?confirm=true", async () => {
const res = await app.request("/clients/client-uuid-1", {
method: "DELETE",
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/confirm/i);
});
it("deletes a client with ?confirm=true", async () => {
selectRows = [ACTIVE_CLIENT];
const res = await app.request("/clients/client-uuid-1?confirm=true", {
method: "DELETE",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(deletedId).toBe("client-uuid-1");
});
it("returns 404 when client not found", async () => {
selectRows = [];
const res = await app.request("/clients/nonexistent?confirm=true", {
method: "DELETE",
});
expect(res.status).toBe(404);
});
});
+340
View File
@@ -0,0 +1,340 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock appointment data ────────────────────────────────────────────────────
const FUTURE_TIME = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 1 week from now
const PAST_TIME = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
const BASE_APPT = {
id: "appt-uuid-1",
clientId: "client-uuid-1",
petId: "pet-uuid-1",
serviceId: "service-uuid-1",
staffId: "staff-uuid-1",
batherStaffId: null,
status: "scheduled" as const,
startTime: FUTURE_TIME,
endTime: new Date(FUTURE_TIME.getTime() + 3600_000),
notes: null,
priceCents: null,
seriesId: null,
seriesIndex: null,
groupId: null,
confirmationStatus: "pending",
confirmedAt: null,
cancelledAt: null,
confirmationToken: "valid-token-abc123",
createdAt: new Date(),
updatedAt: new Date(),
};
// ─── Shared mock DB state ─────────────────────────────────────────────────────
let mockAppt: typeof BASE_APPT | null = BASE_APPT;
let lastUpdate: Record<string, unknown> = {};
function resetMock() {
mockAppt = { ...BASE_APPT };
lastUpdate = {};
}
vi.mock("@groombook/db", () => {
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => (mockAppt ? [mockAppt] : []),
}),
}),
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
lastUpdate = { ...vals };
if (mockAppt) {
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT;
}
return { returning: () => (mockAppt ? [mockAppt] : []) };
},
}),
}),
}),
appointments,
eq: () => ({}),
and: (..._clauses: unknown[]) => ({}),
};
});
// ─── Book router (tokenized endpoints) ───────────────────────────────────────
async function makeBookApp() {
const { bookRouter } = await import("../routes/book.js");
const app = new Hono();
app.route("/api/book", bookRouter);
return app;
}
// ─── Appointments router (portal endpoints) ────────────────────────────────
async function makeAppointmentsApp() {
const { appointmentsRouter } = await import("../routes/appointments.js");
const app = new Hono();
app.route("/api/appointments", appointmentsRouter);
return app;
}
// ─── Tests: tokenized confirm endpoint ────────────────────────────────────────
describe("GET /api/book/confirm/:token", () => {
let app: Hono;
beforeEach(async () => {
vi.resetModules();
resetMock();
app = await makeBookApp();
});
it("redirects to /booking/confirmed on valid token and future appointment", async () => {
const res = await app.request("/api/book/confirm/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/confirmed");
});
it("sets confirmationStatus to confirmed", async () => {
await app.request("/api/book/confirm/valid-token-abc123");
expect(lastUpdate.confirmationStatus).toBe("confirmed");
expect(lastUpdate.confirmedAt).toBeInstanceOf(Date);
});
it("redirects to /booking/error when token not found", async () => {
mockAppt = null;
const res = await app.request("/api/book/confirm/bad-token");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
it("redirects to /booking/error when appointment is in the past", async () => {
mockAppt = { ...BASE_APPT, startTime: PAST_TIME };
const res = await app.request("/api/book/confirm/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
it("redirects to /booking/confirmed idempotently when already confirmed", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
const res = await app.request("/api/book/confirm/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/confirmed");
});
it("redirects to /booking/error when appointment is already customer-cancelled", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
const res = await app.request("/api/book/confirm/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
});
// ─── Tests: tokenized cancel endpoint ────────────────────────────────────────
describe("GET /api/book/cancel/:token", () => {
let app: Hono;
beforeEach(async () => {
vi.resetModules();
resetMock();
app = await makeBookApp();
});
it("redirects to /booking/cancelled on valid token and future appointment", async () => {
const res = await app.request("/api/book/cancel/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/cancelled");
});
it("sets confirmationStatus to cancelled and nullifies token (single-use)", async () => {
await app.request("/api/book/cancel/valid-token-abc123");
expect(lastUpdate.confirmationStatus).toBe("cancelled");
expect(lastUpdate.cancelledAt).toBeInstanceOf(Date);
expect(lastUpdate.confirmationToken).toBeNull();
});
it("redirects to /booking/error when token not found", async () => {
mockAppt = null;
const res = await app.request("/api/book/cancel/bad-token");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
it("redirects to /booking/error when appointment is in the past", async () => {
mockAppt = { ...BASE_APPT, startTime: PAST_TIME };
const res = await app.request("/api/book/cancel/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
it("redirects to /booking/error when already customer-cancelled", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
const res = await app.request("/api/book/cancel/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
});
// ─── Tests: portal confirm endpoint ──────────────────────────────────────────
describe("POST /api/appointments/:id/confirm", () => {
let app: Hono;
beforeEach(async () => {
vi.resetModules();
resetMock();
app = await makeAppointmentsApp();
});
it("confirms a pending appointment", async () => {
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
method: "POST",
});
expect(res.status).toBe(200);
expect(lastUpdate.confirmationStatus).toBe("confirmed");
expect(lastUpdate.confirmedAt).toBeInstanceOf(Date);
});
it("returns 404 when appointment not found", async () => {
mockAppt = null;
const res = await app.request("/api/appointments/nonexistent/confirm", {
method: "POST",
});
expect(res.status).toBe(404);
});
it("returns 409 when appointment is already customer-cancelled", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
method: "POST",
});
expect(res.status).toBe(409);
});
it("returns 200 idempotently when appointment is already confirmed", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
method: "POST",
});
expect(res.status).toBe(200);
});
});
// ─── Tests: portal cancel endpoint ───────────────────────────────────────────
describe("POST /api/appointments/:id/cancel", () => {
let app: Hono;
beforeEach(async () => {
vi.resetModules();
resetMock();
app = await makeAppointmentsApp();
});
it("cancels a pending appointment and nullifies the token", async () => {
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
method: "POST",
});
expect(res.status).toBe(200);
expect(lastUpdate.confirmationStatus).toBe("cancelled");
expect(lastUpdate.cancelledAt).toBeInstanceOf(Date);
expect(lastUpdate.confirmationToken).toBeNull();
});
it("returns 404 when appointment not found", async () => {
mockAppt = null;
const res = await app.request("/api/appointments/nonexistent/cancel", {
method: "POST",
});
expect(res.status).toBe(404);
});
it("returns 409 when appointment is already customer-cancelled", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
method: "POST",
});
expect(res.status).toBe(409);
});
it("can cancel a confirmed appointment", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
method: "POST",
});
expect(res.status).toBe(200);
expect(lastUpdate.confirmationStatus).toBe("cancelled");
});
});
// ─── Tests: token generation helper ──────────────────────────────────────────
describe("generateConfirmationToken", () => {
it("generates a 64-character hex string", async () => {
const { generateConfirmationToken } = await import("../routes/appointments.js");
const token = generateConfirmationToken();
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
it("generates unique tokens on each call", async () => {
const { generateConfirmationToken } = await import("../routes/appointments.js");
const t1 = generateConfirmationToken();
const t2 = generateConfirmationToken();
expect(t1).not.toBe(t2);
});
});
// ─── Tests: reminder email with action links ──────────────────────────────────
describe("buildReminderEmail with confirmation token", () => {
it("includes confirm and cancel links when token is provided", async () => {
const { buildReminderEmail } = await import("../services/email.js");
const mail = buildReminderEmail(
"client@example.com",
{
clientName: "Jane",
petName: "Biscuit",
serviceName: "Full Groom",
groomerName: null,
startTime: new Date(),
},
24,
"abc123token"
);
expect(mail.text).toContain("abc123token");
expect(mail.html as string).toContain("abc123token");
expect(mail.html as string).toContain("Confirm Appointment");
expect(mail.html as string).toContain("Cancel Appointment");
});
it("omits action links when no token is provided", async () => {
const { buildReminderEmail } = await import("../services/email.js");
const mail = buildReminderEmail(
"client@example.com",
{
clientName: "Jane",
petName: "Biscuit",
serviceName: "Full Groom",
groomerName: null,
startTime: new Date(),
},
24,
null
);
expect(mail.html as string).not.toContain("Confirm Appointment");
expect(mail.html as string).not.toContain("Cancel Appointment");
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { encryptSecret, decryptSecret } from "@groombook/db";
describe("encryptSecret / decryptSecret", () => {
const originalEnv = process.env.BETTER_AUTH_SECRET;
beforeEach(() => {
process.env.BETTER_AUTH_SECRET = "test-secret-key-for-unit-tests-32bytes!";
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.BETTER_AUTH_SECRET = originalEnv;
} else {
delete process.env.BETTER_AUTH_SECRET;
}
});
it("encrypts and decrypts a simple secret", () => {
const plaintext = "my-client-secret-123";
const encrypted = encryptSecret(plaintext);
const decrypted = decryptSecret(encrypted);
expect(decrypted).toBe(plaintext);
});
it("produces output in salt:iv:ciphertext:authTag format", () => {
const encrypted = encryptSecret("test");
const parts = encrypted.split(":");
expect(parts).toHaveLength(4);
// Each part should be valid base64
parts.forEach((part) => {
expect(() => Buffer.from(part, "base64")).not.toThrow();
});
});
it("different plaintexts produce different ciphertexts", () => {
const encrypted1 = encryptSecret("secret1");
const encrypted2 = encryptSecret("secret2");
expect(encrypted1).not.toBe(encrypted2);
});
it("same plaintext produces different ciphertexts (due to random IV)", () => {
const encrypted1 = encryptSecret("same-secret");
const encrypted2 = encryptSecret("same-secret");
expect(encrypted1).not.toBe(encrypted2);
// But both should decrypt to the same value
expect(decryptSecret(encrypted1)).toBe("same-secret");
expect(decryptSecret(encrypted2)).toBe("same-secret");
});
it("throws if BETTER_AUTH_SECRET is not set", () => {
delete process.env.BETTER_AUTH_SECRET;
expect(() => encryptSecret("test")).toThrow(
"BETTER_AUTH_SECRET environment variable is required"
);
});
it("throws when decrypting invalid format (wrong number of parts)", () => {
const encrypted = encryptSecret("test");
// Replace the last two parts with a single part to create a 2-part string
// This can't be parsed as either legacy (3 parts) or new (4 parts) format
const invalid = encrypted.replace(/:[^:]+$/, "").replace(/:[^:]+$/, "");
expect(() => decryptSecret(invalid)).toThrow(
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
);
});
it("handles empty string secret", () => {
const plaintext = "";
const encrypted = encryptSecret(plaintext);
const decrypted = decryptSecret(encrypted);
expect(decrypted).toBe(plaintext);
});
it("handles unicode secret", () => {
const plaintext = "密码🔐中文";
const encrypted = encryptSecret(plaintext);
const decrypted = decryptSecret(encrypted);
expect(decrypted).toBe(plaintext);
});
it("handles long secret", () => {
const plaintext = "a".repeat(10000);
const encrypted = encryptSecret(plaintext);
const decrypted = decryptSecret(encrypted);
expect(decrypted).toBe(plaintext);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect } from "vitest";
import {
buildConfirmationEmail,
buildReminderEmail,
} from "../services/email.js";
const START = new Date("2026-03-25T15:00:00Z");
const BASE = {
clientName: "Jane Doe",
petName: "Biscuit",
serviceName: "Full Groom",
groomerName: "Alex",
startTime: START,
};
describe("buildConfirmationEmail", () => {
it("addresses the correct recipient", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.to).toBe("jane@example.com");
});
it("includes the pet name in the subject", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.subject).toContain("Biscuit");
});
it("includes confirmation wording in subject", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.subject).toMatch(/confirmed/i);
});
it("includes client name in the plain text body", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.text).toContain("Jane Doe");
});
it("includes service name in plain text body", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.text).toContain("Full Groom");
});
it("includes groomer name when provided", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.text).toContain("Alex");
});
it("omits groomer when groomerName is null", () => {
const mail = buildConfirmationEmail("jane@example.com", {
...BASE,
groomerName: null,
});
expect(mail.text).not.toContain("with ");
});
it("includes HTML body", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.html).toBeTruthy();
expect(mail.html).toContain("Biscuit");
});
});
describe("buildReminderEmail", () => {
it("addresses the correct recipient", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.to).toBe("jane@example.com");
});
it("says 'tomorrow' for 24-hour reminder", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.subject).toContain("tomorrow");
expect(mail.text).toContain("tomorrow");
});
it("says 'in X hours' for sub-24-hour reminders", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 2);
expect(mail.subject).toContain("in 2 hours");
expect(mail.text).toContain("in 2 hours");
});
it("includes pet name in subject", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.subject).toContain("Biscuit");
});
it("includes service name in plain text body", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.text).toContain("Full Groom");
});
it("includes groomer name when provided", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.text).toContain("Alex");
});
it("omits groomer when groomerName is null", () => {
const mail = buildReminderEmail("jane@example.com", { ...BASE, groomerName: null }, 24);
expect(mail.text).not.toContain("with ");
});
it("includes HTML body", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.html).toBeTruthy();
expect(mail.html).toContain("Biscuit");
});
});
+216
View File
@@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
resetFactoryCounters,
buildStaff,
buildClient,
buildPet,
buildService,
buildAppointment,
} from "@groombook/db/factories";
describe("resetFactoryCounters", () => {
it("resets all counters so IDs restart from 1", () => {
buildStaff();
buildStaff();
buildClient();
resetFactoryCounters();
const staff = buildStaff();
const client = buildClient();
expect(staff.id).toBe("staff-1");
expect(client.id).toBe("client-1");
});
it("resets counters for every entity type", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
buildAppointment({
clientId: client.id,
petId: pet.id,
serviceId: service.id,
staffId: "staff-1",
});
resetFactoryCounters();
expect(buildStaff().id).toBe("staff-1");
expect(buildClient().id).toBe("client-1");
expect(buildService().id).toBe("service-1");
const c = buildClient();
expect(buildPet({ clientId: c.id }).id).toBe("pet-1");
const s = buildService();
const p = buildPet({ clientId: c.id });
expect(
buildAppointment({ clientId: c.id, petId: p.id, serviceId: s.id, staffId: "s-1" }).id
).toBe("appointment-1");
});
});
describe("counter determinism", () => {
beforeEach(() => {
resetFactoryCounters();
});
it("increments staff IDs sequentially", () => {
expect(buildStaff().id).toBe("staff-1");
expect(buildStaff().id).toBe("staff-2");
expect(buildStaff().id).toBe("staff-3");
});
it("increments client IDs sequentially", () => {
expect(buildClient().id).toBe("client-1");
expect(buildClient().id).toBe("client-2");
});
it("increments pet IDs sequentially", () => {
const client = buildClient();
expect(buildPet({ clientId: client.id }).id).toBe("pet-1");
expect(buildPet({ clientId: client.id }).id).toBe("pet-2");
});
it("increments service IDs sequentially", () => {
expect(buildService().id).toBe("service-1");
expect(buildService().id).toBe("service-2");
});
it("increments appointment IDs sequentially", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
const required = { clientId: client.id, petId: pet.id, serviceId: service.id, staffId: "staff-1" };
expect(buildAppointment(required).id).toBe("appointment-1");
expect(buildAppointment(required).id).toBe("appointment-2");
});
it("each entity type maintains its own independent counter", () => {
buildStaff();
buildStaff();
buildClient();
// staff counter is at 2; client counter is at 1
expect(buildStaff().id).toBe("staff-3");
expect(buildClient().id).toBe("client-2");
});
});
describe("override merging", () => {
beforeEach(() => {
resetFactoryCounters();
});
it("buildStaff applies overrides over defaults", () => {
const staff = buildStaff({ role: "manager", name: "Boss" });
expect(staff.role).toBe("manager");
expect(staff.name).toBe("Boss");
expect(staff.id).toBe("staff-1");
expect(staff.active).toBe(true); // default preserved
});
it("buildStaff id override is respected without disrupting the counter", () => {
const staff = buildStaff({ id: "custom-id" });
expect(staff.id).toBe("custom-id");
// counter still ticked — next call gets staff-2
expect(buildStaff().id).toBe("staff-2");
});
it("buildClient applies overrides over defaults", () => {
const client = buildClient({ name: "Alice Smith", emailOptOut: true });
expect(client.name).toBe("Alice Smith");
expect(client.emailOptOut).toBe(true);
expect(client.status).toBe("active"); // default preserved
});
it("buildPet merges overrides and sets clientId from required arg", () => {
const pet = buildPet({ clientId: "client-99", name: "Fluffy", breed: "Poodle" });
expect(pet.clientId).toBe("client-99");
expect(pet.name).toBe("Fluffy");
expect(pet.breed).toBe("Poodle");
expect(pet.species).toBe("Dog"); // default preserved
});
it("buildService applies overrides over defaults", () => {
const service = buildService({ basePriceCents: 9900, active: false });
expect(service.basePriceCents).toBe(9900);
expect(service.active).toBe(false);
expect(service.durationMinutes).toBe(60); // default preserved
});
it("buildAppointment applies overrides over defaults", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
const appt = buildAppointment({
clientId: client.id,
petId: pet.id,
serviceId: service.id,
staffId: "staff-1",
status: "confirmed",
notes: "allergic to lavender",
});
expect(appt.status).toBe("confirmed");
expect(appt.notes).toBe("allergic to lavender");
expect(appt.clientId).toBe(client.id);
expect(appt.petId).toBe(pet.id);
// defaults preserved
expect(appt.batherStaffId).toBeNull();
expect(appt.priceCents).toBeNull();
});
});
describe("buildAppointment required fields", () => {
beforeEach(() => {
resetFactoryCounters();
});
it("produces a fully-populated AppointmentRow", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
const appt = buildAppointment({
clientId: client.id,
petId: pet.id,
serviceId: service.id,
staffId: "staff-1",
});
expect(appt.id).toBeDefined();
expect(appt.clientId).toBe(client.id);
expect(appt.petId).toBe(pet.id);
expect(appt.serviceId).toBe(service.id);
expect(appt.staffId).toBe("staff-1");
expect(appt.startTime).toBeInstanceOf(Date);
expect(appt.endTime).toBeInstanceOf(Date);
expect(appt.status).toBe("scheduled");
expect(appt.batherStaffId).toBeNull();
expect(appt.seriesId).toBeNull();
expect(appt.seriesIndex).toBeNull();
expect(appt.groupId).toBeNull();
expect(appt.notes).toBeNull();
expect(appt.priceCents).toBeNull();
expect(appt.createdAt).toBeInstanceOf(Date);
expect(appt.updatedAt).toBeInstanceOf(Date);
});
// TypeScript compile-time enforcement: omitting any required field produces a type error.
// The overrides parameter type is `Partial<AppointmentRow> & { clientId: string; petId: string; serviceId: string; staffId: string }`.
// The test below verifies the type signature is correct by using @ts-expect-error.
it("type error when required fields are missing — compile-time enforcement", () => {
// @ts-expect-error clientId is required
buildAppointment({ petId: "p", serviceId: "s", staffId: "st" });
// @ts-expect-error petId is required
buildAppointment({ clientId: "c", serviceId: "s", staffId: "st" });
// @ts-expect-error serviceId is required
buildAppointment({ clientId: "c", petId: "p", staffId: "st" });
// @ts-expect-error staffId is required
buildAppointment({ clientId: "c", petId: "p", serviceId: "s" });
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Groomer Isolation Tests
*
* Validates row-level data scoping for the groomer role.
*
* The role guard tests verify the core groomer identification logic.
* Integration tests with the real database validate the full filter behavior.
*/
import { describe, it, expect } from "vitest";
import type { StaffRow } from "../middleware/rbac.js";
// ─── Mock staff ───────────────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const GROOMER: StaffRow = {
...MANAGER,
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
role: "groomer",
name: "Groomer Gary",
email: "groomer@example.com",
};
const RECEPTIONIST: StaffRow = {
...MANAGER,
id: "staff-receptionist-id",
oidcSub: "oidc-receptionist-sub",
role: "receptionist",
name: "Receptionist Rita",
email: "receptionist@example.com",
};
// ─── Role guard ──────────────────────────────────────────────────────────────
/**
* The isGroomer guard (staffRow?.role === "groomer") is the foundation of
* all row-level filtering in appointments.ts, clients.ts, and pets.ts.
* These tests verify it handles all roles correctly.
*/
describe("Groomer role guard", () => {
const isGroomer = (s: StaffRow | undefined) => s?.role === "groomer";
it("manager is not groomer", () => expect(isGroomer(MANAGER)).toBe(false));
it("receptionist is not groomer", () => expect(isGroomer(RECEPTIONIST)).toBe(false));
it("groomer is groomer", () => expect(isGroomer(GROOMER)).toBe(true));
/** Safe fallback when staff context is not set (e.g., missing auth middleware) */
it("undefined staff is not groomer", () => expect(isGroomer(undefined)).toBe(false));
});
// ─── Groomer filter data shapes ───────────────────────────────────────────────
/**
* These constants match the shape used in route handlers to validate
* the groomer filter conditions:
* or(eq(appointments.staffId, staffRow.id), eq(appointments.batherStaffId, staffRow.id))
* This verifies the groomer can see appointments they own OR bathe.
*/
describe("Groomer appointment filter data", () => {
const GROOMER_APPT = { id: "appt-1", staffId: GROOMER.id, batherStaffId: null as string | null };
const BATHER_APPT = { id: "appt-2", staffId: MANAGER.id, batherStaffId: GROOMER.id };
const OTHER_APPT = { id: "appt-3", staffId: MANAGER.id, batherStaffId: null as string | null };
it("groomer appointment has groomer staffId", () => {
expect(GROOMER_APPT.staffId).toBe(GROOMER.id);
expect(GROOMER_APPT.batherStaffId).toBeNull();
});
it("groomer can see appointment where they are the bather", () => {
expect(BATHER_APPT.batherStaffId).toBe(GROOMER.id);
expect(BATHER_APPT.staffId).toBe(MANAGER.id);
});
it("other appointment is not assigned to groomer", () => {
expect(OTHER_APPT.staffId).toBe(MANAGER.id);
expect(OTHER_APPT.batherStaffId).toBeNull();
});
it("filter: groomer sees only their appointments", () => {
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
const groomerView = all.filter(
(a) => a.staffId === GROOMER.id || a.batherStaffId === GROOMER.id
);
expect(groomerView).toHaveLength(2);
expect(groomerView.map((a) => a.id)).toEqual(["appt-1", "appt-2"]);
});
it("filter: manager sees all appointments", () => {
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
expect(all).toHaveLength(3);
});
});
+560
View File
@@ -0,0 +1,560 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
import { buildStaff } from "@groombook/db/factories";
// ─── 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("@groombook/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);
});
});
+293
View File
@@ -0,0 +1,293 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const GROOMER: StaffRow = {
...MANAGER,
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
role: "groomer",
name: "Groomer Gary",
email: "groomer@example.com",
};
// ─── Shared mutable DB state ──────────────────────────────────────────────────
const PET_ID = "pet-uuid-1234";
const PHOTO_KEY = `pets/${PET_ID}/1700000000000.jpg`;
let dbPetRow: Record<string, unknown> | null;
function resetDb() {
dbPetRow = { id: PET_ID, name: "Biscuit", photoKey: null, photoUploadedAt: null };
}
// ─── Module mocks ─────────────────────────────────────────────────────────────
vi.mock("@groombook/db", () => {
const pets = new Proxy(
{ _name: "pets" },
{ get(t, p) { return p === "_name" ? "pets" : {}; } }
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => (dbPetRow ? [dbPetRow] : []),
}),
}),
update: () => ({
set: () => ({
where: () => ({
returning: () => (dbPetRow ? [{ ...dbPetRow }] : []),
}),
}),
}),
}),
pets,
eq: vi.fn(),
};
});
vi.mock("../lib/s3.js", () => ({
getPresignedUploadUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-put"),
getPresignedGetUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-get"),
deleteObject: vi.fn().mockResolvedValue(undefined),
}));
// ─── Import after mocks are set up ───────────────────────────────────────────
const { petsRouter } = await import("../routes/pets.js");
// ─── App builder ─────────────────────────────────────────────────────────────
function buildApp(staffRow: StaffRow) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" });
c.set("staff", staffRow);
await next();
});
app.route("/pets", petsRouter);
return app;
}
// ─── Reset before each test ───────────────────────────────────────────────────
beforeEach(() => {
resetDb();
vi.clearAllMocks();
});
// ─── POST /:petId/photo/upload-url ───────────────────────────────────────────
describe("POST /pets/:petId/photo/upload-url", () => {
it("returns presigned upload URL and object key for valid image contentType", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 1024 }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { uploadUrl: string; key: string };
expect(body.uploadUrl).toBe("https://storage.example.com/presigned-put");
expect(body.key).toMatch(/^pets\//);
expect(body.key).toContain(PET_ID);
});
it("rejects non-image contentType with 400", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "application/pdf", fileSizeBytes: 1024 }),
});
expect(res.status).toBe(400);
});
it("rejects image/svg+xml with 400 (allowlist enforcement)", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "image/svg+xml", fileSizeBytes: 1024 }),
});
expect(res.status).toBe(400);
});
it("rejects fileSizeBytes over 5 MB with 400", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 6 * 1024 * 1024 }),
});
expect(res.status).toBe(400);
});
it("returns 404 when pet does not exist", async () => {
dbPetRow = null;
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 1024 }),
});
expect(res.status).toBe(404);
});
it("allows groomers to request an upload URL", async () => {
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "image/png", fileSizeBytes: 1024 }),
});
expect(res.status).toBe(200);
});
});
// ─── POST /:petId/photo/confirm ───────────────────────────────────────────────
describe("POST /pets/:petId/photo/confirm", () => {
it("confirms upload and returns ok: true", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: PHOTO_KEY }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 400 when key is missing", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
it("returns 404 when pet does not exist", async () => {
dbPetRow = null;
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: PHOTO_KEY }),
});
expect(res.status).toBe(404);
});
it("returns 400 when key does not belong to the pet", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "pets/other-pet-id/1700000000000.jpg" }),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/invalid key/i);
});
it("deletes old photo from storage when re-uploading", async () => {
const { deleteObject } = await import("../lib/s3.js");
const oldKey = `pets/${PET_ID}/old.jpg`;
dbPetRow = { ...dbPetRow!, photoKey: oldKey };
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: PHOTO_KEY }),
});
expect(res.status).toBe(200);
expect(deleteObject).toHaveBeenCalledWith(oldKey);
});
});
// ─── DELETE /:petId/photo ────────────────────────────────────────────────────
describe("DELETE /pets/:petId/photo", () => {
it("returns 404 with 'no photo' message when pet has no photo", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/no photo/i);
});
it("deletes photo and returns ok: true when photo exists", async () => {
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 404 when pet does not exist", async () => {
dbPetRow = null;
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
expect(res.status).toBe(404);
});
});
// ─── GET /:petId/photo ────────────────────────────────────────────────────────
describe("GET /pets/:petId/photo", () => {
it("returns 404 when pet has no photo", async () => {
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`);
expect(res.status).toBe(404);
});
it("returns presigned GET URL when photo exists", async () => {
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`);
expect(res.status).toBe(200);
const body = (await res.json()) as { url: string; photoKey: string };
expect(body.url).toBe("https://storage.example.com/presigned-get");
expect(body.photoKey).toBe(PHOTO_KEY);
});
it("returns 404 when pet does not exist", async () => {
dbPetRow = null;
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/photo`);
expect(res.status).toBe(404);
});
it("groomer can read photo URL", async () => {
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/photo`);
expect(res.status).toBe(200);
});
});
+423
View File
@@ -0,0 +1,423 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
const APPOINTMENT_ID = "660e8400-e29b-41d4-a716-446655440002";
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
const pastDate = () => new Date(Date.now() - 5 * 60 * 1000);
const ACTIVE_SESSION = {
id: SESSION_ID,
clientId: CLIENT_ID,
status: "active" as const,
expiresAt: futureDate(),
createdAt: new Date(),
};
const EXPIRED_SESSION = {
id: SESSION_ID,
clientId: CLIENT_ID,
status: "active" as const,
expiresAt: pastDate(),
createdAt: new Date(),
};
const APPOINTMENT = {
id: APPOINTMENT_ID,
clientId: CLIENT_ID,
startTime: futureDate(),
endTime: futureDate(),
customerNotes: null,
confirmationToken: "secret-token-leak-test",
status: "scheduled" as const,
confirmationStatus: "pending" as const,
confirmedAt: null,
cancelledAt: null,
};
let selectSessionRow: Record<string, unknown> | null = null;
let selectAppointmentRow: Record<string, unknown> | null = null;
let updatedValues: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectAppointmentRow = null;
updatedValues = [];
}
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
return target[prop];
},
});
return chain;
}
const impersonationSessions = new Proxy(
{ _name: "impersonationSessions" },
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
);
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
if (table._name === "impersonationSessions") {
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
}
if (table._name === "appointments") {
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
}
return makeChainable([]);
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => ({
returning: () => {
if (selectAppointmentRow) {
const updated = { ...selectAppointmentRow, ...vals };
updatedValues.push(vals);
return [updated];
}
return [];
},
}),
}),
}),
}),
impersonationSessions,
appointments,
eq: vi.fn(),
and: vi.fn(),
};
});
const { portalRouter } = await import("../routes/portal.js");
const app = new Hono();
app.route("/portal", portalRouter);
function jsonPatch(path: string, body: unknown, headers?: Record<string, string>) {
return app.request(path, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
});
}
beforeEach(() => resetMock());
describe("PATCH /portal/appointments/:id/notes", () => {
it("returns updated appointment with safe fields only", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Please be gentle with Fido" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("id");
expect(body).toHaveProperty("customerNotes", "Please be gentle with Fido");
expect(body).toHaveProperty("updatedAt");
expect(body).not.toHaveProperty("confirmationToken");
expect(body).not.toHaveProperty("clientId");
});
it("returns 401 without X-Impersonation-Session-Id header", async () => {
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" }
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 401 with expired session", async () => {
selectSessionRow = EXPIRED_SESSION;
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 401 with ended session", async () => {
selectSessionRow = null;
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 403 when appointment belongs to different client", async () => {
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
selectAppointmentRow = { ...APPOINTMENT };
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toBe("Forbidden");
});
it("returns 422 for past appointment", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.error).toMatch(/past|in-progress|cannot edit/i);
});
it("returns 422 when appointment is in progress", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, startTime: new Date(Date.now() - 2 * 60 * 1000) };
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 404 when appointment not found", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = null;
const res = await jsonPatch(
`/portal/appointments/nonexistent-id/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(404);
});
it("accepts notes at exactly 500 characters", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
const longNote = "a".repeat(500);
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: longNote },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.customerNotes).toBe(longNote);
});
it("rejects notes exceeding 500 characters", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
const longNote = "a".repeat(501);
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: longNote },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(400);
});
});
// ─── POST /portal/appointments/:id/confirm ────────────────────────────────────
function jsonPost(path: string, headers?: Record<string, string>) {
return app.request(path, {
method: "POST",
headers,
});
}
describe("POST /portal/appointments/:id/confirm", () => {
it("confirms a pending appointment and returns updated status", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.confirmationStatus).toBe("confirmed");
expect(body).toHaveProperty("confirmedAt");
});
it("returns 401 without X-Impersonation-Session-Id header", async () => {
const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/confirm`);
expect(res.status).toBe(401);
});
it("returns 401 with expired session", async () => {
selectSessionRow = EXPIRED_SESSION;
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(401);
});
it("returns 403 when appointment belongs to a different client", async () => {
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
selectAppointmentRow = { ...APPOINTMENT };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(403);
});
it("returns 422 when appointment is in the past", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 422 when appointment is not pending confirmation", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "confirmed" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 422 when cancelling an already-cancelled appointment", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 404 when appointment not found", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = null;
const res = await jsonPost(
`/portal/appointments/nonexistent-id/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(404);
});
});
// ─── POST /portal/appointments/:id/cancel ─────────────────────────────────────
describe("POST /portal/appointments/:id/cancel", () => {
it("cancels a pending appointment and returns updated status", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("cancelled");
expect(body.confirmationStatus).toBe("cancelled");
expect(body).toHaveProperty("cancelledAt");
});
it("returns 401 without X-Impersonation-Session-Id header", async () => {
const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/cancel`);
expect(res.status).toBe(401);
});
it("returns 401 with expired session", async () => {
selectSessionRow = EXPIRED_SESSION;
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(401);
});
it("returns 403 when appointment belongs to a different client", async () => {
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
selectAppointmentRow = { ...APPOINTMENT };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(403);
});
it("returns 422 when appointment is in the past", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 422 when appointment is already cancelled", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 422 when appointment is already completed", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, status: "completed" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 404 when appointment not found", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = null;
const res = await jsonPost(
`/portal/appointments/nonexistent-id/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(404);
});
});
+392
View File
@@ -0,0 +1,392 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
// ─── Mock staff data ──────────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: "ba-user-manager",
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const RECEPTIONIST: StaffRow = {
...MANAGER,
id: "staff-receptionist-id",
oidcSub: "oidc-receptionist-sub",
userId: "ba-user-receptionist",
role: "receptionist",
isSuperUser: false,
name: "Receptionist Rita",
email: "receptionist@example.com",
};
const GROOMER: StaffRow = {
...MANAGER,
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
userId: "ba-user-groomer",
role: "groomer",
isSuperUser: false,
name: "Groomer Gary",
email: "groomer@example.com",
};
// ─── Mock DB ──────────────────────────────────────────────────────────────────
let staffLookupResult: StaffRow | null = null;
let managerFallbackResult: StaffRow | null = MANAGER;
vi.mock("@groombook/db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
get(target, prop) {
if (prop === "_name") return "staff";
if (prop === "$inferSelect") return {};
return { table: "staff", column: prop };
},
}
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => {
// dev mode fallback to first manager
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
if (staffLookupResult) yield staffLookupResult;
},
0: staffLookupResult,
length: staffLookupResult ? 1 : 0,
}),
}),
}),
}),
staff,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
and: vi.fn((..._clauses: unknown[]) => ({})),
};
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function resetMocks() {
staffLookupResult = null;
managerFallbackResult = MANAGER;
}
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
function buildApp(
middleware: MiddlewareHandler<AppEnv>,
handler?: (c: Context<AppEnv>) => Response | Promise<Response>
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
await next();
});
app.use("*", middleware);
const h = handler ?? ((c: Context<AppEnv>) => c.json({ ok: true }));
app.get("/test", h);
app.post("/test", h);
return app;
}
/** Build app with staff pre-set in context (skips resolveStaffMiddleware). */
function buildWithStaff(
staffRow: StaffRow,
guard: MiddlewareHandler<AppEnv>
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffRow.userId ?? "" });
c.set("staff", staffRow);
await next();
});
app.use("*", guard);
app.get("/test", (c) => c.json({ ok: true }));
app.post("/test", (c) => c.json({ ok: true }));
return app;
}
// ─── Import middleware ────────────────────────────────────────────────────────
const { resolveStaffMiddleware, requireRole, requireSuperUser, requireRoleOrSuperUser } = await import(
"../middleware/rbac.js"
);
beforeEach(() => resetMocks());
afterEach(() => {
delete process.env.AUTH_DISABLED;
});
// ─── resolveStaffMiddleware tests ─────────────────────────────────────────────
describe("resolveStaffMiddleware", () => {
it("resolves staff from DB and sets it on context", async () => {
staffLookupResult = MANAGER;
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff).not.toBeNull();
expect(capturedStaff!.id).toBe(MANAGER.id);
});
it("returns 403 when no staff record found for the OIDC sub", async () => {
staffLookupResult = null;
const app = buildApp(resolveStaffMiddleware);
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff record/i);
});
it("dev mode: resolves staff by X-Dev-User-Id header", async () => {
process.env.AUTH_DISABLED = "true";
staffLookupResult = GROOMER;
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test", {
headers: { "X-Dev-User-Id": GROOMER.id },
});
expect(res.status).toBe(200);
expect(capturedStaff!.role).toBe("groomer");
});
it("dev mode: falls back to first manager when no X-Dev-User-Id header", async () => {
process.env.AUTH_DISABLED = "true";
managerFallbackResult = MANAGER;
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff!.role).toBe("manager");
});
it("dev mode: returns 403 when no manager exists and no header provided", async () => {
process.env.AUTH_DISABLED = "true";
managerFallbackResult = null;
const app = buildApp(resolveStaffMiddleware);
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff records found/i);
});
});
// ─── requireRole tests ────────────────────────────────────────────────────────
describe("requireRole", () => {
it("allows access when staff role matches the only allowed role", async () => {
const app = buildWithStaff(MANAGER, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("allows access when staff role is one of multiple allowed roles", async () => {
const app = buildWithStaff(RECEPTIONIST, requireRole("manager", "receptionist"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("returns 403 for an unauthorized role", async () => {
const app = buildWithStaff(GROOMER, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/forbidden/i);
expect(body.error).toContain("groomer");
});
it("includes the role name in the 403 error message", async () => {
const app = buildWithStaff(RECEPTIONIST, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toContain("receptionist");
});
it("groomer is blocked from manager+receptionist-only routes", async () => {
const app = buildWithStaff(GROOMER, requireRole("manager", "receptionist"));
const res = await app.request("/test", { method: "POST" });
expect(res.status).toBe(403);
});
it("manager passes all-role checks", async () => {
const app = buildWithStaff(MANAGER, requireRole("manager", "receptionist", "groomer"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("returns 403 with JSON body (not plain text)", async () => {
const app = buildWithStaff(GROOMER, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const contentType = res.headers.get("content-type") ?? "";
expect(contentType).toContain("application/json");
});
});
// ─── requireSuperUser tests ─────────────────────────────────────────────────
describe("requireSuperUser", () => {
it("allows access when staff is a super user", async () => {
const app = buildWithStaff(MANAGER, requireSuperUser());
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("allows access when manager is also a super user", async () => {
// MANAGER has isSuperUser: true
const app = buildWithStaff(MANAGER, requireSuperUser());
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("returns 403 for a non-super-user receptionist", async () => {
// RECEPTIONIST has isSuperUser: false
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/super user privileges required/i);
});
it("returns 403 for a non-super-user groomer", async () => {
// GROOMER has isSuperUser: false
const app = buildWithStaff(GROOMER, requireSuperUser());
const res = await app.request("/test");
expect(res.status).toBe(403);
});
it("returns 403 when staff record is not resolved", async () => {
// Manually remove staff from context to simulate unresolved staff
const testApp = new Hono<AppEnv>();
testApp.use("*", async (c, next) => {
c.set("jwtPayload", { sub: "test-sub" });
// Do NOT set staff - simulate unresolved staff
await next();
});
testApp.use("*", requireSuperUser());
testApp.get("/test", (c) => c.json({ ok: true }));
const res = await testApp.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/staff record not resolved/i);
});
it("receptionist cannot grant super user status on staff PATCH", async () => {
// This tests the inline guard in staff.ts handler, not the middleware itself,
// but we test requireSuperUser to verify the middleware correctly blocks
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
const res = await app.request("/test", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isSuperUser: true }),
});
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/super user privileges required/i);
});
it("returns 403 with JSON body for super user violation", async () => {
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
const res = await app.request("/test");
expect(res.status).toBe(403);
const contentType = res.headers.get("content-type") ?? "";
expect(contentType).toContain("application/json");
});
});
// ─── requireRoleOrSuperUser tests ─────────────────────────────────────────────
describe("requireRoleOrSuperUser", () => {
it("allows a manager to access manager-only routes", async () => {
const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("allows a super user with receptionist role to access manager-only routes (GRO-412 bug fix)", async () => {
// GRO-412: a receptionist granted super user via Staff UI should access admin routes
const superReceptionist: StaffRow = {
...RECEPTIONIST,
isSuperUser: true,
};
const app = buildWithStaff(superReceptionist, requireRoleOrSuperUser("manager"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("allows a super user with groomer role to access manager-only routes", async () => {
const superGroomer: StaffRow = {
...GROOMER,
isSuperUser: true,
};
const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("blocks a non-super-user receptionist from manager-only routes", async () => {
const app = buildWithStaff(RECEPTIONIST, requireRoleOrSuperUser("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/role.*not permitted/i);
});
it("blocks a non-super-user groomer from manager-only routes", async () => {
const app = buildWithStaff(GROOMER, requireRoleOrSuperUser("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/role.*not permitted/i);
});
it("allows a manager with multiple allowed roles", async () => {
const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager", "receptionist"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("allows a super user with disallowed role to access route with multiple allowed roles", async () => {
const superGroomer: StaffRow = {
...GROOMER,
isSuperUser: true,
};
const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager", "receptionist"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
});
+162
View File
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock data ────────────────────────────────────────────────────────────────
const ACTIVE_CLIENT = {
id: "client-1",
name: "Alice Johnson",
email: "alice@example.com",
phone: "555-1234",
};
const PET_ROW = {
id: "pet-1",
name: "Bella",
breed: "Golden Retriever",
clientId: "client-1",
ownerName: "Alice Johnson",
};
// ─── Mock DB ──────────────────────────────────────────────────────────────────
let clientResults: typeof ACTIVE_CLIENT[] = [];
let petResults: typeof PET_ROW[] = [];
vi.mock("@groombook/db", () => {
// Proxy objects for table/column references — values don't matter for tests
const tableProxy = (name: string) =>
new Proxy(
{ _name: name },
{ get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }
);
const clients = tableProxy("clients");
const pets = tableProxy("pets");
return {
getDb: () => ({
select: (_fields?: unknown) => {
// Route which mock results to use based on a global flag set per test
return {
from: (table: { _name?: string }) => {
const results = table._name === "pets" ? petResults : clientResults;
const chain: Record<string, unknown> = {};
chain.where = () => chain;
chain.innerJoin = () => chain;
chain.limit = () => Promise.resolve(results);
return chain;
},
};
},
}),
clients,
pets,
and: (...args: unknown[]) => ({ and: args }),
or: (...args: unknown[]) => ({ or: args }),
eq: (a: unknown, b: unknown) => ({ eq: [a, b] }),
ilike: (col: unknown, pat: unknown) => ({ ilike: [col, pat] }),
};
});
// ─── App under test ───────────────────────────────────────────────────────────
async function makeApp() {
const { searchRouter } = await import("../routes/search.js");
const app = new Hono();
app.route("/search", searchRouter);
return app;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
beforeEach(() => {
vi.resetModules();
clientResults = [];
petResults = [];
});
describe("GET /search", () => {
it("returns 400 when q is missing", async () => {
const app = await makeApp();
const res = await app.request("/search");
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBeTruthy();
});
it("returns 400 when q is empty string", async () => {
const app = await makeApp();
const res = await app.request("/search?q=");
expect(res.status).toBe(400);
});
it("returns 400 when q is only whitespace", async () => {
const app = await makeApp();
const res = await app.request("/search?q= ");
expect(res.status).toBe(400);
});
it("returns matching clients and pets", async () => {
clientResults = [ACTIVE_CLIENT];
petResults = [PET_ROW];
const app = await makeApp();
const res = await app.request("/search?q=bell");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.clients).toEqual([ACTIVE_CLIENT]);
expect(body.pets).toEqual([PET_ROW]);
});
it("returns empty arrays when no matches", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
const res = await app.request("/search?q=xyzzy");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.clients).toEqual([]);
expect(body.pets).toEqual([]);
});
it("returns shape with clients and pets keys", async () => {
const app = await makeApp();
const res = await app.request("/search?q=a");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("clients");
expect(body).toHaveProperty("pets");
expect(Array.isArray(body.clients)).toBe(true);
expect(Array.isArray(body.pets)).toBe(true);
});
it("handles special characters in query without throwing", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
// These characters should be escaped, not cause errors
const res = await app.request("/search?q=foo%25bar_baz");
expect(res.status).toBe(200);
});
});
describe("escapeLike helper (via integration)", () => {
it("% in query does not break the request", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
const res = await app.request("/search?q=%25");
expect(res.status).toBe(200);
});
it("_ in query does not break the request", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
const res = await app.request("/search?q=_");
expect(res.status).toBe(200);
});
});
+720
View File
@@ -0,0 +1,720 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import { setupRouter } from "../routes/setup.js";
// ─── Types ────────────────────────────────────────────────────────────────────
interface MockStaff {
id: string;
role: string;
isSuperUser: boolean;
}
// ─── Mock DB state ────────────────────────────────────────────────────────────
let dbStaffRows: MockStaff[] = [];
let dbBusinessSettingsRows: { id: string; businessName: string }[] = [];
let dbAuthConfigRows: { id: string; enabled: boolean }[] = [];
let insertedAuthConfig: Record<string, unknown>[] = [];
let insertedStaff: Record<string, unknown>[] = [];
let encryptCalls: string[] = [];
// Track env vars set per test
const originalEnv = { ...process.env };
function resetMock() {
dbStaffRows = [];
dbBusinessSettingsRows = [];
dbAuthConfigRows = [];
insertedAuthConfig = [];
insertedStaff = [];
encryptCalls = [];
}
function clearAuthEnv() {
delete process.env.OIDC_ISSUER;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET;
}
// ─── Mock db module ───────────────────────────────────────────────────────────
vi.mock("@groombook/db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
get(_target, prop) {
if (prop === "_name") return "auth_provider_config";
if (prop === "$inferSelect") return {};
return { table: "auth_provider_config", column: prop };
},
}
);
const staff = new Proxy(
{ _name: "staff" },
{
get(_target, prop) {
if (prop === "_name") return "staff";
if (prop === "$inferSelect") return {};
return { table: "staff", column: prop };
},
}
);
const businessSettings = new Proxy(
{ _name: "business_settings" },
{
get(_target, prop) {
if (prop === "_name") return "business_settings";
if (prop === "$inferSelect") return {};
return { table: "business_settings", column: prop };
},
}
);
// Build a shared tx mock that operates on current-state snapshots
function makeTxMock() {
function getRowsForTable(table: unknown) {
if (table === authProviderConfig) return dbAuthConfigRows;
if (table === staff) return dbStaffRows;
if (table === businessSettings) return dbBusinessSettingsRows;
return [];
}
return {
select: () => ({
from: (table: unknown) => {
const rows = getRowsForTable(table);
const base = {
where: (cond?: unknown) => {
const filtered = cond ? rows.filter((r) => evaluateCond(cond, r as Record<string, unknown>)) : rows;
return {
limit: () => filtered,
for: () => ({
limit: () => filtered,
[Symbol.iterator]: function* () {
for (const item of filtered) yield item;
},
0: filtered[0],
length: filtered.length,
}),
[Symbol.iterator]: function* () {
for (const item of filtered) yield item;
},
0: filtered[0],
length: filtered.length,
};
},
[Symbol.iterator]: function* () {
for (const item of rows) yield item;
},
0: rows[0],
length: rows.length,
};
// Some calls use .limit() directly on from() result (no where())
(base as any).limit = () => rows;
return base;
},
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
const row = { ...vals, id: "new-id-" + Math.random(), createdAt: new Date(), updatedAt: new Date() };
if (vals.providerId) {
insertedAuthConfig.push(vals);
dbAuthConfigRows.push({ id: row.id as string, enabled: vals.enabled as boolean });
} else if (vals.email) {
// staff insert
insertedStaff.push(vals);
dbStaffRows.push(row as unknown as MockStaff);
} else if (vals.businessName) {
dbBusinessSettingsRows.push(row as unknown as { id: string; businessName: string });
}
return { returning: () => [row] };
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => ({
returning: () => {
const updated = { ...dbStaffRows[0], ...vals, updatedAt: new Date() };
return [updated];
},
}),
}),
}),
};
}
return {
getDb: () => ({
select: () => ({
from: (table: unknown) => ({
where: (cond?: unknown) => {
const rows =
table === authProviderConfig
? dbAuthConfigRows
: table === staff
? dbStaffRows
: table === businessSettings
? dbBusinessSettingsRows
: [];
const filtered = cond ? rows.filter((r) => evaluateCond(cond, r as Record<string, unknown>)) : rows;
return {
limit: () => filtered,
for: () => ({
limit: () => filtered,
[Symbol.iterator]: function* () {
for (const item of filtered) yield item;
},
0: filtered[0],
length: filtered.length,
}),
[Symbol.iterator]: function* () {
for (const item of filtered) yield item;
},
0: filtered[0],
length: filtered.length,
};
},
[Symbol.iterator]: function* () {
const rows =
table === authProviderConfig
? dbAuthConfigRows
: table === staff
? dbStaffRows
: table === businessSettings
? dbBusinessSettingsRows
: [];
for (const item of rows) yield item;
},
0:
table === authProviderConfig
? dbAuthConfigRows[0]
: table === staff
? dbStaffRows[0]
: table === businessSettings
? dbBusinessSettingsRows[0]
: undefined,
length:
table === authProviderConfig
? dbAuthConfigRows.length
: table === staff
? dbStaffRows.length
: table === businessSettings
? dbBusinessSettingsRows.length
: 0,
}),
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
const row = { ...vals, id: "new-id-" + Math.random(), createdAt: new Date(), updatedAt: new Date() };
if (vals.providerId) {
insertedAuthConfig.push(vals);
dbAuthConfigRows.push({ id: row.id as string, enabled: vals.enabled as boolean });
} else if (vals.email) {
insertedStaff.push(vals);
dbStaffRows.push(row as unknown as MockStaff);
} else if (vals.businessName) {
dbBusinessSettingsRows.push(row as unknown as { id: string; businessName: string });
}
return { returning: () => [row] };
},
}),
transaction: (cb: (tx: unknown) => Promise<unknown>) => cb(makeTxMock()),
}),
authProviderConfig,
staff,
businessSettings,
eq: (col: unknown, val: unknown) => ({ __type: "eq", col, val }),
and: (...conds: unknown[]) => ({ __type: "and", conds }),
isNull: (col: unknown) => ({ __type: "isNull", col }),
sql: (strings: TemplateStringsArray, ...values: unknown[]) => {
// Mock sql template tag — raw SQL can't be evaluated in mock, always passes
void strings; void values;
return { __type: "sql" };
},
encryptSecret: (val: string) => {
encryptCalls.push(val);
return `encrypted:${val}`;
},
};
});
// Helper to evaluate mock conditions against a row
function evaluateCond(cond: unknown, row: Record<string, unknown>): boolean {
if (!cond || typeof cond !== "object") return true;
const c = cond as Record<string, unknown>;
if (c.__type === "eq") {
const colObj = c.col as Record<string, unknown>;
const colName = colObj.column as string;
return row[colName] === c.val;
}
if (c.__type === "and") {
return (c.conds as unknown[]).every((sub) => evaluateCond(sub, row));
}
if (c.__type === "isNull") {
const colObj = c.col as Record<string, unknown>;
const colName = colObj.column as string;
return row[colName] === null || row[colName] === undefined;
}
if (c.__type === "sql") {
// Raw SQL can't be evaluated in mock — pass through
return true;
}
return true;
}
// ─── Build test app ───────────────────────────────────────────────────────────
interface JwtPayload {
sub: string;
email?: string;
name?: string;
}
function makeApp(staff?: MockStaff | null, jwtPayload?: JwtPayload | null) {
const app = new Hono();
// Inject optional staff and jwtPayload context for authenticated routes
app.use("/setup/*", async (c, next) => {
if (jwtPayload) {
(c as any).set("jwtPayload", jwtPayload);
}
if (staff) {
(c as any).set("staff", staff);
}
await next();
});
app.route("/setup", setupRouter as unknown as Hono);
return app;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
type ResponseBody = Record<string, unknown>;
async function getStatus(app: Hono) {
const res = await app.request("/setup/status", { method: "GET" });
return { status: res.status, body: (await res.json()) as ResponseBody };
}
async function postAuthProvider(app: Hono, body: unknown) {
const res = await app.request("/setup/auth-provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await res.text();
let parsed: ResponseBody;
try {
parsed = JSON.parse(text) as ResponseBody;
} catch {
parsed = { error: text };
}
return { status: res.status, body: parsed };
}
async function postAuthProviderTest(app: Hono, body: unknown) {
const res = await app.request("/setup/auth-provider/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await res.text();
let parsed: ResponseBody;
try {
parsed = JSON.parse(text) as ResponseBody;
} catch {
parsed = { error: text };
}
return { status: res.status, body: parsed };
}
async function postSetup(app: Hono, body: unknown) {
const res = await app.request("/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await res.text();
let parsed: ResponseBody;
try {
parsed = JSON.parse(text) as ResponseBody;
} catch {
parsed = { error: text };
}
return { status: res.status, body: parsed };
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("GET /setup/status — OOBE bootstrap logic", () => {
beforeEach(() => {
resetMock();
process.env = { ...originalEnv };
clearAuthEnv();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("fresh install (no super user, no env vars) → needsSetup=true, showAuthProviderStep=true", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
// env vars are cleared
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(true);
expect(body.showAuthProviderStep).toBe(true);
expect(body.authConfigExists).toBe(false);
expect(body.authEnvVarsSet).toBe(false);
});
it("fresh install (no super user, env vars set) → needsSetup=true, showAuthProviderStep=false", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.OIDC_ISSUER = "https://auth.example.com";
process.env.OIDC_CLIENT_ID = "client-id";
process.env.OIDC_CLIENT_SECRET = "client-secret";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(true);
expect(body.showAuthProviderStep).toBe(false); // env vars already provide auth
expect(body.authConfigExists).toBe(false);
expect(body.authEnvVarsSet).toBe(true);
});
it("setup complete (super user exists) → needsSetup=false, showAuthProviderStep=false", async () => {
dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }];
dbAuthConfigRows = [{ id: "prov-1", enabled: true }];
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.showAuthProviderStep).toBe(false);
expect(body.authConfigExists).toBe(true);
});
it("no super user but DB config exists → showAuthProviderStep=false", async () => {
dbStaffRows = [];
dbAuthConfigRows = [{ id: "prov-1", enabled: true }];
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(true);
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
expect(body.authConfigExists).toBe(true);
});
it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => {
dbStaffRows = []; // no super user
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "true";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.showAuthProviderStep).toBe(false);
expect(body.authConfigExists).toBe(false);
expect(body.authEnvVarsSet).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=1 also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "1";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=yes also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "yes";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
});
describe("POST /setup/auth-provider — OOBE bootstrap", () => {
beforeEach(() => {
resetMock();
process.env = { ...originalEnv };
clearAuthEnv();
});
afterEach(() => {
process.env = { ...originalEnv };
});
const validBody = {
providerId: "authentik",
displayName: "Authentik SSO",
issuerUrl: "https://auth.example.com",
clientId: "my-client",
clientSecret: "my-secret",
scopes: "openid profile email",
};
it("creates auth provider config when no super user exists", async () => {
dbStaffRows = []; // no super user
dbAuthConfigRows = [];
const app = makeApp();
const { status, body } = await postAuthProvider(app, validBody);
expect(status).toBe(201);
expect(body.providerId).toBe("authentik");
expect(body.clientSecret).toBeUndefined(); // secret should not be returned plaintext
expect(encryptCalls).toContain("my-secret");
expect(insertedAuthConfig.length).toBe(1);
});
it("returns 403 after setup is complete (super user exists)", async () => {
dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }];
const app = makeApp();
const { status, body } = await postAuthProvider(app, validBody);
expect(status).toBe(403);
expect(body.error).toMatch(/already been completed/i);
});
it("returns 409 if auth provider is already configured", async () => {
dbStaffRows = [];
dbAuthConfigRows = [{ id: "prov-1", enabled: true }]; // already configured
const app = makeApp();
const { status, body } = await postAuthProvider(app, validBody);
expect(status).toBe(409);
expect(body.error).toMatch(/already configured/i);
});
it("returns 400 for invalid schema (Zod validation failure)", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
const app = makeApp();
// providerId="" fails Zod min(1), issuerUrl="not-a-url" fails Zod url()
const { status } = await postAuthProvider(app, {
providerId: "",
displayName: "Test",
issuerUrl: "not-a-url",
clientId: "c",
clientSecret: "s",
});
// Zod throws ZodError which Hono's error handler should format as 400
// Currently returns 500 — route needs error handler for Zod errors
// TODO(cleanup): add error handler to route; expect 400 once fixed
expect(status).toBeGreaterThanOrEqual(400);
});
it("encrypts clientSecret before storing", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
const app = makeApp();
await postAuthProvider(app, validBody);
expect(encryptCalls).toContain("my-secret");
expect(insertedAuthConfig[0]!.clientSecret).toBe("encrypted:my-secret");
});
});
describe("POST /setup/auth-provider/test — OOBE test connection", () => {
beforeEach(() => {
resetMock();
process.env = { ...originalEnv };
clearAuthEnv();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("returns 403 after setup is complete (super user exists)", async () => {
dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }];
const app = makeApp();
const { status, body } = await postAuthProviderTest(app, {
issuerUrl: "https://auth.example.com",
});
expect(status).toBe(403);
expect(body.error).toMatch(/already been completed/i);
});
it("returns ok=false for unreachable issuer URL", async () => {
dbStaffRows = [];
const app = makeApp();
const { status, body } = await postAuthProviderTest(app, {
issuerUrl: "https://192.0.2.1/", // TEST-NET, never reachable
});
expect(status).toBe(200);
expect(body.ok).toBe(false);
expect(body.error).toBeTruthy();
}, 15000);
it("accepts valid issuerUrl", async () => {
dbStaffRows = [];
// Mock fetch to simulate a valid OIDC discovery response
const mockFetch = vi.fn(() => Promise.resolve({ ok: true }));
vi.stubGlobal("fetch", mockFetch);
const app = makeApp();
const { status, body } = await postAuthProviderTest(app, {
issuerUrl: "https://auth.example.com",
});
expect(status).toBe(200);
expect(body.ok).toBe(true);
vi.restoreAllMocks();
});
it("returns ok=false for invalid issuer URL (non-200 response)", async () => {
dbStaffRows = [];
const mockFetch = vi.fn(() =>
Promise.resolve({ ok: false, status: 404 })
);
vi.stubGlobal("fetch", mockFetch);
const app = makeApp();
const { status, body } = await postAuthProviderTest(app, {
issuerUrl: "https://auth.example.com",
});
expect(status).toBe(200);
expect(body.ok).toBe(false);
expect(body.error).toMatch(/discovery failed/i);
vi.restoreAllMocks();
});
});
describe("POST /setup — OOBE regression (GRO-485)", () => {
beforeEach(() => {
resetMock();
process.env = { ...originalEnv };
clearAuthEnv();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("creates staff record during OOBE when no staff record exists for authenticated user", async () => {
// No staff rows — this is a fresh OOBE user
dbStaffRows = [];
dbBusinessSettingsRows = [];
const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
const app = makeApp(null, jwtPayload);
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
expect(status).toBe(201);
expect(body.ok).toBe(true);
expect(body.staff).toBeDefined();
expect((body.staff as MockStaff).isSuperUser).toBe(true);
expect((body.staff as any).email).toBe("alice@example.com");
expect((body.staff as MockStaff).role).toBe("manager");
// New staff record was created
expect(insertedStaff.length).toBe(1);
expect(insertedStaff[0]!.email).toBe("alice@example.com");
expect(insertedStaff[0]!.userId).toBe("user-123");
});
it("still works for user who already has a staff record", async () => {
// Staff record exists for this user
dbStaffRows = [{ id: "staff-existing", role: "groomer", isSuperUser: false }];
dbBusinessSettingsRows = [];
const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
// Inject the existing staff record into context
const app = makeApp({ id: "staff-existing", role: "groomer", isSuperUser: false }, jwtPayload);
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
expect(status).toBe(201);
expect(body.ok).toBe(true);
expect((body.staff as MockStaff).isSuperUser).toBe(true);
// No new staff was created (insertedStaff should be empty since staff was pre-existing)
});
it("auto-links staff by email if record exists with matching email but no userId", async () => {
// Staff record exists with matching email but no userId (legacy record)
dbStaffRows = [{ id: "staff-legacy", role: "manager", isSuperUser: false, email: "alice@example.com", userId: null } as unknown as MockStaff];
dbBusinessSettingsRows = [];
const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
// No staff injected into context — the handler must find it by email
const app = makeApp(null, jwtPayload);
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
expect(status).toBe(201);
expect(body.ok).toBe(true);
expect((body.staff as MockStaff).isSuperUser).toBe(true);
});
it("returns 400 if JWT has no email claim and no staff record exists", async () => {
dbStaffRows = [];
dbBusinessSettingsRows = [];
// JWT with no email
const jwtPayload = { sub: "user-123" };
const app = makeApp(null, jwtPayload);
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
expect(status).toBe(400);
expect(body.error).toMatch(/no email claim/i);
});
it("returns 409 if a super user already exists", async () => {
// Super user already exists
dbStaffRows = [{ id: "staff-super", role: "manager", isSuperUser: true }];
dbBusinessSettingsRows = [];
const jwtPayload = { sub: "user-456", email: "bob@example.com", name: "Bob" };
const app = makeApp(null, jwtPayload);
const { status, body } = await postSetup(app, { businessName: "Bob's Grooming" });
expect(status).toBe(409);
expect(body.error).toMatch(/already been completed/i);
});
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import {
generateAvailableSlots,
BUSINESS_START_HOUR,
BUSINESS_END_HOUR,
} from "../lib/slots.js";
const DATE = "2026-03-18";
const G1 = "groomer-1";
const G2 = "groomer-2";
function utc(h: number, m = 0): Date {
const d = new Date(`${DATE}T00:00:00Z`);
d.setUTCHours(h, m, 0, 0);
return d;
}
describe("generateAvailableSlots", () => {
it("returns slots within business hours", () => {
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [G1],
booked: [],
});
expect(slots.length).toBeGreaterThan(0);
slots.forEach((s) => {
const h = new Date(s).getUTCHours();
expect(h).toBeGreaterThanOrEqual(BUSINESS_START_HOUR);
expect(h).toBeLessThan(BUSINESS_END_HOUR);
});
});
it("returns correct count of 60-min slots across 8-hour window", () => {
// 09:0017:00 = 8 hours → 8 one-hour slots
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [G1],
booked: [],
});
expect(slots).toHaveLength(8);
});
it("returns empty array when no groomers", () => {
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [],
booked: [],
});
expect(slots).toHaveLength(0);
});
it("excludes slots blocked by a booking", () => {
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [G1],
booked: [{ staffId: G1, startTime: utc(9), endTime: utc(10) }],
});
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
expect(slots).toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
});
it("keeps slot available when only the other groomer is booked", () => {
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [G1, G2],
booked: [{ staffId: G1, startTime: utc(9), endTime: utc(10) }],
});
// G2 is free at 09:00 so slot should still appear
expect(slots).toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
});
it("excludes a slot only when ALL groomers are booked", () => {
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [G1, G2],
booked: [
{ staffId: G1, startTime: utc(9), endTime: utc(10) },
{ staffId: G2, startTime: utc(9), endTime: utc(10) },
],
});
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
});
it("correctly handles a booking that partially overlaps a slot", () => {
// Booking 09:3010:30 should block the 09:00 and 10:00 slots for G1
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [G1],
booked: [{ staffId: G1, startTime: utc(9, 30), endTime: utc(10, 30) }],
});
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
expect(slots).not.toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
expect(slots).toContain(new Date(`${DATE}T11:00:00.000Z`).toISOString());
});
it("does not generate a slot that would exceed business hours end", () => {
// 30-min slots: last valid start is 16:30 (ends at 17:00)
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 30,
groomerIds: [G1],
booked: [],
});
const last = slots[slots.length - 1];
expect(last).toBeDefined();
expect(new Date(last!).getUTCHours()).toBe(16);
expect(new Date(last!).getUTCMinutes()).toBe(30);
});
});
+285
View File
@@ -0,0 +1,285 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
const VALID_UUID_1 = "550e8400-e29b-41d4-a716-446655440001";
const VALID_UUID_2 = "550e8400-e29b-41d4-a716-446655440002";
const VALID_UUID_3 = "550e8400-e29b-41d4-a716-446655440003";
const VALID_UUID_4 = "550e8400-e29b-41d4-a716-446655440004";
const VALID_UUID_5 = "550e8400-e29b-41d4-a716-446655440005";
const WAITLIST_ENTRY = {
id: VALID_UUID_1,
clientId: VALID_UUID_2,
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "10:00",
status: "active",
notifiedAt: null,
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const ACTIVE_SESSION = {
id: VALID_UUID_5,
clientId: VALID_UUID_2,
status: "active" as const,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
createdAt: new Date(),
};
const EXPIRED_SESSION = {
id: "660e8400-e29b-41d4-a716-446655440006",
clientId: VALID_UUID_2,
status: "active" as const,
expiresAt: new Date(Date.now() - 60 * 60 * 1000),
createdAt: new Date(),
};
let selectRows: Record<string, unknown>[] = [];
let selectSessionRow: Record<string, unknown> | null = null;
let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
function resetMock() {
selectRows = [];
selectSessionRow = null;
insertedValues = [];
updatedValues = [];
}
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" || prop === "leftJoin") {
return () => chain;
}
// @ts-expect-error proxy
return target[prop];
},
});
return chain;
}
const waitlistEntries = new Proxy(
{ _name: "waitlistEntries" },
{ get: (t, p) => (p === "_name" ? "waitlistEntries" : { table: "waitlistEntries", column: p }) }
);
const impersonationSessions = new Proxy(
{ _name: "impersonationSessions" },
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
);
const clients = new Proxy(
{ _name: "clients" },
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
const pets = new Proxy(
{ _name: "pets" },
{ get: (t, p) => (p === "_name" ? "pets" : { table: "pets", column: p }) }
);
const services = new Proxy(
{ _name: "services" },
{ get: (t, p) => (p === "_name" ? "services" : { table: "services", column: p }) }
);
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
if (table._name === "impersonationSessions") {
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
}
if (table._name === "waitlistEntries") {
return makeChainable(selectRows);
}
return makeChainable([]);
},
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
insertedValues.push(vals);
return {
returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
};
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
updatedValues.push(vals);
return {
returning: () =>
selectRows.length > 0
? [{ ...selectRows[0], ...vals }]
: [],
};
},
}),
}),
delete: () => ({
where: () => {
return {
returning: () =>
selectRows.length > 0 ? [selectRows[0]] : [],
};
},
}),
}),
waitlistEntries,
impersonationSessions,
clients,
pets,
services,
appointments,
eq: vi.fn(),
and: vi.fn(),
lt: vi.fn(),
};
});
const { waitlistRouter } = await import("../routes/waitlist.js");
const { portalRouter } = await import("../routes/portal.js");
const app = new Hono();
app.route("/waitlist", waitlistRouter);
app.route("/portal", portalRouter);
function jsonRequest(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
return app.request(path, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => resetMock());
describe("POST /portal/waitlist", () => {
it("creates entry with valid session", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "10:00",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(201);
const body = await res.json();
expect(body.petId).toBe(VALID_UUID_3);
expect(insertedValues).toHaveLength(1);
});
it("returns 401 without session", async () => {
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "10:00",
});
expect(res.status).toBe(401);
});
it("returns 401 with expired session", async () => {
selectSessionRow = EXPIRED_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "10:00",
}, { "X-Impersonation-Session-Id": EXPIRED_SESSION.id });
expect(res.status).toBe(401);
});
});
describe("DELETE /portal/waitlist/:id", () => {
it("deletes entry with valid session and correct owner", async () => {
selectSessionRow = ACTIVE_SESSION;
selectRows = [WAITLIST_ENTRY];
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
method: "DELETE",
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
});
it("returns 401 without session", async () => {
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
method: "DELETE",
});
expect(res.status).toBe(401);
});
it("returns 403 with valid session but wrong owner", async () => {
selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" };
selectRows = [WAITLIST_ENTRY];
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
method: "DELETE",
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
});
expect(res.status).toBe(403);
});
it("returns 404 when entry not found", async () => {
selectSessionRow = ACTIVE_SESSION;
selectRows = [];
const res = await app.request("/portal/waitlist/nonexistent", {
method: "DELETE",
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
});
expect(res.status).toBe(404);
});
});
describe("PATCH /portal/waitlist/:id", () => {
it("updates entry with valid session and correct owner", async () => {
selectSessionRow = ACTIVE_SESSION;
selectRows = [WAITLIST_ENTRY];
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
status: "cancelled",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(200);
expect(updatedValues[0]?.status).toBe("cancelled");
});
it("returns 401 without session", async () => {
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
status: "cancelled",
});
expect(res.status).toBe(401);
});
it("returns 403 with valid session but wrong owner", async () => {
selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" };
selectRows = [WAITLIST_ENTRY];
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
status: "cancelled",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(403);
});
it("returns 404 when entry not found", async () => {
selectSessionRow = ACTIVE_SESSION;
selectRows = [];
const res = await jsonRequest("PATCH", "/portal/waitlist/nonexistent", {
status: "cancelled",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(404);
});
});