Compare commits

...

8 Commits

Author SHA1 Message Date
Flea Flicker f67b96ddfe Merge pull request 'fix(GRO-2123): serialize seed.ts with Postgres advisory lock' (#155) from flea-flicker/gro-2123-seed-advisory-lock into dev
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Images (push) Successful in 25s
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (pull_request) Successful in 28s
2026-06-04 11:23:41 +00:00
Flea Flicker d1a68d93de fix(GRO-2123): serialize seed.ts with Postgres advisory lock
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 58s
The reset-demo-data CronJob in groombook-uat intermittently failed with
FK 23503 on invoice_tip_splits because two pods could run the seed
concurrently: the new pod's TRUNCATE deleted rows the old pod was still
inserting.

Acquire a session-level advisory lock for the full duration of the seed.
CRITICAL: with postgres-js connection pooling, a pg_advisory_lock
acquired on one pooled connection and released on a different one is a
no-op (the lock is bound to the pg-backend that took it). We therefore
reserve a dedicated connection for the lock, take pg_advisory_lock(KEY)
on it, run the seed on the pooled connections, and release the lock +
reserved connection in a try/finally so a thrown seed error cannot leak
the lock or the connection.

Defence-in-depth with the infra PR that switches
concurrencyPolicy: Replace → Forbid on the reset-demo-data CronJob.

- Adds withSeedAdvisoryLock helper and runSeedBody extracted function
- Wraps seed() body in the helper; client.end() runs after the lock
  releases so a reserved connection is not returned to a closed pool
- SEED_ADVISORY_LOCK_KEY = 0x47524f4f ("GROO" in ASCII) — arbitrary
  stable 32-bit key, referenced in runbooks
- UAT_PLAYBOOK.md §3.29 documents the regression check

cc @cpfarhood

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-04 11:12:17 +00:00
Flea Flicker e9f94a2bd7 fix(seed): GRO-2100 run uat-groomer linkage AFTER services seed (regression in #151) (#153)
CI / Test (push) Successful in 12s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 29s
CI / Lint & Typecheck (push) Failing after 12m57s
CI / Build & Push Docker Images (push) Has been skipped
fix(seed): GRO-2100 run uat-groomer linkage after services seed (#153)

Co-authored-by: Flea Flicker <flea@groombook.dev>
Co-committed-by: Flea Flicker <flea@groombook.dev>
2026-06-02 20:11:45 +00:00
Flea Flicker de16c50040 fix(seed): GRO-2100 deterministic uat-groomer ↔ UAT Pup Alpha linkage (#151)
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Images (pull_request) Successful in 45s
CI / Test (push) Successful in 2m20s
CI / Lint & Typecheck (push) Successful in 2m25s
CI / Build & Push Docker Images (push) Successful in 28s
2026-06-02 18:09:31 +00:00
Flea Flicker fc6c6ef752 fix(db): make services seed idempotent across resets (GRO-2064, GRO-2033 close-out) (#148)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 28s
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 20s
CI / Build & Push Docker Images (pull_request) Successful in 39s
2026-06-02 04:54:33 +00:00
The Dogfather 1a6a54cc84 security(audit): log owner-bypass reads in GET /pets/:id/profile-summary (GRO-2062) (#146)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 40s
CI / Build & Push Docker Images (pull_request) Successful in 27s
QA-approved (gb_lint) + CTO-approved. Defense-in-depth audit row on staff owner-bypass. GRO-2063.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 04:20:23 +00:00
Flea Flicker 1f888ac716 security(audit): log owner-bypass reads in GET /pets/:id/profile-summary (GRO-2062)
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 1m16s
Adds a defense-in-depth audit row to impersonationAuditLogs when the
staff-side owner-bypass path fires. Mirrors the failure-isolation
pattern in src/middleware/portalAudit.ts: insert failures are logged
and swallowed so a working read can never turn into a 500.

- New writeOwnerBypassAudit helper called only when isOwner === true.
- No DB migration; petId + actorStaffId go inside metadata jsonb.
- resolveImpersonationClientId stays pure (no audit side effects).
- Positive + negative tests + a cross-tenant regression test.
- UAT_PLAYBOOK.md §3.19d: TC-API-3.19d documents the audit assertion.

Parent tracking: GRO-2062 (Paperclip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 04:10:58 +00:00
Flea Flicker 91eb2ccf71 fix(rbac): port Better-Auth user auto-provision into legacy ./src tree (GRO-2052) (#143)
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 15s
CI / Test (pull_request) Successful in 9s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (push) Successful in 36s
CI / Build & Push Docker Images (pull_request) Successful in 26s
fix(rbac): port Better-Auth user auto-provision into legacy ./src tree (GRO-2052)

Ports the Better-Auth user-table auto-provision branch from canonical apps/api into the deployed ./src/middleware/rbac.ts so the owner-bypass in pets.ts is reachable for Better-Auth email/password customers. OIDC account branch retained as backward-compat fallback. Adds 5 rbac.test.ts cases and UAT_PLAYBOOK pre-condition docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Flea Flicker <flea@groombook.dev>
Co-committed-by: Flea Flicker <flea@groombook.dev>
2026-06-02 02:40:43 +00:00
7 changed files with 702 additions and 57 deletions
+47
View File
@@ -40,6 +40,24 @@ CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
**How to apply:** at the start of every UAT run that touches TC-API-1.4 / 1.5 / 1.6 / 1.7 / 3.18 / 3.21 / 3.23, refresh these four env vars from the cluster before issuing the sign-in request.
### rbac auto-provision for Better-Auth customers (GRO-2052)
> Applies to TC-API-3.16 / 3.19a / 3.19b / 3.19c (customer-as-owner profile-summary paths) and any future case where the test user authenticates via Better-Auth email/password and the route relies on `resolveStaffMiddleware` to resolve a `staff` row.
**Pre-condition (rbac auto-provision):** The test user must have a row in the Better-Auth `user` table (email/password sign-in creates this automatically — see TC-API-1.6 / 1.7). On first authenticated call, `resolveStaffMiddleware` (`./src/middleware/rbac.ts`) auto-provisions a `groomer` staff row keyed by `staff.user_id = user.id` (Better-Auth branch fires before the legacy OIDC `account` branch).
**Verify the auto-provision fired** by querying the DB after the first authenticated call:
```sql
SELECT user_id, role FROM staff WHERE user_id = '<test-user-id>';
```
Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the OIDC `account` branch and 403'd, or the user has no `user` row — fix the test sign-in path before re-running.
**Why this matters:** without the auto-provision branch, Better-Auth email/password customers (e.g. `uat-customer@groombook.dev`) have no `account` row for the OIDC providers, so `resolveStaffMiddleware` falls through to `403 "Forbidden: no staff record found for authenticated user"` *before* `pets.ts` can run the owner-bypass added in GRO-2013. The owner-bypass code is unreachable unless the auto-provision has fired. A green TC-API-3.19a therefore implicitly proves the auto-provision worked; if 3.19a fails with the pre-fix 403, the auto-provision branch is missing from the deployed `./src` tree (see [GRO-2052](/GRO/issues/GRO-2052)).
**How to apply:** for every run of TC-API-3.16 / 3.19a / 3.19b / 3.19c, sign in via TC-API-1.6 (email+password) first to guarantee the `user` row exists, then run the profile-summary call, then assert the `staff` row above before declaring pass.
## Test Cases
### 4.0 Health Check
@@ -128,6 +146,7 @@ CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
| TC-API-3.19a | Get pet profile summary — customer owner-bypass (GRO-2013) | Sign in as `uat-customer@groombook.dev`; `POST /api/portal/session-from-auth`; then `GET /api/pets/{ownPetId}/profile-summary` with header `X-Impersonation-Session-Id: {sessionId}` for either of the customer's seeded pets (`c0000001-0000-0000-0000-000000000002` UAT Pup Alpha, `c0000001-0000-0000-0000-000000000003` UAT Pup Beta) | 200 OK, aggregated profile returned (owner-bypass: customer with valid portal session for pet's clientId is allowed even though rbac.ts auto-provisions them as a `groomer` staff row with no appointment linkage) |
| TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) |
| TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) |
| TC-API-3.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) |
| TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) |
| TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) |
| TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` |
@@ -147,6 +166,7 @@ CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
| 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` |
| TC-API-3.28 | Verify pet_size_category enum has all seed values | After UAT seed completes, inspect the pet_size_category enum on the UAT DB — it must contain: small, medium, large, extra_large | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; pet_size_category includes all 4 values used by seed.ts `petSizeCategoryPool` (regression for GRO-1999, mirrors TC-API-3.27) |
| TC-API-3.29 | Verify `reset-demo-data` CronJob does not fail with FK 23503 on `invoice_tip_splits` (GRO-2123) | Trigger the CronJob manually: `kubectl create job --from=cronjob/reset-demo-data verify-gro2123 -n groombook-uat`. Wait for pod to terminate. Inspect logs: `kubectl logs -n groombook-uat -l job-name=verify-gro2123` | Pod reaches `Completed` state; logs show `✓ Acquired seed advisory lock` and `✓ Released seed advisory lock` from `seed.ts`; no `PostgresError: … violates foreign key constraint "invoice_tip_splits_invoice_id_invoices_id_fk"` (code 23503); final counts unchanged (500 clients, ~4000 invoices) |
### 4.4 Appointment Scheduling
@@ -173,6 +193,33 @@ CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
| TC-API-5.4 | Update service | PATCH /api/services/{id} with updated fields | 200 OK, service updated |
| TC-API-5.5 | Delete service | DELETE /api/services/{id} | 200 OK, service deleted |
#### 4.5.1 Seed/Reset idempotency (GRO-2064)
Services seeding is now keyed on the deterministic `services.id` (not `name`) and
the reset path now `TRUNCATE`s `services` alongside the other dynamic tables.
This means:
- Running the seed Job twice in a row (no reset in between) converges to the
same catalogue — no `services_pkey` collision.
- A `pnpm reset` followed by `pnpm seed` (or a CronJob reset fire) leaves the
catalogue exactly matching `servicesDef` (10 rows, ids `b0000001-…-001`
`…-00a`), regardless of any stale rows that were present beforehand.
- Mixed `seedKnownUsers` + full `seed()` invocations are safe — the
`demoSvcs` subset (Bath & Brush, Full Groom Small/Medium, Nail Trim) is
keyed on ids `…-001`, `…-002`, `…-003`, `…-005` and the upsert target
is `services.id`, so the same-id / different-name collision that broke
GRO-2033 (id `…-004` = "Nail Trim" vs servicesDef `…-004` =
"Full Groom — Large") cannot recur.
**UAT regression** (verify after a new image is rolled out):
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-SEED-1 | Reset → seed converges | `kubectl -n groombook exec deploy/api -- pnpm reset && pnpm seed` | Seed completes 1/1, `services` count = 10, all ids match `servicesDef` |
| TC-SEED-2 | Idempotent re-seed | Re-run `pnpm seed` without reset | Seed completes 1/1, no `services_pkey` errors, `services` count still 10 |
| TC-SEED-3 | Catalogue matches servicesDef | `psql -c "SELECT id, name FROM services ORDER BY id"` | Rows `…-001``…-00a` with names "Bath & Brush"…"Sanitary Trim" exactly as in `servicesDef` |
| TC-SEED-4 | Demo subset coexists | Run `seedKnownUsers` then full `seed` | No collision, demo subset (4 services) ends up with the same rows the full seed would write |
### 4.6 Staff Management
| # | Scenario | Steps | Expected |
+27 -12
View File
@@ -636,21 +636,28 @@ async function seedKnownUsers() {
}
}
// ── 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.
// ── Services: idempotent upsert keyed on `id` ─────────────────────────────
// GRO-2064: previously keyed on `services.name` while writing a
// deterministic `id`. If a stale row existed with the same `id` but a
// different `name`, PostgreSQL raised `services_pkey` (id collision)
// before the name-targeted ON CONFLICT could fire. Switch the conflict
// target to `services.id` so deterministic ids always win; pair with
// `TRUNCATE services … CASCADE` above so each reset rebuilds the
// catalogue from `servicesDef` cleanly. GRO-2033 close-out.
// Id↔name map MUST stay in sync with `servicesDef` (the canonical source
// of truth in the main `seed()` function).
const demoSvcs = [
{ 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 },
{ id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 },
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
{ id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
];
for (const svc of demoSvcs) {
await db.insert(schema.services)
.values({ ...svc, active: true })
.onConflictDoUpdate({
target: schema.services.name,
set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
target: schema.services.id,
set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
});
}
console.log(`✓ Seeded ${demoSvcs.length} services`);
@@ -757,7 +764,13 @@ async function seed() {
({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
);
await db.execute(sql`TRUNCATE impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
// GRO-2064: also TRUNCATE `services` so each reset rebuilds the catalogue
// from `servicesDef` (deterministic IDs + UNIQUE(name)). Stale service rows
// (e.g. a prior `seedKnownUsers` run that wrote a different `name` for the
// same `id`) would otherwise cause the deterministic upsert to PK-collide
// on `services.id` — see CTO review on infra PR #605 (rev #4230). TRUNCATE
// CASCADE handles appointments/invoices FKs to services.id.
await db.execute(sql`TRUNCATE services, impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
for (const s of allStaff) {
@@ -828,9 +841,11 @@ async function seed() {
}
// ── Services ──
// Upsert services using name as unique key. With deterministic IDs in
// servicesDef and TRUNCATE clearing downstream tables first, this is
// idempotent: first run inserts, subsequent runs update existing rows.
// GRO-2064: key the upsert on `services.id` (not `name`) so deterministic
// ids always win, and rely on the TRUNCATE above to clear stale rows before
// the catalogue is rebuilt. The previous name-targeted upsert failed with
// `services_pkey` when a prior run had left a row with the same id but a
// different name (CTO review on infra PR #605, rev #4230).
const serviceIds: string[] = [];
for (const s of servicesDef) {
serviceIds.push(s.id);
@@ -844,8 +859,8 @@ async function seed() {
active: true,
})
.onConflictDoUpdate({
target: schema.services.name,
set: { description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
target: schema.services.id,
set: { name: s.name, description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
});
}
console.log(`✓ Created ${servicesDef.length} services`);
+243 -17
View File
@@ -401,7 +401,9 @@ const servicesDef = [
*
* In seedKnownUsers() this replaces the inline UAT-staff block.
*/
async function seedUatStaffAccounts(db: ReturnType<typeof drizzle>) {
async function seedUatStaffAccounts(
db: ReturnType<typeof drizzle>,
): Promise<string | null> {
// ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ──
const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB;
if (uatSuperOidcSub) {
@@ -668,6 +670,132 @@ async function seedUatStaffAccounts(db: ReturnType<typeof drizzle>) {
console.log(`✓ Created UAT pet '${pet.name}' with extended fields`);
}
}
// ── GRO-2100: deterministic uat-groomer ↔ pet linkage ───────────────────────
// The UAT groomer (`uat-groomer@groombook.dev`, staffId 00000000-0000-0000-0000-000000000004)
// needs at least one linked pet/appointment or GRO-1987 TC-UAT-2/3 cannot run
// (the pet profile-summary endpoint returns 404 instead of 200/403).
//
// We deterministically link the UAT groomer to the UAT customer's first pet
// ("UAT Pup Alpha") and leave the second pet ("UAT Pup Beta") UNLINKED so
// TC-UAT-2 (200) and TC-UAT-3 (403) can both hardcode the stable petIds.
//
// The linkage call itself is performed by the caller AFTER the `services`
// catalogue has been seeded (this helper runs before services exist,
// which previously caused the linkage to be silently skipped on every
// reset). GRO-2100 follow-up.
return uatCustomerClientId;
}
/**
* GRO-2100: create a deterministic completed appointment linking the UAT groomer
* to "UAT Pup Alpha" (c0000001-0000-0000-0000-000000000002). "UAT Pup Beta"
* (c0000001-0000-0000-0000-000000000003) is intentionally left UNLINKED so
* GRO-1987 TC-UAT-3 can verify the 403 forbidden response.
*
* Idempotent: the deterministic appointment id (`a0000001-…-0001`) is the
* upsert key, so re-running the seed on every reset-demo-data CronJob
* (hourly per apps/overlays/uat/reset-cronjob.yaml) is safe.
*/
async function seedUatGroomerLinkage(
db: ReturnType<typeof drizzle>,
customerClientId: string | null,
): Promise<void> {
const uatGroomerEmail = "uat-groomer@groombook.dev";
const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha
const APPT_ID = "a0000001-0000-0000-0000-000000000001";
// Skip silently if the UAT Customer client wasn't created (non-UAT seed
// profile, e.g. seedKnownUsers() in an env without the UAT personas).
if (!customerClientId) {
return;
}
// Only run if the UAT groomer staff record actually exists — dev/test seeds
// that don't set SEED_UAT_STAFF_OIDC_SUB should not crash.
const [uatGroomerStaff] = await db
.select({ id: schema.staff.id })
.from(schema.staff)
.where(eq(schema.staff.email, uatGroomerEmail))
.limit(1);
if (!uatGroomerStaff) {
return;
}
// Skip if this exact appointment already exists (idempotent on re-seed).
const [existing] = await db
.select({ id: schema.appointments.id })
.from(schema.appointments)
.where(eq(schema.appointments.id, APPT_ID))
.limit(1);
if (existing) {
console.log(`✓ GRO-2100: uat-groomer linkage appointment already exists — skipping`);
return;
}
// Skip if the linked pet hasn't been seeded yet (defensive: caller should
// ensure pets exist; if the helper is re-ordered later we don't want to
// crash here).
const [linkedPet] = await db
.select({ id: schema.pets.id })
.from(schema.pets)
.where(eq(schema.pets.id, LINKED_PET_ID))
.limit(1);
if (!linkedPet) {
console.warn(`⚠ GRO-2100: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping uat-groomer linkage`);
return;
}
// The "Bath & Brush" service id is stable across the reset; falls back to
// any active service if it has not been seeded yet (e.g. seedKnownUsers
// runs in isolation).
const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001";
const [bathService] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.id, BATH_AND_BRUSH_ID))
.limit(1);
let serviceId: string;
if (bathService) {
serviceId = bathService.id;
} else {
const [fallback] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.active, true))
.limit(1);
if (!fallback) {
console.warn(`⚠ GRO-2100: no active services found — skipping uat-groomer linkage`);
return;
}
serviceId = fallback.id;
}
// Schedule the completed appointment 7 days ago so the profile-summary's
// "recentGroomingHistory" window (last 10) reliably includes it.
const startTime = new Date();
startTime.setDate(startTime.getDate() - 7);
startTime.setHours(10, 0, 0, 0);
const endTime = new Date(startTime.getTime() + 45 * 60 * 1000);
await db.insert(schema.appointments).values({
id: APPT_ID,
clientId: customerClientId,
petId: LINKED_PET_ID,
serviceId,
staffId: uatGroomerStaff.id,
batherStaffId: null,
status: "completed",
startTime,
endTime,
notes: "GRO-2100: deterministic uat-groomer linkage for TC-UAT-2/3.",
priceCents: null,
confirmationStatus: "confirmed",
});
console.log(
`✓ GRO-2100: linked uat-groomer (${uatGroomerStaff.id}) → UAT Pup Alpha (${LINKED_PET_ID}) via appointment ${APPT_ID}`,
);
}
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
@@ -745,27 +873,40 @@ async function seedKnownUsers() {
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
// Extracted into seedUatStaffAccounts() so it runs in both seedKnownUsers()
// and the full seed() UAT branch.
await seedUatStaffAccounts(db);
const uatCustomerClientId = await seedUatStaffAccounts(db);
// ── 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.
// ── Services: idempotent upsert keyed on `id` ─────────────────────────────
// GRO-2064: previously keyed on `services.name` while writing a
// deterministic `id`. If a stale row existed with the same `id` but a
// different `name`, PostgreSQL raised `services_pkey` (id collision)
// before the name-targeted ON CONFLICT could fire. Switch the conflict
// target to `services.id` so deterministic ids always win; pair with
// `TRUNCATE services … CASCADE` above so each reset rebuilds the
// catalogue from `servicesDef` cleanly. GRO-2033 close-out.
// Id↔name map MUST stay in sync with `servicesDef` (the canonical source
// of truth in the main `seed()` function).
const demoSvcs = [
{ 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 },
{ id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 },
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
{ id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
];
for (const svc of demoSvcs) {
await db.insert(schema.services)
.values({ ...svc, active: true })
.onConflictDoUpdate({
target: schema.services.name,
set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
target: schema.services.id,
set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
});
}
console.log(`✓ Seeded ${demoSvcs.length} services`);
// GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run
// AFTER services are seeded (this helper looks up an active service id
// to attach to the appointment; on a fresh reset there are none yet at
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// ── Client: Demo Client ──
const [existingClient] = await db
.select()
@@ -835,6 +976,63 @@ async function seedKnownUsers() {
// ── Main seed ────────────────────────────────────────────────────────────────
// ── GRO-2123: serialize reset+seed with a Postgres advisory lock ────────
// The reset-demo-data CronJob runs on an hourly schedule. With
// concurrencyPolicy=Replace, a new pod can start while the previous one
// is still mid-seed; the new pod's TRUNCATE then deletes rows the old pod
// is still inserting, producing FK 23503 errors non-deterministically
// (see GRO-2123: invoice_tip_splits → invoices).
//
// We hold a session-level advisory lock for the full duration of the
// seed so that overlapping invocations block then proceed in order —
// not skip. The key is a stable 32-bit constant so it can be referenced
// from runbooks without ambiguity and binds to the single-argument
// `pg_advisory_lock(int)` form, which postgres-js serializes as a plain
// number (no bigint type plumbing required).
const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable
/**
* Reserve a dedicated connection from `pool`, take the seed advisory lock
* on it, run `fn`, and release the lock + connection in a try/finally.
*
* CRITICAL: with postgres-js connection pooling, a session-level
* `pg_advisory_lock(KEY)` acquired on one pooled connection and released
* on a *different* one is a no-op (the lock is bound to the session /
* pg-backend that took it). We therefore reserve a dedicated connection
* for the lock and release it from the same reserved connection. The
* seed work itself still runs on the pooled connections.
*/
async function withSeedAdvisoryLock<T>(
pool: ReturnType<typeof postgres>,
fn: () => Promise<T>,
): Promise<T> {
const lockConnection = await pool.reserve();
let lockHeld = false;
try {
await lockConnection`SELECT pg_advisory_lock(${SEED_ADVISORY_LOCK_KEY})`;
lockHeld = true;
console.log(`✓ Acquired seed advisory lock (key=${SEED_ADVISORY_LOCK_KEY})`);
const result = await fn();
await lockConnection`SELECT pg_advisory_unlock(${SEED_ADVISORY_LOCK_KEY})`;
lockHeld = false;
console.log(`✓ Released seed advisory lock`);
return result;
} finally {
if (lockHeld) {
try {
await lockConnection`SELECT pg_advisory_unlock(${SEED_ADVISORY_LOCK_KEY})`;
} catch (err) {
console.error("Failed to release seed advisory lock during cleanup:", err);
}
}
try {
lockConnection.release();
} catch (err) {
console.error("Failed to release reserved lock connection:", err);
}
}
}
async function seed() {
const url = process.env.DATABASE_URL;
if (!url) {
@@ -852,6 +1050,22 @@ async function seed() {
const client = postgres(url, { max: 5 });
const db = drizzle(client, { schema });
// GRO-2123: hold the seed advisory lock for the full body of runSeedBody.
// See the withSeedAdvisoryLock comment for why a reserved connection is
// required (postgres-js pooling would silently drop the lock otherwise).
await withSeedAdvisoryLock(client, async () => {
return await runSeedBody(client, db, profile, cfg);
});
await client.end();
}
async function runSeedBody(
client: ReturnType<typeof postgres>,
db: ReturnType<typeof drizzle>,
profile: SeedProfile,
cfg: ProfileConfig,
): Promise<void> {
console.log(`Seeding Groom Book database (profile: ${profile})...\n`);
// ── Staff ──
@@ -868,7 +1082,13 @@ async function seed() {
({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
);
await db.execute(sql`TRUNCATE impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
// GRO-2064: also TRUNCATE `services` so each reset rebuilds the catalogue
// from `servicesDef` (deterministic IDs + UNIQUE(name)). Stale service rows
// (e.g. a prior `seedKnownUsers` run that wrote a different `name` for the
// same `id`) would otherwise cause the deterministic upsert to PK-collide
// on `services.id` — see CTO review on infra PR #605 (rev #4230). TRUNCATE
// CASCADE handles appointments/invoices FKs to services.id.
await db.execute(sql`TRUNCATE services, impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
for (const s of allStaff) {
@@ -916,12 +1136,14 @@ async function seed() {
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
// Seeds deterministic UAT staff with numeric OIDC subs and Better Auth credentials.
// Must run AFTER random staff are created so upserts land correctly.
await seedUatStaffAccounts(db);
const uatCustomerClientId = await seedUatStaffAccounts(db);
// ── Services ──
// Upsert services using name as unique key. With deterministic IDs in
// servicesDef and TRUNCATE clearing downstream tables first, this is
// idempotent: first run inserts, subsequent runs update existing rows.
// GRO-2064: key the upsert on `services.id` (not `name`) so deterministic
// ids always win, and rely on the TRUNCATE above to clear stale rows before
// the catalogue is rebuilt. The previous name-targeted upsert failed with
// `services_pkey` when a prior run had left a row with the same id but a
// different name (CTO review on infra PR #605, rev #4230).
const serviceIds: string[] = [];
for (const s of servicesDef) {
serviceIds.push(s.id);
@@ -935,12 +1157,18 @@ async function seed() {
active: true,
})
.onConflictDoUpdate({
target: schema.services.name,
set: { description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
target: schema.services.id,
set: { name: s.name, description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
});
}
console.log(`✓ Created ${servicesDef.length} services`);
// GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run
// AFTER services are seeded (this helper looks up an active service id
// to attach to the appointment; on a fresh reset there are none yet at
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// ── Clients & Pets ──
const now = new Date();
const appointmentsBackDate = new Date(now);
@@ -1459,8 +1687,6 @@ async function seed() {
}
console.log(`✓ Created ${visitLogCount} grooming visit logs`);
console.log("\nSeed complete!");
await client.end();
}
seed().catch((err) => {
+79 -1
View File
@@ -182,6 +182,11 @@ let selectQueue: Array<{
throw?: string;
}> = [];
// Captured `db.insert(table).values(vals)` calls. Mirrors the pattern from
// src/__tests__/impersonation.test.ts so the GRO-2063 audit row assertions
// can inspect what the route tried to write without needing a real DB.
let insertCapture: Array<{ table: string; vals: Record<string, unknown> }> = [];
function enqueue(table: string, rows: unknown[] = []) {
selectQueue.push({ table, rows });
}
@@ -196,6 +201,7 @@ function resetMock() {
servicesTable = [makeService()];
sessionsTable = [makeSession()];
selectQueue = [];
insertCapture = [];
}
// ─── Module mocks ───────────────────────────────────────────────────────────
@@ -269,7 +275,12 @@ vi.mock("@groombook/db", () => {
select: (_cols?: Record<string, unknown>) => ({
from: (table: { _name?: string }) => wrapRows(takeQueuedRows(table._name ?? "")),
}),
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
insert: (table: { _name?: string }) => ({
values: (vals: Record<string, unknown>) => {
insertCapture.push({ table: table._name ?? "unknown", vals });
return { returning: () => [{}] };
},
}),
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
}),
@@ -278,6 +289,7 @@ vi.mock("@groombook/db", () => {
staff: makeTable("staff"),
services: makeTable("services"),
impersonationSessions: makeTable("impersonationSessions"),
impersonationAuditLogs: makeTable("impersonation_audit_logs"),
and: vi.fn((..._args: unknown[]) => ({})),
desc: vi.fn((c: unknown) => c),
eq: vi.fn((_a: unknown, _b: unknown) => ({})),
@@ -567,3 +579,69 @@ describe("GET /:id/profile-summary — owner-bypass (GRO-2013)", () => {
expect(res.status).toBe(403);
});
});
// ─── GRO-2063 owner-bypass audit write ──────────────────────────────────────
describe("GET /:id/profile-summary — owner-bypass audit row (GRO-2063)", () => {
it("writes exactly one audit row on the owner-bypass success path", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", sessionsTable); // valid active session for CLIENT_ID
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(200);
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(1);
const vals = auditInserts[0]!.vals;
expect(vals.action).toBe("read_profile_summary");
expect(vals.sessionId).toBe("sess-owner");
expect(vals.pageVisited).toBe(`/pets/${PET_ID}/profile-summary`);
expect(vals.metadata).toEqual({
petId: PET_ID,
actorStaffId: CUSTOMER_STAFF.id,
});
});
it("does NOT write an audit row on the normal groomer-linkage success path", async () => {
// GROOMER is a "real" groomer with appointment linkage, NOT the
// auto-provisioned customer-as-groomer. No impersonation header is
// present, so the owner-bypass branch never executes.
enqueue("pets", petsTable);
enqueue("appointments", [{ id: "appt-1" }]); // linkage found
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(0);
});
it("does NOT write an audit row when the owner-bypass attempt is denied (cross-tenant)", async () => {
// Customer has a valid session but it points at a different client.
// isOwner=false, falls through to groomer linkage check, returns 403.
enqueue("pets", [
makePet({ id: OTHER_CLIENT_PET_ID, clientId: "c0000002-0000-0000-0000-000000000002" }),
]);
enqueue("impersonationSessions", sessionsTable); // session is for CLIENT_ID
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${OTHER_CLIENT_PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(403);
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(0);
});
});
+214 -25
View File
@@ -43,42 +43,103 @@ const GROOMER: StaffRow = {
// ─── Mock DB ──────────────────────────────────────────────────────────────────
// staffLookupResult drives every `from(staff)` query that doesn't go through
// the dev-mode `.limit()` shortcut. Tests that want to simulate "no staff row"
// leave it null.
let staffLookupResult: StaffRow | null = null;
// managerFallbackResult is only consumed by the dev-mode `from(staff).limit(1)`
// path (looking up the first manager when AUTH_DISABLED=true and no header).
let managerFallbackResult: StaffRow | null = MANAGER;
// userLookupResult drives `from(user).limit(1)` for the Better-Auth user
// auto-provision branch (GRO-2052). Tests that simulate "no Better-Auth user"
// leave it null.
type UserRow = { id: string; name: string | null; email: string | null };
let userLookupResult: UserRow | null = null;
// accountLookupResult drives `from(account).limit(1)` for the legacy OIDC
// auto-provision branch. Null means "no OIDC account row".
let accountLookupResult: { id: string } | null = null;
// insertReturningResult drives `insert(staff).values(...).returning()` for
// any auto-provision branch that actually creates a staff record. Null means
// the INSERT returned no rows (simulating a DB failure).
let insertReturningResult: StaffRow | null = null;
vi.mock("@groombook/db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
get(target, prop) {
if (prop === "_name") return "staff";
if (prop === "$inferSelect") return {};
return { table: "staff", column: prop };
},
}
);
function tableMarker(name: string) {
return new Proxy(
{ _name: name },
{
get(_target, prop) {
if (prop === "_name") return name;
if (prop === "$inferSelect") return {};
return { table: name, column: prop };
},
}
);
}
const staff = tableMarker("staff");
const user = tableMarker("user");
const account = tableMarker("account");
function lookupFor(tableName: string) {
if (tableName === "user") return userLookupResult;
if (tableName === "account") return accountLookupResult;
return staffLookupResult;
}
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => {
// dev mode fallback to first manager
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
if (staffLookupResult) yield staffLookupResult;
},
0: staffLookupResult,
length: staffLookupResult ? 1 : 0,
}),
select: (_columns?: unknown) => ({
from: (table: { _name?: string }) => {
const name = table?._name ?? "staff";
return {
where: () => ({
limit: () => {
// The user / account auto-provision branches always call
// `.limit(1)`; route to the per-table lookup state.
if (name === "user")
return userLookupResult ? [userLookupResult] : [];
if (name === "account")
return accountLookupResult ? [accountLookupResult] : [];
// dev-mode `from(staff).limit(1)` falls back to the first
// manager when AUTH_DISABLED is set with no header.
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
const row = lookupFor(name);
if (row) yield row;
},
0: lookupFor(name),
length: lookupFor(name) ? 1 : 0,
}),
};
},
}),
insert: (_table: unknown) => ({
values: (_v: unknown) => ({
returning: () =>
insertReturningResult ? [insertReturningResult] : [],
}),
}),
update: (_table: unknown) => ({
set: (_v: unknown) => ({
where: () => Promise.resolve(undefined),
}),
}),
}),
staff,
user,
account,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
and: vi.fn((..._clauses: unknown[]) => ({})),
sql: Object.assign(
vi.fn((..._tpl: unknown[]) => ({})),
{ raw: vi.fn(() => ({})) }
),
};
});
@@ -87,16 +148,25 @@ vi.mock("@groombook/db", () => {
function resetMocks() {
staffLookupResult = null;
managerFallbackResult = MANAGER;
userLookupResult = null;
accountLookupResult = null;
insertReturningResult = null;
}
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
function buildApp(
middleware: MiddlewareHandler<AppEnv>,
handler?: (c: Context<AppEnv>) => Response | Promise<Response>
handler?: (c: Context<AppEnv>) => Response | Promise<Response>,
jwtOverride?: Partial<{ sub: string; email: string; name: string }>
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
const defaultSub = staffLookupResult?.userId ?? "unknown-sub";
c.set("jwtPayload", {
sub: jwtOverride?.sub ?? defaultSub,
...(jwtOverride?.email !== undefined ? { email: jwtOverride.email } : {}),
...(jwtOverride?.name !== undefined ? { name: jwtOverride.name } : {}),
});
await next();
});
app.use("*", middleware);
@@ -204,6 +274,125 @@ describe("resolveStaffMiddleware", () => {
});
});
// ─── Auto-provision branches (GRO-2052) ───────────────────────────────────────
//
// Each branch creates a staff row on first authenticated request when no row
// exists yet. The Better-Auth branch (user table) is the primary path for
// email/password customers; the OIDC branch (account table) is a fallback for
// legacy authentik/google/github sessions.
describe("resolveStaffMiddleware — auto-provision", () => {
const PROVISIONED: StaffRow = {
...MANAGER,
id: "staff-provisioned-id",
oidcSub: null,
userId: "ba-user-customer",
role: "groomer",
isSuperUser: false,
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
it("Better-Auth: creates a groomer staff row when user exists but no staff record (GRO-2052)", async () => {
// No existing staff row, no OIDC account row, but a Better-Auth user row.
staffLookupResult = null;
userLookupResult = {
id: "ba-user-customer",
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
accountLookupResult = null;
insertReturningResult = PROVISIONED;
let capturedStaff: StaffRow | null = null;
const app = buildApp(
resolveStaffMiddleware,
(c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
},
{ sub: "ba-user-customer", email: "uat-customer@groombook.dev" }
);
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff).not.toBeNull();
expect(capturedStaff!.role).toBe("groomer");
expect(capturedStaff!.userId).toBe("ba-user-customer");
});
it("Better-Auth: returns 500 if INSERT yields no row", async () => {
staffLookupResult = null;
userLookupResult = {
id: "ba-user-customer",
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
insertReturningResult = null; // simulate INSERT … RETURNING returning []
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "ba-user-customer",
email: "uat-customer@groombook.dev",
});
const res = await app.request("/test");
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toMatch(/auto-provision failed/i);
});
it("Better-Auth branch runs before OIDC branch (does not require jwt.email)", async () => {
// A Better-Auth user row alone is sufficient: jwt.email is intentionally
// absent. The pre-GRO-2052 code only auto-provisioned inside `if (jwt.email)`.
staffLookupResult = null;
userLookupResult = {
id: "ba-user-customer",
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
insertReturningResult = PROVISIONED;
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "ba-user-customer",
});
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("OIDC fallback: still provisions when user row is missing but account row exists", async () => {
// No staff row, no Better-Auth user, but an OIDC account row.
staffLookupResult = null;
userLookupResult = null;
accountLookupResult = { id: "oidc-account-id" };
insertReturningResult = { ...PROVISIONED, userId: "oidc-sub" };
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "oidc-sub",
email: "oidc-user@example.com",
});
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("falls through to 403 when neither Better-Auth user nor OIDC account row exists", async () => {
staffLookupResult = null;
userLookupResult = null;
accountLookupResult = null;
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "ghost-sub",
email: "ghost@example.com",
});
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff record/i);
});
});
// ─── requireRole tests ────────────────────────────────────────────────────────
describe("requireRole", () => {
+43 -2
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff, account } from "@groombook/db";
import { and, eq, getDb, sql, staff, account, user } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -111,8 +111,49 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
}
}
// Auto-provision for Better-Auth users (GRO-2052): the user signed in via
// Better-Auth (email/password, magic link, etc.), so a row exists in `user`
// for jwt.sub but no `account` provider row is required. Create a minimal
// groomer staff record on first login. This is the primary auto-provision
// path; the OIDC branch below remains as a fallback for legacy accounts
// that exist in `account` but not in `user`.
const [userRow] = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, jwt.sub))
.limit(1);
if (userRow) {
const emailPrefix = userRow.email ? userRow.email.split("@")[0] : "Unknown";
const name = userRow.name?.trim() || jwt.name?.trim() || emailPrefix;
const [newStaff] = await db
.insert(staff)
.values({
userId: jwt.sub,
email: userRow.email ?? jwt.email ?? "",
name,
role: "groomer",
isSuperUser: false,
active: true,
} as Parameters<typeof db.insert>[0] extends { values: infer V } ? V : never)
.returning()!;
if (!newStaff) {
return c.json({ error: "Forbidden: auto-provision failed" }, 500);
}
console.log(
`[rbac] auto-provisioned staff record for Better-Auth user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
);
c.set("staff", newStaff);
await next();
return;
}
// Auto-provision for OIDC users: check if jwt.sub has an OAuth/OIDC account
// (e.g. authentik). If so, create a groomer staff record on the fly.
// (e.g. authentik). If so, create a groomer staff record on the fly. This
// is kept for backward compatibility with legacy OIDC sessions whose user
// row may not yet exist in the Better-Auth `user` table.
if (jwt.email) {
const [oidcAccount] = await db
.select({ id: account.id })
+49
View File
@@ -7,6 +7,7 @@ import {
eq,
exists,
getDb,
impersonationAuditLogs,
impersonationSessions,
or,
pets,
@@ -156,6 +157,40 @@ async function resolveImpersonationClientId(
return session.clientId;
}
/**
* Defense-in-depth audit write for the staff-side owner-bypass path in
* GET /pets/:id/profile-summary. Mirrors the failure-isolation pattern in
* src/middleware/portalAudit.ts: errors are logged but never thrown, so a
* misbehaving audit insert cannot turn a working read into a 500.
*
* Called only when the owner-bypass actually fires (i.e. the requester is a
* groomer-role staff row with no appointment linkage, but supplies a valid
* X-Impersonation-Session-Id whose clientId matches the pet's owner). The
* `petId` and `actorStaffId` are written inside `metadata` because the
* impersonation_audit_logs schema has no first-class columns for them and
* adding a migration is out of scope.
*/
async function writeOwnerBypassAudit(
db: ReturnType<typeof getDb>,
args: {
sessionId: string;
petId: string;
actorStaffId: string;
pageVisited: string;
}
): Promise<void> {
try {
await db.insert(impersonationAuditLogs).values({
sessionId: args.sessionId,
action: "read_profile_summary",
pageVisited: args.pageVisited,
metadata: { petId: args.petId, actorStaffId: args.actorStaffId },
});
} catch (err) {
console.error("[pets] failed to write owner-bypass audit log:", err);
}
}
petsRouter.get("/:id/profile-summary", async (c) => {
const db = getDb();
const petId = c.req.param("id");
@@ -188,8 +223,22 @@ petsRouter.get("/:id/profile-summary", async (c) => {
// `groomer` staff row with no appointment linkage.
let isOwner = false;
if (isGroomer) {
const headerSessionId = c.req.header("X-Impersonation-Session-Id");
const ownerClientId = await resolveImpersonationClientId(db, c);
isOwner = !!ownerClientId && ownerClientId === pet.clientId;
if (isOwner && headerSessionId) {
// GRO-2063: defense-in-depth audit row. Only fires when the bypass
// is actually granted; never on the normal groomer-linkage path,
// 403/404/401, or when the header is absent. Failure is swallowed
// (try/catch inside writeOwnerBypassAudit) so this can never turn a
// working read into a 500.
await writeOwnerBypassAudit(db, {
sessionId: headerSessionId,
petId: pet.id,
actorStaffId: staffRow.id,
pageVisited: c.req.path,
});
}
}
// Groomer RBAC: check appointment linkage to this pet's client