diff --git a/apps/api/src/__tests__/seed-uat-credentials.test.ts b/apps/api/src/__tests__/seed-uat-credentials.test.ts index 7f954ae..75eaffc 100644 --- a/apps/api/src/__tests__/seed-uat-credentials.test.ts +++ b/apps/api/src/__tests__/seed-uat-credentials.test.ts @@ -173,7 +173,10 @@ async function seedUatCredentials( ); if (existingAccount) { - // skip — already has credential account + // Re-hash and update the password (mirrors seed.ts behavior) + const { hashPassword } = await import("better-auth/crypto"); + const passwordHash = await hashPassword(password); + existingAccount.password = passwordHash; } else { // Use Better-Auth's hashPassword so test helper matches production seed.ts const { hashPassword } = await import("better-auth/crypto"); @@ -351,6 +354,49 @@ describe("seedUatCredentials — credential provisioning logic", () => { expect(insertedAccounts).toHaveLength(0); }); + // ── AC-8: existing account password IS updated (not frozen at first-seed) ── + + it("AC-8: re-seeding with a changed password env var updates the stored hash", async () => { + const ORIGINAL_PASSWORD = "original-password"; + const ROTATED_PASSWORD = "rotated-password-456"; + + process.env.SEED_UAT_CUSTOMER_PASSWORD = ROTATED_PASSWORD; + + const preExistingUsers: UserRow[] = [ + { id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true }, + ]; + // Account was created with the original password on first seed + const originalHash = await hashPassword(ORIGINAL_PASSWORD); + const preExistingAccounts: AccountRow[] = [ + { + id: "pre-existing-acct", + accountId: "pre-existing-user", + providerId: "credential", + userId: "pre-existing-user", + password: originalHash, + }, + ]; + + // Re-seed with the rotated password env var + await seedUatCredentials([UAT_ACCOUNTS[2]!], { + users: preExistingUsers, + accounts: preExistingAccounts, + staff: [], + }); + + // No new user or account created + expect(insertedUsers).toHaveLength(0); + expect(insertedAccounts).toHaveLength(0); + + // The pre-existing account's password WAS updated (not frozen at first-seed). + // hashPassword uses a random salt so we verify by format + that it is a new, + // different valid hash from the original. + const updatedAcct = preExistingAccounts[0]!; + expect(updatedAcct.password).toBeDefined(); + expect(updatedAcct.password).toMatch(/^[a-f0-9]{32}:[a-f0-9]{128}$/); + expect(updatedAcct.password).not.toBe(originalHash); // it actually changed + }); + // ── AC-6: missing env var skips with warning ──────────────────────────────── it("AC-6: missing SEED_UAT_*_PASSWORD env var skips that account (no error)", async () => { diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index fc65098..e5601d1 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -594,7 +594,15 @@ async function seedKnownUsers() { .limit(1); if (existingAccount) { - console.log(`✓ Credential account for '${acct.email}' already exists — skipping`); + // Re-hash and update the password so that re-seeding rotates credentials + // when the env var changes (e.g. after a password rotation). Previously + // this branch skipped entirely, freezing the hash at first-seed. + const { hashPassword } = await import("better-auth/crypto"); + const passwordHash = await hashPassword(password); + 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`); } 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