171 lines
12 KiB
JavaScript
171 lines
12 KiB
JavaScript
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:[email protected]/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);
|
|
});
|