Compare commits

..

4 Commits

Author SHA1 Message Date
Flea Flicker 14db60079b docs(UAT_PLAYBOOK): note GRO-1977 seed idempotency fix for TC-API-1.4-1.7
CI / Lint & Typecheck (pull_request) Successful in 12s
CI / Test (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 3m14s
Updated UAT_PLAYBOOK.md §4.1 — add note that seed credential provisioning
is idempotent and 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).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:15:19 +00:00
Flea Flicker 6af6c52f52 fix(seed): update credential password on re-run instead of skipping
CI / Lint & Typecheck (pull_request) Successful in 12s
CI / Test (pull_request) Successful in 13s
CI / Build & Push Docker Images (pull_request) Failing after 1m5s
- seed.ts: existingAccount branch now re-hashes the current
  SEED_UAT_*_PASSWORD env value and updates the stored credential.
  Re-running the seed now properly rotates credentials.
- seed-uat-credentials.test.ts: AC-5 updated to reflect update
  (not insert) behavior on existing accounts. AC-5b added to verify
  password rotation when re-seeded with a new env password.

Fixes GRO-1977. Unblocks GRO-1969.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:14:29 +00:00
The Dogfather 040ff4a253 Merge pull request 'feat(gro-1743): add UAT customer and pets to admin seed endpoint' (#69) from fix/gro-1743-uat-seed-data into dev
CI / Lint & Typecheck (push) Successful in 11s
CI / Test (push) Successful in 12s
CI / Build & Push Docker Images (push) Successful in 18s
Merge PR #69: feat(gro-1743): add UAT customer and pets to admin seed endpoint
2026-05-25 15:37:10 +00:00
Flea Flicker a1466b44c9 feat(gro-1743): add UAT customer and pets to admin seed endpoint
CI / Lint & Typecheck (pull_request) Successful in 12s
CI / Test (pull_request) Successful in 12s
CI / Build & Push Docker Images (pull_request) Successful in 1m12s
Add UAT Customer (uat-customer@groombook.dev) with two pets (Bella and Max)
to the idempotent admin seed endpoint for portal UAT testing.

- Client: UAT Customer, email: uat-customer@groombook.dev, phone: 555-0100, status: active
- Pet 1: Bella, Dog, Poodle, coatType: curly
- Pet 2: Max, Dog, Labrador Retriever, coatType: short

Issue: GRO-1743
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-25 15:29:36 +00:00
6 changed files with 116 additions and 215 deletions
+2 -3
View File
@@ -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 |
@@ -139,9 +141,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-8.5 | Add waitlist entry | POST /api/portal/waitlist with pet and service | 201 Created, waitlist entry created |
| TC-API-8.6 | View portal invoices | GET /api/portal/invoices | 200 OK, list of client's invoices returned |
| TC-API-8.7 | Pay multiple invoices | POST /api/portal/invoices/pay-multiple with invoice IDs | 200 OK, payment intent created |
| TC-API-8.8 | Update pet profile | PATCH /api/portal/pets/{id} with name, breed, groomingNotes | 200 OK, pet updated in portal shape |
| TC-API-8.9 | Update pet — ownership check | PATCH /api/portal/pets/{id} with session for different client | 403 Forbidden, pet belongs to another client |
| TC-API-8.10 | Update pet — not found | PATCH /api/portal/pets/{nonexistent-id} | 404 Not Found |
### 4.9 Waitlist
@@ -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,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-6: missing env var skips with warning ────────────────────────────────
+8 -1
View File
@@ -561,7 +561,14 @@ async function seedKnownUsers() {
.limit(1);
if (existingAccount) {
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
// Idempotent: re-hash the current env password and update the stored hash.
// This ensures re-running the seed with a new SEED_UAT_*_PASSWORD rotates the credential.
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
+55
View File
@@ -36,6 +36,18 @@ const DEMO_PET = {
weightKg: "30.00",
};
const UAT_CLIENT = {
name: "UAT Customer",
email: "uat-customer@groombook.dev",
phone: "555-0100",
status: "active" as const,
};
const UAT_PETS = [
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly" as const },
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "short" as const },
];
const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
@@ -128,6 +140,49 @@ adminSeedRouter.post("/seed", async (c) => {
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
}
// ── Client: UAT Customer ──────────────────────────────────────────────────
const [existingUatClient] = await db
.select()
.from(clients)
.where(eq(clients.email, UAT_CLIENT.email));
let uatClientId: string;
if (existingUatClient) {
uatClientId = existingUatClient.id;
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
} else {
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
uatClientId = created!.id;
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
}
// ── Pets: UAT Customer Pets ───────────────────────────────────────────────
const existingUatPets = await db
.select()
.from(pets)
.where(eq(pets.clientId, uatClientId));
for (const uatPet of UAT_PETS) {
const existingPet = existingUatPets.find(
(p) => p.name === uatPet.name && p.species === uatPet.species
);
if (existingPet) {
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existingPet.id})`);
} else {
const [created] = await db
.insert(pets)
.values({
clientId: uatClientId,
name: uatPet.name,
species: uatPet.species,
breed: uatPet.breed,
coatType: uatPet.coatType,
})
.returning();
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
}
}
return c.json({
message: "Seed complete",
details: results,
+12 -145
View File
@@ -4,7 +4,6 @@ import { Hono } from "hono";
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
const APPOINTMENT_ID = "660e8400-e29b-41d4-a716-446655440002";
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
const PET_ID = "880e8400-e29b-41d4-a716-446655440004";
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
const pastDate = () => new Date(Date.now() - 5 * 60 * 1000);
@@ -38,38 +37,13 @@ const APPOINTMENT = {
cancelledAt: null,
};
const PET = {
id: PET_ID,
clientId: CLIENT_ID,
name: "Fido",
species: "dog",
breed: "Labrador",
weightKg: "30.00",
dateOfBirth: null,
healthAlerts: null,
groomingNotes: null,
cutStyle: null,
shampooPreference: null,
specialCareNotes: null,
coatType: null,
petSizeCategory: null,
customFields: {},
photoKey: null,
photoUploadedAt: null,
image: null,
createdAt: new Date(),
updatedAt: new Date(),
};
let selectSessionRow: Record<string, unknown> | null = null;
let selectAppointmentRow: Record<string, unknown> | null = null;
let selectPetRow: Record<string, unknown> | null = null;
let updatedValues: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectAppointmentRow = null;
selectPetRow = null;
updatedValues = [];
}
@@ -88,8 +62,6 @@ vi.mock("@groombook/db", () => {
return chain;
}
let activeUpdateTable: string | null = null;
const impersonationSessions = new Proxy(
{ _name: "impersonationSessions" },
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
@@ -100,16 +72,6 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
const pets = new Proxy(
{ _name: "pets" },
{ get: (t, p) => (p === "_name" ? "pets" : { table: "pets", column: p }) }
);
const impersonationAuditLogs = new Proxy(
{ _name: "impersonationAuditLogs" },
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
);
return {
getDb: () => ({
select: () => ({
@@ -120,44 +82,26 @@ vi.mock("@groombook/db", () => {
if (table._name === "appointments") {
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
}
if (table._name === "pets") {
return makeChainable(selectPetRow ? [selectPetRow] : []);
}
return makeChainable([]);
},
}),
insert: () => ({
values: () => ({
returning: () => [{}],
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => ({
returning: () => {
if (selectAppointmentRow) {
const updated = { ...selectAppointmentRow, ...vals };
updatedValues.push(vals);
return [updated];
}
return [];
},
}),
}),
}),
update: (table: { _name: string }) => {
activeUpdateTable = table._name;
return {
set: (vals: Record<string, unknown>) => ({
where: () => ({
returning: () => {
if (activeUpdateTable === "appointments" && selectAppointmentRow) {
const updated = { ...selectAppointmentRow, ...vals };
updatedValues.push(vals);
return [updated];
}
if (activeUpdateTable === "pets" && selectPetRow) {
const updated = { ...selectPetRow, ...vals };
updatedValues.push(vals);
return [updated];
}
return [];
},
}),
}),
};
},
}),
impersonationSessions,
appointments,
pets,
impersonationAuditLogs,
eq: vi.fn(),
and: vi.fn(),
};
@@ -476,81 +420,4 @@ describe("POST /portal/appointments/:id/cancel", () => {
);
expect(res.status).toBe(404);
});
});
// ─── PATCH /portal/pets/:id ───────────────────────────────────────────────────
function jsonPetPatch(path: string, body: unknown, headers?: Record<string, string>) {
return app.request(path, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
});
}
describe("PATCH /portal/pets/:id", () => {
it("updates a pet and returns the updated pet in portal shape", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = { ...PET, dateOfBirth: new Date("2020-01-15"), photoKey: "pets/test.jpg" };
const res = await jsonPetPatch(
`/portal/pets/${PET_ID}`,
{ name: "Fido Jr.", groomingNotes: "Needs extra brushing" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("id");
expect(body).toHaveProperty("name", "Fido Jr.");
expect(body).toHaveProperty("notes", "Needs extra brushing");
expect(body).toHaveProperty("breed");
expect(body).toHaveProperty("photoUrl");
expect(body).not.toHaveProperty("clientId");
expect(body).not.toHaveProperty("customFields");
});
it("returns 401 without X-Impersonation-Session-Id header", async () => {
const res = await jsonPetPatch(`/portal/pets/${PET_ID}`, { name: "Test" });
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 401 with expired session", async () => {
selectSessionRow = EXPIRED_SESSION;
const res = await jsonPetPatch(
`/portal/pets/${PET_ID}`,
{ name: "Test" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 403 when pet belongs to a different client", async () => {
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
selectPetRow = { ...PET };
const res = await jsonPetPatch(
`/portal/pets/${PET_ID}`,
{ name: "Hacked" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toBe("Forbidden");
});
it("returns 404 when pet not found", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = null;
const res = await jsonPetPatch(
`/portal/pets/nonexistent-id`,
{ name: "Ghost" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(404);
});
});
-61
View File
@@ -152,67 +152,6 @@ portalRouter.get("/pets", async (c) => {
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
});
const portalUpdatePetSchema = z.object({
name: z.string().min(1).max(200).optional(),
species: z.string().min(1).max(100).optional(),
breed: z.string().max(200).optional(),
weightKg: z.number().positive().optional(),
dateOfBirth: z.string().datetime().optional(),
healthAlerts: z.string().max(2000).optional(),
groomingNotes: z.string().max(2000).optional(),
cutStyle: z.string().max(500).optional(),
shampooPreference: z.string().max(500).optional(),
specialCareNotes: z.string().max(2000).optional(),
customFields: z.record(z.string(), z.string()).optional(),
petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
});
portalRouter.patch(
"/pets/:id",
zValidator("json", portalUpdatePetSchema),
async (c) => {
const db = getDb();
const petId = c.req.param("id");
const clientId = c.get("portalClientId");
const body = c.req.valid("json");
const [existing] = await db
.select()
.from(pets)
.where(eq(pets.id, petId))
.limit(1);
if (!existing) return c.json({ error: "Not found" }, 404);
if (existing.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
const { weightKg, dateOfBirth, customFields, ...rest } = body;
const [updated] = await db
.update(pets)
.set({
...rest,
weightKg: weightKg?.toString(),
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
...(customFields !== undefined ? { customFields } : {}),
updatedAt: new Date(),
})
.where(eq(pets.id, petId))
.returning();
if (!updated) return c.json({ error: "Not found" }, 404);
return c.json({
id: updated.id,
name: updated.name,
breed: updated.breed,
weight: updated.weightKg,
birthDate: updated.dateOfBirth,
photoUrl: updated.photoKey,
notes: updated.groomingNotes,
});
}
);
portalRouter.get("/invoices", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");