/** * Live transcript, phase 2: ACTIVE-provider outcomes over a real socket. * ISOLATION (r8 judge blocker): everything runs under an isolated DATA_DIR * (mktemp -d) via the platform-paths env override — the real /etc/dashcaddy * stores are never touched, and try/finally removes the dir on every exit * path (no process.exit() before cleanup). * Self-authenticating: prints git HEAD, asserts every expected * status/header/body, and exits non-zero on any mismatch. * Only the network edge (whois) is stubbed; stores, registry, app, route, * and HTTP are all real. */ 'use strict'; const http = require('http'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { execSync } = require('child_process'); // Isolated data dir BEFORE requiring anything that reads platform-paths. const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'p7live2-')); process.env.DATA_DIR = TMP; const { createApp } = require('../src/app'); const registry = require('../src/network/registry'); function req(port, method, reqPath, headers = {}) { return new Promise((resolve, reject) => { const r = http.request({ host: '127.0.0.1', port, method, path: reqPath, headers }, (res) => { let body = ''; res.on('data', (c) => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); }); r.on('error', reject); r.end(); }); } let FAILURES = 0; function expect(name, actual, predicate, rendered) { const ok = predicate(actual); console.log(`< assert ${name}: ${ok ? 'PASS' : 'FAIL'} (${rendered || actual})`); if (!ok) FAILURES += 1; } async function main() { let server = null; try { const head = execSync('git -C .. rev-parse HEAD', { encoding: 'utf8' }).trim(); console.log(`# git HEAD: ${head}`); console.log(`# isolated DATA_DIR: ${TMP}`); const now = new Date().toISOString(); fs.writeFileSync(path.join(TMP, 'network-people.json'), JSON.stringify({ people: { 'p-alice': { id: 'p-alice', displayName: 'Alice', providerUserId: '1001', groups: ['Admin'], status: 'active', devices: [], createdAt: now, updatedAt: now }, 'p-bob': { id: 'p-bob', displayName: 'Bob', providerUserId: '1002', groups: ['Family'], status: 'active', devices: [], createdAt: now, updatedAt: now }, }, }, null, 2)); fs.writeFileSync(path.join(TMP, 'network-resources.json'), JSON.stringify({ resources: [ { id: 'r-plex', name: 'plex', host: 'plex.internal', ports: ['*'], sensitive: false, createdAt: now, updatedAt: now }, { id: 'r-vault', name: 'vault', host: 'vault.internal', ports: ['*'], sensitive: true, createdAt: now, updatedAt: now }, ], }, null, 2)); fs.writeFileSync(path.join(TMP, 'network-access.json'), JSON.stringify({ grants: [ { principal: { type: 'person', id: 'p-alice' }, resource: 'plex', allowed: true, updatedAt: now }, { principal: { type: 'person', id: 'p-alice' }, resource: 'vault', allowed: true, updatedAt: now }, ], updatedAt: now, }, null, 2)); const DEVICE_IP = '10.53.0.7'; const stub = { id: () => 'stub-live', capabilities: () => ({ id: 'stub-live' }), getStatus: async () => ({ ok: true }), listDevices: async () => [], listPeople: async () => [], createInvite: async () => { throw new Error('not needed'); }, revokeInvite: async () => { throw new Error('not needed'); }, blockPerson: async () => { throw new Error('not needed'); }, unblockPerson: async () => { throw new Error('not needed'); }, removePerson: async () => { throw new Error('not needed'); }, revokeDevice: async () => { throw new Error('not needed'); }, syncAccess: async () => { throw new Error('not needed'); }, whois: async (ip) => (ip === DEVICE_IP ? { personId: 'p-alice', deviceId: 'dev-1' } : (ip === '10.53.0.9' ? { personId: 'p-bob', deviceId: 'dev-2' } : { personId: null, deviceId: null })), configureDns: async () => { throw new Error('not needed'); }, }; registry.registerProvider('stub-live', () => stub); registry.setActiveProvider('stub-live'); const { app } = await createApp(); server = app.listen(0, '127.0.0.1'); await new Promise((r) => server.on('listening', r)); const port = server.address().port; const FA = '/api/v1/network/forward-auth/'; // ALLOWED let res = await req(port, 'GET', FA, { 'X-Forwarded-For': DEVICE_IP, 'X-Forwarded-Host': 'plex.internal' }); console.log('### ALLOWED non-sensitive (alice → plex.internal)'); console.log(`< HTTP ${res.status}`); if (res.headers['x-forwarded-user']) console.log(`< x-forwarded-user: ${res.headers['x-forwarded-user']}`); console.log(`< body: ${res.body.slice(0, 240)}`); expect('allowed.status=200', res.status, (s) => s === 200); expect('allowed.personId=p-alice', res.body, (b) => b.includes('"p-alice"')); // DENIED res = await req(port, 'GET', FA, { 'X-Forwarded-For': '10.53.0.9', 'X-Forwarded-Host': 'plex.internal' }); console.log('### DENIED (bob → plex.internal, no grant)'); console.log(`< HTTP ${res.status}`); console.log(`< body: ${res.body.slice(0, 240)}`); expect('denied.status=403', res.status, (s) => s === 403); expect('denied.error=forbidden', res.body, (b) => b.includes('"forbidden"')); // UNKNOWN-DEVICE res = await req(port, 'GET', FA, { 'X-Forwarded-For': '10.99.99.99', 'X-Forwarded-Host': 'plex.internal' }); console.log('### UNKNOWN-DEVICE (unrecognized ip)'); console.log(`< HTTP ${res.status}`); if (res.headers['www-authenticate']) console.log(`< www-authenticate: ${res.headers['www-authenticate']}`); console.log(`< body: ${res.body.slice(0, 240)}`); expect('unknown.status=401', res.status, (s) => s === 401); expect('unknown.www-authenticate', res.headers['www-authenticate'] || '', (h) => h.includes('ForwardAuth')); expect('unknown.error', res.body, (b) => b.includes('unknown-device')); // STEP-UP res = await req(port, 'GET', FA, { 'X-Forwarded-For': DEVICE_IP, 'X-Forwarded-Host': 'vault.internal' }); console.log('### SENSITIVE step-up REQUIRED (alice → vault.internal, no session)'); console.log(`< HTTP ${res.status}`); console.log(`< body: ${res.body.slice(0, 240)}`); expect('stepup.status=401', res.status, (s) => s === 401); expect('stepup.error', res.body, (b) => b.includes('step-up-required')); console.log(`# asserts: ${FAILURES === 0 ? 'ALL PASS' : FAILURES + ' FAILURES'}`); } finally { if (server) await new Promise((r) => server.close(r)); fs.rmSync(TMP, { recursive: true, force: true }); console.log(`# cleanup: removed ${TMP}`); } // background timers (caddy-upstream-watcher etc.) would hold the loop // open — exit explicitly AFTER cleanup, with the assert verdict. process.exit(FAILURES === 0 ? 0 : 1); } main().catch(async (e) => { console.error('FATAL', e && e.stack || e); process.exit(1); });