pediatric-ai-scribe-v3/e2e/tests/session-persistence.spec.js
Daniel 82ed46d01b Fix Turnstile in the mobile app; drop it from login
The Turnstile challenge failed reliably inside the Capacitor WebView,
which blocked login and registration from the Android app.

Three separate causes:

1. Android WebView blocks third-party cookies by default. Turnstile runs
   in a cross-origin iframe from challenges.cloudflare.com and needs its
   own storage, so the widget never emitted a token. MainActivity now
   calls setAcceptThirdPartyCookies on the app's own WebView.

2. The register handler read the Turnstile response with an unscoped
   document.querySelector, which matched the *login* widget's input (it
   comes first in the DOM). Registration therefore submitted the login
   widget's token — single-use with a 5 minute expiry, so any prior login
   attempt or slow signup made it fail server-side.

3. The register and forgot-password widgets auto-rendered inside forms
   that start at display:none, where Turnstile does not reliably complete
   a challenge, and nothing re-rendered them when the form was shown.

Widgets are now rendered explicitly when their form first becomes
visible, and tokens are captured from the render callback instead of
being read back out of the injected input — which makes the unscoped
lookup in (2) structurally impossible. Added error/expired/timeout
callbacks so a widget failure surfaces the Cloudflare error code instead
of failing silently behind a generic toast.

Login is no longer gated at all. It is the path mobile users hit
constantly, and it is already covered by a 10-per-15-min per-IP rate
limit, a constant-time credential check, and TOTP 2FA. Registration and
password reset — the endpoints that actually attract bots — stay gated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:22:03 +02:00

87 lines
3.9 KiB
JavaScript

// ============================================================
// SESSION PERSISTENCE — full logout → login → still on the same
// tab + same sub-pill.
//
// The test does a programmatic logout (clear the ped_auth cookie,
// same effect server-side as clicking Logout) followed by a fresh
// programmatic login. This exercises the same localStorage
// persistence path a real logout/login would.
//
// (Historically this was a workaround for the Turnstile challenge on
// the login form, which could not be completed in the e2e container.
// Login is no longer gated, but driving it programmatically keeps
// the test focused on persistence rather than form mechanics.)
// ============================================================
const { test, expect, E2E_BASE, loginAs } = require('../fixtures');
async function openDesktopTab(page, name) {
const vp = page.viewportSize();
if (vp && vp.width <= 768) {
await page.click('#btn-menu-toggle').catch(() => {});
}
await page.click(`button.tab-btn[data-tab="${name}"]`);
await page.waitForFunction((t) => {
const el = document.getElementById(t + '-tab');
return el && el.classList.contains('active') && el.innerHTML.trim().length > 100;
}, name, { timeout: 15000 });
}
async function logoutClearsCookie(context) {
// Remove the ped_auth cookie — identical server-side to hitting /logout.
const cookies = await context.cookies();
const keep = cookies.filter(c => c.name !== 'ped_auth');
await context.clearCookies();
if (keep.length) await context.addCookies(keep);
}
test.describe('Logout → login restores last tab + sub-pill', () => {
test('tab choice + calc pill survive a full logout/login cycle', async ({ context, request, page }) => {
await loginAs(context, request);
await page.goto(E2E_BASE + '/');
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
await openDesktopTab(page, 'calculators');
await page.click('button.calc-nav-pill[data-calc="bili"]');
await expect(page.locator('button.calc-nav-pill[data-calc="bili"].active')).toBeVisible();
// Simulate logout (clear session cookie)
await logoutClearsCookie(context);
// Visiting the app with no cookie lands on the auth screen
await page.goto(E2E_BASE + '/');
await expect(page.locator('#auth-screen')).toBeVisible({ timeout: 10000 });
// Log back in (fresh cookie) and revisit the app
await loginAs(context, request);
await page.goto(E2E_BASE + '/');
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
// Restore must put us back on Calculators / Bilirubin pill
await expect(page.locator('#calculators-tab.active')).toHaveCount(1, { timeout: 10000 });
await expect(page.locator('button.calc-nav-pill[data-calc="bili"].active')).toBeVisible({ timeout: 10000 });
await expect(page.locator('#calc-bili')).not.toHaveClass(/hidden/);
});
test('bedside sub-pill survives logout/login', async ({ context, request, page }) => {
await loginAs(context, request);
await page.goto(E2E_BASE + '/');
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
await openDesktopTab(page, 'bedside');
await page.click('button.calc-pill[data-em="sepsis"]');
await expect(page.locator('button.calc-pill[data-em="sepsis"].active')).toBeVisible();
await logoutClearsCookie(context);
await page.goto(E2E_BASE + '/');
await expect(page.locator('#auth-screen')).toBeVisible({ timeout: 10000 });
await loginAs(context, request);
await page.goto(E2E_BASE + '/');
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
await expect(page.locator('#bedside-tab.active')).toHaveCount(1, { timeout: 10000 });
await expect(page.locator('button.calc-pill[data-em="sepsis"].active')).toBeVisible({ timeout: 10000 });
await expect.poll(async () =>
await page.locator('#em-sepsis').evaluate(el => el.style.display),
{ timeout: 5000 }).not.toBe('none');
});
});