Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37e9634323 | |||
| 31404befee | |||
| a9db0ca9ac | |||
| 4bbb0c9fc5 | |||
| 03f79a3701 | |||
| 2b92c2ab6c | |||
| e9ad92de01 | |||
| bfe1a29c08 | |||
| 1ad43ce701 | |||
| 96dbb8c41d | |||
| 636fa713e1 | |||
| 6120b96c7c | |||
| eb92f99c4a | |||
| 587fd4ec95 | |||
| 6e2e46daf8 | |||
| 8cf72d926d | |||
| 8721f0b63c | |||
| 027e012a58 | |||
| b3db206588 | |||
| fc072d51f4 | |||
| 6538406db2 | |||
| e2eacbc9fe | |||
| e639cc82d1 | |||
| f2931d7be2 | |||
| d4a4ddce37 | |||
| bd384bdf5c | |||
| c92fb2539d | |||
| 411c42b2c4 | |||
| bf97849324 | |||
| 2a6242d3de | |||
| 7181d41b24 | |||
| 4e9c4c5e08 | |||
| 16c959434b | |||
| 23484dc90a | |||
| 766728865e | |||
| 6a81a52a50 | |||
| 5a4b9a98bd | |||
| f7f88156e1 | |||
| 8af5a49d14 | |||
| 403634eb96 | |||
| 152abfc4d5 | |||
| c8bbb12edb | |||
| ba95088653 | |||
| dd83f29736 | |||
| 185fce8e17 | |||
| 081379c189 | |||
| e01c12a316 |
@@ -102,7 +102,7 @@ jobs:
|
|||||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max,ignore-error=true
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
||||||
|
|
||||||
- name: Build and push Migrate image
|
- name: Build and push Migrate image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -116,7 +116,7 @@ jobs:
|
|||||||
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max,ignore-error=true
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
||||||
|
|
||||||
- name: Smoke test migrate image (blackhole npmjs.org)
|
- name: Smoke test migrate image (blackhole npmjs.org)
|
||||||
run: |
|
run: |
|
||||||
@@ -141,7 +141,7 @@ jobs:
|
|||||||
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max,ignore-error=true
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max
|
||||||
|
|
||||||
- name: Build and push Reset image
|
- name: Build and push Reset image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max,ignore-error=true
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
|
||||||
|
|
||||||
- name: Smoke test seed image (blackhole npmjs.org)
|
- name: Smoke test seed image (blackhole npmjs.org)
|
||||||
run: |
|
run: |
|
||||||
@@ -185,4 +185,3 @@ jobs:
|
|||||||
"$IMAGE" \
|
"$IMAGE" \
|
||||||
sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; echo "HOME=$HOME"; pnpm --version'
|
sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; echo "HOME=$HOME"; pnpm --version'
|
||||||
echo "reset image: pnpm resolves to /usr/local/bin/pnpm, HOME=/tmp, runs offline ✓"
|
echo "reset image: pnpm resolves to /usr/local/bin/pnpm, HOME=/tmp, runs offline ✓"
|
||||||
|
|
||||||
|
|||||||
@@ -65,11 +65,8 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
|
|||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|---|----------|-------|----------|
|
|---|----------|-------|----------|
|
||||||
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
|
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
|
||||||
| TC-API-0.2 | DB-touching readiness check — healthy (GRO-2678) | GET /api/readyz | 200 OK, `{"status":"ready"}` |
|
|
||||||
| TC-API-0.3 | DB-touching readiness check — response body safe | GET /api/readyz and inspect body | Body contains only `status` and (on error) `check` fields — no raw SQL, driver messages, or stack traces |
|
|
||||||
|
|
||||||
> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule).
|
> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule).
|
||||||
> **Note (GRO-2678):** `/api/readyz` is a separate DB-touching endpoint for monitoring. It is intentionally NOT used for K8s liveness/readiness probes — those remain DB-less to avoid pod cycling on transient DB blips.
|
|
||||||
|
|
||||||
### 4.1 Authentication
|
### 4.1 Authentication
|
||||||
|
|
||||||
@@ -111,11 +108,6 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
|
|||||||
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
|
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
|
||||||
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
|
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
|
||||||
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
|
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
|
||||||
| TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
|
||||||
| TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
|
||||||
| TC-API-1.29 | CORS — untrusted origin blocked (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://evil.example.com` header | Response has **no** `Access-Control-Allow-Origin` header — attacker origin is not reflected | `Access-Control-Allow-Origin: https://evil.example.com` present in response |
|
|
||||||
| TC-API-1.30 | CORS — trusted origin allowed (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://uat.groombook.dev` header | `Access-Control-Allow-Origin: https://uat.groombook.dev` + `Access-Control-Allow-Credentials: true` | CORS header absent or trusted origin rejected |
|
|
||||||
| TC-API-1.31 | CORS — untrusted preflight blocked (GRO-2586) | `curl -i -X OPTIONS https://uat.groombook.dev/api/auth/sign-in/social -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'` | Response has **no** `Access-Control-Allow-Origin: https://evil.example.com` | Preflight reflects attacker origin |
|
|
||||||
|
|
||||||
### 4.2 Client Management
|
### 4.2 Client Management
|
||||||
|
|
||||||
@@ -296,7 +288,6 @@ This means:
|
|||||||
| TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. |
|
| TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. |
|
||||||
| TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) |
|
| TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) |
|
||||||
| TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. |
|
| TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. |
|
||||||
| TC-API-8.20 | Portal waitlist card populates service {id, name} (GRO-2342) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. The synthetic `waitlisted` card returned for the active waitlist entry has `service: {id: "<serviceId>", name: "<serviceName>"}` (full service record, not just `{id}`), matching the shape the appointments join returns. The portal Upcoming list therefore renders the actual service name in place of the fallback "Service" label. |
|
|
||||||
|
|
||||||
### 4.9 Waitlist
|
### 4.9 Waitlist
|
||||||
|
|
||||||
@@ -442,38 +433,6 @@ Both use the stops' stored `latitude`/`longitude` in `stopOrder`: **origin = fir
|
|||||||
| TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) |
|
| TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) |
|
||||||
| TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) |
|
| TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) |
|
||||||
|
|
||||||
|
|
||||||
### 4.19 Boot Resilience — ECONNRESET Recovery (GRO-2652)
|
|
||||||
|
|
||||||
Verifies the API process does not crash on transient boot-time DB connection resets and that auth routes degrade gracefully until initialization succeeds.
|
|
||||||
|
|
||||||
| TC | Test Case | Steps | Expected Result |
|
|
||||||
|----|-----------|-------|-----------------|
|
|
||||||
| TC-API-19.1 | Health endpoint available before auth init | 1. Deploy the image (or restart the api pod)<br>2. `GET /health` immediately (within first 2 s of pod start) | 200 `{"status":"ok"}` — server accepts requests before `initAuth()` completes |
|
|
||||||
| TC-API-19.2 | Auth routes return 503 when auth not yet initialized | 1. Temporarily set `OIDC_ISSUER` to an unreachable host so `initAuth()` keeps retrying<br>2. `POST /api/auth/sign-in/email` during the retry window | 503 `{"error":"Authentication not configured"}` — process stays alive, does not exit |
|
|
||||||
| TC-API-19.3 | Pod does not crash on first-attempt DB reset | 1. Review pod restart count after normal deployment<br>2. Confirm `kubectl get pod -n groombook` shows `RESTARTS: 0` (or same as before deploy) for the new pod | No new restarts — ECONNRESET causes retry, not process exit |
|
|
||||||
| TC-API-19.4 | DB query retry log lines visible | After deploy, `kubectl logs -n groombook <api-pod>` | If any DB retry occurred, log lines matching `[auth] DB query attempt N failed` are present; on clean boot no retry lines appear |
|
|
||||||
| TC-API-19.5 | Auth init retry log lines visible | When auth init fails and retries, check pod logs | Log lines matching `[auth] initAuth attempt N failed` present; process continues; no `process.exit` |
|
|
||||||
| TC-API-19.6 | Auth succeeds after transient DB hiccup | 1. Allow pod to retry until DB is available<br>2. `POST /api/auth/sign-in/email` with valid credentials after init succeeds | 200 with session cookie — auth recovers without pod restart |
|
|
||||||
| TC-API-19.7 | Normal sign-in still works end-to-end | Follow TC-WEB-SSO-3 (SSO sign-in) on UAT | Successful sign-in, staff list visible — no regression from resilience changes |
|
|
||||||
| TC-API-19.8 | Public routes unaffected during auth retry | While auth is retrying (TC-API-19.2 setup), `GET /api/branding` | 200 with branding data — public routes bypass auth and serve normally |
|
|
||||||
|
|
||||||
### 4.20 Portal OOBE — Create Client from Auth (GRO-2359)
|
|
||||||
|
|
||||||
Verifies the `POST /api/portal/clients-from-auth` endpoint that creates a new `clients` row for a first-time SSO user (out-of-box-experience registration). This endpoint requires a valid Better Auth session but does NOT require a portal session; it is the pre-portal step in the new-user OOBE flow.
|
|
||||||
|
|
||||||
| TC | Test Case | Steps | Expected Result |
|
|
||||||
|----|-----------|-------|-----------------|
|
|
||||||
| TC-API-20.1 | Successful client creation | 1. Sign in via SSO to obtain a Better Auth session<br>2. `POST /api/portal/clients-from-auth` with `{ "name": "Test User" }` | 201 `{ "id": "<uuid>", "name": "Test User", "email": "<sso-email>" }` — new `clients` row created |
|
|
||||||
| TC-API-20.2 | All optional fields accepted | `POST /api/portal/clients-from-auth` with `{ "name": "Test User", "phone": "555-1234", "address": "1 Main St", "notes": "VIP" }` (authenticated) | 201 with `id`, `name`, `email`; row in DB has all four fields |
|
|
||||||
| TC-API-20.3 | Invalid body — missing name | `POST /api/portal/clients-from-auth` with `{}` (authenticated) | 400 (Zod validation failure); no row created |
|
|
||||||
| TC-API-20.4 | Invalid body — empty name | `POST /api/portal/clients-from-auth` with `{ "name": "" }` (authenticated) | 400 — name must be at least 1 character |
|
|
||||||
| TC-API-20.5 | No session — 401 | `POST /api/portal/clients-from-auth` with a valid body but **no** Better Auth session cookie | 401 `{ "error": "Unauthorized" }` |
|
|
||||||
| TC-API-20.6 | Existing email — 409 | 1. Create a client row whose email matches the signed-in SSO user's email<br>2. `POST /api/portal/clients-from-auth` as that user | 409 `{ "error": "A customer record with this email already exists" }` — no duplicate row |
|
|
||||||
| TC-API-20.7 | Auth not configured — 503 | Temporarily disable auth (e.g., point `OIDC_ISSUER` to an invalid host) so `getAuth()` throws<br>2. `POST /api/portal/clients-from-auth` | 503 `{ "error": "Authentication not configured" }` — graceful degradation |
|
|
||||||
| TC-API-20.8 | Concurrent insert race — 409 | Simulate two near-simultaneous requests from the same SSO user (before any row exists) | At most one request returns 201; the other returns 409 — no duplicate row, no 500 |
|
|
||||||
|
|
||||||
|
|
||||||
## Pass/Fail Criteria
|
## Pass/Fail Criteria
|
||||||
|
|
||||||
**Pass:**
|
**Pass:**
|
||||||
@@ -495,4 +454,3 @@ Verifies the `POST /api/portal/clients-from-auth` endpoint that creates a new `c
|
|||||||
## Update Policy
|
## Update Policy
|
||||||
|
|
||||||
Any PR that changes user-facing behaviour MUST update this file. Test cases must be added, modified, or removed to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.4 — new appointment rescheduling flow").
|
Any PR that changes user-facing behaviour MUST update this file. Test cases must be added, modified, or removed to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.4 — new appointment rescheduling flow").
|
||||||
|
|
||||||
|
|||||||
@@ -69,14 +69,9 @@ describe("auth init", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
dbSelectResult = [];
|
dbSelectResult = [];
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Stub fetch so OIDC discovery requests resolve instantly during tests.
|
|
||||||
// Without this, AbortSignal.timeout(5000) in auth.ts races with vitest's
|
|
||||||
// 5000ms default test timeout and causes flaky failures.
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"wait-for-db": "node ./scripts/wait-for-db.mjs",
|
"wait-for-db": "node ./scripts/wait-for-db.mjs",
|
||||||
"migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate",
|
"migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate",
|
||||||
"seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts",
|
"seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts",
|
||||||
"reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts",
|
"reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts",
|
||||||
"studio": "drizzle-kit studio",
|
"studio": "drizzle-kit studio",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
+39
-121
@@ -1,51 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* reset.ts — Drop all application tables, re-run migrations, and re-seed.
|
* reset.ts — Drop all application tables and re-run migrations + seed.
|
||||||
*
|
*
|
||||||
* Intended for local development only. Never run against production.
|
* Intended for local development only. Never run against production.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
|
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
|
||||||
*
|
|
||||||
* GRO-2139: the entire drop→migrate→seed chain runs inside a single
|
|
||||||
* Postgres advisory lock (SEED_ADVISORY_LOCK_KEY) so a concurrent
|
|
||||||
* `seed.ts` (e.g. the dev `seed-test-data-*` Job being recreated at
|
|
||||||
* the top of the hour) cannot interleave between `reset.ts` (DROP)
|
|
||||||
* and `seed.ts` (TRUNCATE+insert) and collide on `invoices_pkey`.
|
|
||||||
*
|
|
||||||
* Why this matters: `seed.ts` derives every primary key from a single
|
|
||||||
* shared Mulberry32 PRNG seeded with 42 (see `createPrng(42)` and
|
|
||||||
* `uuid()` in seed.ts). Two concurrent same-profile seeders therefore
|
|
||||||
* emit *identical* ids for the same logical row, and any moment
|
|
||||||
* between a concurrent `seed.ts` TRUNCATE and INSERT is exactly the
|
|
||||||
* window in which the second seeder's INSERT can hit a pkey already
|
|
||||||
* taken by the first. Pre-GRO-2123 this raced unconditionally;
|
|
||||||
* GRO-2123 added the advisory lock around `runSeedBody` but left
|
|
||||||
* `reset.ts` and `drizzle-kit migrate` outside the lock. This script
|
|
||||||
* now wraps the *whole* chain in the same lock: `withSeedAdvisoryLock`
|
|
||||||
* pins the lock to one reserved session and the DROP → migrate → seed
|
|
||||||
* work runs on the rest of the pool, so the lock guarantees mutual
|
|
||||||
* exclusion against any concurrent seeder for the entire chain.
|
|
||||||
*
|
|
||||||
* See: groombook/infra `apps/base/reset-cronjob.yaml` (CronJob) and
|
|
||||||
* `apps/base/seed-job.yaml` (one-shot Job) — both invoke the same
|
|
||||||
* `seed.ts` code path on the same database in `groombook-dev`.
|
|
||||||
*/
|
*/
|
||||||
import postgres from "postgres";
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import * as schema from "./schema.js";
|
|
||||||
import {
|
|
||||||
SEED_ADVISORY_LOCK_KEY,
|
|
||||||
withSeedAdvisoryLock,
|
|
||||||
getProfile,
|
|
||||||
runSeedBody,
|
|
||||||
profiles,
|
|
||||||
} from "./seed.js";
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
import postgres from "postgres";
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
async function reset() {
|
async function reset() {
|
||||||
const url = process.env.DATABASE_URL;
|
const url = process.env.DATABASE_URL;
|
||||||
@@ -54,96 +16,52 @@ async function reset() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") {
|
||||||
process.env.NODE_ENV === "production" &&
|
console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true.");
|
||||||
process.env.ALLOW_RESET !== "true"
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
"[FATAL] db:reset must not be run in production without ALLOW_RESET=true.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pool sizing is load-bearing here. `withSeedAdvisoryLock` does
|
const client = postgres(url, { max: 1 });
|
||||||
// `pool.reserve()` to pin the advisory lock to one dedicated session
|
|
||||||
// (a session-level lock released on a *different* pooled connection is
|
|
||||||
// a no-op), and the DROP / migrate / seed work then runs on the
|
|
||||||
// *remaining* pooled connections. The lock provides mutual exclusion
|
|
||||||
// across processes regardless of how many connections the work uses —
|
|
||||||
// it does NOT require the work to share the lock's session.
|
|
||||||
//
|
|
||||||
// Therefore `max` must be >= 2: 1 reserved for the lock + >=1 free for
|
|
||||||
// the work. `max: 1` would let `reserve()` consume the only connection
|
|
||||||
// and every query inside the callback would block forever waiting for
|
|
||||||
// a connection that never frees (connection-starvation deadlock). We
|
|
||||||
// use `max: 6` to match `seed()`'s headroom (1 reserved + 5 work).
|
|
||||||
const client = postgres(url, { max: 6 });
|
|
||||||
const db = drizzle(client, { schema });
|
|
||||||
|
|
||||||
try {
|
console.log("Dropping all application tables...\n");
|
||||||
await withSeedAdvisoryLock(client, async () => {
|
|
||||||
console.log("Dropping all application tables...\n");
|
|
||||||
|
|
||||||
// Drop dependencies (tables) first
|
// Drop in dependency order (children before parents)
|
||||||
await client`
|
await client`
|
||||||
DO $$ DECLARE
|
DO $$ DECLARE
|
||||||
r RECORD;
|
r RECORD;
|
||||||
BEGIN
|
BEGIN
|
||||||
FOR r IN (
|
FOR r IN (
|
||||||
SELECT tablename FROM pg_tables
|
SELECT tablename FROM pg_tables
|
||||||
WHERE schemaname = 'public'
|
WHERE schemaname = 'public'
|
||||||
) LOOP
|
) LOOP
|
||||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
|
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Drop custom enums
|
// Drop custom enums
|
||||||
await client`
|
await client`
|
||||||
DO $$ DECLARE
|
DO $$ DECLARE
|
||||||
r RECORD;
|
r RECORD;
|
||||||
BEGIN
|
BEGIN
|
||||||
FOR r IN (
|
FOR r IN (
|
||||||
SELECT typname FROM pg_type
|
SELECT typname FROM pg_type
|
||||||
WHERE typtype = 'e' AND typnamespace = (
|
WHERE typtype = 'e' AND typnamespace = (
|
||||||
SELECT oid FROM pg_namespace WHERE nspname = 'public'
|
SELECT oid FROM pg_namespace WHERE nspname = 'public'
|
||||||
)
|
)
|
||||||
) LOOP
|
) LOOP
|
||||||
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
|
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Drop the drizzle migrations tracking table
|
// Drop the drizzle migrations tracking table
|
||||||
await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`;
|
await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`;
|
||||||
await client`DROP SCHEMA IF EXISTS drizzle CASCADE`;
|
await client`DROP SCHEMA IF EXISTS drizzle CASCADE`;
|
||||||
|
|
||||||
console.log("✓ All tables and enums dropped\n");
|
console.log("✓ All tables and enums dropped\n");
|
||||||
|
|
||||||
console.log("Running migrations...");
|
await client.end();
|
||||||
// GRO-2672: drizzle-orm's migrate() has a high-water-mark bug that skips
|
|
||||||
// migrations with stale `when` timestamps (0001, 0003, 0010, 0011). Use
|
|
||||||
// drizzle-kit instead -- it applies migrations by hash, matching the K8s
|
|
||||||
// migrate Job behaviour exactly.
|
|
||||||
execSync("pnpm exec drizzle-kit migrate", {
|
|
||||||
stdio: "inherit",
|
|
||||||
env: { ...process.env },
|
|
||||||
cwd: resolve(__dirname, ".."),
|
|
||||||
});
|
|
||||||
console.log("✓ Migrations applied\n");
|
|
||||||
|
|
||||||
console.log("Seeding database...");
|
|
||||||
const profile = getProfile();
|
|
||||||
const cfg = profiles[profile];
|
|
||||||
await runSeedBody(client, db, profile, cfg);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`\n✓ Reset complete (advisory lock key=0x${SEED_ADVISORY_LOCK_KEY.toString(16)})`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reset().catch((err) => {
|
reset().catch((err) => {
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import type { MedicalAlert } from "@groombook/types";
|
|||||||
|
|
||||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||||
|
|
||||||
export type SeedProfile = "dev" | "uat" | "demo";
|
type SeedProfile = "dev" | "uat" | "demo";
|
||||||
|
|
||||||
export interface ProfileConfig {
|
interface ProfileConfig {
|
||||||
staffCount: { manager: number; receptionist: number; groomer: number; bather: number };
|
staffCount: { manager: number; receptionist: number; groomer: number; bather: number };
|
||||||
clientCount: number;
|
clientCount: number;
|
||||||
appointmentsBackDays: number;
|
appointmentsBackDays: number;
|
||||||
@@ -35,7 +35,7 @@ export interface ProfileConfig {
|
|||||||
includeUatClients: boolean;
|
includeUatClients: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const profiles: Record<SeedProfile, ProfileConfig> = {
|
const profiles: Record<SeedProfile, ProfileConfig> = {
|
||||||
dev: {
|
dev: {
|
||||||
staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
|
staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
|
||||||
clientCount: 100,
|
clientCount: 100,
|
||||||
@@ -70,8 +70,6 @@ function getProfile(): SeedProfile {
|
|||||||
return "uat";
|
return "uat";
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getProfile };
|
|
||||||
|
|
||||||
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
|
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1402,7 +1400,7 @@ async function seedKnownUsers() {
|
|||||||
// from runbooks without ambiguity and binds to the single-argument
|
// from runbooks without ambiguity and binds to the single-argument
|
||||||
// `pg_advisory_lock(int)` form, which postgres-js serializes as a plain
|
// `pg_advisory_lock(int)` form, which postgres-js serializes as a plain
|
||||||
// number (no bigint type plumbing required).
|
// number (no bigint type plumbing required).
|
||||||
export const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable
|
const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserve a dedicated connection from `pool`, take the seed advisory lock
|
* Reserve a dedicated connection from `pool`, take the seed advisory lock
|
||||||
@@ -1415,7 +1413,7 @@ export const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitra
|
|||||||
* for the lock and release it from the same reserved connection. The
|
* for the lock and release it from the same reserved connection. The
|
||||||
* seed work itself still runs on the pooled connections.
|
* seed work itself still runs on the pooled connections.
|
||||||
*/
|
*/
|
||||||
export async function withSeedAdvisoryLock<T>(
|
async function withSeedAdvisoryLock<T>(
|
||||||
pool: ReturnType<typeof postgres>,
|
pool: ReturnType<typeof postgres>,
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
@@ -1473,7 +1471,7 @@ async function seed() {
|
|||||||
await client.end();
|
await client.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSeedBody(
|
async function runSeedBody(
|
||||||
client: ReturnType<typeof postgres>,
|
client: ReturnType<typeof postgres>,
|
||||||
db: ReturnType<typeof drizzle>,
|
db: ReturnType<typeof drizzle>,
|
||||||
profile: SeedProfile,
|
profile: SeedProfile,
|
||||||
|
|||||||
@@ -69,14 +69,9 @@ describe("auth init", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
dbSelectResult = [];
|
dbSelectResult = [];
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Stub fetch so OIDC discovery requests resolve instantly during tests.
|
|
||||||
// Without this, AbortSignal.timeout(5000) in auth.ts races with vitest's
|
|
||||||
// 5000ms default test timeout and causes flaky failures.
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { enforceAuthCors } from "../lib/auth-cors.js";
|
|
||||||
|
|
||||||
const TRUSTED = ["https://uat.groombook.dev", "https://dev.groombook.dev"];
|
|
||||||
|
|
||||||
/** Simulates Better Auth reflecting the request Origin (the pre-fix bug). */
|
|
||||||
function makeReflectedResponse(origin: string | null): Response {
|
|
||||||
return new Response('{"ok":true}', {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(origin
|
|
||||||
? {
|
|
||||||
"Access-Control-Allow-Origin": origin,
|
|
||||||
"Access-Control-Allow-Credentials": "true",
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("enforceAuthCors (GRO-2586)", () => {
|
|
||||||
it("passes trusted origin through with credentials", () => {
|
|
||||||
const origin = "https://uat.groombook.dev";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin);
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips ACAO for attacker origin (credentialed cross-origin read blocked)", () => {
|
|
||||||
const origin = "https://evil.example.com";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips ACAO when no Origin header (undefined)", () => {
|
|
||||||
const res = enforceAuthCors(undefined, TRUSTED, makeReflectedResponse(null));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves non-CORS response headers and status from Better Auth", () => {
|
|
||||||
const origin = "https://evil.example.com";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Content-Type")).toBe("application/json");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("second trusted origin is also allowed", () => {
|
|
||||||
const origin = "https://dev.groombook.dev";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty string origin is treated as untrusted", () => {
|
|
||||||
const res = enforceAuthCors("", TRUSTED, makeReflectedResponse(""));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
|
|
||||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let selectImpl: () => Promise<unknown>;
|
|
||||||
|
|
||||||
vi.mock("@groombook/db", () => {
|
|
||||||
const staff = new Proxy(
|
|
||||||
{ _name: "staff" },
|
|
||||||
{
|
|
||||||
get(_target, prop) {
|
|
||||||
if (prop === "_name") return "staff";
|
|
||||||
return { table: "staff", column: prop };
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
getDb: () => ({
|
|
||||||
select: (_fields: unknown) => ({
|
|
||||||
from: (_table: unknown) => ({
|
|
||||||
limit: (_n: number) => selectImpl(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
staff,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Build test app ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function makeApp() {
|
|
||||||
const { getDb, staff } = await import("@groombook/db");
|
|
||||||
|
|
||||||
const app = new Hono();
|
|
||||||
app.get("/health/ready", async (c) => {
|
|
||||||
try {
|
|
||||||
await getDb().select({ id: staff.id }).from(staff).limit(1);
|
|
||||||
return c.json({ status: "ready" }, 200);
|
|
||||||
} catch (err) {
|
|
||||||
const pgCode = (err as Record<string, unknown>).code ?? "unknown";
|
|
||||||
console.error("[health/ready] DB check failed:", pgCode);
|
|
||||||
return c.json({ status: "degraded" }, 503);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("GET /health/ready", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 200 {status:'ready'} when DB query succeeds", async () => {
|
|
||||||
selectImpl = () => Promise.resolve([{ id: "staff-1" }]);
|
|
||||||
|
|
||||||
const app = await makeApp();
|
|
||||||
const res = await app.request("/health/ready", { method: "GET" });
|
|
||||||
const body = (await res.json()) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.status).toBe("ready");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 503 {status:'degraded'} when DB query throws (schema dropped)", async () => {
|
|
||||||
const schemaErr = Object.assign(new Error("relation \"staff\" does not exist"), { code: "42P01" });
|
|
||||||
selectImpl = () => Promise.reject(schemaErr);
|
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
|
|
||||||
const app = await makeApp();
|
|
||||||
const res = await app.request("/health/ready", { method: "GET" });
|
|
||||||
const body = (await res.json()) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(res.status).toBe(503);
|
|
||||||
expect(body.status).toBe("degraded");
|
|
||||||
|
|
||||||
// Must not leak SQL error details in the response body
|
|
||||||
expect(JSON.stringify(body)).not.toContain("42P01");
|
|
||||||
expect(JSON.stringify(body)).not.toContain("relation");
|
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 503 {status:'degraded'} on any DB connection error", async () => {
|
|
||||||
selectImpl = () => Promise.reject(new Error("ECONNREFUSED"));
|
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
|
|
||||||
const app = await makeApp();
|
|
||||||
const res = await app.request("/health/ready", { method: "GET" });
|
|
||||||
const body = (await res.json()) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(res.status).toBe(503);
|
|
||||||
expect(body.status).toBe("degraded");
|
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -42,7 +42,6 @@ let selectAppointmentRow: Record<string, unknown> | null = null;
|
|||||||
let selectWaitlistRows: Record<string, unknown>[] = [];
|
let selectWaitlistRows: Record<string, unknown>[] = [];
|
||||||
let selectPetRows: Record<string, unknown>[] = [];
|
let selectPetRows: Record<string, unknown>[] = [];
|
||||||
let selectStaffRows: Record<string, unknown>[] = [];
|
let selectStaffRows: Record<string, unknown>[] = [];
|
||||||
let selectServiceRows: Record<string, unknown>[] = [];
|
|
||||||
let updatedValues: Record<string, unknown>[] = [];
|
let updatedValues: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
function resetMock() {
|
function resetMock() {
|
||||||
@@ -51,7 +50,6 @@ function resetMock() {
|
|||||||
selectWaitlistRows = [];
|
selectWaitlistRows = [];
|
||||||
selectPetRows = [];
|
selectPetRows = [];
|
||||||
selectStaffRows = [];
|
selectStaffRows = [];
|
||||||
selectServiceRows = [];
|
|
||||||
updatedValues = [];
|
updatedValues = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +83,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
const waitlistEntries = mkTable("waitlistEntries");
|
const waitlistEntries = mkTable("waitlistEntries");
|
||||||
const pets = mkTable("pets");
|
const pets = mkTable("pets");
|
||||||
const staff = mkTable("staff");
|
const staff = mkTable("staff");
|
||||||
const services = mkTable("services");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
@@ -106,9 +103,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
if (table._name === "staff") {
|
if (table._name === "staff") {
|
||||||
return makeChainable(selectStaffRows);
|
return makeChainable(selectStaffRows);
|
||||||
}
|
}
|
||||||
if (table._name === "services") {
|
|
||||||
return makeChainable(selectServiceRows);
|
|
||||||
}
|
|
||||||
return makeChainable([]);
|
return makeChainable([]);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -132,7 +126,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
waitlistEntries,
|
waitlistEntries,
|
||||||
pets,
|
pets,
|
||||||
staff,
|
staff,
|
||||||
services,
|
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
inArray: vi.fn(),
|
inArray: vi.fn(),
|
||||||
@@ -205,56 +198,6 @@ describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GRO-2342: GET /portal/appointments must populate the synthetic waitlist
|
|
||||||
// card's `service` object with the full service record (id + name) — same
|
|
||||||
// shape the appointments join returns — so the portal renders the real
|
|
||||||
// service name in place of the fallback "Service" label.
|
|
||||||
describe("GET /portal/appointments (waitlist service name — GRO-2342)", () => {
|
|
||||||
it("returns service {id, name} on the synthetic waitlist card", async () => {
|
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
|
||||||
selectAppointmentRow = { ...APPOINTMENT };
|
|
||||||
selectWaitlistRows = [
|
|
||||||
{
|
|
||||||
id: "22222222-2222-2222-2222-222222222222",
|
|
||||||
petId: "pet-1",
|
|
||||||
serviceId: "svc-1",
|
|
||||||
preferredDate: "2099-01-01",
|
|
||||||
preferredTime: "13:00:00",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }];
|
|
||||||
selectServiceRows = [{ id: "svc-1", name: "Full Groom" }];
|
|
||||||
|
|
||||||
const res = await app.request("/portal/appointments", {
|
|
||||||
headers: { "X-Impersonation-Session-Id": SESSION_ID },
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
const waitlistCard = body.appointments.find(
|
|
||||||
(a: { status: string }) => a.status === "waitlisted",
|
|
||||||
);
|
|
||||||
expect(waitlistCard).toBeTruthy();
|
|
||||||
expect(waitlistCard.service).toEqual({ id: "svc-1", name: "Full Groom" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns service {id, name} on the appointment card (same shape)", async () => {
|
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
|
||||||
selectAppointmentRow = { ...APPOINTMENT, serviceId: "svc-appt" };
|
|
||||||
selectServiceRows = [{ id: "svc-appt", name: "Bath & Brush" }];
|
|
||||||
|
|
||||||
const res = await app.request("/portal/appointments", {
|
|
||||||
headers: { "X-Impersonation-Session-Id": SESSION_ID },
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
const apptCard = body.appointments.find(
|
|
||||||
(a: { status: string }) => a.status === "scheduled",
|
|
||||||
);
|
|
||||||
expect(apptCard).toBeTruthy();
|
|
||||||
expect(apptCard.service).toEqual({ id: "svc-appt", name: "Bath & Brush" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PATCH /portal/appointments/:id/notes", () => {
|
describe("PATCH /portal/appointments/:id/notes", () => {
|
||||||
it("returns updated appointment with safe fields only", async () => {
|
it("returns updated appointment with safe fields only", async () => {
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
import { getAuth } from "../lib/auth.js";
|
|
||||||
|
|
||||||
const NEW_USER_EMAIL = "new-sso-user@example.com";
|
|
||||||
const NEW_USER_NAME = "New SSO User";
|
|
||||||
const NEW_USER_ID = "11111111-2222-3333-4444-555555555555";
|
|
||||||
|
|
||||||
const BETTER_AUTH_SESSION = {
|
|
||||||
user: {
|
|
||||||
id: "auth-user-new",
|
|
||||||
email: NEW_USER_EMAIL,
|
|
||||||
name: NEW_USER_NAME,
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
id: "ba-session-new",
|
|
||||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let mockGetAuth: ReturnType<typeof vi.fn>;
|
|
||||||
let mockGetSession: ReturnType<typeof vi.fn>;
|
|
||||||
let existingClientRow: Record<string, unknown> | null = null;
|
|
||||||
let insertedClientValues: Record<string, unknown> | null = null;
|
|
||||||
let insertShouldThrow: { code?: string } | null = null;
|
|
||||||
|
|
||||||
function makeChainable(data: unknown[]): unknown {
|
|
||||||
const arr = [...data];
|
|
||||||
return new Proxy(arr, {
|
|
||||||
get(target, prop) {
|
|
||||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
|
||||||
return () => makeChainable(target);
|
|
||||||
}
|
|
||||||
// @ts-expect-error proxy
|
|
||||||
return target[prop];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("@groombook/db", () => {
|
|
||||||
const clients = new Proxy(
|
|
||||||
{ _name: "clients" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
getDb: () => ({
|
|
||||||
select: () => ({
|
|
||||||
from: (table: { _name: string }) => {
|
|
||||||
if (table._name === "clients") {
|
|
||||||
return makeChainable(existingClientRow ? [existingClientRow] : []);
|
|
||||||
}
|
|
||||||
return makeChainable([]);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
insert: (table: { _name: string }) => ({
|
|
||||||
values: (vals: Record<string, unknown>) => {
|
|
||||||
if (insertShouldThrow) {
|
|
||||||
const err = new Error("unique violation") as Error & { code?: string };
|
|
||||||
err.code = insertShouldThrow.code;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
returning: () => {
|
|
||||||
if (table._name === "clients") {
|
|
||||||
insertedClientValues = { id: NEW_USER_ID, ...vals };
|
|
||||||
return [insertedClientValues];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
clients,
|
|
||||||
eq: vi.fn(),
|
|
||||||
and: vi.fn(),
|
|
||||||
inArray: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../lib/auth.js", () => ({
|
|
||||||
getAuth: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { portalRouter } = await import("../routes/portal.js");
|
|
||||||
|
|
||||||
const app = new Hono();
|
|
||||||
app.route("/portal", portalRouter);
|
|
||||||
|
|
||||||
describe("POST /portal/clients-from-auth (GRO-2359)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
existingClientRow = null;
|
|
||||||
insertedClientValues = null;
|
|
||||||
insertShouldThrow = null;
|
|
||||||
mockGetSession = vi.fn();
|
|
||||||
mockGetAuth = vi.fn(() => ({
|
|
||||||
api: {
|
|
||||||
getSession: mockGetSession,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mocked(getAuth).mockImplementation(mockGetAuth);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 401 when no Better Auth session is present", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(null);
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "Test User" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.error).toBe("Unauthorized");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when body fails zod validation (empty name)", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a new client row bound to the auth user's email and returns 201", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: " New SSO User ",
|
|
||||||
phone: "555-1234",
|
|
||||||
address: "1 Main St",
|
|
||||||
notes: "test note",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
id: NEW_USER_ID,
|
|
||||||
name: "New SSO User",
|
|
||||||
email: NEW_USER_EMAIL,
|
|
||||||
});
|
|
||||||
// Trim must be applied to the persisted values.
|
|
||||||
expect(insertedClientValues).not.toBeNull();
|
|
||||||
expect((insertedClientValues as Record<string, unknown>).name).toBe("New SSO User");
|
|
||||||
expect((insertedClientValues as Record<string, unknown>).email).toBe(NEW_USER_EMAIL);
|
|
||||||
expect((insertedClientValues as Record<string, unknown>).phone).toBe("555-1234");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes empty optional fields to null on insert", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
|
||||||
await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "Test", phone: "", address: " " }),
|
|
||||||
});
|
|
||||||
expect(insertedClientValues).not.toBeNull();
|
|
||||||
expect((insertedClientValues as Record<string, unknown>).phone).toBeNull();
|
|
||||||
expect((insertedClientValues as Record<string, unknown>).address).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 409 when a client row already exists for this email", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
|
||||||
existingClientRow = { id: "existing-client-id", email: NEW_USER_EMAIL };
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "Test" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.error).toMatch(/already exists/i);
|
|
||||||
expect(insertedClientValues).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 409 on unique constraint race (23505)", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
|
||||||
insertShouldThrow = { code: "23505" };
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "Test" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 503 when auth is not configured", async () => {
|
|
||||||
mockGetAuth.mockImplementation(() => {
|
|
||||||
throw new Error("Auth not initialized");
|
|
||||||
});
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "Test" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(503);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
|
|
||||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let selectImpl: () => Promise<unknown>;
|
|
||||||
|
|
||||||
vi.mock("@groombook/db", () => {
|
|
||||||
const staff = new Proxy(
|
|
||||||
{ _name: "staff" },
|
|
||||||
{
|
|
||||||
get(_target, prop) {
|
|
||||||
if (prop === "_name") return "staff";
|
|
||||||
return { table: "staff", column: prop };
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
getDb: () => ({
|
|
||||||
select: (_fields: unknown) => ({
|
|
||||||
from: (_table: unknown) => ({
|
|
||||||
limit: (_n: number) => selectImpl(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
staff,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Build test app ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function makeApp() {
|
|
||||||
// Import after mocks are in place
|
|
||||||
const { getDb, staff } = await import("@groombook/db");
|
|
||||||
|
|
||||||
const app = new Hono();
|
|
||||||
app.get("/api/readyz", async (c) => {
|
|
||||||
try {
|
|
||||||
await getDb().select({ id: staff.id }).from(staff).limit(1);
|
|
||||||
return c.json({ status: "ready" }, 200);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[readyz] DB check failed:", err);
|
|
||||||
return c.json({ status: "degraded", check: "db" }, 503);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("GET /api/readyz", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 200 {status:'ready'} when DB query succeeds", async () => {
|
|
||||||
selectImpl = () => Promise.resolve([{ id: "staff-1" }]);
|
|
||||||
|
|
||||||
const app = await makeApp();
|
|
||||||
const res = await app.request("/api/readyz", { method: "GET" });
|
|
||||||
const body = (await res.json()) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.status).toBe("ready");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 503 {status:'degraded',check:'db'} when DB query throws", async () => {
|
|
||||||
selectImpl = () => Promise.reject(new Error("42P01: relation staff does not exist"));
|
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
|
|
||||||
const app = await makeApp();
|
|
||||||
const res = await app.request("/api/readyz", { method: "GET" });
|
|
||||||
const body = (await res.json()) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(res.status).toBe(503);
|
|
||||||
expect(body.status).toBe("degraded");
|
|
||||||
expect(body.check).toBe("db");
|
|
||||||
|
|
||||||
// Raw SQL / driver error must NOT appear in the response body
|
|
||||||
expect(JSON.stringify(body)).not.toContain("42P01");
|
|
||||||
expect(JSON.stringify(body)).not.toContain("relation");
|
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+4
-48
@@ -3,7 +3,6 @@ import { Hono } from "hono";
|
|||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
||||||
import { enforceAuthCors } from "./lib/auth-cors.js";
|
|
||||||
import { clientsRouter } from "./routes/clients.js";
|
import { clientsRouter } from "./routes/clients.js";
|
||||||
import { petsRouter } from "./routes/pets.js";
|
import { petsRouter } from "./routes/pets.js";
|
||||||
import { servicesRouter } from "./routes/services.js";
|
import { servicesRouter } from "./routes/services.js";
|
||||||
@@ -65,28 +64,6 @@ app.use(
|
|||||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||||
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
|
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
|
||||||
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
||||||
// /health/ready: DB-touching readiness probe — K8s removes pod from endpoints when schema is dropped (GRO-2689)
|
|
||||||
app.get("/health/ready", async (c) => {
|
|
||||||
try {
|
|
||||||
await getDb().select({ id: staff.id }).from(staff).limit(1);
|
|
||||||
return c.json({ status: "ready" }, 200);
|
|
||||||
} catch (err) {
|
|
||||||
const pgCode = (err as Record<string, unknown>).code ?? "unknown";
|
|
||||||
console.error("[health/ready] DB check failed:", pgCode);
|
|
||||||
return c.json({ status: "degraded" }, 503);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// /api/readyz: DB-touching deep health check consumed by monitoring (not K8s probes)
|
|
||||||
// Distinct from /health so a dropped schema triggers an alert without cycling pods (GRO-2678)
|
|
||||||
app.get("/api/readyz", async (c) => {
|
|
||||||
try {
|
|
||||||
await getDb().select({ id: staff.id }).from(staff).limit(1);
|
|
||||||
return c.json({ status: "ready" }, 200);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[readyz] DB check failed:", err);
|
|
||||||
return c.json({ status: "degraded", check: "db" }, 503);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Public booking routes — no auth required, must be registered before auth middleware
|
// Public booking routes — no auth required, must be registered before auth middleware
|
||||||
app.route("/api/book", bookRouter);
|
app.route("/api/book", bookRouter);
|
||||||
@@ -223,10 +200,9 @@ api.use("*", resolveStaffMiddleware);
|
|||||||
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||||
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||||
const authRouter = new Hono();
|
const authRouter = new Hono();
|
||||||
authRouter.all("/*", async (c) => {
|
authRouter.all("/*", (c) => {
|
||||||
try {
|
try {
|
||||||
const res = await getAuth().handler(c.req.raw);
|
return getAuth().handler(c.req.raw);
|
||||||
return enforceAuthCors(c.req.header("origin"), TRUSTED_ORIGINS, res);
|
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ error: "Authentication not configured" }, 503);
|
return c.json({ error: "Authentication not configured" }, 503);
|
||||||
}
|
}
|
||||||
@@ -314,34 +290,14 @@ api.route("/search", searchRouter);
|
|||||||
api.route("/buffer-rules", bufferRulesRouter);
|
api.route("/buffer-rules", bufferRulesRouter);
|
||||||
api.route("/routes", routesRouter);
|
api.route("/routes", routesRouter);
|
||||||
|
|
||||||
// Start the HTTP server first so /health and public routes are available immediately.
|
|
||||||
// Auth initialization runs afterward with retry — a transient DB ECONNRESET at boot
|
|
||||||
// must not crash the process (GRO-2652). Auth routes return 503 until initAuth succeeds.
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
const server = serve({ fetch: app.fetch, port });
|
await initAuth();
|
||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
|
const server = serve({ fetch: app.fetch, port });
|
||||||
|
|
||||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||||
startReminderScheduler();
|
startReminderScheduler();
|
||||||
|
|
||||||
let initAttempt = 0;
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
await initAuth();
|
|
||||||
break;
|
|
||||||
} catch (err) {
|
|
||||||
initAttempt++;
|
|
||||||
const delay = Math.min(2 ** initAttempt * 500, 30_000);
|
|
||||||
console.error(`[auth] initAuth attempt ${initAttempt} failed: ${err}`);
|
|
||||||
if (initAttempt >= 10) {
|
|
||||||
console.error("[auth] auth init permanently failed — auth endpoints will serve 503");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
console.error(`[auth] retrying in ${delay}ms`);
|
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
console.log("Shutting down gracefully...");
|
console.log("Shutting down gracefully...");
|
||||||
// SIGTERM/SIGINT → server.close() → callback → process.exit(0)
|
// SIGTERM/SIGINT → server.close() → callback → process.exit(0)
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Enforces the trusted-origins CORS allowlist on a raw Response from Better Auth.
|
|
||||||
* Better Auth reflects the request Origin into Access-Control-Allow-Origin
|
|
||||||
* regardless of the trustedOrigins config, allowing credentialed cross-origin reads
|
|
||||||
* from arbitrary attacker origins. This wrapper strips CORS headers for any origin
|
|
||||||
* not in the allowlist. (GRO-2586)
|
|
||||||
*/
|
|
||||||
export function enforceAuthCors(
|
|
||||||
requestOrigin: string | undefined,
|
|
||||||
trustedOrigins: string[],
|
|
||||||
res: Response
|
|
||||||
): Response {
|
|
||||||
const headers = new Headers(res.headers);
|
|
||||||
if (requestOrigin && trustedOrigins.includes(requestOrigin)) {
|
|
||||||
headers.set("Access-Control-Allow-Origin", requestOrigin);
|
|
||||||
headers.set("Access-Control-Allow-Credentials", "true");
|
|
||||||
} else {
|
|
||||||
headers.delete("Access-Control-Allow-Origin");
|
|
||||||
headers.delete("Access-Control-Allow-Credentials");
|
|
||||||
}
|
|
||||||
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
||||||
}
|
|
||||||
+9
-31
@@ -118,34 +118,18 @@ export async function initAuth(): Promise<void> {
|
|||||||
updateAge: 60 * 60 * 24,
|
updateAge: 60 * 60 * 24,
|
||||||
cookieCache: { enabled: false },
|
cookieCache: { enabled: false },
|
||||||
},
|
},
|
||||||
trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||||
.split(",").map((s) => s.trim()).filter(Boolean),
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Try to load config from DB, with retry-with-backoff for transient ECONNRESET (GRO-2652).
|
// Step 1: Try to load config from DB
|
||||||
// A single connection reset during boot must not abort initialization.
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
let dbQueryRows: (typeof authProviderConfig.$inferSelect)[] = [];
|
const [dbConfig] = await db
|
||||||
let dbAttempt = 0;
|
.select()
|
||||||
while (true) {
|
.from(authProviderConfig)
|
||||||
try {
|
.where(eq(authProviderConfig.enabled, true))
|
||||||
dbQueryRows = await db
|
.limit(1);
|
||||||
.select()
|
|
||||||
.from(authProviderConfig)
|
|
||||||
.where(eq(authProviderConfig.enabled, true))
|
|
||||||
.limit(1);
|
|
||||||
break;
|
|
||||||
} catch (err) {
|
|
||||||
dbAttempt++;
|
|
||||||
if (dbAttempt >= 5) throw err;
|
|
||||||
const delay = Math.min(1000 * 2 ** (dbAttempt - 1), 8_000);
|
|
||||||
console.warn(`[auth] DB query attempt ${dbAttempt} failed (${err}), retrying in ${delay}ms`);
|
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const [dbConfig] = dbQueryRows;
|
|
||||||
|
|
||||||
let providerConfig: {
|
let providerConfig: {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -324,15 +308,9 @@ export async function initAuth(): Promise<void> {
|
|||||||
maxAge: 5 * 60, // 5 minutes
|
maxAge: 5 * 60, // 5 minutes
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||||
.split(",").map((s) => s.trim()).filter(Boolean),
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
try {
|
await authInitPromise;
|
||||||
await authInitPromise;
|
|
||||||
} catch (err) {
|
|
||||||
authInitPromise = null; // allow retry on next call
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-125
@@ -147,114 +147,6 @@ portalRouter.post("/session-from-auth", async (c) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GRO-2359 — register a brand-new SSO user. The post-auth handler in the
|
|
||||||
// web portal redirects here when `session-from-auth` returns 404, so the
|
|
||||||
// OOBE can complete a customer record for the new user. Auth is via the
|
|
||||||
// Better Auth session (same shape as `session-from-auth`), so this is
|
|
||||||
// registered BEFORE the `validatePortalSession` middleware.
|
|
||||||
//
|
|
||||||
// Contract:
|
|
||||||
// POST /api/portal/clients-from-auth
|
|
||||||
// Body: { name: string; phone?: string|null; address?: string|null; notes?: string|null }
|
|
||||||
// 201: { id, name, email }
|
|
||||||
// 400: invalid body (zod failure)
|
|
||||||
// 401: no Better Auth session
|
|
||||||
// 409: a `clients` row already exists for this email (portal selection case)
|
|
||||||
// 500: insert failed
|
|
||||||
//
|
|
||||||
// We do NOT auto-link the user's auth account to the new client row; the
|
|
||||||
// existing `session-from-auth` endpoint re-resolves the row by email on the
|
|
||||||
// next call, so the OOBE's success path just navigates the user back to
|
|
||||||
// `/` and lets the bridge mint a portal session.
|
|
||||||
const createClientFromAuthSchema = z.object({
|
|
||||||
name: z.string().min(1).max(200),
|
|
||||||
phone: z.string().max(50).nullish(),
|
|
||||||
address: z.string().max(500).nullish(),
|
|
||||||
notes: z.string().max(2000).nullish(),
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.post(
|
|
||||||
"/clients-from-auth",
|
|
||||||
zValidator("json", createClientFromAuthSchema),
|
|
||||||
async (c) => {
|
|
||||||
let auth;
|
|
||||||
try {
|
|
||||||
auth = getAuth();
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: "Authentication not configured" }, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: c.req.raw.headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Pre-check: if a client already exists for this email, return 409 so
|
|
||||||
// the OOBE can render the "portal selection" message (the user needs
|
|
||||||
// to contact their groomer to link the new SSO identity to the
|
|
||||||
// pre-existing customer record). We don't return the existing row to
|
|
||||||
// avoid leaking PII about other accounts.
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ id: clients.id })
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.email, session.user.email))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
return c.json(
|
|
||||||
{ error: "A customer record with this email already exists" },
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let row;
|
|
||||||
try {
|
|
||||||
[row] = await db
|
|
||||||
.insert(clients)
|
|
||||||
.values({
|
|
||||||
name: body.name.trim(),
|
|
||||||
email: session.user.email,
|
|
||||||
phone: body.phone?.trim() || null,
|
|
||||||
address: body.address?.trim() || null,
|
|
||||||
notes: body.notes?.trim() || null,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
} catch (err) {
|
|
||||||
// Concurrent insert from a parallel OOBE submit — treat as 409.
|
|
||||||
if (
|
|
||||||
err instanceof Error &&
|
|
||||||
"code" in err &&
|
|
||||||
(err as { code?: string }).code === "23505"
|
|
||||||
) {
|
|
||||||
return c.json(
|
|
||||||
{ error: "A customer record with this email already exists" },
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return c.json({ error: "Failed to create client" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
email: row.email,
|
|
||||||
},
|
|
||||||
201,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Apply middleware to all portal routes
|
// Apply middleware to all portal routes
|
||||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||||
|
|
||||||
@@ -327,22 +219,12 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
...waitlistRows.map(w => w.petId),
|
...waitlistRows.map(w => w.petId),
|
||||||
];
|
];
|
||||||
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
||||||
// GRO-2342: services must be looked up for both appointment and waitlist cards
|
|
||||||
// so the portal can render `service.name` in place of the fallback "Service"
|
|
||||||
// label (CMPO sign-off on the GRO-2319 waitlist card explicitly excluded the
|
|
||||||
// service name; this follow-up closes the cosmetic gap).
|
|
||||||
const serviceIds = [
|
|
||||||
...allAppts.map(a => a.serviceId).filter((id): id is string => id !== null),
|
|
||||||
...waitlistRows.map(w => w.serviceId).filter((id): id is string => id !== null),
|
|
||||||
];
|
|
||||||
|
|
||||||
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
|
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
|
||||||
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
|
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
|
||||||
const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : [];
|
|
||||||
|
|
||||||
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
||||||
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
||||||
const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s]));
|
|
||||||
|
|
||||||
const appts = allAppts.map(a => ({
|
const appts = allAppts.map(a => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
@@ -353,17 +235,13 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
customerNotes: a.customerNotes,
|
customerNotes: a.customerNotes,
|
||||||
notes: a.notes,
|
notes: a.notes,
|
||||||
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
|
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
|
||||||
service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name } : null,
|
service: a.serviceId ? { id: a.serviceId } : null,
|
||||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Derive a display `startTime` from the entry's preferred date/time so the
|
// Derive a display `startTime` from the entry's preferred date/time so the
|
||||||
// portal can sort/classify the synthetic card (an invalid combination simply
|
// portal can sort/classify the synthetic card (an invalid combination simply
|
||||||
// yields a null startTime, which the portal tolerates). GRO-2342: also
|
// yields a null startTime, which the portal tolerates).
|
||||||
// populate the synthetic card's `service` object with the full service
|
|
||||||
// record (id + name) — same shape the appointments join returns — so the
|
|
||||||
// portal renders the real service name in place of the fallback "Service"
|
|
||||||
// label.
|
|
||||||
const waitlistAppts = waitlistRows.map(w => {
|
const waitlistAppts = waitlistRows.map(w => {
|
||||||
const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`);
|
const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`);
|
||||||
const startTime = Number.isNaN(parsed.getTime()) ? null : parsed;
|
const startTime = Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
@@ -376,7 +254,7 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
customerNotes: null,
|
customerNotes: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey },
|
pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey },
|
||||||
service: w.serviceId ? { id: w.serviceId, name: serviceMap[w.serviceId]?.name } : null,
|
service: { id: w.serviceId },
|
||||||
staff: null,
|
staff: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user