/* * 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(); }); });