import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { checkAccounts, loadAccounts, accountsRepairs, assertAccountsOwner, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs'; import { templateRender } from '../src/org-service.mjs'; import { loadBusiness } from '../src/organization-config.mjs'; import { REPO_ROLES } from './helpers.mjs'; import { withRegistry, readRegistryLocked } from '../src/org-registry.mjs'; export const OP = 'u-000000000001', USER = 'u-000000000002'; export const context = { providers: [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }] }] }; export const user = (id = OP, sub = 'owner', instanceRole = 'operator') => ({ id, subjects: [{ provider: 'pocket', sub }], name: 'Person', instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' }); export const member = () => ({ org: 'acme', incarnation: 'a'.repeat(32), user: USER, role: 'member', rights: ['board.read', 'org.view', 'thread.read'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, added: '2026-11-10T00:00:00Z', by: OP, via: 'invite:abc' }); export const document = () => ({ accountsVersion: 1, revision: 1, users: [user(), user(USER, 'member', 'user')], memberships: [], invites: [] }); function setup(t, doc = document()) { const dir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true })); const file = join(dir, 'accounts.json'); writeFileSync(file, JSON.stringify(doc), { mode: 0o600 }); return { configDir: dir, instance: 'test', ...context, file }; } const write = (ctx, revision, fn, rest = {}) => withAccounts(ctx, { revision, verb: 'user.signout', ...rest }, fn); test('loader refuses every malformed document as a whole, including nested unknown keys', t => { const cases = [d => d.accountsVersion = 2, d => d.revision = 0, d => d.cookie = 'secret', d => d.users[0].id = 'human', d => d.users[0].authGen = 0, d => d.users[0].authGen = 1.5, d => d.users[0].instanceRole = 'owner', d => d.users[0].state = 'disabled', d => d.users[0].subjects[0].provider = 'local-dev', d => d.users[0].subjects[0].provider = 'missing', d => d.users[1].subjects = d.users[0].subjects, d => d.users[0].subjects[0].iss = 'https://evil', d => d.users[0].created = '2026-02-30T00:00:00Z', d => d.memberships.push({}), d => { const m = member(); m.rights.push('org.setup'); d.memberships.push(m); }, d => { const m = member(); m.rights.reverse(); d.memberships.push(m); }, d => { const m = member(); m.scope.instances = ['*', 'coder']; d.memberships.push(m); }, d => { const m = member(); m.user = 'u-000000000003'; d.memberships.push(m); }]; for (const mutate of cases) { const d = document(); mutate(d); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); } assert.throws(() => checkAccounts(document(), { providers: [{ id: 'pocket', issuer: 'local-dev' }] })); const s = setup(t); assert.throws(() => loadAccounts(s.file, s, { uid: process.getuid() + 1 }), /uid/); for (const mode of [0o620, 0o602]) { chmodSync(s.file, mode); assert.throws(() => loadAccounts(s.file, s), /writable/); } chmodSync(s.file, 0o600); const target = join(s.configDir, 'real'); writeFileSync(target, JSON.stringify(document()), { mode: 0o600 }); unlinkSync(s.file); symlinkSync(target, s.file); assert.throws(() => loadAccounts(s.file, s), /symbolic/); }); test('unknown role and right load as repair rows; account may be a business human', () => { const d = document(); const m = member(); m.role = 'future'; m.rights.push('zz.future'); d.memberships.push(m); const loaded = checkAccounts(d, { ...context, businesses: { acme: { human: OP } } }); assert.equal(loaded.users[0].id, OP); assert.ok(Object.isFrozen(loaded.users[0])); assert.deepEqual(accountsRepairs(loaded), [{ kind: 'membership', org: 'acme', user: USER, unknownRole: 'future', unknownRights: ['zz.future'] }]); }); test('writes refuse stale revision, unknown role/right and reserved rights without a receipt', async t => { const s = setup(t); const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 9, () => {}), /revision-conflict/); for (const change of [m => m.role = 'future', m => m.rights.push('zz.future'), m => m.rights.push('org.setup')]) { await assert.rejects(write(s, 1, d => { const m = member(); change(m); d.memberships.push(m); })); } assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(existsSync(join(s.configDir, 'accounts-receipts.jsonl')), false); }); test('rights/scope revisions and security generations are maintained by the writer', async t => { const d = document(); d.memberships.push(member()); const s = setup(t, d); let rev = 1; for (const change of [u => u.state = 'disabled', u => u.state = 'active', u => u.instanceRole = 'operator', u => u.subjects[0].sub = 'new-sub']) { const result = await write(s, rev++, next => change(next.users[1])); assert.equal(result.users[1].authGen, rev); } let result = await write(s, rev++, next => next.users[1].authGen++); assert.equal(result.users[1].authGen, rev); result = await write(s, rev++, next => next.memberships[0].rights.push('task.write')); assert.equal(result.memberships[0].rev, 2); result = await write(s, rev++, next => next.memberships[0].scope.instances = ['coder']); assert.equal(result.memberships[0].rev, 3); assert.equal(result.users[1].authGen, 6); const lines = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse); assert.equal(lines.length, 7); for (const r of lines) { assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'user.signout'); } assert.equal(statSync(s.file).mode & 0o777, 0o600); }); test('concurrent writes serialize and the later stale write changes nothing', async t => { const s = setup(t); let release, entered; const ready = new Promise(r => entered = r); const wait = new Promise(r => release = r); const first = write(s, 1, async d => { entered(); await wait; d.users[1].authGen++; }); await ready; const second = write(s, 1, d => { d.users[1].authGen++; }); release(); const results = await Promise.allSettled([first, second]); assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); assert.match(results[1].reason.message, /revision-conflict/); assert.equal(loadAccounts(s.file, s).revision, 2); }); test('locked helpers require live handles; registry dependency is rechecked before replace', async t => { const s = setup(t); let held; await assert.rejects(readRegistryLocked({}), /lock/); await assert.rejects(withAccountsLocked({}, { revision: 1, verb: 'user.signout' }, () => {}), /lock/); await withRegistry(s, async registry => { assert.equal(await readRegistryLocked(registry), registry); await withAccountsLock(s, async handle => { held = handle; await withAccountsLocked(handle, { revision: 1, verb: 'user.signout', registry }, d => d.users[1].authGen++); }); }); await assert.rejects(withAccountsLocked(held, { revision: 2, verb: 'user.signout' }, () => {}), /lock/); const before = readFileSync(s.file, 'utf8'); await withRegistry(s, async registry => { await assert.rejects(write(s, 2, d => { d.users[1].authGen++; writeFileSync(join(s.configDir, 'org-runtime.json'), JSON.stringify({ version: 1, instance: 'test', orgs: { changed: { id: 'changed', root: '/elsewhere', state: 'active', revision: 1, created: 'now' } } }), { mode: 0o600 }); }, { registry }), /registry-changed/); }, { write: false }); assert.equal(readFileSync(s.file, 'utf8'), before); }); test('failure before replace leaves old bytes, no receipt line and no temporary file', async t => { const s = setup(t); const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { throw new Error('injected failure'); } }), /injected failure/); assert.equal(readFileSync(s.file, 'utf8'), before); assert.deepEqual(readdirSync(s.configDir).filter(f => f.startsWith('.accounts-')), []); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); }); test('receipts never serialize subject secrets, tokens, cookies, names or arbitrary actor text', async t => { const s = setup(t); const secret = 'subject-secret'; await write(s, 1, d => { d.users[1].subjects[0].sub = secret; d.users[1].name = 'cookie-secret'; }, { actor: OP }); const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(secret)); assert.ok(!raw.includes('cookie-secret')); await assert.rejects(write(s, 2, () => {}, { verb: 'token-secret' })); await assert.rejects(write(s, 2, () => {}, { actor: 'cookie-secret' })); }); test('malformed membership rows refuse all of the file; reserved rights never load', () => { const mutations = [m => m.role = 'owner', m => m.rev = 0, m => m.incarnation = 'old', m => m.rights = ['thread.read'], m => m.rights = ['org.view', 'org.view'], m => m.scope = { projects: [], instances: ['*'] }, m => m.scope.projects = ['not-a-uuid'], m => m.scope.expires = 'not-a-time', m => m.extra = true, m => m.by = 'human', m => m.via = '', m => m.added = 'bad']; for (const mutate of mutations) { const d = document(), m = member(); mutate(m); d.memberships.push(m); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); } for (const right of ['members.manage', 'org.setup', 'org.lifecycle', 'request.write', 'owner']) { const d = document(), m = member(); m.rights = ['org.view', right].sort(); d.memberships.push(m); assert.throws(() => checkAccounts(d, context)); } }); function invitation() { const { user: ignored, rev, added, via, ...row } = member(); return { ...row, id: '11111111-2222-3333-4444-555555555555', bind: { email: 'pat@example.com' }, tokenHash: 'b'.repeat(64), created: '2026-11-10T00:00:00Z', expires: '2026-11-13T00:00:00Z', state: 'open' }; } test('invite schema refuses raw secrets, domain/bearer bindings, malformed hashes and unknown fields', () => { const d = document(); d.invites.push(invitation()); assert.equal(checkAccounts(d, context).invites.length, 1); for (const mutate of [i => i.token = 'raw-token', i => i.cookie = 'raw-cookie', i => i.bind = { domain: 'example.com' }, i => i.bind = {}, i => i.bind = { email: '@example.com' }, i => i.bind.sub = 'extra-binding', i => i.tokenHash = 'raw-token', i => i.state = 'other', i => i.rights = ['org.setup', 'org.view'], i => i.scope.level = 'collaborator']) { const next = structuredClone(d); mutate(next.invites[0]); assert.throws(() => checkAccounts(next, context)); } d.invites[0].role = 'future'; d.invites[0].rights.push('zz.future'); assert.equal(accountsRepairs(checkAccounts(d, context))[0].kind, 'invite'); }); test('unsafe receipt destination refuses before publication and leaves its target intact', async t => { const s = setup(t), target = join(s.configDir, 'target'); writeFileSync(target, 'untouched', { mode: 0o600 }); symlinkSync(target, join(s.configDir, 'accounts-receipts.jsonl')); const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++)); assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(target, 'utf8'), 'untouched'); }); test('account lock allows sequential writes and registry handles cannot cross instance boundaries', async t => { const s = setup(t), other = setup(t); await withAccountsLock(s, async handle => { await withAccountsLocked(handle, { revision: 1, verb: 'user.signout' }, d => d.users[1].authGen++); await withAccountsLocked(handle, { revision: 2, verb: 'user.signout' }, d => d.users[1].authGen++); }); await withRegistry(other, async registry => { await assert.rejects(write(s, 3, () => {}, { registry }), /another instance/); }); assert.equal(loadAccounts(s.file, s).revision, 3); }); test('invite token hashes and subject bindings never reach receipts', async t => { const s = setup(t); const invite = invitation(); invite.bind = { provider: 'pocket', sub: 'subject-secret' }; await write(s, 1, d => d.invites.push(invite), { verb: 'invite.create' }); const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(invite.tokenHash)); assert.ok(!raw.includes('subject-secret')); assert.equal(JSON.parse(raw).verb, 'invite.create'); }); test('a failed rename preserves published bytes and appends no receipt', async t => { const s = setup(t), before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { const temp = readdirSync(s.configDir).find(f => f.startsWith('.accounts-')); unlinkSync(join(s.configDir, temp)); } }), { code: 'ENOENT' }); assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); assert.ok(!existsSync(join(s.configDir, 'accounts.lock'))); }); test('review P2-1: rollback rows survive unrelated disable/signout but cannot be added or edited', async t => { const d = document(), m = member(), invite = invitation(); m.role = 'future'; m.rights.push('zz.future'); invite.rights.push('zz.future'); d.memberships.push(m); d.invites.push(invite); const s = setup(t, d); const disabled = await write(s, 1, next => next.users[1].state = 'disabled', { verb: 'user.disable' }); assert.deepEqual(disabled.memberships, d.memberships); assert.deepEqual(disabled.invites, d.invites); const signedOut = await write(s, 2, next => next.users[0].authGen++); assert.deepEqual(signedOut.memberships, d.memberships); assert.equal(signedOut.users[0].authGen, 2); await assert.rejects(write(s, 3, next => next.memberships[0].scope.instances = ['coder']), /unknown/); await assert.rejects(write(s, 3, next => next.invites[0].state = 'revoked'), /unknown/); const repaired = await write(s, 3, next => { next.memberships[0].role = 'member'; next.memberships[0].rights = ['org.view']; }); assert.equal(repaired.memberships[0].rev, 2); assert.deepEqual(repaired.invites, d.invites); }); test('review P3-1: control and bidi characters refuse in names, subjects and provenance', () => { for (const control of ['\x1b', '\x07', '\r', '\n', '\x7f', '\u0085', '\u202e', '\u2066']) for (const field of ['name', 'sub', 'via']) { const d = document(); d.memberships.push(member()); if (field === 'via') d.memberships[0].via += control; else if (field === 'sub') d.users[1].subjects[0].sub += control; else d.users[1].name += control; assert.throws(() => checkAccounts(d, context)); } }); test('review P3-2: invite validity is between one and 168 hours inclusive', () => { for (const hours of [0.5, 169, 87600]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.throws(() => checkAccounts(d, context)); } for (const hours of [1, 168]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.equal(checkAccounts(d, context).invites.length, 1); } }); test('review P3-3: receipts record actual changed field names even for a misleading verb', async t => { const s = setup(t); await write(s, 1, d => d.users[1].instanceRole = 'operator'); const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.deepEqual(r.userChanges, [{ id: USER, changed: ['instanceRole', 'authGen'] }]); }); test('review P3-4: directory durability failure reports committed state and appends no success receipt', async t => { const s = setup(t); let error; try { await write(s, 1, d => d.users[1].authGen++, { syncDirectory: async () => { throw new Error('injected directory sync failure'); } }); } catch (e) { error = e; } assert.match(error?.message ?? '', /injected directory sync failure/); assert.equal(error.committed, true); assert.equal(loadAccounts(s.file, s).revision, 2); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); }); test('review P3-5: init cannot clobber a file created after its last absence check', async t => { const s = setup(t); unlinkSync(s.file); const intruder = 'concurrent file'; await assert.rejects(withAccounts(s, { revision: 0, verb: 'init', init: true, beforeReplace: () => writeFileSync(s.file, intruder, { mode: 0o600 }) }, d => d.users.push(user())), { code: 'EEXIST' }); assert.equal(readFileSync(s.file, 'utf8'), intruder); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); }); test('review P3-6: a real business file and accounts file load with the same human id', async t => { const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 }); assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP); }); // org-acct-anchor (#1873, A2 review P2-1): every ancestor of the config directory must belong to root // or this uid, and be writable by others only with the sticky bit. Otherwise another user could rename // the directory away and put their own accounts.json in its place. No refusal quotes the path. const SECRETISH = 'sk-test-NOT-A-SECRET-1234'; function nested(t) { const top = mkdtempSync(join(tmpdir(), 'a-')); t.after(() => { chmodSync(top, 0o700); rmSync(top, { recursive: true, force: true }); }); const parent = join(top, SECRETISH), configDir = join(parent, 'cfg'); mkdirSync(configDir, { recursive: true, mode: 0o700 }); chmodSync(parent, 0o700); const accountsFile = join(configDir, 'accounts.json'); writeFileSync(accountsFile, JSON.stringify(document()), { mode: 0o600 }); return { top, parent, configDir, instance: 'test', ...context, file: accountsFile }; } const unsafe = rule => e => e.code === 'accounts-unsafe' && rule.test(e.message) && !e.message.includes(SECRETISH) && !e.message.includes('/'); const ANCESTOR = /ancestor must belong to root or uid \d+ and not be writable by others without the sticky bit/; test('org-acct-anchor: a 0777 ancestor refuses load, init and the lock; 1777 passes', async t => { const s = nested(t), initDir = join(s.parent, 'new'); mkdirSync(initDir, { mode: 0o700 }); const init = { ...s, configDir: initDir, file: join(initDir, 'accounts.json') }; for (const dir of [s.parent, s.top]) { chmodSync(dir, 0o777); try { assert.throws(() => loadAccounts(s.file, s), unsafe(ANCESTOR)); await assert.rejects(withAccounts(init, { revision: 0, verb: 'init', init: true }, d => d.users.push(user())), unsafe(ANCESTOR)); let entered = false; await assert.rejects(withAccountsLock(s, async () => { entered = true; }), unsafe(ANCESTOR)); assert.equal(entered, false); for (const c of [s, init]) for (const name of ['accounts.lock', 'accounts-receipts.jsonl']) assert.equal(existsSync(join(c.configDir, name)), false, name); assert.equal(existsSync(init.file), false); chmodSync(dir, 0o1777); assert.equal(loadAccounts(s.file, s).revision, 1); await withAccountsLock(s, async () => { entered = true; }); assert.equal(entered, true); await withAccounts(init, { revision: 0, verb: 'init', init: true }, d => d.users.push(user())); assert.equal(loadAccounts(init.file, init).revision, 1); rmSync(init.file); rmSync(join(init.configDir, 'accounts-receipts.jsonl')); } finally { chmodSync(dir, 0o700); } } // A group-writable ancestor without the sticky bit refuses too. chmodSync(s.parent, 0o770); try { assert.throws(() => loadAccounts(s.file, s), unsafe(ANCESTOR)); } finally { chmodSync(s.parent, 0o700); } }); test('org-acct-anchor: an ancestor owned by another uid refuses; root-owned ancestors pass', t => { const s = nested(t), other = process.geteuid() + 1; // Simulated as the agent-levels tests do: as another uid, this uid's directories are foreign. assert.throws(() => assertAccountsOwner(s.configDir, { uid: other }), unsafe(new RegExp(`ancestor must belong to root or uid ${other} `))); assert.throws(() => loadAccounts(s.file, s, { uid: other }), unsafe(ANCESTOR)); // The real ancestors above the scratch root include root-owned ones (at least /), which pass. assert.equal(lstatSync('/').uid, 0); assertAccountsOwner(s.configDir); assert.equal(loadAccounts(s.file, s).revision, 1); }); test('org-acct-anchor: a symlinked ancestor refuses, and leaf refusals name no path', async t => { const s = nested(t), link = join(s.top, 'link'); symlinkSync(s.parent, link); const via = { ...s, configDir: join(link, 'cfg'), file: join(link, 'cfg', 'accounts.json') }; assert.throws(() => loadAccounts(via.file, via), unsafe(/symbolic link or non-directory/)); await assert.rejects(withAccountsLock(via, async () => {}), unsafe(/symbolic link or non-directory/)); assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); // The leaf checks are unchanged, but no longer quote the path. const other = process.geteuid() + 1; for (const mode of [0o720, 0o702]) { chmodSync(s.configDir, mode); try { assert.throws(() => loadAccounts(s.file, s), unsafe(/^config directory must not be writable by group or other$/)); } finally { chmodSync(s.configDir, 0o700); } } // As another uid the ancestors refuse first; the leaf's uid rule is the same no-path form (source check below). assert.throws(() => assertAccountsOwner(s.configDir, { uid: process.geteuid() + 1 }), e => !e.message.includes(SECRETISH) && !e.message.includes('/')); const source = readFileSync(new URL('../src/config-dir-trust.mjs', import.meta.url), 'utf8') + readFileSync(new URL('../src/accounts.mjs', import.meta.url), 'utf8'); assert.match(source, /`config directory must belong to uid \$\{uid\}`/); assert.match(source, /`accounts file must belong to uid \$\{uid\}`/); assert.doesNotMatch(source, /must belong to uid \$\{uid\}: \$\{/); assert.throws(() => loadAccounts(join(s.configDir, 'missing', 'accounts.json'), s), unsafe(/^config directory is missing$/)); assert.throws(() => assertAccountsOwner(`${s.configDir}/../cfg`), unsafe(/absolute normalized/)); });