test(idaa): add Playwright auth tests for Novi UUID verification

Covers 5 scenarios with extensive inline comments explaining business
context and the 2026-03-25 stale-cache root-cause fix:

1. Auth gate (Sev-1 regression guard) — no UUID → Access Denied
2. Happy path — valid UUID + fresh cfg → access granted
3. Invalid UUID — Novi 404 → Access Denied
4. Stale cache — StorageEvent delivers fresh site_cfg_json →
   Effect 2 retries verification without reload (tests the reactive
   tracking fix in (idaa)/+layout.svelte)
5. iframe mode — Reload/Retry button visible on Access Denied

Key lesson found while writing: ae_idaa_loc seed must include the full
bb object or verify_novi_uuid() throws on bb.qry__hidden assignment,
caught silently, resetting novi_uuid to null even after a successful
Novi API call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-25 19:00:03 -04:00
parent ab294c2a0b
commit 48a39b16d5

View File

@@ -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=<CustomerUniqueId>&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/<uuid>
* 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();
});
});