PR #1916 rule: a refusal names the field or file, never a config value or a host path. readJsonFile's three refusals and parseJsonFileText now name the file by its `what` label only. Callers whose label alone is ambiguous add a name that already passed a strict pattern (business id, role name, org id, turn id, project key) or a fixed path under the data root. Exit codes, refusal codes and check order are unchanged. organization: util, access, project, config-command, library-seats, org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and organization-config.mjs change only the readJsonFile label. cli: access lock, accounts owner, host state/lock, notifier journal, notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console. discord: turn record label names the turn id. Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each changed refusal and fail on BASE; old path pins updated.
117 lines
12 KiB
JavaScript
117 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, symlinkSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { accountsCommand } from '../src/accounts.mjs';
|
|
import { main } from '../src/cli.mjs';
|
|
import { withRegistry, reserveOrg } from '../../organization/src/org-registry.mjs';
|
|
const providers = [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }, { sub: 'other-owner' }] }, { id: 'second', issuer: 'https://second.example', subjects: [{ sub: 'owner' }] }];
|
|
function setup(t) {
|
|
const configDir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(configDir, 0o700); t.after(() => rmSync(configDir, { recursive: true, force: true }));
|
|
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } }; let output = '';
|
|
const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
|
|
return { configDir, deps, io, run: args => accountsCommand(args, io, deps), read: () => JSON.parse(readFileSync(join(configDir, 'accounts.json'))), output: () => output };
|
|
}
|
|
test('init makes exactly one operator from all configured subjects, then refuses rerun', async t => {
|
|
const s = setup(t); await s.run(['init']); const d = s.read(); assert.equal(d.users.length, 1); assert.equal(d.users[0].instanceRole, 'operator'); assert.equal(d.users[0].subjects.length, 3); assert.match(d.users[0].id, /^u-[0-9a-f]{12}$/); assert.equal(d.revision, 1);
|
|
await assert.rejects(s.run(['init']), /already exists/); await s.run(['list', '--json']); assert.match(s.output(), /authGen/);
|
|
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'init'); assert.ok(!JSON.stringify(r).includes('other-owner'));
|
|
});
|
|
test('CLI add, disable, enable, signout and remove; last operator guard and stale revisions', async t => {
|
|
const s = setup(t); await s.run(['init']); const op = s.read().users[0].id;
|
|
await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat:sub', '--revision', '1']); const id = s.read().users[1].id;
|
|
for (const [verb, revision, generation] of [['disable', 2, 2], ['enable', 3, 3], ['signout', 4, 4]]) { await s.run(['user', verb, id, '--revision', String(revision)]); assert.equal(s.read().users[1].authGen, generation); }
|
|
await assert.rejects(s.run(['user', 'disable', op, '--revision', '5']), /active operator/);
|
|
await assert.rejects(s.run(['user', 'signout', id, '--revision', '1']), /revision-conflict/);
|
|
await s.run(['user', 'remove', id, '--revision', '5']); assert.equal(s.read().users.length, 1); assert.equal(s.read().revision, 6);
|
|
assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').length, 6);
|
|
});
|
|
test('host uid refusal happens before any lock, even on list or remove', async t => {
|
|
const s = setup(t); for (const args of [['list'], ['init'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
|
|
await assert.rejects(accountsCommand(args, s.io, { ...s.deps, uid: process.getuid() + 1 }), /uid/);
|
|
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); assert.equal(existsSync(join(s.configDir, 'org-runtime.lock')), false);
|
|
}
|
|
});
|
|
test('remove holds registry before accounts, refuses ownership of active or archived orgs, including a preceding create', async t => {
|
|
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
|
const root = join(s.configDir, 'acme'); mkdirSync(root, { mode: 0o700 });
|
|
let release, entered; const ready = new Promise(r => entered = r), hold = new Promise(r => release = r);
|
|
const create = withRegistry(s.deps, async registry => { entered(); await hold; registry.orgs.acme = { id: 'acme', root, state: 'archived', revision: 1, created: '2026-11-10T00:00:00Z' }; writeFileSync(join(root, 'owner.json'), JSON.stringify({ provider: 'pocket', iss: 'https://id.example', sub: 'pat' }), { mode: 0o600 }); });
|
|
await ready; const removing = s.run(['user', 'remove', id, '--revision', '2']); release(); await create; await assert.rejects(removing, /user-owns-org/); assert.equal(s.read().revision, 2);
|
|
});
|
|
test('remove drops memberships atomically, and malformed ownership metadata refuses', async t => {
|
|
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const d = s.read(), id = d.users[1].id;
|
|
d.memberships.push({ org: 'acme', incarnation: 'a'.repeat(32), user: id, role: 'member', rights: ['org.view'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, by: d.users[0].id, added: '2026-11-10T00:00:00Z', via: 'invite:abc' }); writeFileSync(join(s.configDir, 'accounts.json'), JSON.stringify(d));
|
|
await s.run(['user', 'remove', id, '--revision', '2']); assert.deepEqual(s.read().memberships, []);
|
|
const root = join(s.configDir, 'broken'); mkdirSync(root, { mode: 0o700 });
|
|
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{}', { mode: 0o600 }); });
|
|
await assert.rejects(s.run(['user', 'remove', s.read().users[0].id, '--revision', '3']), /owner/);
|
|
});
|
|
test('mosaic main wires accounts without a bus; tester, agent and unexpected args refuse', async t => {
|
|
const s = setup(t); await main(['accounts', 'init'], s.io, { system: s.deps.system, accountsConfigDir: s.configDir }); assert.equal(s.read().users.length, 1);
|
|
for (const args of [['list', '--tester'], ['list', '--subject', 'pocket:x'], ['user', 'add', 'chosen-id', '--name', 'Pat', '--subject', 'pocket:x', '--revision', '1'], ['list', '--json', '--json']]) await assert.rejects(s.run(args));
|
|
await assert.rejects(accountsCommand(['list'], { ...s.io, env: { MOSAIC_RUN_ID: 'run' } }, s.deps), /human shell/);
|
|
});
|
|
|
|
|
|
test('review P2-2: real reservation wins before owner.json exists, and removal refuses', async t => {
|
|
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
|
const dataRoot = join(s.configDir, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: s.configDir, instance: 'test', dataRoot };
|
|
const org = await reserveOrg(ctx, 'acme'); assert.equal(existsSync(join(org.root, 'owner.json')), false);
|
|
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), /org-create-in-progress/); assert.equal(s.read().users.length, 2); assert.equal(s.read().revision, 2);
|
|
});
|
|
|
|
|
|
test('host boundary and receipt use effective uid, not real uid (current design clarification)', async t => {
|
|
const s = setup(t), realUid = process.getuid, effectiveUid = process.geteuid();
|
|
process.getuid = () => effectiveUid + 1;
|
|
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
|
|
finally { process.getuid = realUid; }
|
|
});
|
|
|
|
// The uid identifies this process, not the human who invoked it. The instance
|
|
// uid can rewrite its receipt; this is neither human attribution nor tamper evidence.
|
|
test('M3 CLI integration refuses foreign effective uid before any account mutation, and receipts identify the process', async t => {
|
|
const { spawnSync } = await import('node:child_process'); const { fixture } = await import('./helpers.mjs');
|
|
const root=mkdtempSync(join(tmpdir(),'ai-')); t.after(()=>rmSync(root,{recursive:true,force:true})); const f=fixture(root);
|
|
const config=JSON.parse(readFileSync(f.env.MOSAIC_CONFIG)); config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'pocket',issuer:'https://id.test',clientId:'test',clientSecret:{slot:'pocket'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
|
|
writeFileSync(f.env.MOSAIC_CONFIG,JSON.stringify(config));
|
|
const prelude=join(root,'uid.mjs'); const cli=new URL('../src/cli.mjs',import.meta.url).pathname;
|
|
const run=args=>spawnSync(process.execPath,['--import',prelude,cli,'accounts',...args],{env:{...process.env,...f.env},encoding:'utf8',timeout:10000});
|
|
writeFileSync(prelude,`process.geteuid=()=>${process.geteuid()+1};`); const refused=run(['init']); assert.equal(refused.status,3,refused.stderr); assert.match(refused.stderr,/accounts-unsafe: (a config directory ancestor|config directory) must belong to (root or )?uid \d+/);
|
|
for(const name of ['accounts.json','accounts.lock','accounts-receipts.jsonl']) assert.equal(existsSync(join(root,'config',name)),false);
|
|
// Different real uid, same effective uid: the effective identity is recorded.
|
|
writeFileSync(prelude,`process.getuid=()=>${process.geteuid()+2};`); const ok=run(['init']); assert.equal(ok.status,0,ok.stderr);
|
|
const receipt=JSON.parse(readFileSync(join(root,'config/accounts-receipts.jsonl'),'utf8')); assert.equal(receipt.uid,process.geteuid());
|
|
});
|
|
|
|
// org-acct-anchor (#1873): `mosaic accounts init` refuses under an ancestor another user could
|
|
// rename, before any lock or write, and the refusal names the rule, never the path.
|
|
test('org-acct-anchor: init, list and remove refuse a 0777 ancestor before any lock; 1777 passes', async t => {
|
|
const top = mkdtempSync(join(tmpdir(), 'a-')); t.after(() => { chmodSync(top, 0o700); rmSync(top, { recursive: true, force: true }); });
|
|
const parent = join(top, 'sk-test-NOT-A-SECRET-1234'), configDir = join(parent, 'cfg'); mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
let output = ''; const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
|
|
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } };
|
|
chmodSync(parent, 0o777);
|
|
for (const args of [['init'], ['list'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
|
|
await assert.rejects(accountsCommand(args, io, deps), e => e.exitCode === 3 && /^refused: accounts-unsafe: a config directory ancestor must belong to root or uid \d+ and not be writable by others without the sticky bit$/.test(e.message) && !e.message.includes('sk-test') && !e.message.includes('/'));
|
|
for (const name of ['accounts.json', 'accounts.lock', 'accounts-receipts.jsonl', 'org-runtime.lock']) assert.equal(existsSync(join(configDir, name)), false, name);
|
|
}
|
|
chmodSync(parent, 0o1777);
|
|
await accountsCommand(['init'], io, deps); assert.match(output, /accounts init: u-[0-9a-f]{12}, revision 1/);
|
|
});
|
|
// PR #1916 (org-redact5): an unreadable owner file is named by the registry's checked
|
|
// id, never by the organization root's host path.
|
|
test('remove: an invalid or linked owner file is named by the org id, never by host path', async t => {
|
|
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
|
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
|
const root = join(s.configDir, SENTINEL, 'broken'); mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{not json', { mode: 0o600 }); });
|
|
const clean = (pattern) => e => { assert.equal(e.exitCode, 3); assert.match(e.message, pattern); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(root), e.message); return true; };
|
|
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken is not valid JSON$/));
|
|
rmSync(join(root, 'owner.json')); writeFileSync(join(root, 'real.json'), '{}', { mode: 0o600 }); symlinkSync(join(root, 'real.json'), join(root, 'owner.json'));
|
|
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken must be a regular, non-symbolic-link file$/));
|
|
assert.equal(s.read().users.length, 2, 'nothing was removed');
|
|
});
|