// oauth-browser-session.mjs — the last piece of batch 04: a real browser // session must survive an OAuth refresh. // // oauth-refresh-e2e.sh proves the refresh path itself (CAS, no burned token). // This proves the user-visible symptom the range was written to stop: a signed-in // browser getting thrown out when the access token rotates underneath it. // // The test is only meaningful because ui_sessions is a DB table carrying an // oauth_session_id, not an in-memory map — so restarting the appview to clear the // refresher's in-memory cache does not, by itself, log the browser out. If UI // sessions were in-memory this would prove nothing. // // node test/e2e/oauth-browser-session.mjs import { execSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; import { open, reporter, APPVIEW } from './lib.mjs'; const { record, summarize } = reporter(); const sh = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); // SQL goes in over stdin rather than inside `sh -c '...'`. Statements here // contain single quotes — readfile('/tmp/bs.json') — which would otherwise // terminate the shell quoting around the command. const q = (sql) => execSync('docker exec -i atcr-appview sqlite3 /var/lib/atcr/ui.db', { input: sql, encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], }).trim(); const revOf = () => q('select rev from oauth_sessions limit 1'); const uiSessions = () => q('select count(*) from ui_sessions'); const ctx = await open(); const page = ctx.pages()[0] ?? (await ctx.newPage()); // ATCR_E2E_FRESH=1 signs out of the APPVIEW so the login flow actually runs, // rather than the test silently reusing a session from a previous invocation. // // Scoped to the appview's own host on purpose. An unfiltered clearCookies() // also drops the PDS session, which turns a handle-and-consent flow into a // password prompt and makes the login impossible to drive unattended. if (process.env.ATCR_E2E_FRESH) { const host = new URL(APPVIEW).hostname; await ctx.clearCookies({ domain: host }); console.log(`Cleared ${host} cookies — signed out of the appview, PDS session left alone.`); } // A page that requires a session: unauthenticated it redirects to login. const SETTINGS = `${APPVIEW}/settings`; const atLogin = (u) => /\/login|\/auth\/oauth|\/oauth\/authorize|jarrett\.app/.test(u); // Checks the CURRENT page without navigating. Navigating during the wait would // reload the login form out from under whoever is typing into it, which is // exactly what an earlier version of this script did. const loggedIn = async () => !atLogin(page.url()); await page.goto(SETTINGS, { waitUntil: 'domcontentloaded' }); // Settle: an existing session bounces through the OAuth host briefly, and // checking too early reports "not signed in" for a session that is perfectly // fine. await page.waitForTimeout(3000); // Drives handle entry and the consent click, which is the whole flow whenever // the PDS already has a live session. It deliberately stops at a password // field: typing one is a human's job, and blindly resubmitting the form would // clobber whatever they were typing. const autoLogin = async (handle) => { let deadline = Date.now() + 90 * 1000; let warned = false; while (Date.now() < deadline) { if (!atLogin(page.url())) return true; // A password is a human's job. Wait for it to be submitted and then resume // driving — bailing out entirely here leaves the flow stranded on the // Authorize screen afterwards, with nothing left to click it. const pw = page.locator('input[type="password"]').first(); if (await pw.count().catch(() => 0)) { if (!(await pw.inputValue().catch(() => ''))) { if (!warned) { console.log('Password field — over to you. I will take over again once you submit.'); warned = true; } deadline = Date.now() + 5 * 60 * 1000; // human pace, not machine pace await page.waitForTimeout(2000); continue; } } const field = page .locator('input[name*="handle" i], input[id*="handle" i], input[placeholder*="handle" i], input[name="username"]') .first(); if (await field.count().catch(() => 0)) { if (!(await field.inputValue().catch(() => ''))) { await field.fill(handle).catch(() => {}); await page.waitForTimeout(300); } } const btn = page .locator('button[type="submit"], input[type="submit"], button:has-text("Authorize"), button:has-text("Approve"), button:has-text("Continue"), button:has-text("Sign in")') .first(); if (await btn.count().catch(() => 0)) { await btn.click({ timeout: 3000 }).catch(() => {}); } await page.waitForTimeout(2000); } return !atLogin(page.url()); }; if (!(await loggedIn())) { const handle = process.env.ATCR_E2E_HANDLE; if (handle) { console.log(`Driving the OAuth flow as ${handle}...`); await autoLogin(handle); await page.waitForTimeout(1500); } console.log(`Not signed in. Complete the OAuth login in the open window.`); console.log('This script will NOT touch the page while you type — it only watches the URL.'); console.log('Waiting up to 10 minutes...'); const deadline = Date.now() + 10 * 60 * 1000; let ok = false; while (Date.now() < deadline) { await page.waitForTimeout(2000); if (!atLogin(page.url())) { // Let any post-callback redirect settle before confirming. await page.waitForTimeout(2500); if (!atLogin(page.url())) { ok = true; break; } } } if (!ok) { console.error('FAILED: never reached an authenticated page.'); await ctx.close(); process.exit(1); } // Now that login is done, confirm the protected page really loads. await page.goto(SETTINGS, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1000); if (atLogin(page.url())) { console.error('FAILED: still redirected to login after completing OAuth.'); await ctx.close(); process.exit(1); } } record('browser is signed in', true, page.url()); record('ui_sessions row exists', Number(uiSessions()) > 0, `${uiSessions()} row(s)`); const revBefore = revOf(); // Stale the access token and restart, so the next PDS call must refresh. The // refresher caches sessions in memory; editing the DB alone changes nothing. const session = JSON.parse(q('select session_data from oauth_sessions limit 1')); session.access_token = 'expired-browser-test-' + session.access_token.slice(-8); writeFileSync('/tmp/bs.json', JSON.stringify(session)); sh('docker cp /tmp/bs.json atcr-appview:/tmp/bs.json'); q("update oauth_sessions set session_data = readfile('/tmp/bs.json')"); record('access token staled in the live session row', true, `rev before = ${revBefore}`); sh('docker compose restart atcr-appview'); for (let i = 0; i < 90; i++) { try { const code = sh(`curl -s -o /dev/null -w '%{http_code}' ${APPVIEW}/v2/`); if (code === '401') break; } catch { /* still coming up */ } await page.waitForTimeout(1000); } // Force the refresh through the registry path, then check the browser. let pullOK = true; try { sh('crane pull --insecure 127.0.0.1:5000/evan.jarrett.net/valtest:v4 /tmp/bs-pull.tar'); } catch { pullOK = false; } record('pull succeeded after staling (refresh ran)', pullOK); const revAfter = revOf(); record('oauth session actually rotated', revAfter !== revBefore, `rev ${revBefore} -> ${revAfter}`); // The point of the whole exercise. Navigating is fine here — the login form is // long gone, so there is nothing to interrupt. await page.goto(SETTINGS, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1500); const stillIn = await loggedIn(); record('browser session survived the refresh', stillIn, page.url()); record('ui_sessions row still present', Number(uiSessions()) > 0, `${uiSessions()} row(s)`); const failures = summarize('batch 04 — browser session across an OAuth refresh'); await ctx.close(); process.exit(failures ? 1 : 0);