import { ownerAccounts } from './helpers/owner-accounts.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { request } from 'node:http'; import childProcess from 'node:child_process'; import https from 'node:https'; import { syncBuiltinESMExports } from 'node:module'; import { Credentials } from '../../bus/src/credentials.mjs'; import { Broker } from '../../bus/src/broker.mjs'; import { Client } from '../../bus/src/client.mjs'; import { realRemoteFixture } from './helpers/w15-project-remote-real.mjs'; const url = 'https://git.example/owner/repo.git'; function call(f, method, body, headers = {}) { return new Promise((resolve, reject) => { const data = body === undefined ? undefined : JSON.stringify(body); const req = request(f.base + '/api/project-remotes/stack', { method, headers: { ...(data ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => { let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)])); }); req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data); }); } const get = f => call(f, 'GET'); const post = (f, body, headers) => call(f, 'POST', body, headers); test('owner saves over HTTP and the broker socket, then reads after runtime restart', async t => { const f = await realRemoteFixture(t), [status, before] = await get(f); assert.equal(status, 200); assert.equal(before.remote, null); // Fail the save if it attempts a forge call, process/Git command, credential mutation, // or agent launch. Install only after fixture startup and restore before shutdown. const effects = { process: 0, forge: 0, credential: 0, launch: 0 }; const originals = []; const fence = (object, key, kind) => { originals.push([object, key, object[key]]); object[key] = () => { effects[kind]++; throw Error('unexpected ' + kind); }; }; for (const key of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) fence(childProcess, key, 'process'); for (const key of ['request', 'get']) fence(https, key, 'forge'); for (const key of ['issue', 'revoke', 'remove', 'unlinkStore', 'writeModel', 'register', 'unregister', 'registerModel']) fence(Credentials.prototype, key, 'credential'); fence(Broker.prototype, 'bindLaunch', 'launch'); fence(globalThis, 'fetch', 'forge'); syncBuiltinESMExports(); let savedStatus, saved; try { [savedStatus, saved] = await post(f, { revision: before.revision, remote: url }); } finally { for (const [object, key, original] of originals) object[key] = original; syncBuiltinESMExports(); } assert.deepEqual(effects, { process: 0, forge: 0, credential: 0, launch: 0 }); assert.equal(savedStatus, 200, JSON.stringify(saved)); assert.equal(saved.remote, url); assert.equal(JSON.parse(readFileSync(f.file)).projects.stack.remote, url); await f.restart(); const [reloadStatus, reloaded] = await get(f); assert.equal(reloadStatus, 200); assert.deepEqual(reloaded, saved); const [existingStatus, existing] = await post(f, { revision: reloaded.revision, remote: 'https://git.example/other/repo' }); assert.equal(existingStatus, 409); assert.equal(existing.error, 'project-remote-configured'); }); test('sign-out, stale, credentials, extra fields and cross-origin do not write', async t => { const f = await realRemoteFixture(t), [, before] = await get(f), original = readFileSync(f.file); const body = { revision: before.revision, remote: url }; for (const [b, headers, expected] of [ [{ ...body, revision: 'a'.repeat(64) }, {}, 'revision-conflict'], [{ ...body, remote: 'https://user:secret@git.example/owner/repo' }, {}, 'project-remote-invalid'], [{ revision: body.revision }, {}, 'invalid-request'], [{ ...body, publish: true }, {}, 'invalid-request'], [body, { origin: 'https://other.example' }, 'cross-origin request refused'], ]) { const [status, result] = await post(f, b, headers); assert.ok(status >= 400); assert.equal(result.error, expected); assert.ok(!JSON.stringify(result).includes('secret')); assert.deepEqual(readFileSync(f.file), original); } f.signOut(); const [status, result] = await post(f, body); assert.equal(status, 401); assert.equal(result.error, 'sign-in-required'); assert.deepEqual(readFileSync(f.file), original); }); test('real SSO refuses viewer and collaborator and sign-out prevents the saved write', async t => { const { fakeOidc } = await import('./helpers/fake-oidc.mjs'); const { createSso, SESSION_COOKIE } = await import('../src/sso.mjs'); const { consoleAccess } = await import('../src/access.mjs'); const { startServer } = await import('../src/serve.mjs'); const { close } = await import('./fixture.mjs'); const op = await fakeOidc(); t.after(() => op.close()); const issuer = op.issuer('a'), origin = 'https://console.example.test'; const access = { accessVersion: 1, revision: 1, users: [ { id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] }, { id: 'bob', home: 'local', subjects: [{ provider: 'a', sub: 'bob-sub' }] }, ], grants: [ { id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'viewer' }, { id: 'g-2', user: 'bob', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }, ] }; const config = { publicOrigin: origin, providers: [{ id: 'a', issuer: issuer.issuer, clientId: issuer.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] }; const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', issuer.clientSecret]]), fetch: op.fetch, users: consoleAccess({ load: () => access, business: () => 'demo' }), log: () => {} }); t.after(() => sso.close()); const f = await realRemoteFixture(t), original = readFileSync(f.file); let writes = 0; const transport = (verb, args, ctx) => new Client({ path: f.path, console: { subject: ctx.subject } }).call(verb, args); const read = transport; const write = (verb, args, ctx) => { writes++; return transport(verb, args, ctx); }; const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), requestRead: read, requestWrite: write, log: () => {} }); t.after(() => close(web)); const send = (method, path, cookie, body, extraHeaders = {}) => new Promise((resolve, reject) => { const data = body === undefined ? undefined : JSON.stringify(body); const req = request({ host: '127.0.0.1', port: web.address().port, method, path, headers: { host: 'console.example.test', ...(cookie ? { cookie } : {}), ...(data ? { origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...extraHeaders } }, res => { let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text })); }); req.on('error', reject); req.setTimeout(10000, () => req.destroy(Error('request timed out'))); req.end(data); }); const signIn = async sub => { const start = await send('GET', '/auth/login'); const cookie = start.headers['set-cookie'][0].split(';')[0]; const grant = op.grant(issuer, start.headers.location, { sub }); const q = new URLSearchParams({ code: grant.code, state: new URL(start.headers.location).searchParams.get('state') }); const result = await send('GET', '/auth/callback?' + q, cookie); return result.headers['set-cookie'].find(c => c.startsWith(SESSION_COOKIE + '=') && !c.includes('Max-Age=0')).split(';')[0]; }; const body = { revision: 'a'.repeat(64), remote: url }; for (const sub of ['alice-sub', 'bob-sub']) { const cookie = await signIn(sub); for (const method of ['GET', 'POST']) { const response = await send(method, '/api/project-remotes/stack', cookie, method === 'POST' ? body : undefined); assert.equal(response.status, 403); assert.equal(response.body.error, 'owner-only'); } } // A CLI human or agent cannot turn an unauthenticated HTTP request into an owner session. for (const headers of [{ 'x-user': 'jason' }, { 'x-agent': 'pm' }]) for (const method of ['GET', 'POST']) { const response = await send(method, '/api/project-remotes/stack', null, method === 'POST' ? body : undefined, headers); assert.equal(response.status, 401); assert.equal(response.body.error, 'sign-in-required'); } const owner = await signIn('owner-sub'); const readOwner = await send('GET', '/api/project-remotes/stack', owner); assert.equal(readOwner.status, 200); assert.equal(readOwner.body.remote, null); assert.equal((await send('POST', '/auth/logout', owner, {})).status, 303); const after = await send('POST', '/api/project-remotes/stack', owner, { ...body, revision: readOwner.body.revision }); assert.equal(after.status, 401); assert.equal(after.body.error, 'sign-in-required'); assert.equal(writes, 0); assert.deepEqual(readFileSync(f.file), original); }); test('tester listener refuses both remote methods', async t => { const { mkdtempSync, rmSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const { startServer } = await import('../src/serve.mjs'); const { close } = await import('./fixture.mjs'); const root = mkdtempSync(join(tmpdir(), 'remote-tester-')), socket = join(root, 'tester.sock'); const server = await startServer({ tester: true, testerSocket: socket, board: 'http://127.0.0.1:9', log: () => {} }); t.after(async () => { await close(server); rmSync(root, { recursive: true, force: true }); }); for (const method of ['GET', 'POST']) { const result = await new Promise((resolve, reject) => { const req = request({ socketPath: socket, path: '/api/project-remotes/stack', method, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); }); req.on('error', reject); req.end(); }); assert.equal(result, 403); } for (const path of ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css']) { const result = await new Promise((resolve, reject) => { const req = request({ socketPath: socket, path, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); }); req.on('error', reject); req.end(); }); assert.equal(result, 403, path); } }); test('library targets and unrelated project mapping drift are refused', async t => { const { mappingEntry } = await import('../../library/tests/helpers.mjs'); const { writeFileSync } = await import('node:fs'); const f = await realRemoteFixture(t, doc => { doc.projects.books = mappingEntry(); doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } }; }); const library = await new Promise((resolve, reject) => { const req = request(f.base + '/api/project-remotes/books', res => { let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)])); }); req.on('error', reject); req.end(); }); assert.deepEqual(library, [403, { error: 'project-remote-library' }]); const [, before] = await get(f), document = JSON.parse(readFileSync(f.file)); document.projects.stack.root += '-other'; writeFileSync(f.file, JSON.stringify(document)); const bytes = readFileSync(f.file), [status, result] = await post(f, { revision: before.revision, remote: url }); assert.equal(status, 409); assert.equal(result.error, 'project-remote-protected'); assert.deepEqual(readFileSync(f.file), bytes); });