From 5bed2228b7566a463f2c8f87f8e296385072e892 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 4 Jun 2026 11:44:52 -0400 Subject: [PATCH] test(01): verification (human_needed, 11/13) + persist Gate 2 human-UAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code verified against codebase, 24 tests pass, CAL-01 + CAL-08 proven live. AUTH-01/02/03 live login + SSE-over-Pangolin smoke test require operator infra — tracked as 01-HUMAN-UAT.md (status: partial). --- .../01-HUMAN-UAT.md | 41 ++++ .../01-VERIFICATION.md | 203 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 .planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md create mode 100644 .planning/phases/01-foundation-broker-spike/01-VERIFICATION.md diff --git a/.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md b/.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md new file mode 100644 index 0000000..7bdcfab --- /dev/null +++ b/.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md @@ -0,0 +1,41 @@ +--- +status: partial +phase: 01-foundation-broker-spike +source: [01-VERIFICATION.md] +started: "2026-06-04" +updated: "2026-06-04" +--- + +## Current Test + +[awaiting human testing — requires live Authelia + Pangolin infrastructure] + +## Tests + +### 1. AUTH-01 — Live Authelia OIDC login over the public Pangolin URL +expected: From an external network, opening `https://familysync.` redirects to Authelia; after logging in as Lucas, the app shell loads showing his name, his assigned color, and one real cached Fastmail event. No Fastmail credentials are entered in the app. +result: [pending] +setup: Register the `familysync` OIDC client in Authelia (`require_pkce: true`, `pkce_challenge_method: S256`, `token_endpoint_auth_method: client_secret_basic`, redirect `https://familysync./callback`, scopes openid/profile/email). Set OIDC env vars in `.env` — note `OIDC_AUTH_EXTERNAL_URL` is mandatory behind Pangolin. Deploy via `docker compose up -d`. + +### 2. AUTH-02 — Session persists across browser restart +expected: After authenticating, fully close and reopen the browser, revisit the URL → no re-login prompt; the shell loads directly. +result: [pending] + +### 3. AUTH-03 — Second member gets a distinct color +expected: The wife logs in via Authelia on her device and is assigned a stable color distinct from Lucas's; it does not change on subsequent logins. +result: [pending] + +### 4. SSE-over-Pangolin smoke test (de-risks Phase 4) +expected: With a valid session cookie, `curl -N -H "Cookie: oidc-auth=" https://familysync./api/sse/heartbeat` streams a `heartbeat` event roughly every 10s and stays open for 5+ minutes without Pangolin cutting the stream. PASS = continuous heartbeats; FAIL = stream cut early (investigate Pangolin idle-timeout; note as Phase 4 constraint, ref issue #1034). +result: [pending] + +## Summary + +total: 4 +passed: 0 +issues: 0 +pending: 4 +skipped: 0 +blocked: 0 + +## Gaps diff --git a/.planning/phases/01-foundation-broker-spike/01-VERIFICATION.md b/.planning/phases/01-foundation-broker-spike/01-VERIFICATION.md new file mode 100644 index 0000000..0a5cc19 --- /dev/null +++ b/.planning/phases/01-foundation-broker-spike/01-VERIFICATION.md @@ -0,0 +1,203 @@ +--- +phase: 01-foundation-broker-spike +verified: 2026-06-04T11:45:00Z +status: human_needed +score: 10/13 must-haves verified (3 human-pending) +overrides_applied: 0 +human_verification: + - test: "AUTH-01: Open https://familysync. from an external network, confirm redirect to Authelia authorize endpoint, log in, land on shell with name + color — no Fastmail credential prompt" + expected: "Browser redirects to Authelia, login succeeds, PWA shell renders member name and color swatch (MemberBadge component)" + why_human: "Requires live Authelia + Pangolin infrastructure not available in this environment; oidcAuthMiddleware only validates at runtime against a real OIDC issuer" + - test: "AUTH-02: Close browser completely after step above, reopen the public URL, confirm no re-login required" + expected: "Session cookie persists; /api/me still returns 200 without re-authenticating; access-token refresh rotation has kept the session alive" + why_human: "Session persistence is enforced by @hono/oidc-auth refresh-token rotation at runtime; cannot verify without real Authelia token endpoint" + - test: "AUTH-03 (cross-member): Log in as the second member (wife) over the same public URL, confirm she receives a visually distinct color from Lucas" + expected: "palette[1] (#E8734A warm coral) assigned; both members' MemberBadge components show different colors; identity row for second member present in users table" + why_human: "Requires a second live Authelia account and real browser session; unit tests verify color assignment logic but not end-to-end identity creation for both members" + - test: "SSE smoke test (D-08): From external network with a valid session cookie, run 'curl -N https://familysync./api/sse/heartbeat' for 5+ minutes, confirm heartbeat events keep arriving" + expected: "Server-sent events arrive every 10 seconds without proxy timeout; stream stays alive through the Pangolin/Newt tunnel for Phase 4 transport decision" + why_human: "Pangolin idle-timeout behavior is network-infrastructure-dependent; only observable over the real tunnel" +--- + +# Phase 01: Foundation + Broker Spike — Verification Report + +**Phase Goal:** The app stack is running, both members can authenticate through Authelia OIDC, and the CalDAV broker can read Fastmail calendars — with a confirmed go/no-go decision on personal-calendar cross-account sharing. +**Verified:** 2026-06-04T11:45:00Z +**Status:** human_needed +**Re-verification:** No — initial verification + +--- + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | Docker Compose stack starts MariaDB healthy and API serving | VERIFIED | docker-compose.yml has mariadb:11 with healthcheck; api depends_on service_healthy; 01-01-SUMMARY confirms `/health` returned `{"ok":true,"db":"up"}` live | +| 2 | GET /health returns 200 with real DB round-trip | VERIFIED | `apps/api/src/routes/health.ts` executes `db.execute(sql'SELECT 1')` before returning `{ok:true,db:"up"}`; health.test.ts 2/2 green | +| 3 | Drizzle schema pushed to live MariaDB (4 tables) | VERIFIED | 01-01-SUMMARY records drizzle-kit push clearing Task 3 checkpoint; all 4 tables listed in SHOW TABLES output | +| 4 | React PWA fetches /health and renders result | VERIFIED | `apps/pwa/src/App.tsx` uses `useQuery({queryKey:['health'],queryFn:fetchHealth})` and renders "stack: up/down" | +| 5 | OIDC middleware guards /api/* and redirects unauthenticated requests (AUTH-01 code path) | VERIFIED | `apps/api/src/index.ts` line 24: `app.use('/api/*', oidcAuthMiddleware())`; /callback registered before guard; /health before guard | +| 6 | upsertUser keyed on oidc_iss + oidc_sub with stable color assignment (AUTH-03) | VERIFIED | `apps/api/src/auth/user.ts` SELECT by `and(eq(users.oidcIss,...),eq(users.oidcSub,...))`, never email; COLOR_PALETTE 6 entries; user.test.ts 6/6 green | +| 7 | GET /api/me returns authenticated member identity + color | VERIFIED | `apps/api/src/routes/me.ts` calls getAuth → upsertUser → returns `{user:{id,displayName,color}}`; mounted behind oidcAuthMiddleware | +| 8 | AES-256-GCM app-password encryption: lossless roundtrip, unique IVs, tamper detection | VERIFIED | `apps/api/src/broker/crypto.ts` uses node:crypto aes-256-gcm, 96-bit random IV; crypto.test.ts 5/5 green (roundtrip, IV uniqueness, tamper-throws x2, payload shape) | +| 9 | CalDAV broker reads Fastmail calendars and caches VEVENTs with D-13 all-day DATE handling | VERIFIED | CAL-08-DECISION.md records live REPORT against Fastmail fetching 503 real events; sync.test.ts 6/6 green confirming timed→dtstart_utc, all-day→dtstart_date, onDuplicateKeyUpdate idempotency | +| 10 | Broker poller skips DB writes when ctag unchanged | VERIFIED | `apps/api/src/broker/poller.ts` lines 59-61: skip when `currentCtag !== null && currentCtag === knownCtag`; poller.test.ts 5/5 green | +| 11 | CAL-08 go/no-go decision recorded | VERIFIED | `CAL-08-DECISION.md` present, committed (0b074cd), contains "Decision: GO" — per-member app-password model proven live; 503 events cached; cross-account ACL concern resolved as moot | +| 12 | Both members authenticate live through Authelia over Pangolin (AUTH-01/02) | HUMAN-PENDING | Code wired (oidcAuthMiddleware, processOAuthCallback, OIDC env vars); live verification requires real Authelia + Pangolin infrastructure | +| 13 | Both members have distinct stable colors confirmed in real browser (AUTH-03 cross-member) | HUMAN-PENDING | upsertUser unit-tested for color assignment; live cross-member test requires two real OIDC sessions | + +**Score:** 11/13 truths verified (2 human-pending, counted as HUMAN-PENDING not FAILED; see requirements section for SSE smoke test) + +--- + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `apps/api/src/db/schema.ts` | 4 mysqlTable defs, D-13 dtstart split, D-10 iss+sub unique key | VERIFIED | All 4 tables; unique('uniq_oidc_identity').on(oidcIss,oidcSub); separate dtstart_utc (timestamp) + dtstart_date (date) + allDay boolean | +| `apps/api/src/db/client.ts` | drizzle(mysql2 pool) db singleton export | VERIFIED | Exports `db = drizzle({client:pool,schema,mode:'default'})`; connectionLimit 10 | +| `apps/api/src/routes/health.ts` | GET / with real DB round-trip | VERIFIED | SELECT 1 via db.execute; returns 200 or 503; unauthenticated | +| `docker-compose.yml` | api + mariadb:11 (healthcheck) + redis | VERIFIED | mariadb healthcheck using healthcheck.sh --connect; api depends_on service_healthy; redis present | +| `apps/api/src/auth/user.ts` | upsertUser + COLOR_PALETTE | VERIFIED | Exports both; palette 6 entries; SELECT-first idempotent pattern | +| `apps/api/src/auth/middleware.ts` | oidcAuthMiddleware re-export | VERIFIED | Re-exports oidcAuthMiddleware, processOAuthCallback, getAuth from @hono/oidc-auth | +| `apps/api/src/routes/me.ts` | GET /api/me → {user:{id,displayName,color}} | VERIFIED | getAuth → upsertUser → returns user shape; exports meRouter | +| `apps/api/src/broker/crypto.ts` | encryptPassword/decryptPassword (AES-256-GCM) | VERIFIED | node:crypto aes-256-gcm, 96-bit IV, JSON {iv,authTag,ciphertext} payload | +| `apps/api/src/broker/client.ts` | createFastmailClient → tsdav DAVClient | VERIFIED | caldav.fastmail.com, Basic auth, caldav account type | +| `apps/api/src/broker/sync.ts` | syncCalendar: REPORT → ical.js → upsert | VERIFIED | ical.js ICAL.Time.isDate routing, onDuplicateKeyUpdate on calendarId+uid | +| `apps/api/src/broker/poller.ts` | startBrokerPoller (node-cron */5) + ctag detection | VERIFIED | schedule('*/5 * * * *'); ctag skip logic; per-credential try/catch; exports runPoll for tests | +| `apps/api/src/routes/events.ts` | GET /api/events reads DB cache only | VERIFIED | db.select().from(calendarEvents); no tsdav import; exports eventsRouter | +| `apps/api/src/routes/sse.ts` | GET /api/sse/heartbeat (streamSSE) | VERIFIED | streamSSE every 10s; exports sseRouter; mounted behind oidcAuthMiddleware | +| `apps/api/src/broker/spike.ts` | CAL-08 spike script | VERIFIED | createFastmailClient → fetchCalendars → print URLs; never logs password | +| `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md` | Decision: GO/NO-GO recorded | VERIFIED | "Decision: GO"; 503 events cached; ctag/syncToken findings recorded | +| `apps/pwa/src/App.tsx` | Renders MemberBadge + EventProof | VERIFIED | useQuery(['me']) → MemberBadge; EventProof component rendered | +| `apps/pwa/src/components/EventProof.tsx` | Fetches /api/events, renders first event | VERIFIED | useQuery(['events'],fetchEvents); renders event SUMMARY + date or empty state | +| `apps/api/tests/auth/user.test.ts` | 6 passing tests | VERIFIED | 6/6 green (palette[0], palette[1], idempotent, iss+sub identity, full row shape, COLOR_PALETTE validity) | +| `apps/api/tests/broker/crypto.test.ts` | 5 passing tests | VERIFIED | 5/5 green | +| `apps/api/tests/broker/sync.test.ts` | 6 passing tests | VERIFIED | 6/6 green | +| `apps/api/tests/broker/poller.test.ts` | 5 passing tests | VERIFIED | 5/5 green | +| `apps/api/tests/health.test.ts` | 2 passing tests | VERIFIED | 2/2 green | + +**Artifact total: 22/22 present and substantive.** + +--- + +### Key Link Verification + +| From | To | Via | Status | Details | +|------|-----|-----|--------|---------| +| `apps/api/src/routes/health.ts` | `apps/api/src/db/client.ts` | `db.execute()` | WIRED | `import { db } from '../db/client.js'`; execute called in route handler | +| `apps/pwa/src/App.tsx` | `/health` | `fetch('/health')` in fetchHealth | WIRED | `fetchHealth` calls `fetch('/health')` inside useQuery | +| `apps/api/src/routes/me.ts` | `apps/api/src/auth/user.ts` | `upsertUser(iss, sub, email)` | WIRED | `import { upsertUser }` + called in handler body | +| `apps/api/src/index.ts` | `@hono/oidc-auth` | `oidcAuthMiddleware` on `/api/*` | WIRED | `app.use('/api/*', oidcAuthMiddleware())` line 24 | +| `apps/pwa/src/App.tsx` | `/api/me` | React Query via fetchMe | WIRED | `import { fetchMe }` + `useQuery({queryKey:['me'],queryFn:fetchMe})` | +| `apps/api/src/broker/poller.ts` | `apps/api/src/broker/crypto.ts` | `decryptPassword` | WIRED | `import { decryptPassword }` + called before createFastmailClient | +| `apps/api/src/broker/sync.ts` | `apps/api/src/db/client.ts` | calendarEvents upsert | WIRED | `import { db }` + `db.insert(calendarEvents)...onDuplicateKeyUpdate()` | +| `apps/api/src/routes/events.ts` | `apps/api/src/db/client.ts` | cache read | WIRED | `import { db }` + `db.select().from(calendarEvents)` | +| `apps/api/src/index.ts` | `apps/api/src/broker/poller.ts` | `startBrokerPoller()` on boot | WIRED | `import { startBrokerPoller }` + called unconditionally at module level | +| `apps/api/src/index.ts` | `apps/api/src/routes/events.ts` | `app.route('/api/events', eventsRouter)` | WIRED | Line 28 in index.ts | +| `apps/pwa/src/components/EventProof.tsx` | `/api/events` | React Query via fetchEvents | WIRED | `import { fetchEvents }` + `useQuery({queryKey:['events'],queryFn:fetchEvents})` | + +**All 11 key links WIRED.** + +--- + +### Data-Flow Trace (Level 4) + +| Artifact | Data Variable | Source | Produces Real Data | Status | +|----------|---------------|--------|-------------------|--------| +| `apps/pwa/src/App.tsx` (MemberBadge) | `meQuery.data.user` | `/api/me` → upsertUser → MariaDB `users` table | Yes — upsertUser does SELECT then INSERT; backed by real DB | FLOWING | +| `apps/pwa/src/App.tsx` (health indicator) | `healthQuery.data` | `/health` → db.execute SELECT 1 | Yes — real DB round-trip | FLOWING | +| `apps/pwa/src/components/EventProof.tsx` | `events[0]` | `/api/events` → db.select().from(calendarEvents) | Yes — live Fastmail REPORT cached 503 events into MariaDB (CAL-08-DECISION.md); dev DB populated | FLOWING | + +--- + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +|----------|---------|--------|--------| +| All 24 unit tests pass | `cd apps/api && pnpm vitest run` | 24/24 passed, 5 test files | PASS | +| TypeScript compiles clean (api) | `cd apps/api && pnpm exec tsc --noEmit` | Exit 0, no errors | PASS | +| tsdav imported only under broker module | `grep -rn "import.*tsdav" apps/api/src/` | `broker/sync.ts`, `broker/client.ts` only | PASS | +| events route imports no tsdav or broker client | `grep -rn "createFastmailClient\|tsdav" apps/api/src/routes/` | No matches | PASS | +| oidcAuthMiddleware mounted on /api/* | `grep -n "oidcAuthMiddleware" apps/api/src/index.ts` | Line 24: `app.use('/api/*', oidcAuthMiddleware())` | PASS | + +--- + +### Probe Execution + +No `scripts/*/tests/probe-*.sh` files declared or found. Task 3 of Plan 01 and Tasks 2+3 of Plan 04 are `checkpoint:human-action` gates that require live infrastructure. These are routed to Human Verification. + +--- + +### Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|-------------|-------------|--------|----------| +| AUTH-01 | 01-02 | User can log in through Authelia (OIDC SSO) | HUMAN-PENDING | Code wired: oidcAuthMiddleware on /api/*, processOAuthCallback on /callback; live Authelia test pending | +| AUTH-02 | 01-02 | User stays logged in across sessions | HUMAN-PENDING | @hono/oidc-auth refresh-token rotation documented in middleware.ts; live session persistence test pending | +| AUTH-03 | 01-02 | Stable iss+sub identity + consistent per-member color | PARTIALLY VERIFIED | Unit-tested fully (6 tests); live cross-member color check pending human verification | +| CAL-01 | 01-01, 01-03 | Broker reads shared Fastmail calendar via CalDAV, caches locally (ctag polling) | VERIFIED | CAL-08-DECISION.md: 503 real events cached live via REPORT; poller ctag-detection unit tested | +| CAL-08 | 01-04 | Personal calendar spike go/no-go | VERIFIED | CAL-08-DECISION.md: Decision GO; per-member app-password model proven; no fallback needed | + +--- + +### Anti-Patterns Found + +| File | Pattern | Severity | Impact | +|------|---------|----------|--------| +| None found | — | — | — | + +No TBD/FIXME/XXX markers in source files. No `return null` or `return []` stubs in non-test production code. No hardcoded empty values flowing to rendering. No console.log of credentials or encryption key in broker code. + +The one notable "pending" marker is in `CAL-08-DECISION.md` history — the initial template had `Decision: [PENDING]` but was overwritten by commit `0b074cd` with the live GO decision. Current state is fully filled. + +--- + +### Human Verification Required + +#### 1. AUTH-01 — Authelia OIDC Login (Member 1: Lucas) + +**Test:** From an external network (not the Docker host), open `https://familysync.`. Confirm the browser is redirected to Authelia's authorize endpoint. Log in with Lucas's Authelia credentials. Confirm landing on the PWA shell showing MemberBadge (name + color swatch). +**Expected:** Successful redirect to Authelia, login completes, PWA renders `MemberBadge` with Lucas's display name and assigned hex color (`#4A90D9` if first user). No separate FamilySync login, no Fastmail credential prompt. +**Why human:** oidcAuthMiddleware contacts the real OIDC issuer at runtime; OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_AUTH_EXTERNAL_URL must all be set and Authelia must have the client registered (YAML in 01-02-SUMMARY.md). + +--- + +#### 2. AUTH-02 — Session Persistence Across Browser Restart + +**Test:** After AUTH-01 passes, close the browser completely (not just the tab). Reopen `https://familysync.`. Confirm no Authelia login prompt appears — the PWA loads directly to the authenticated shell. +**Expected:** Session cookie survives browser restart; @hono/oidc-auth refresh-token rotation silently renews the access token; `/api/me` returns 200 without re-authentication. +**Why human:** Session cookie persistence and refresh-token rotation require live interaction with Authelia's token endpoint over time. + +--- + +#### 3. AUTH-03 — Cross-Member Distinct Color (Member 2: Wife) + +**Test:** Log in as the second member (wife) at `https://familysync.`. Confirm she lands on the PWA shell with a MemberBadge showing a **different** color from Lucas. +**Expected:** palette[1] (`#E8734A` warm coral) assigned to wife's account; both members' shells display visually distinct color swatches; wife's `users` row is present in MariaDB with a different `color` value. +**Why human:** Requires a second live Authelia account and a real second browser session to trigger upsertUser for the second member. + +--- + +#### 4. SSE Smoke Test — Pangolin Pass-Through (D-08) + +**Test:** From an external network with a valid authenticated session cookie, run: `curl -N -b "session=" https://familysync./api/sse/heartbeat`. Keep the connection open for 5+ minutes. +**Expected:** Heartbeat events (`{"ts":"...","id":0}`, `{"ts":"...","id":1}`, ...) arrive every 10 seconds without the stream being cut. Result is PASS (SSE viable for Phase 4 real-time list sync) or FAIL (Pangolin idle-timeout needs configuration). +**Why human:** Pangolin/Newt proxy idle-timeout behavior is network-infrastructure-dependent and can only be observed over the real tunnel. The sseRouter code is verified; tunnel compatibility is the open question. + +--- + +## Gaps Summary + +No gaps (no must-haves are FAILED or MISSING). All code artifacts are present, substantive, and wired. All 24 unit tests pass. TypeScript is clean. The CAL-08 GO decision is recorded from a live run. + +The 4 human verification items are classified as HUMAN-PENDING (code implemented, live operator confirmation required), not as failures. This is the expected terminal state for a phase whose Plans 01-04 included `checkpoint:human-action` tasks requiring real Authelia + Pangolin infrastructure that is not available in the dev environment. + +The 01-04 SUMMARY explicitly notes "Code-complete. Live verification (CAL-08 spike + Authelia login + Pangolin SSE smoke) pending human action." CAL-08 was subsequently cleared (commit `0b074cd`). AUTH-01/02/03 live tests and SSE smoke remain pending. + +--- + +_Verified: 2026-06-04T11:45:00Z_ +_Verifier: Claude (gsd-verifier)_