diff --git a/test/e2e/oauth-browser-session.mjs b/test/e2e/oauth-browser-session.mjs new file mode 100644 index 0000000..0411b56 --- /dev/null +++ b/test/e2e/oauth-browser-session.mjs @@ -0,0 +1,188 @@ +// 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);