194 lines
10 KiB
JavaScript
194 lines
10 KiB
JavaScript
// Runs only against a disposable container and volume created by this command.
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join as joinPath } from 'node:path';
|
|
import { describeFailure } from './admin-verification-diagnostics.mjs';
|
|
|
|
const image = process.argv[2];
|
|
if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE');
|
|
const name = `dune-verify-${randomUUID()}`;
|
|
const volume = `${name}-data`;
|
|
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim();
|
|
let base;
|
|
const adminFiles = mkdtempSync(joinPath(tmpdir(), 'dune-admin-container-'));
|
|
let adminCookie;
|
|
let stage = 'isolated volume and cold start';
|
|
function annotateFailure(message) {
|
|
if (process.env.GITHUB_ACTIONS !== 'true') return;
|
|
const safe = String(message).replaceAll(/dune-admin\.[0-9a-f-]{36}\.[0-9a-f]{64}/g, '[REDACTED ADMIN KEY]')
|
|
.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');
|
|
console.error(`::error title=Container verification - ${stage}::${safe}`);
|
|
}
|
|
async function ready() {
|
|
for (let attempt = 0; attempt < 90; attempt++) {
|
|
try {
|
|
const result = await fetch(base, { signal: AbortSignal.timeout(2000) });
|
|
if (result.ok) return;
|
|
} catch { /* Startup includes all additive migrations. */ }
|
|
await delay(1000);
|
|
}
|
|
throw new Error('Container did not become ready');
|
|
}
|
|
function start(publicOrigin = '') {
|
|
docker('run', '-d', '--name', name, '--init', '--cap-drop=ALL',
|
|
'--security-opt=no-new-privileges:true',
|
|
'-p', '127.0.0.1::3000', '-v', `${volume}:/data`,
|
|
'-e', `DUNE_PUBLIC_ORIGIN=${publicOrigin}`, image);
|
|
base = `http://${docker('port', name, '3000/tcp')}`;
|
|
}
|
|
const bootstrapKeys = (logs) => logs.match(/dune-admin\.[0-9a-f-]{36}\.[0-9a-f]{64}/g) || [];
|
|
function checkBootstrap(count) {
|
|
const keys = bootstrapKeys(docker('logs', name));
|
|
assert.equal(keys.length, count, 'First owner key must be logged exactly once per new database');
|
|
return keys[0];
|
|
}
|
|
try {
|
|
docker('volume', 'create', volume);
|
|
start();
|
|
await ready();
|
|
stage = 'first owner bootstrap and login';
|
|
const bootstrapKey = checkBootstrap(1);
|
|
const ownerLogin = await fetch(`${base}/api/admin/session`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json', origin: base },
|
|
body: JSON.stringify({ action: 'login', key: bootstrapKey }),
|
|
});
|
|
assert.equal(ownerLogin.status, 200, 'Bootstrapped owner must be able to sign in');
|
|
stage = 'operator provisioning and login';
|
|
execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'operator', '--out', joinPath(adminFiles, 'key')]);
|
|
execFileSync('docker', ['exec', '-i', name, 'node', '-e', "require('node:fs').writeFileSync('/tmp/dune-admin-provision.sql', require('node:fs').readFileSync(0), {mode:0o600})"], {
|
|
input: readFileSync(joinPath(adminFiles, 'key/provision.sql')), // Container USER owns this private file.
|
|
});
|
|
docker('exec', name, 'node', 'node_modules/wrangler/bin/wrangler.js', 'd1', 'execute', 'DB', '--local', '--config', 'tools/wrangler.local.json', '--persist-to', '/data', '--file', '/tmp/dune-admin-provision.sql');
|
|
const adminKey = readFileSync(joinPath(adminFiles, 'key/access-key.txt'), 'utf8').trim();
|
|
const adminLogin = await fetch(`${base}/api/admin/session`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json', origin: base },
|
|
body: JSON.stringify({ action: 'login', key: adminKey }),
|
|
});
|
|
assert.equal(adminLogin.status, 200);
|
|
adminCookie = adminLogin.headers.get('set-cookie')?.split(';')[0];
|
|
assert.ok(adminCookie);
|
|
stage = 'mapped origin and room creation';
|
|
const headers = { 'content-type': 'application/json', origin: base };
|
|
const body = JSON.stringify({ name: 'Container verification', faction: 'atreides', advanced: false, expansions: [] });
|
|
const create = await fetch(`${base}/api/rooms`, { method: 'POST', headers, body });
|
|
assert.equal(create.status, 201, 'Mapped host/port must pass origin validation');
|
|
const cookie = create.headers.get('set-cookie')?.split(';')[0];
|
|
assert.ok(cookie);
|
|
const room = await create.json();
|
|
const foreign = await fetch(`${base}/api/rooms`, {
|
|
method: 'POST', headers: { ...headers, origin: 'http://foreign.invalid' }, body,
|
|
});
|
|
assert.equal(foreign.status, 403);
|
|
async function restored() {
|
|
const result = await fetch(`${base}/api/rooms/${room.code}`, { headers: { cookie } });
|
|
assert.equal(result.status, 200);
|
|
assert.deepEqual(await result.json(), room, 'Saved seat and room must survive replacement');
|
|
if (adminCookie) {
|
|
const admin = await fetch(`${base}/api/admin/session`, { headers: { cookie: adminCookie } });
|
|
assert.equal(admin.status, 200, 'Administrator session must survive replacement');
|
|
}
|
|
}
|
|
stage = 'container restart and saved witnesses';
|
|
docker('restart', '--time', '60', name);
|
|
// Docker may assign a different ephemeral host port after a restart.
|
|
base = `http://${docker('port', name, '3000/tcp')}`;
|
|
await ready();
|
|
assert.equal(checkBootstrap(1), bootstrapKey, 'Restart must not replace the owner key');
|
|
await restored();
|
|
stage = 'container replacement and bootstrap custody';
|
|
docker('stop', '--time', '60', name);
|
|
docker('rm', name);
|
|
start();
|
|
await ready();
|
|
await restored();
|
|
checkBootstrap(0);
|
|
stage = 'administrator HTTP acceptance';
|
|
execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', joinPath(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', joinPath(adminFiles, 'http')], { stdio: 'inherit' });
|
|
adminCookie = undefined; // The acceptance test intentionally signs out all sessions.
|
|
stage = 'HTTP integration';
|
|
const integration = spawnSync('npm', ['run', 'test:integration'], {
|
|
encoding: 'utf8', maxBuffer: 16 * 1024 * 1024,
|
|
env: { ...process.env, DUNE_TEST_URL: base },
|
|
});
|
|
if (integration.stdout) process.stdout.write(integration.stdout);
|
|
if (integration.stderr) process.stderr.write(integration.stderr);
|
|
if (integration.error) throw integration.error;
|
|
if (integration.status !== 0) {
|
|
const details = `${integration.stdout ?? ''}\n${integration.stderr ?? ''}`;
|
|
const failures = details.match(/^\s*\u2716 [^\r\n]+/gm) ?? [];
|
|
const reported = new Set();
|
|
for (const failure of failures) {
|
|
const name = failure.trim();
|
|
if (name.includes('failing tests:') || reported.has(name)) continue;
|
|
reported.add(name); annotateFailure(name);
|
|
if (reported.size === 3) break;
|
|
}
|
|
throw new Error(`HTTP integration exited with ${integration.signal ?? integration.status}`);
|
|
}
|
|
stage = 'HTTPS proxy replacement and original witnesses';
|
|
docker('stop', '--time', '60', name);
|
|
docker('rm', name);
|
|
const publicOrigin = 'https://dune.example.test';
|
|
start(publicOrigin);
|
|
await ready();
|
|
await restored();
|
|
checkBootstrap(0);
|
|
stage = 'HTTPS external-origin login and cookies';
|
|
const proxyAdmin = await fetch(`${base}/api/admin/session`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json', origin: publicOrigin },
|
|
body: JSON.stringify({ action: 'login', key: adminKey }),
|
|
});
|
|
assert.equal(proxyAdmin.status, 200);
|
|
assert.ok(proxyAdmin.headers.get('set-cookie')?.includes('; Secure'));
|
|
stage = 'HTTPS external-origin room, join and discussion';
|
|
const proxyCreate = await fetch(`${base}/api/rooms`, {
|
|
method: 'POST', headers: { ...headers, origin: publicOrigin }, body,
|
|
});
|
|
assert.equal(proxyCreate.status, 201, 'HTTPS external origin must work over HTTP upstream');
|
|
assert.ok(proxyCreate.headers.get('set-cookie')?.includes('; Secure'));
|
|
const proxyCookie = proxyCreate.headers.get('set-cookie').split(';')[0];
|
|
const proxyRoom = await proxyCreate.json();
|
|
const join = await fetch(`${base}/api/rooms/${proxyRoom.code}`, {
|
|
method: 'POST', headers: { ...headers, origin: publicOrigin },
|
|
body: JSON.stringify({ type: 'join', name: 'Proxy peer', faction: 'emperor' }),
|
|
});
|
|
assert.equal(join.status, 200);
|
|
assert.ok(join.headers.get('set-cookie')?.includes('; Secure'));
|
|
const message = await fetch(`${base}/api/rooms/${proxyRoom.code}/messages`, {
|
|
method: 'POST', headers: { ...headers, origin: publicOrigin, cookie: proxyCookie },
|
|
body: JSON.stringify({ id: randomUUID(), recipientId: null, text: 'Proxy verification' }),
|
|
});
|
|
assert.equal(message.status, 200, 'Table discussion must work through HTTPS proxy');
|
|
stage = 'foreign-origin mutation rejection';
|
|
for (const path of ['/api/rooms', `/api/rooms/${proxyRoom.code}`, `/api/rooms/${proxyRoom.code}/control`, `/api/rooms/${proxyRoom.code}/messages`]) {
|
|
const rejected = await fetch(base + path, {
|
|
method: 'POST', headers: { ...headers, origin: 'https://foreign.invalid' }, body,
|
|
});
|
|
assert.equal(rejected.status, 403, path);
|
|
}
|
|
console.log('Container origin, restart, replacement, saved seat and HTTP integration checks passed.');
|
|
} catch (error) {
|
|
annotateFailure(stage === 'administrator HTTP acceptance'
|
|
? 'Administrator HTTP acceptance failed; see sanitized details below.'
|
|
: String(error?.message ?? error).split('\n')[0]);
|
|
if (stage === 'administrator HTTP acceptance') {
|
|
try {
|
|
const report = JSON.parse(readFileSync(joinPath(adminFiles, 'http/report.json'), 'utf8'));
|
|
const request = report.failureRequest ?? report.lastRequest;
|
|
const detail = describeFailure(request, report.failureLocation);
|
|
if (detail) annotateFailure(detail);
|
|
} catch { /* Early failures may not create the private report; the original error remains fatal. */ }
|
|
}
|
|
try { console.error(docker('logs', name).replaceAll(/dune-admin\.[0-9a-f-]{36}\.[0-9a-f]{64}/g, '[REDACTED ADMIN KEY]')); } catch { /* May not exist yet. */ }
|
|
throw error;
|
|
} finally {
|
|
// Names are unique to this invocation; never remove an existing application.
|
|
try { docker('rm', '-f', name); } catch { /* Already removed or not created. */ }
|
|
try { docker('volume', 'rm', volume); } catch { /* Failure remains visible. */ }
|
|
rmSync(adminFiles, { recursive: true, force: true });
|
|
}
|