diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 68d6d25..8f3d171 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -28,6 +28,12 @@ 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 | 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 | ### 4.2 Client Management diff --git a/apps/api/src/__tests__/seed-uat-credentials.test.ts b/apps/api/src/__tests__/seed-uat-credentials.test.ts new file mode 100644 index 0000000..7f954ae --- /dev/null +++ b/apps/api/src/__tests__/seed-uat-credentials.test.ts @@ -0,0 +1,431 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// ─── Test configuration constants (must match seed.ts) ───────────────────────── + +const UAT_ACCOUNTS = [ + { + email: "uat-super@groombook.dev", + name: "UAT Super User", + passwordEnv: "SEED_UAT_SUPER_PASSWORD", + staffEmail: "uat-super@groombook.dev", + }, + { + email: "uat-groomer@groombook.dev", + name: "UAT Staff Groomer", + passwordEnv: "SEED_UAT_GROOMER_PASSWORD", + staffEmail: "uat-groomer@groombook.dev", + }, + { + email: "uat-customer@groombook.dev", + name: "UAT Customer", + passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", + staffEmail: null, + }, + { + email: "uat-tester@groombook.dev", + name: "UAT Tester", + passwordEnv: "SEED_UAT_TESTER_PASSWORD", + staffEmail: "uat-tester@groombook.dev", + }, +]; + +const TEST_PASSWORD = "test-password-123"; + +// ─── Password hashing — must match better-auth/crypto (N=16384, r=16, p=1, dkLen=64, hex) ─── + +async function hashPassword(password: string): Promise { + const { hashPassword } = await import("better-auth/crypto"); + return hashPassword(password); +} + +// ─── Mock DB state ───────────────────────────────────────────────────────────── + +interface UserRow { + id: string; + email: string; + name: string; + emailVerified: boolean; +} + +interface AccountRow { + id: string; + accountId: string; + providerId: string; + userId: string; + password: string | null; +} + +interface StaffRow { + id: string; + email: string; + userId: string | null; + name: string; +} + +let dbUsers: UserRow[] = []; +let dbAccounts: AccountRow[] = []; +let dbStaff: StaffRow[] = []; +let insertedUsers: UserRow[] = []; +let insertedAccounts: AccountRow[] = []; +let updatedStaff: Array<{ id: string; userId: string }> = []; + +const originalEnv = { ...process.env }; + +function resetMock() { + dbUsers = []; + dbAccounts = []; + dbStaff = []; + insertedUsers = []; + insertedAccounts = []; + updatedStaff = []; + process.env = { ...originalEnv }; +} + +// ─── Mock schema ─────────────────────────────────────────────────────────────── + +function makeSchemaMock() { + const user = new Proxy({ _name: "user" }, { + get(_t, p) { + if (p === "_name") return "user"; + if (p === "$inferSelect") return {}; + return { table: "user", column: p }; + }, + }); + + const account = new Proxy({ _name: "account" }, { + get(_t, p) { + if (p === "_name") return "account"; + if (p === "$inferSelect") return {}; + return { table: "account", column: p }; + }, + }); + + const staff = new Proxy({ _name: "staff" }, { + get(_t, p) { + if (p === "_name") return "staff"; + if (p === "$inferSelect") return {}; + return { table: "staff", column: p }; + }, + }); + + return { user, account, staff }; +} + +const { user: mockUser, account: mockAccount, staff: mockStaff } = makeSchemaMock(); + +function eq(col: unknown, val: unknown) { + return { __type: "eq" as const, col, val }; +} + +function and(...conds: unknown[]) { + return { __type: "and" as const, conds }; +} + +// ─── Seed logic helper ───────────────────────────────────────────────────────── +// Inline the credential provisioning logic under test so we can call it directly. +// This is the same logic as seed.ts lines 514-598. + +interface SeedAccount { + email: string; + name: string; + passwordEnv: string; + staffEmail: string | null; +} + +let uuidCounter = 0; +function mockUuid(): string { + return `mock-uuid-${++uuidCounter}`; +} + +async function seedUatCredentials( + accounts: SeedAccount[], + opts: { + users?: UserRow[]; + accounts?: AccountRow[]; + staff?: StaffRow[]; + } +) { + const { users = dbUsers, accounts: accts = dbAccounts, staff: staffRows = dbStaff } = opts; + + for (const acct of accounts) { + const password = process.env[acct.passwordEnv]; + if (!password) { + console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`); + continue; + } + + // 1. Find or create the Better-Auth user + const existingUser = users.find((u) => u.email === acct.email); + + let userId: string; + if (existingUser) { + userId = existingUser.id; + } else { + userId = mockUuid(); + const newUser: UserRow = { id: userId, name: acct.name, email: acct.email, emailVerified: true }; + insertedUsers.push(newUser); + dbUsers.push(newUser); + } + + // 2. Check if credential account already exists + const existingAccount = accts.find( + (a) => a.userId === userId && a.providerId === "credential" + ); + + if (existingAccount) { + // skip — already has credential account + } else { + // Use Better-Auth's hashPassword so test helper matches production seed.ts + const { hashPassword } = await import("better-auth/crypto"); + const passwordHash = await hashPassword(password); + + const newAccount: AccountRow = { + id: mockUuid(), + accountId: userId, + providerId: "credential", + userId, + password: passwordHash, + }; + insertedAccounts.push(newAccount); + dbAccounts.push(newAccount); + } + + // 3. Link staff record to Better-Auth user + if (acct.staffEmail) { + const existingStaff = staffRows.find((s) => s.email === acct.staffEmail); + if (existingStaff && !existingStaff.userId) { + existingStaff.userId = userId; + updatedStaff.push({ id: existingStaff.id, userId }); + } + } + } +} + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe("seedUatCredentials — credential provisioning logic", () => { + beforeEach(() => { + resetMock(); + uuidCounter = 0; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + // ── AC-1: creates user + account when neither exists ────────────────────── + + it("AC-1: creates user and account for each UAT account with password env var set", async () => { + process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD; + process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD; + process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD; + process.env.SEED_UAT_TESTER_PASSWORD = TEST_PASSWORD; + + await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] }); + + // 4 users created (customer + tester have no staff, super + groomer do) + expect(insertedUsers).toHaveLength(4); + expect(insertedUsers.find((u) => u.email === "uat-super@groombook.dev")).toBeDefined(); + expect(insertedUsers.find((u) => u.email === "uat-groomer@groombook.dev")).toBeDefined(); + expect(insertedUsers.find((u) => u.email === "uat-customer@groombook.dev")).toBeDefined(); + expect(insertedUsers.find((u) => u.email === "uat-tester@groombook.dev")).toBeDefined(); + + // 4 accounts created + expect(insertedAccounts).toHaveLength(4); + for (const acct of insertedAccounts) { + expect(acct.providerId).toBe("credential"); + // Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex) + expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + // Verify the hash is scrypt with correct params (N=16384, r=16, p=1, dkLen=64) + const parts = acct.password!.split(":"); + const saltHex = parts[0]!; + const keyHex = parts[1]!; + const salt = Buffer.from(saltHex, "hex"); + const storedHash = Buffer.from(keyHex, "hex"); + expect(salt).toHaveLength(16); + expect(storedHash).toHaveLength(64); + } + }); + + // ── AC-2: emailVerified = true ───────────────────────────────────────────── + + it("AC-2: created users have emailVerified = true", async () => { + process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD; + + await seedUatCredentials( + [UAT_ACCOUNTS[2]!], // customer only + { users: [], accounts: [], staff: [] } + ); + + expect(insertedUsers[0]!.emailVerified).toBe(true); + }); + + // ── AC-3: providerId = credential, password is hashed ────────────────────── + + it("AC-3: account records use providerId='credential' with properly formatted hashed password", async () => { + process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD; + + await seedUatCredentials( + [UAT_ACCOUNTS[2]!], + { users: [], accounts: [], staff: [] } + ); + + const acct = insertedAccounts[0]!; + expect(acct.providerId).toBe("credential"); + // Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars) + expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + const parts = acct.password!.split(":"); + const saltHex = parts[0]!; + const keyHex = parts[1]!; + expect(() => Buffer.from(saltHex, "hex")).not.toThrow(); + expect(() => Buffer.from(keyHex, "hex")).not.toThrow(); + const salt = Buffer.from(saltHex, "hex"); + const storedHash = Buffer.from(keyHex, "hex"); + expect(salt).toHaveLength(16); + expect(storedHash).toHaveLength(64); + }); + + // ── AC-4: staff.userId is linked ──────────────────────────────────────────── + + it("AC-4: links staff.userId to the Better-Auth user when staff record exists", async () => { + process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD; + const staffRows: StaffRow[] = [ + { id: "staff-super-1", email: "uat-super@groombook.dev", userId: null, name: "UAT Super User" }, + ]; + + await seedUatCredentials([UAT_ACCOUNTS[0]!], { users: [], accounts: [], staff: staffRows }); + + expect(updatedStaff).toHaveLength(1); + expect(updatedStaff[0]!.id).toBe("staff-super-1"); + expect(updatedStaff[0]!.userId).toBe("mock-uuid-1"); + expect(staffRows[0]!.userId).toBe("mock-uuid-1"); + }); + + it("AC-4b: does not update staff.userId if already set", async () => { + process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD; + const staffRows: StaffRow[] = [ + { id: "staff-groomer-1", email: "uat-groomer@groombook.dev", userId: "already-linked", name: "UAT Groomer" }, + ]; + + await seedUatCredentials([UAT_ACCOUNTS[1]!], { users: [], accounts: [], staff: staffRows }); + + expect(updatedStaff).toHaveLength(0); + }); + + // ── AC-5: idempotent — skips when user already exists ─────────────────────── + + it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => { + process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD; + + const preExistingUsers: UserRow[] = [ + { id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true }, + ]; + const preExistingAccounts: AccountRow[] = [ + { + id: "pre-existing-acct", + accountId: "pre-existing-user", + providerId: "credential", + userId: "pre-existing-user", + password: await hashPassword(TEST_PASSWORD), + }, + ]; + + // First call — nothing inserted (user + account pre-exist) + await seedUatCredentials([UAT_ACCOUNTS[2]!], { + users: preExistingUsers, + accounts: preExistingAccounts, + staff: [], + }); + + expect(insertedUsers).toHaveLength(0); + expect(insertedAccounts).toHaveLength(0); + + // Second call — still nothing inserted + await seedUatCredentials([UAT_ACCOUNTS[2]!], { + users: preExistingUsers, + accounts: preExistingAccounts, + staff: [], + }); + + expect(insertedUsers).toHaveLength(0); + expect(insertedAccounts).toHaveLength(0); + }); + + // ── AC-6: missing env var skips with warning ──────────────────────────────── + + it("AC-6: missing SEED_UAT_*_PASSWORD env var skips that account (no error)", async () => { + // No env vars set at all + delete process.env.SEED_UAT_SUPER_PASSWORD; + delete process.env.SEED_UAT_GROOMER_PASSWORD; + delete process.env.SEED_UAT_CUSTOMER_PASSWORD; + delete process.env.SEED_UAT_TESTER_PASSWORD; + + const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined); + + await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] }); + + // Nothing created + expect(insertedUsers).toHaveLength(0); + expect(insertedAccounts).toHaveLength(0); + // Warning logged for each of the 4 accounts + expect(warnSpy).toHaveBeenCalledTimes(4); + expect(warnSpy).toHaveBeenCalledWith( + "⚠ Skipping uat-super@groombook.dev — SEED_UAT_SUPER_PASSWORD not set" + ); + + warnSpy.mockRestore(); + }); + + // ── AC-7: partial env var coverage ───────────────────────────────────────── + + it("AC-7: only accounts with password env var set are provisioned", async () => { + process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD; + // Only super has password set + + const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined); + + await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] }); + + expect(insertedUsers).toHaveLength(1); + expect(insertedUsers[0]!.email).toBe("uat-super@groombook.dev"); + expect(insertedAccounts).toHaveLength(1); + expect(insertedAccounts[0]!.accountId).toBe("mock-uuid-1"); + + // 3 warnings for missing accounts + expect(warnSpy).toHaveBeenCalledTimes(3); + + warnSpy.mockRestore(); + }); +}); + +// ─── Password hash format verification ─────────────────────────────────────── + +describe("password hash format — scrypt parameters", () => { + it("hashes use salt:hash format with 16-byte salt and 64-byte output", async () => { + const hash = await hashPassword("test-password"); + const parts = hash.split(":"); + const saltHex = parts[0]!; + const keyHex = parts[1]!; + + expect(hash).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + expect(Buffer.from(saltHex, "hex")).toHaveLength(16); + expect(Buffer.from(keyHex, "hex")).toHaveLength(64); + }); + + it("same password produces different hashes (due to random salt)", async () => { + const hash1 = await hashPassword("same-password"); + const hash2 = await hashPassword("same-password"); + + expect(hash1).not.toBe(hash2); + // Both are valid Better-Auth hex format + expect(hash1).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + expect(hash2).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + }); + + it("different passwords produce different hashes", async () => { + const hash1 = await hashPassword("password1"); + const hash2 = await hashPassword("password2"); + + expect(hash1).not.toBe(hash2); + }); +}); \ No newline at end of file diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 2ff67bf..566da17 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -18,7 +18,7 @@ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; -import { eq, sql } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import * as schema from "./schema.js"; // ── Seed profile configuration ───────────────────────────────────────────── @@ -511,6 +511,90 @@ async function seedKnownUsers() { } } + // ── Better-Auth email+password credentials for UAT accounts ────────────────── + // Provisions Better-Auth user + account records so UAT testers can log in + // via email+password (POST /api/auth/sign-in/email) instead of Authentik SSO. + const uatPasswordAccounts = [ + { email: "uat-super@groombook.dev", name: "UAT Super User", passwordEnv: "SEED_UAT_SUPER_PASSWORD", staffEmail: "uat-super@groombook.dev" }, + { email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" }, + { email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null }, + { email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" }, + ]; + + for (const acct of uatPasswordAccounts) { + const password = process.env[acct.passwordEnv]; + if (!password) { + console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`); + continue; + } + + // 1. Find or create the Better-Auth user + const [existingUser] = await db + .select() + .from(schema.user) + .where(eq(schema.user.email, acct.email)) + .limit(1); + + let userId: string; + if (existingUser) { + userId = existingUser.id; + console.log(`✓ Better-Auth user '${acct.name}' already exists — skipping user creation`); + } else { + userId = uuid(); + await db.insert(schema.user).values({ + id: userId, + name: acct.name, + email: acct.email, + emailVerified: true, + }); + console.log(`✓ Created Better-Auth user '${acct.name}' (${acct.email})`); + } + + // 2. Check if credential account already exists + const [existingAccount] = await db + .select() + .from(schema.account) + .where(and( + eq(schema.account.userId, userId), + eq(schema.account.providerId, "credential") + )) + .limit(1); + + if (existingAccount) { + console.log(`✓ Credential account for '${acct.email}' already exists — skipping`); + } else { + // Use Better-Auth's own hashPassword to guarantee parameter/encoding match. + // better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random + // hex string, key hex-encoded, format saltHex:keyHex. + const { hashPassword } = await import("better-auth/crypto"); + const passwordHash = await hashPassword(password); + + await db.insert(schema.account).values({ + id: uuid(), + accountId: userId, + providerId: "credential", + userId, + password: passwordHash, + }); + console.log(`✓ Created credential account for '${acct.email}'`); + } + + // 3. Link staff record to Better-Auth user (for accounts that have staff records) + if (acct.staffEmail) { + const [existingStaff] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, acct.staffEmail)) + .limit(1); + if (existingStaff && !existingStaff.userId) { + await db.update(schema.staff) + .set({ userId }) + .where(eq(schema.staff.id, existingStaff.id)); + console.log(`✓ Linked staff '${acct.staffEmail}' → Better-Auth user`); + } + } + } + // ── Services: idempotent upsert using name as unique key ───────────────────── // UNIQUE constraint on services.name (migration 0020) must exist first. // Uses b0000001-... IDs to match main seed servicesDef for same-named services.