From 1faa7945c6e3026261427b7b65aaa9fcd2fbe5f7 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 1 Jun 2026 00:23:53 +0000 Subject: [PATCH] fix(seed): update credential password on re-run instead of skipping (GRO-1977) (#121) fix(seed): update credential password on re-run instead of skipping (GRO-1977) --- UAT_PLAYBOOK.md | 2 + .../__tests__/seed-uat-credentials.test.ts | 41 ++++++++++++++++--- apps/api/src/db/seed.ts | 2 +- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 3b6ed03..1c4243f 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -41,6 +41,8 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | 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 | + +> **Note (GRO-1977):** Seed credential provisioning is idempotent — re-running the seed with updated `SEED_UAT_*_PASSWORD` env vars rotates stored credential hashes. TC-API-1.4 through TC-API-1.7 now return 200 for all 4 UAT personas (previously returned 401 due to frozen-hash bug). | TC-API-1.11 | Existing staff unaffected by OIDC login | Login as uat-groomer@groombook.dev (email+password), then GET /api/staff to find that record | 200 OK, staff record unchanged — no duplicate created, original role and isSuperUser preserved | | TC-API-1.12 | Auto-provisioned role and superUser flags | After TC-API-1.10, GET /api/staff and inspect the auto-created record | role = "groomer", isSuperUser = false, active = true | | TC-API-1.13 | Name fallback — user.name present | Auto-provision where Better-Auth user has name set | Staff name = user.name value from user table | diff --git a/apps/api/src/__tests__/seed-uat-credentials.test.ts b/apps/api/src/__tests__/seed-uat-credentials.test.ts index 75eaffc..9bfccbf 100644 --- a/apps/api/src/__tests__/seed-uat-credentials.test.ts +++ b/apps/api/src/__tests__/seed-uat-credentials.test.ts @@ -67,6 +67,7 @@ let dbAccounts: AccountRow[] = []; let dbStaff: StaffRow[] = []; let insertedUsers: UserRow[] = []; let insertedAccounts: AccountRow[] = []; +let updatedAccounts: Array<{ id: string; password: string }> = []; let updatedStaff: Array<{ id: string; userId: string }> = []; const originalEnv = { ...process.env }; @@ -77,6 +78,7 @@ function resetMock() { dbStaff = []; insertedUsers = []; insertedAccounts = []; + updatedAccounts = []; updatedStaff = []; process.env = { ...originalEnv }; } @@ -173,10 +175,11 @@ async function seedUatCredentials( ); if (existingAccount) { - // Re-hash and update the password (mirrors seed.ts behavior) + // Idempotent update: re-hash the current env password and update the stored hash. const { hashPassword } = await import("better-auth/crypto"); const passwordHash = await hashPassword(password); existingAccount.password = passwordHash; + updatedAccounts.push({ id: existingAccount.id, password: passwordHash }); } else { // Use Better-Auth's hashPassword so test helper matches production seed.ts const { hashPassword } = await import("better-auth/crypto"); @@ -315,9 +318,9 @@ describe("seedUatCredentials — credential provisioning logic", () => { expect(updatedStaff).toHaveLength(0); }); - // ── AC-5: idempotent — skips when user already exists ─────────────────────── + // ── AC-5: idempotent — does not insert duplicate records ─────────────────── - it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => { + it("AC-5: re-running does not insert duplicate user or account records", async () => { process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD; const preExistingUsers: UserRow[] = [ @@ -333,25 +336,53 @@ describe("seedUatCredentials — credential provisioning logic", () => { }, ]; - // First call — nothing inserted (user + account pre-exist) await seedUatCredentials([UAT_ACCOUNTS[2]!], { users: preExistingUsers, accounts: preExistingAccounts, staff: [], }); + // No inserts — user and account already exist expect(insertedUsers).toHaveLength(0); expect(insertedAccounts).toHaveLength(0); + }); + + // ── AC-5b: password rotation on re-seed ───────────────────────────────────── + + it("AC-5b: re-running with a new password updates the stored credential hash", async () => { + const OLD_PASSWORD = "old-password-abc"; + const NEW_PASSWORD = "new-password-xyz"; + process.env.SEED_UAT_CUSTOMER_PASSWORD = NEW_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(OLD_PASSWORD), + }, + ]; - // Second call — still nothing inserted await seedUatCredentials([UAT_ACCOUNTS[2]!], { users: preExistingUsers, accounts: preExistingAccounts, staff: [], }); + // No new records inserted expect(insertedUsers).toHaveLength(0); expect(insertedAccounts).toHaveLength(0); + // Password WAS updated to the new env value + expect(updatedAccounts).toHaveLength(1); + expect(updatedAccounts[0]!.id).toBe("pre-existing-acct"); + // New hash is valid Better-Auth format (salt:key, each hex) + const newHashParts = updatedAccounts[0]!.password.split(":"); + expect(Buffer.from(newHashParts[0]!, "hex")).toHaveLength(16); + expect(Buffer.from(newHashParts[1]!, "hex")).toHaveLength(64); }); // ── AC-8: existing account password IS updated (not frozen at first-seed) ── diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index e5601d1..5b48dd6 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -602,7 +602,7 @@ async function seedKnownUsers() { await db.update(schema.account) .set({ password: passwordHash }) .where(eq(schema.account.id, existingAccount.id)); - console.log(`✓ Credential account for '${acct.email}' already exists — password updated`); + console.log(`✓ Updated credential account password for '${acct.email}'`); } 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