diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index b5796eb..5db2f6b 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -26,14 +26,13 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | # | Scenario | Steps | Expected | |---|----------|-------|----------| | TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims | -| TC-API-1.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds | -| TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 | | TC-API-1.4 | Email+password login (UAT) | POST /api/auth/sign-in/email with uat-super@groombook.dev + SEED_UAT_SUPER_PASSWORD | 200 OK, session cookie returned | | TC-API-1.5 | Email+password login — groomer | POST /api/auth/sign-in/email with uat-groomer@groombook.dev + SEED_UAT_GROOMER_PASSWORD | 200 OK, session cookie returned | | TC-API-1.6 | Email+password login — customer | POST /api/auth/sign-in/email with uat-customer@groombook.dev + SEED_UAT_CUSTOMER_PASSWORD | 200 OK, session cookie returned | | TC-API-1.7 | Email+password login — tester | POST /api/auth/sign-in/email with uat-tester@groombook.dev + SEED_UAT_TESTER_PASSWORD | 200 OK, session cookie returned | | TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned | | TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned | +| TC-API-1.10 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table | ### 4.2 Client Management diff --git a/apps/api/src/__tests__/rbac.test.ts b/apps/api/src/__tests__/rbac.test.ts index dc3d7de..9eb0918 100644 --- a/apps/api/src/__tests__/rbac.test.ts +++ b/apps/api/src/__tests__/rbac.test.ts @@ -45,40 +45,76 @@ const GROOMER: StaffRow = { let staffLookupResult: StaffRow | null = null; let managerFallbackResult: StaffRow | null = MANAGER; +let userLookupResult: { id: string; name: string | null; email: string | null } | null = null; +let _insertedStaff: StaffRow | null = null; vi.mock("../db", () => { - const staff = new Proxy( - { _name: "staff" }, - { - get(target, prop) { - if (prop === "_name") return "staff"; - if (prop === "$inferSelect") return {}; - return { table: "staff", column: prop }; - }, - } - ); + const makeTableProxy = (name: string) => + new Proxy( + { _name: name }, + { + get(target, prop) { + if (prop === "_name") return name; + if (prop === "$inferSelect") return {}; + return { table: name, column: prop }; + }, + } + ); + + const staff = makeTableProxy("staff"); + const user = makeTableProxy("user"); + + const buildQuery = (result: unknown, fallback: unknown) => ({ + [Symbol.iterator]: function* () { + if (result) yield result; + }, + limit: (_n: number) => { + const item = result ?? fallback; + return { + [Symbol.iterator]: function* () { if (item) yield item; }, + 0: item, + length: item ? 1 : 0, + }; + }, + }); 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, - }), + from: (table: unknown) => ({ + where: () => buildQuery( + table === staff ? staffLookupResult : userLookupResult, + table === staff ? managerFallbackResult : null + ), + }), + }), + insert: (_table: unknown) => ({ + values: (vals: Record) => ({ + returning: () => { + const newStaff: StaffRow = { + id: "new-staff-id", + oidcSub: null, + userId: vals.userId as string, + role: vals.role as StaffRow["role"], + isSuperUser: false, + name: vals.name as string, + email: vals.email as string, + active: true, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + _insertedStaff = newStaff; + return [newStaff]; + }, }), }), }), staff, + user, eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })), and: vi.fn((..._clauses: unknown[]) => ({})), + sql: vi.fn((..._args: unknown[]) => ({})), }; }); @@ -87,6 +123,8 @@ vi.mock("../db", () => { function resetMocks() { staffLookupResult = null; managerFallbackResult = MANAGER; + userLookupResult = null; + _insertedStaff = null; } /** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */ @@ -96,7 +134,10 @@ function buildApp( ) { const app = new Hono(); app.use("*", async (c, next) => { - c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" }); + c.set("jwtPayload", { + sub: userLookupResult?.id ?? staffLookupResult?.userId ?? "unknown-sub", + email: userLookupResult?.email, + }); await next(); }); app.use("*", middleware); @@ -202,6 +243,50 @@ describe("resolveStaffMiddleware", () => { const body = await res.json(); expect(body.error).toMatch(/no staff records found/i); }); + + it("auto-provision: creates groomer staff record on first login when Better-Auth user exists", async () => { + staffLookupResult = null; + userLookupResult = { id: "ba-user-new", name: "New User", email: "newuser@example.com" }; + 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!.role).toBe("groomer"); + expect(capturedStaff!.userId).toBe("ba-user-new"); + expect(capturedStaff!.name).toBe("New User"); + expect(capturedStaff!.email).toBe("newuser@example.com"); + expect(capturedStaff!.isSuperUser).toBe(false); + }); + + it("auto-provision: falls back to email prefix when user has no name", async () => { + staffLookupResult = null; + userLookupResult = { id: "ba-user-noname", name: null, email: "firstlogin@example.com" }; + 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!.name).toBe("firstlogin"); + }); + + it("auto-provision: returns 403 when no staff record and no Better-Auth user exists", async () => { + staffLookupResult = null; + userLookupResult = 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 found for authenticated user/i); + }); }); // ─── requireRole tests ──────────────────────────────────────────────────────── diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index a3c9d8b..1d315ac 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "hono"; -import { and, eq, getDb, sql, staff } from "../db/index.js"; +import { and, eq, getDb, sql, staff, user } from "../db/index.js"; export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRow = typeof staff.$inferSelect; @@ -110,6 +110,33 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( return; } } + // Auto-provision: no staff record exists for this user at all, but a valid + // Better-Auth user session exists (jwt.sub = user.id from user table). + // Create a minimal groomer staff record on first login. + const [userRow] = await db + .select({ id: user.id, name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, jwt.sub)) + .limit(1); + if (userRow) { + const [newStaff] = await db + .insert(staff) + .values({ + name: userRow.name ?? jwt.email?.split("@")[0] ?? "Unknown", + email: userRow.email ?? jwt.email ?? "", + userId: jwt.sub, + role: "groomer", + isSuperUser: false, + active: true, + }) + .returning(); + if (!newStaff) { + return c.json({ error: "Internal error: staff record creation failed" }, 500); + } + c.set("staff", newStaff); + await next(); + return; + } return c.json( { error: "Forbidden: no staff record found for authenticated user" }, 403