// P4: real HTTP boundary; mutant: proxy route moved ahead of granted-user fence. import test from 'node:test'; import assert from 'node:assert/strict'; import { request } from 'node:http'; import { startServer } from '../src/serve.mjs'; import { consoleBus } from '../src/console-bus.mjs'; import { consoleAccess } from '../src/access.mjs'; import { createSsoSession } from './helpers/sso-session.mjs'; const routes = [ ['accounts', 'GET', 'proxy.accounts', {}], ['keys', 'GET', 'proxy.keys', {}], ['plugins', 'GET', 'plugins', {}], ['plugins/set', 'POST', 'plugins.set', { revision: 'a'.repeat(64), plugins: { 'model-provider': ['cliproxy'] } }], ['login/start', 'POST', 'proxy.login.start', { provider: 'claude' }], ['login/submit', 'POST', 'proxy.login.submit', { redirect_url: 'http://localhost/callback?state=fixture&code=fake' }], ['login/status', 'POST', 'proxy.login.status', { state: 'fixture' }], ['login/cancel', 'POST', 'proxy.login.cancel', { state: 'fixture' }], ['apikey/add', 'POST', 'proxy.apikey.add', { provider: 'z.ai', key: 'p4-canary-key' }], ['priority/set', 'POST', 'proxy.priority.set', { accountId: 'a'.repeat(12), priority: 5 }], ['group/models/set', 'POST', 'proxy.group.models.set', { accountId: 'a'.repeat(12), models: ['fixture-model'] }], ]; const call = (s, path, method = 'GET', value, hs = s.headers) => new Promise((resolve, reject) => { const req = request({ hostname: '127.0.0.1', port: s.address().port, path, method, headers: hs }, res => { let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text })); }); req.on('error', reject); req.end(value === undefined ? undefined : JSON.stringify(value)); }); async function setup(t, options = {}) { const calls = [], logs = []; const auth = await createSsoSession(t, { host: 'console.example', owner: { sub: 'owner-sub', name: 'owner' } }); const s = await startServer({ port: 0, sso: auth.sso, send: async () => ({}), proxyCall: async (verb, args) => { calls.push([verb, args]); return { ok: true }; }, log: l => logs.push(l), ...options }); t.after(() => new Promise(r => s.close(r))); const session = await auth.signIn(s.address().port); s.headers = auth.writeHeaders(session); return { s, calls, logs }; } test('owner proxy entry paths redirect GET and HEAD to the root hash route', async t => { const { s } = await setup(t); for (const path of ['/proxy', '/settings/model-proxy']) for (const method of ['GET', 'HEAD']) { const r = await call(s, path, method); assert.equal(r.status, 303, `${method} ${path}`); assert.equal(r.headers.location, '/#/settings/model-proxy', `${method} ${path}`); assert.equal(r.headers['cache-control'], 'no-store'); } }); test('owner proxy routes each make exactly one allowlisted call', async t => { const { s, calls, logs } = await setup(t); for (const [path, method, verb, args] of routes) { const r = await call(s, '/api/proxy/' + path, method, method === 'POST' ? args : undefined); assert.equal(r.status, 200, path); assert.deepEqual(calls.at(-1), [verb, args]); } assert.equal(calls.length, routes.length); for (const value of ['p4-canary-key', 'fixture', 'redirect_url']) assert.equal(logs.join('\n').includes(value), false); }); for (const [name, change, code] of [ ['signed-out', hs => { delete hs.cookie; }, 401], ['loopback', hs => { hs.host = '127.0.0.1'; }, 403], ['cross-origin', hs => { hs.origin = 'https://other.example'; }, 403], ['missing Fetch Metadata', hs => { delete hs['sec-fetch-site']; }, 403], ]) test(`proxy write refuses ${name}`, async t => { const { s, calls } = await setup(t); const hs = { ...s.headers }; change(hs); assert.equal((await call(s, '/api/proxy/apikey/add', 'POST', { provider: 'z.ai', key: 'fixture-key' }, hs)).status, code); assert.deepEqual(calls, []); }); test('all proxy writes are read-only without --send', async t => { const { s, calls } = await setup(t, { send: null }); for (const [path, method, , args] of routes.filter(r => r[1] === 'POST')) assert.equal((await call(s, '/api/proxy/' + path, method, args)).status, 403, path); assert.deepEqual(calls, []); }); test('granted user never reaches owner page or any proxy route', async t => { const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }], grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] }; const users = consoleAccess({ load: () => access, business: () => 'demo' }); const auth = await createSsoSession(t, { host: 'console.example', users }); const calls = []; const s = await startServer({ port: 0, sso: auth.sso, send: async () => ({}), proxyCall: async (...args) => { calls.push(args); return {}; }, userOptions: () => { throw Error('must not bind'); } }); t.after(() => new Promise(resolve => s.close(resolve))); const session = await auth.signIn(s.address().port, { sub: 'alice-sub' }); s.headers = auth.writeHeaders(session); for (const path of ['/proxy', '/settings/model-proxy', '/proxy.js']) for (const method of ['GET', 'HEAD']) assert.equal((await call(s, path, method)).status, 403, `${method} ${path}`); for (const [path, method, , args] of routes) assert.equal((await call(s, '/api/proxy/' + path, method, method === 'POST' ? args : undefined)).status, 403, path); assert.deepEqual(calls, []); }); test('native page offers write-only key input and no panel script', async t => { const { s } = await setup(t); const old = await call(s, '/proxy'); assert.equal(old.status, 303); assert.equal(old.headers.location, '/#/settings/model-proxy'); const r = await call(s, '/'); assert.equal(r.status, 200); const script = await call(s, '/display.js'); assert.equal(script.status, 200); assert.match(script.text, /type="password"/); assert.match(script.text, /autocomplete="off"/); assert.doesNotMatch(r.text, /management\.html|iframe/); }); test('Console caller rejects every non-proxy verb', async () => { const calls = []; const o = consoleBus(async (...a) => { calls.push(a); return {}; }, { send: true }); assert.equal(typeof o.proxyCall, 'function'); await assert.rejects(o.proxyCall('message.send', {})); assert.deepEqual(calls, []); }); test('tester socket refuses proxy page, reads and writes before any owner call', async t => { const { mkdtempSync, rmSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const { startTesterSocket, testerSocketPath, TESTER_ORIGIN } = await import('../src/tester-socket.mjs'); const { testerRequest } = await import('../../cli/src/tester-http.mjs'); const root = mkdtempSync(join(tmpdir(), 'p4-tester-')); t.after(() => rmSync(root, { recursive: true, force: true })); let calls = 0; const s = await startTesterSocket({ dataRoot: root, proxyCall: async () => { calls++; return {}; }, log: () => {} }); t.after(() => new Promise(r => s.close(r))); for (const path of ['/proxy', '/settings/model-proxy', '/proxy.js']) { const page = await testerRequest({ dataRoot: root, path }); assert.equal(page.status, 403, `GET ${path}`); const head = await new Promise((resolve, reject) => { const req = request({ socketPath: testerSocketPath(root), path, method: 'HEAD', headers: { host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'sec-fetch-site': 'same-origin' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); }); req.on('error', reject); req.end(); }); assert.equal(head, 403, `HEAD ${path}`); } for (const [path, method, , args] of routes) { const r = await testerRequest({ dataRoot: root, path: '/api/proxy/' + path, method, body: method === 'POST' ? args : undefined }); assert.equal(r.status, 403, path); } assert.equal(calls, 0); }); test('malformed API-key JSON never appears in errors or logs', async t => { const { s, calls, logs } = await setup(t); const canary = 'p4-canary-key'; const r = await new Promise((resolve, reject) => { const req = request({ hostname: '127.0.0.1', port: s.address().port, path: '/api/proxy/apikey/add', method: 'POST', headers: s.headers }, res => { let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text })); }); req.on('error', reject); req.end('{"key":"' + canary); }); assert.equal(r.status, 400); assert.equal(r.text.includes(canary), false); assert.equal(logs.join('\n').includes(canary), false); assert.deepEqual(calls, []); }); test('proxy pages require sign-in and named host before redirect or content', async t => { const { s } = await setup(t); for (const path of ['/proxy', '/settings/model-proxy', '/proxy.js']) { const signedOut = { ...s.headers }; delete signedOut.cookie; const response = await call(s, path, 'GET', undefined, signedOut); assert.equal(response.status, 303, path); assert.match(response.headers.location, /^\/auth\/login\?/); const loopback = { ...s.headers, host: '127.0.0.1' }; for (const method of ['GET', 'HEAD']) assert.equal((await call(s, path, method, undefined, loopback)).status, 403, `${method} ${path}`); } }); test('proxy refuses route and body mismatch, unavailable transport, and bus refusal', async t => { const { s, calls } = await setup(t); assert.equal((await call(s, '/api/proxy/unknown')).status, 404); assert.equal((await call(s, '/api/proxy/accounts', 'POST', {})).status, 405); assert.equal((await call(s, '/api/proxy/apikey/add', 'POST', { provider: 'z.ai', key: 'fixture', extra: true })).status, 400); assert.deepEqual(calls, []); const absent = await setup(t, { proxyCall: null }); assert.equal((await call(absent.s, '/api/proxy/accounts')).status, 503); const refused = await setup(t, { proxyCall: async () => { throw Object.assign(new Error('private'), { code: 'not-configured' }); } }); for (const [path, method, , args] of routes) { const r = await call(refused.s, '/api/proxy/' + path, method, method === 'POST' ? args : undefined); assert.equal(r.status, 503, path); assert.deepEqual(JSON.parse(r.text), { error: 'not-configured' }); } }); test('ambiguous model mappings pass their named refusal to the owner', async t => { const {s}=await setup(t,{proxyCall:async()=>{throw Object.assign(new Error('private'),{code:'models-ambiguous'});}}); const response=await call(s,'/api/proxy/group/models/set','POST',{accountId:'a'.repeat(12),models:['fixture']}); assert.equal(response.status,400);assert.deepEqual(JSON.parse(response.text),{error:'models-ambiguous'}); }); test('plugin switch accepts only one allowed kind with string names', async t => { const {s,calls}=await setup(t); const revision='a'.repeat(64); for (const plugins of [{harness:['pi','claude-code']},{'model-provider':['cliproxy']}]) { assert.equal((await call(s,'/api/proxy/plugins/set','POST',{revision,plugins})).status,200); assert.deepEqual(calls.at(-1),['plugins.set',{revision,plugins}]); } const count=calls.length; for (const plugins of [{harness:[1]},{harness:'pi'},{harness:['pi'], 'model-provider':[]},{channel:[]},{}]) assert.equal((await call(s,'/api/proxy/plugins/set','POST',{revision,plugins})).status,400); assert.equal(calls.length,count); }); test('plugin refusal code is returned to the owner', async t => { const {s}=await setup(t,{proxyCall:async()=>{throw Object.assign(new Error('private'),{code:'revision-conflict'});}}); const response=await call(s,'/api/proxy/plugins/set','POST',{revision:'a'.repeat(64),plugins:{harness:['pi']}}); assert.equal(response.status,400);assert.deepEqual(JSON.parse(response.text),{error:'revision-conflict'}); });