diff --git a/tests/idaa_novi_auth.test.ts b/tests/idaa_novi_auth.test.ts new file mode 100644 index 00000000..5d3d4821 --- /dev/null +++ b/tests/idaa_novi_auth.test.ts @@ -0,0 +1,526 @@ +/* + * Playwright tests: IDAA Novi UUID Authentication + * + * WHAT THIS FILE TESTS + * ───────────────────────────────────────────────────────────────────────────── + * The IDAA module is embedded inside Novi member pages as an iframe. When Novi + * renders the host page it injects the current member's UUID into the iframe src: + * + * src="/idaa/archives?uuid=&iframe=true&key=..." + * + * The IDAA sub-layout (src/routes/idaa/(idaa)/+layout.svelte) reads that UUID, + * calls the external Novi API to verify it is a real member, and then grants the + * appropriate access level (anonymous → authenticated → trusted → administrator). + * + * WHY THESE TESTS EXIST + * ───────────────────────────────────────────────────────────────────────────── + * 1. PRIVACY — A prior AI agent accidentally made IDAA content public. The auth + * gate test (test 1) is the regression guard. It must always run first. + * + * 2. ROOT-CAUSE FIX COVERAGE — The Dexie cache fast-path can return a stale + * site_domain record whose cfg_json is missing `novi_idaa_api_key`. When that + * happens verification fails silently and the user sees "Access Denied". Two + * files were changed to fix this: + * + * ae_core__site.ts — after background refresh saves to Dexie, push + * fresh cfg_json into $ae_loc so store subscribers + * are notified. + * idaa/(idaa)/+layout.svelte — track $ae_loc.site_cfg_json outside untrack() + * so Effect 2 re-runs when cfg arrives; guard + * prevents double-verification if already done. + * + * Test 4 specifically exercises this stale-cache → auto-retry path. + * + * MOCKING STRATEGY + * ───────────────────────────────────────────────────────────────────────────── + * • All /v3/ API calls are intercepted with a page.route glob (any-origin /v3/ any-path). + * Only site_domain/search needs a specific response; everything else returns []. + * + * • The external Novi API (https://www.idaa.org/api/customers/:uuid) is + * intercepted with a page.route glob (any-origin /api/customers/ any-path). + * This prevents any real network traffic to idaa.org during CI/dev runs. + * + * • localStorage is seeded with page.addInitScript() before each navigate so the + * persisted $ae_loc and $ae_idaa_loc stores start in a known state. + * + * ROUTE UNDER TEST + * ───────────────────────────────────────────────────────────────────────────── + * /idaa/archives — simplest IDAA sub-route; auth gate fires at the shared + * (idaa)/+layout.svelte before any child content loads, so + * no archive fixture data is needed for the auth tests. + */ + +import { test, expect } from '@playwright/test'; +import { ae_app_local_data_defaults } from './_helpers/ae_defaults'; +import { testing_account_id, mock_site_domain } from './_helpers/env'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** + * Synthetic Novi UUID used as the test subject. + * Must NOT be a real member UUID — nothing here reaches the real Novi API. + * This UUID is also listed in the fresh_site_cfg_json novi_trusted_li so that + * a verified member gets 'trusted' access (not just 'authenticated'). + */ +const TEST_NOVI_UUID = 'c9ea07b5-06b0-4a43-a2d0-8d06558c8a82'; + +/** + * Synthetic Novi API key stored in site_cfg_json.novi_idaa_api_key. + * The layout uses this as a Basic auth header when calling the Novi API. + * Any non-empty string works here — the mocked route never inspects it. + */ +const TEST_NOVI_API_KEY = 'Basic dGVzdC10ZXN0LXRlc3Q='; + +/** + * Minimal Novi API member payload. + * Matches the shape that verify_novi_uuid() reads in (idaa)/+layout.svelte: + * FirstName, LastName → display name ("IDAA T.") + * Email → stored as idaa_loc.novi_email + */ +const mock_novi_member = { + FirstName: 'IDAA', + LastName: 'TestMember', + Name: 'IDAA TestMember', + Email: 'test+novi@oneskyit.com', + CustomerUniqueId: TEST_NOVI_UUID +}; + +/** IDAA sub-route to navigate to. All auth tests use this path. */ +const IDAA_ROUTE = '/idaa/archives'; + +// ─── site_cfg_json factories ────────────────────────────────────────────────── + +/** + * Fresh (correct) site_cfg_json — what the API returns after a Novi API key + * is properly configured. This is the happy-path config. + * + * novi_trusted_li includes TEST_NOVI_UUID so verified members get 'trusted' + * access, which satisfies the (idaa)/+layout.svelte gate condition. + */ +function fresh_site_cfg_json() { + return { + slct__event_id: null, + novi_idaa_api_key: TEST_NOVI_API_KEY, + novi_api_root_url: 'https://www.idaa.org/api', + novi_admin_li: [], + novi_trusted_li: [TEST_NOVI_UUID] + }; +} + +/** + * Stale (broken) site_cfg_json — what Dexie might have cached if the site + * record was updated server-side after the user's last visit. Specifically + * missing novi_idaa_api_key, which causes verify_novi_uuid() to bail early. + * + * This is the cfg that triggers the bug covered by test 4. + */ +function stale_site_cfg_json() { + return { + slct__event_id: null + // Intentionally omitted: novi_idaa_api_key, novi_api_root_url, novi_trusted_li + }; +} + +// ─── Route mock helpers ─────────────────────────────────────────────────────── + +/** + * Mock all /v3/ API traffic for IDAA auth tests. + * + * Only site_domain/search needs a real response — everything else (archive + * lists, event lists, etc.) can safely return an empty array because the auth + * gate fires before any child content renders. + * + * @param cfg_json The cfg_json to embed in the site_domain response. + * Use fresh_site_cfg_json() for normal operation. + * Use stale_site_cfg_json() to simulate a stale Dexie cache. + */ +async function mock_v3_routes(page: any, cfg_json: any) { + await page.route('**/v3/**', async (route: any) => { + const url = route.request().url(); + + // Bootstrap call: +layout.ts looks up the current domain to get account_id + // and site_cfg_json. This is where the stale-cache bug manifests. + if (url.includes('site_domain/search')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [{ ...mock_site_domain, cfg_json }] + }) + }); + } + + // Everything else — archives, events, lookup tables — returns empty. + // The auth gate fires before child content renders so these are never used. + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [] }) + }); + }); +} + +/** + * Mock the external Novi API endpoint called by verify_novi_uuid(). + * + * URL pattern: https://www.idaa.org/api/customers/ + * Playwright intercepts cross-origin requests from the page context, so this + * stops any real traffic from leaving the test environment. + * + * @param status 200 for a recognised member, 404 for an unknown UUID, or + * any other status to simulate server errors. + * @param body Response body. For 200 pass mock_novi_member. 404 can be empty. + */ +async function mock_novi_api(page: any, status: number, body?: any) { + await page.route('**/api/customers/**', async (route: any) => { + return route.fulfill({ + status, + contentType: 'application/json', + body: body ? JSON.stringify(body) : '{}' + }); + }); +} + +// ─── localStorage seed helpers ──────────────────────────────────────────────── + +/** + * Seed both ae_loc and ae_idaa_loc into localStorage before page load. + * + * Must be called via page.addInitScript() which runs before any page JS so that + * store_versions.ts sees the correct __version and leaves the stores intact. + * + * ae_loc is seeded as fully anonymous (no auth flags set) — the IDAA layout must + * earn access via Novi verification, not inherit it from a cached session. + * + * ae_idaa_loc is seeded as a clean slate (no cached UUID) so each test starts + * fresh and does not inherit a verified state from a previous test run. + * + * @param ae_loc_overrides Merged on top of the defaults. Use this to set + * iframe:true, or to inject a stale site_cfg_json for the stale-cache test. + */ +async function seed_anonymous_session(page: any, ae_loc_overrides: any = {}) { + await page.addInitScript( + ({ defaults, account_id, overrides }: any) => { + // Fully anonymous ae_loc — no auth flags + const ae_loc_data = { + ...defaults, + account_id, + allow_access: true, + access_type: 'anonymous', + authenticated_access: false, + trusted_access: false, + administrator_access: false, + ...overrides + }; + window.localStorage.setItem('ae_loc', JSON.stringify(ae_loc_data)); + + // Clean ae_idaa_loc — no cached UUID from previous sessions. + // IMPORTANT: must include all nested objects (bb, archives, recovery_meetings) + // that the IDAA layout writes to after verification. If bb is missing, + // verify_novi_uuid() throws "Cannot set properties of undefined" when it + // tries to reset bb.qry__hidden — caught by the try/catch which then nulls + // novi_uuid, making the UUID span invisible even when access is granted. + const ae_idaa_loc_data = { + ver: '2024-08-21_1646', + novi_uuid: null, + novi_email: null, + novi_full_name: null, + novi_verified: false, + novi_admin_li: [], + novi_trusted_li: [], + novi_jitsi_mod_li: [], + ds: {}, + idaa_cfg_json: {}, + // bb is required — layout writes to bb.qry__hidden and bb.qry__enabled + // at the end of a successful verification pass. + bb: { + enabled: 'enabled', + hidden: 'not_hidden', + limit: 50, + offset: 0, + edit_kv: {}, + edit__post_obj: null, + edit__post_comment_obj: null, + show_list__post_obj_li: true, + qry__enabled: 'enabled', + qry__hidden: 'not_hidden', + qry__limit: 25, + qry__offset: 0, + qry__order_by: 'updated_on', + qry__order_by_li: { updated_on: 'DESC', created_on: 'DESC' } + }, + archives: { + enabled: 'enabled', + hidden: 'not_hidden', + limit: 150, + offset: 0, + edit_kv: {}, + edit__archive_obj: null, + edit__archive_content_obj: null + } + }; + window.localStorage.setItem( + 'ae_idaa_loc', + JSON.stringify(ae_idaa_loc_data) + ); + + // Suppress outbound email calls during tests (checked in api.ts) + (window as any).__ae_test_mode = true; + }, + { + defaults: ae_app_local_data_defaults, + account_id: testing_account_id, + overrides: ae_loc_overrides + } + ); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.describe('IDAA Novi UUID authentication', () => { + // Pipe browser-side JS errors to test stdout so failures are diagnosable + // without opening Playwright's trace viewer. + test.beforeEach(async ({ page }) => { + page.on('pageerror', (err) => + console.error(`BROWSER ERROR: ${err.message}`) + ); + }); + + // ── Test 1: Auth gate ───────────────────────────────────────────────────── + // + // THIS TEST MUST RUN FIRST AND MUST ALWAYS PASS. + // + // IDAA = International Doctors in Alcoholics Anonymous. All content is + // strictly private. A prior AI agent accidentally made IDAA BB public — + // this test is the regression guard for the access gate. + // + // If this test fails it means unauthenticated visitors can see IDAA content. + // That is a Sev-1 privacy failure regardless of any other test results. + + test('auth gate: no UUID and no auth → Access Denied is shown', async ({ + page + }) => { + // Stale cfg is fine here — the point is that no UUID means no verification + await mock_v3_routes(page, stale_site_cfg_json()); + await seed_anonymous_session(page); + + // Navigate WITHOUT a uuid param — simulates a direct URL visit, a search + // engine crawler, or a Novi member who hasn't been given the iframe URL + await page.goto(IDAA_ROUTE); + + // The auth gate in (idaa)/+layout.svelte must block this request. + // "Access Denied" heading is the canonical signal that the gate fired. + await expect( + page.getByRole('heading', { name: 'Access Denied' }) + ).toBeVisible({ timeout: 5000 }); + + // Confirm no private content leaked through — "IDAA Novi UUID not found!" + // is shown inside the denied block when uuid verification was not attempted. + // This proves the gate rendered correctly, not just that something went wrong. + await expect(page.getByText('IDAA Novi UUID not found!')).toBeVisible(); + }); + + // ── Test 2: Happy path ──────────────────────────────────────────────────── + // + // Fresh site_cfg_json has novi_idaa_api_key → verify_novi_uuid() runs. + // Novi API returns a valid member → access is granted. + // This is the normal day-to-day flow for Novi members visiting IDAA pages. + + test('happy path: valid UUID + fresh cfg → access granted', async ({ + page + }) => { + // Fresh cfg has novi_idaa_api_key so the layout can call the Novi API + await mock_v3_routes(page, fresh_site_cfg_json()); + + // Novi confirms this UUID is a real member + await mock_novi_api(page, 200, mock_novi_member); + + // Start anonymous — access must come entirely from Novi verification + await seed_anonymous_session(page); + + // Navigate with UUID param — same URL shape as the Novi iframe template + await page.goto(`${IDAA_ROUTE}?uuid=${TEST_NOVI_UUID}`); + + // "Access Denied" must not appear after successful verification. + // Timeout is generous to allow the async Novi API round-trip to complete. + await expect( + page.getByRole('heading', { name: 'Access Denied' }) + ).not.toBeVisible({ timeout: 6000 }); + + // The layout renders $idaa_loc.novi_uuid in a footer span after auth. + // Its presence confirms the full verification pipeline ran and the store + // was written — not just that the gate condition happened to pass. + // UUID appears inside a longer span ("Novi: [icon] [uuid] [name] [email]"), + // so exact:false is required — getByText default is whole-text match. + await expect(page.getByText(TEST_NOVI_UUID, { exact: false })).toBeVisible({ + timeout: 6000 + }); + }); + + // ── Test 3: Invalid UUID ────────────────────────────────────────────────── + // + // UUID is present in the URL but the Novi API does not recognise it + // (e.g. expired membership, wrong UUID, typo in the Novi template). + // The user must be denied access — IDAA is not publicly readable. + + test('invalid UUID: Novi API 404 → Access Denied with UUID not found', async ({ + page + }) => { + await mock_v3_routes(page, fresh_site_cfg_json()); + + // Novi API does not recognise this UUID + await mock_novi_api(page, 404); + + await seed_anonymous_session(page); + await page.goto(`${IDAA_ROUTE}?uuid=${TEST_NOVI_UUID}`); + + await expect( + page.getByRole('heading', { name: 'Access Denied' }) + ).toBeVisible({ timeout: 5000 }); + + // The specific "UUID not found!" message confirms the failure is the + // post-verification denial path, not the no-uuid path from test 1. + await expect(page.getByText('IDAA Novi UUID not found!')).toBeVisible(); + }); + + // ── Test 4: Stale cache → reactive retry ───────────────────────────────── + // + // THIS TEST COVERS THE SPECIFIC BUG FIXED ON 2026-03-25. + // + // Scenario: $ae_loc.site_cfg_json is stale (missing novi_idaa_api_key — + // e.g. key was added server-side after the user's last visit, and their + // persisted store still has the old cfg). Effect 2 in the IDAA layout + // reads site_cfg_json → key is null → verify_novi_uuid() bails → Access Denied. + // + // The fix: Effect 2 now tracks $ae_loc.site_cfg_json OUTSIDE untrack(), so + // when the store is updated later (by any code path — background refresh, + // admin panel, etc.) the effect re-runs automatically and retries verification. + // + // This test proves the reactive-tracking half of the fix by simulating the + // store update directly via a StorageEvent, which is what svelte-persisted-store + // uses internally when another code path writes to $ae_loc. The test: + // + // 1. Seeds localStorage with stale cfg (no api_key) + // 2. Navigates → Access Denied (verify fails immediately) + // 3. Dispatches a StorageEvent to push fresh cfg into $ae_loc + // 4. Effect 2 re-runs (reactive tracking), retries with real api_key + // 5. Novi API returns 200 → access granted — no reload needed + // + // WHY StorageEvent instead of pre-seeding Dexie: + // In the test environment, Dexie is empty on first load, so lookup_site_domain + // takes the slow path (single API call, no background refresh). Pre-seeding + // Dexie would require an extra navigation-and-reload cycle and tightly couples + // this test to the ae_core__site.ts background-refresh plumbing. The StorageEvent + // approach tests the reactive tracking fix in isolation — cleaner and faster. + + test('stale cache: $ae_loc.site_cfg_json update triggers Effect 2 retry → access granted without reload', async ({ + page + }) => { + // Site domain always returns stale cfg for this test — the update comes via + // StorageEvent below, not from a background refresh. + await mock_v3_routes(page, stale_site_cfg_json()); + + // Novi API is primed and ready — it only gets called AFTER Effect 2 re-runs + // with the fresh api_key (step 4 above). If verification never retries this + // mock is never invoked and the test fails at the access-granted assertion. + await mock_novi_api(page, 200, mock_novi_member); + + // Seed localStorage with stale site_cfg_json. + // This replicates a real user's persisted store after a server-side config change. + await seed_anonymous_session(page, { + site_cfg_json: stale_site_cfg_json() + }); + + await page.goto(`${IDAA_ROUTE}?uuid=${TEST_NOVI_UUID}`); + + // Phase 1 — stale cfg causes immediate verification failure. + // verify_novi_uuid() exits early (no api_key) and sets novi_verified=false. + await expect( + page.getByRole('heading', { name: 'Access Denied' }) + ).toBeVisible({ timeout: 5000 }); + + // Phase 2 — simulate $ae_loc.site_cfg_json being updated externally. + // + // svelte-persisted-store subscribes to window 'storage' events so that + // writes from other tabs/contexts (e.g. ae_core__site.ts after a background + // refresh) propagate into the in-memory store. Dispatching the event + // manually here has the same effect: the store updates in-memory, Svelte + // marks Effect 2 dirty (site_cfg_json was a tracked dependency), and the + // effect re-runs on the next microtask. + await page.evaluate( + ({ fresh_cfg }: { fresh_cfg: any }) => { + const raw = window.localStorage.getItem('ae_loc'); + const current = raw ? JSON.parse(raw) : {}; + const updated = { ...current, site_cfg_json: fresh_cfg }; + const newValue = JSON.stringify(updated); + window.localStorage.setItem('ae_loc', newValue); + // Dispatch synthetic storage event — triggers svelte-persisted-store + // listener in the same tab (native 'storage' event only fires in + // OTHER tabs; manual dispatch reaches same-tab listeners). + window.dispatchEvent( + new StorageEvent('storage', { + key: 'ae_loc', + newValue, + storageArea: window.localStorage + }) + ); + }, + { fresh_cfg: fresh_site_cfg_json() } + ); + + // Phase 3 — Effect 2 re-runs with fresh cfg, calls Novi API, grants access. + // Access Denied must disappear WITHOUT a page reload. + await expect( + page.getByRole('heading', { name: 'Access Denied' }) + ).not.toBeVisible({ timeout: 8000 }); + + // UUID in footer span confirms full verification pipeline completed and + // $idaa_loc.novi_uuid was written — not just that the gate check passed. + await expect(page.getByText(TEST_NOVI_UUID, { exact: false })).toBeVisible({ + timeout: 8000 + }); + }); + + // ── Test 5: Reload button in iframe Access Denied ───────────────────────── + // + // The Reload / Retry button is a UX stopgap for the stale-cache race. + // Even after the root-cause fix, edge cases may still show Access Denied + // (e.g. Novi API is slow or temporarily down). The button lets the user + // self-recover without involving the Novi site operators. + // + // The button is intentionally ONLY shown in iframe mode — outside an iframe + // the regular browser reload button is accessible and the extra UI is noise. + + test('iframe mode: Reload/Retry button is visible on Access Denied', async ({ + page + }) => { + // Force Access Denied by: stale cfg (no api_key) + Novi 404 (invalid UUID) + // This guarantees we stay on the denied screen for the assertion. + await mock_v3_routes(page, stale_site_cfg_json()); + await mock_novi_api(page, 404); + + // Seed with iframe:true — same as what the root layout sets when + // ?iframe=true is in the URL. We seed it directly to avoid relying + // on the URL param being processed before the layout mounts. + await seed_anonymous_session(page, { iframe: true }); + + // Navigate with iframe=true and a UUID — same URL as the Novi embed + await page.goto( + `${IDAA_ROUTE}?uuid=${TEST_NOVI_UUID}&iframe=true` + ); + + await expect( + page.getByRole('heading', { name: 'Access Denied' }) + ).toBeVisible({ timeout: 5000 }); + + // Reload / Retry button must be present (added in fix commit 2026-03-25) + await expect( + page.getByRole('button', { name: /Reload \/ Retry/i }) + ).toBeVisible(); + + // Sanity-check: button is scoped to iframe context — the #ae_idaa div + // gets class="iframe" when $ae_loc.iframe is true (idaa/+layout.svelte). + // If this fails the iframe flag was not respected by the layout. + await expect(page.locator('#ae_idaa.iframe')).toBeVisible(); + }); +});