Files
Dune/tools/verify-container.mjs
T
Quintine 8c922b446b Add administrator pause and joining-lock controls
Provide authenticated owner/operator room controls with live account binding, durable audit and exact saved retries. Pause gates human, bot and automatic writes while preserving recovery, discussion and taking back control. Resume keeps decisions and restores AI pacing. Add directory availability filters and player notices.

Verified types, lint, 5223 offline tests, 52 HTTP tests and build. Independent persistence/privacy review and authenticated HTTP acceptance passed. Desktop/mobile browser QA confirmed lost-response refresh/retry makes one audit row and resume restores controls. Existing games preserved. Authenticated deployed admin acceptance remains pending explicit QA access confirmation.
2026-09-22 10:41:09 +10:00

133 lines
6.8 KiB
JavaScript

// Runs only against a disposable container and volume created by this command.
import assert from 'node:assert/strict';
import { execFileSync } 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';
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;
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')}`;
}
try {
docker('volume', 'create', volume);
start();
await ready();
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);
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');
}
}
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();
await restored();
docker('stop', '--time', '60', name);
docker('rm', name);
start();
await ready();
await restored();
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.
execFileSync('npm', ['run', 'test:integration'], {
stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },
});
docker('stop', '--time', '60', name);
docker('rm', name);
const publicOrigin = 'https://dune.example.test';
start(publicOrigin);
await ready();
await restored();
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'));
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');
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) {
try { console.error(docker('logs', name)); } 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 });
}