coulomb-social/scripts/native_recovery/app_browser.mjs
tegwick 7b4b75089c
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
Verify native reset SMTP issuer and app recovery through Chromium
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
2026-10-31 00:15:24 +01:00

124 lines
6.6 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFile, writeFile } from 'node:fs/promises';
import { createHash, randomBytes } from 'node:crypto';
import { chromium } from './node_modules/playwright/index.mjs';
const input = JSON.parse(await readFile('/run/fixture/app-browser-input.json', 'utf8'));
const auth = 'https://auth.recovery.test:9091';
const issuer = 'http://127.0.0.1:8080';
const app = 'http://127.0.0.1:8000';
const portal = 'http://127.0.0.1:8089';
const fixture = 'http://127.0.0.1:8088';
const allowed = new Set([auth, issuer, app, portal, fixture]);
let browser;
let stage = 'native_app_browser_startup';
let outside = 0;
try {
browser = await chromium.launch({
executablePath: '/run/fixture/browser/chrome-headless-shell-linux64/chrome-headless-shell',
headless: true, args: ['--host-resolver-rules=MAP auth.recovery.test 127.0.0.1',
'--no-proxy-server', '--disable-background-networking'],
});
const context = await browser.newContext({ignoreHTTPSErrors: true, serviceWorkers: 'block'});
context.on('request', request => {
const u = new URL(request.url());
if (['http:', 'https:'].includes(u.protocol) && !allowed.has(u.origin)) outside += 1;
});
await context.route('**/*', route => {
const u = new URL(route.request().url());
if (!allowed.has(u.origin)) {
return route.abort();
}
return route.continue();
});
const page = await context.newPage();
page.setDefaultTimeout(20000);
let callback;
page.on('request', request => {
const u = new URL(request.url());
if (u.origin === app && u.pathname === '/auth/callback/' && u.searchParams.has('code')) {
callback = u.href; // private, never emitted
}
});
stage = 'native_app_login_redirect';
await page.goto(app + '/auth/login/');
await page.locator('#username-textfield').fill('alice');
stage = 'native_old_password_refusal';
await page.locator('#password-textfield').fill(input.old_password);
const rejected = page.waitForResponse(r => r.url() === auth + '/api/firstfactor');
await page.locator('#sign-in-button').click();
const denied = await rejected;
assert(denied.status() >= 400 || (await denied.json()).status !== 'OK');
await page.getByText('Incorrect username or password', {exact: false}).first().waitFor();
stage = 'native_replacement_password_login';
await page.locator('#password-textfield').fill(input.new_password);
await page.locator('#sign-in-button').click();
await page.waitForURL(app + '/app/');
assert(callback);
await page.goto(app + '/account/session/');
await page.getByRole('heading', {name: 'Session details', exact: true}).waitFor();
const value = label => page.locator('dt').filter({hasText: label}).locator('xpath=following-sibling::dd[1]');
assert.equal((await value(/^Issuer$/).innerText()).trim(), issuer);
assert.equal((await value(/^Subject$/).innerText()).trim(), 'uid=alice,ou=people,dc=example,dc=test');
assert.equal((await value(/^Tenant$/).innerText()).trim(), 'tenant:coulomb');
assert.equal((await value(/^user-engine source$/).innerText()).trim(), 'http');
stage = 'native_app_callback_replay_refusal';
const replay = await context.request.get(callback, {maxRedirects: 0});
assert.equal(replay.status(), 400);
stage = 'native_unknown_client_refusal';
const deniedQuery = {redirect_uri: app + '/auth/callback/', response_type: 'code',
scope: 'openid profile email groups', state: randomBytes(32).toString('base64url'),
code_challenge_method: 'S256', code_challenge: randomBytes(32).toString('base64url')};
const invalid = await context.request.get(issuer + '/authorize?'
+ new URLSearchParams({...deniedQuery, client_id: 'not-registered'}),
{maxRedirects: 0});
assert.equal(invalid.status(), 400);
assert((await invalid.text()).includes('unknown client_id'));
const wrongRedirect = await context.request.get(issuer + '/authorize?'
+ new URLSearchParams({...deniedQuery, client_id: 'coulomb-recovery-test',
redirect_uri: app + '/wrong'}),
{maxRedirects: 0});
assert.equal(wrongRedirect.status(), 400);
assert((await wrongRedirect.text()).includes('redirect_uri does not match'));
stage = 'native_foreign_tenant_client_refusal';
const verifier = randomBytes(32).toString('base64url');
const foreignState = randomBytes(32).toString('base64url');
const query = new URLSearchParams({client_id: 'foreign-recovery-test',
redirect_uri: fixture + '/client-callback', response_type: 'code',
scope: 'openid profile email groups', state: foreignState,
code_challenge_method: 'S256',
code_challenge: createHash('sha256').update(verifier).digest('base64url')});
await page.goto(issuer + '/authorize?' + query);
await page.waitForURL(u => u.origin === fixture && u.pathname === '/client-callback');
const foreignCapture = new URL(page.url());
assert.equal(foreignCapture.searchParams.get('state'), foreignState);
if (!foreignCapture?.searchParams.get('code')) {
throw new Error('Foreign callback metadata: ' + JSON.stringify({captured: !!foreignCapture,
parameters: foreignCapture ? [...foreignCapture.searchParams.keys()] : [],
error: foreignCapture?.searchParams.get('error'),
tenant_error: /tenant/i.test(foreignCapture?.searchParams.get('error_description') || '')}));
}
const foreign = await context.request.post(issuer + '/token', {maxRedirects: 0,
form: {grant_type: 'authorization_code', client_id: 'foreign-recovery-test',
redirect_uri: fixture + '/client-callback', code: foreignCapture.searchParams.get('code'),
code_verifier: verifier}});
assert.equal(foreign.status(), 403);
assert((await foreign.text()).includes('tenant binding conflict'));
const state = await context.request.get(portal + '/fixture/state', {maxRedirects: 0});
assert.equal((await state.json()).users, 1);
assert.equal(outside, 0);
console.log(JSON.stringify({status: 'native_app_journey_passed', old_password_login_refused: true,
replacement_password_login_completed: true, native_keycape_code_and_app_session: true,
real_owner_http_identity_link: true, app_callback_replay_refused: true,
native_unknown_client_refused: true, native_wrong_redirect_refused: true,
native_foreign_tenant_token_refused: true, negative_client_callback_is_fixture: true,
issuer_subject_tenant_provenance_verified: true,
owner_users: 1, requests_outside_fixture_origins: outside}));
await context.close();
} catch (error) {
await writeFile('/run/fixture/browser-diagnostic.txt', String(error.message), {mode: 0o600});
console.log(JSON.stringify({status: 'native_app_journey_failed', stage, failure_type: error.name}));
process.exitCode = 1;
} finally {
if (browser) await browser.close();
}