72 lines
7.0 KiB
JavaScript
72 lines
7.0 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } 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; }
|
|
});
|