mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
test/e2e: prove a browser session survives an OAuth refresh
oauth-refresh-e2e.sh covers the refresh mechanics; this covers the symptom the
range exists to stop — a signed-in browser being thrown out when the access
token rotates underneath it.
The test is only meaningful because ui_sessions is a table carrying an
oauth_session_id rather than an in-memory map, so restarting the appview to
clear the refresher's cache does not by itself log the browser out. Verified
against the row the browser actually uses: one oauth_sessions row, and the
ui_sessions row created by the login points at it. rev advances 1 to 2 while
/settings/user keeps rendering.
Drives the login itself. Given a handle it fills the field and clicks through
consent, which is the whole flow whenever the PDS already has a session. Two
things it must not do, both learned by doing them:
* Never navigate while waiting for a human. The first version re-issued
goto() every two seconds and wiped the login form out from under whoever
was typing into it.
* Never bail permanently at a password field. Returning there left the flow
stranded on the Authorize screen once the password had been submitted,
because nothing was left to click it. It now pauses and resumes.
ATCR_E2E_FRESH clears only the appview's cookies. Clearing all of them takes
the PDS session with it, which turns a handle-and-consent flow into a password
prompt and makes the login impossible to drive unattended.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
30caf43146
commit
0a6f20fa74
@@ -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);
|
||||
Reference in New Issue
Block a user