From 5390131a6a3f835cb9db3375967ab05c1f303e43 Mon Sep 17 00:00:00 2001 From: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Date: Sun, 31 May 2026 23:12:58 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Promote=20dev=E2=86=92uat:=20add=20missing?= =?UTF-8?q?=20coat=5Ftype=20enum=20values=20(GRO-1971)=20(#119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UAT_PLAYBOOK.md | 1 + .../db/migrations/0035_add_missing_coat_type_values.sql | 9 +++++++++ .../db/migrations/0036_add_missing_coat_type_values.sql | 9 +++++++++ packages/db/migrations/meta/_journal.json | 6 +++--- 4 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 packages/db/migrations/0035_add_missing_coat_type_values.sql create mode 100644 packages/db/migrations/0036_add_missing_coat_type_values.sql diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index a458219..3b6ed03 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -116,6 +116,7 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-3.24 | Verify UAT test pet Delta has skin alert | GET /api/pets/{id} where name = "TestRocky" (pet for uat-delta@groombook.dev) | medicalAlerts includes an entry with type: "skin" | | TC-API-3.25 | Verify 30+ total pets in UAT DB | GET /api/pets then count total | 30+ pets returned (UAT seed creates 500 random-pool + 5 UAT test clients + 2 UAT customer = 507 total) | | TC-API-3.26 | Verify 25-35% medicalAlerts distribution | GET /api/pets (first 30 pets), count how many have non-empty medicalAlerts | Ratio is 25-35% (seed uses rand() < 0.3 for ~30% distribution) | +| TC-API-3.27 | Verify coat_type enum has all seed values | After UAT seed completes, inspect the coat_type enum on the UAT DB — it must contain: short, medium, long, double, wire, silky, curly, hairless | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; coat_type includes all 8 values used by seed.ts `coatTypePool` | ### 4.4 Appointment Scheduling diff --git a/packages/db/migrations/0035_add_missing_coat_type_values.sql b/packages/db/migrations/0035_add_missing_coat_type_values.sql new file mode 100644 index 0000000..3b7a2d3 --- /dev/null +++ b/packages/db/migrations/0035_add_missing_coat_type_values.sql @@ -0,0 +1,9 @@ +-- Migration: 0035_add_missing_coat_type_values.sql +-- Adds missing values to coat_type enum that seed.ts requires but which were +-- omitted from the 0031_buffer_rules.sql CREATE TYPE statement (migration drift). +-- 0031 created: 'smooth', 'double', 'wire', 'curly', 'long', 'hairless' +-- Missing (from schema.ts coatTypeEnum): 'short', 'medium', 'silky' + +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short'; +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium'; +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky'; \ No newline at end of file diff --git a/packages/db/migrations/0036_add_missing_coat_type_values.sql b/packages/db/migrations/0036_add_missing_coat_type_values.sql new file mode 100644 index 0000000..026c5ef --- /dev/null +++ b/packages/db/migrations/0036_add_missing_coat_type_values.sql @@ -0,0 +1,9 @@ +-- Migration: 0036_add_missing_coat_type_values.sql +-- Adds missing values to coat_type enum that seed.ts requires but which were +-- omitted from the 0031_buffer_rules.sql CREATE TYPE statement (migration drift). +-- 0031 created: 'smooth', 'double', 'wire', 'curly', 'long', 'hairless' +-- Missing (from schema.ts coatTypeEnum): 'short', 'medium', 'silky' + +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short'; +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium'; +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky'; \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 58d27a7..1c7c56a 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -248,10 +248,10 @@ "breakpoints": true }, { - "idx": 35, + "idx": 36, "version": "7", - "when": 1751140800000, - "tag": "0035_add_short_to_coat_type_enum", + "when": 1751480000000, + "tag": "0036_add_missing_coat_type_values", "breakpoints": true } ] From e5fe005986523d0cd90523b73c1203a442e77f30 Mon Sep 17 00:00:00 2001 From: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Date: Mon, 1 Jun 2026 00:36:36 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Promote=20dev=E2=86=92uat:=20restore=20dete?= =?UTF-8?q?rministic=20TestCooper/TestRocky=20alerts=20(GRO-1962)=20(#123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net> --- UAT_PLAYBOOK.md | 2 + .../__tests__/seed-uat-credentials.test.ts | 87 +++++++++++++++++-- apps/api/src/db/seed.ts | 10 ++- packages/db/src/seed.ts | 34 +++++--- 4 files changed, 113 insertions(+), 20 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 7f954ae..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,7 +175,11 @@ async function seedUatCredentials( ); if (existingAccount) { - // skip — already has credential account + // 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"); @@ -312,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[] = [ @@ -330,25 +336,96 @@ 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) ── + + 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 ──────────────────────────────── diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index fc65098..5b48dd6 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(`✓ 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 diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 27500c4..cf65909 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -1106,14 +1106,17 @@ async function seed() { temperamentScore: randInt(1, 5), temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)), medicalAlerts: (() => { - // ~30% of pets get alerts; TestCooper/TestRocky get deterministic types + // TestCooper always has a behavioral alert; TestRocky always has a skin alert. + // All other UAT test pets follow the 30% random distribution. + // Deterministic alerts on 2 of 507 pets (~0.4%) do not meaningfully shift + // the overall distribution from the 25-35% target band. + if (uc.petName === "TestCooper") { + return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() })); + } + if (uc.petName === "TestRocky") { + return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() })); + } if (rand() < 0.3) { - if (uc.petName === "TestCooper") { - return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() })); - } - if (uc.petName === "TestRocky") { - return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() })); - } const count = rand() < 0.7 ? 1 : 2; return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() })); } @@ -1136,14 +1139,17 @@ async function seed() { temperamentScore: randInt(1, 5), temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)), medicalAlerts: (() => { - // ~30% of pets get alerts; TestCooper/TestRocky get deterministic types + // TestCooper always has a behavioral alert; TestRocky always has a skin alert. + // All other UAT test pets follow the 30% random distribution. + // Deterministic alerts on 2 of 507 pets (~0.4%) do not meaningfully shift + // the overall distribution from the 25-35% target band. + if (uc.petName === "TestCooper") { + return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() })); + } + if (uc.petName === "TestRocky") { + return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() })); + } if (rand() < 0.3) { - if (uc.petName === "TestCooper") { - return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() })); - } - if (uc.petName === "TestRocky") { - return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() })); - } const count = rand() < 0.7 ? 1 : 2; return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() })); }