diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 68d6d25..56eb127 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -28,6 +28,7 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | 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 | 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..4b870e5 100644 --- a/apps/api/src/__tests__/rbac.test.ts +++ b/apps/api/src/__tests__/rbac.test.ts @@ -45,40 +45,72 @@ 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) => ({ + limit: () => ({ + [Symbol.iterator]: function* () { + if (result) yield result; }, - } - ); + 0: result, + length: result ? 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 +119,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. */ @@ -202,6 +236,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 ────────────────────────────────────────────────────────