Files
stack/packages/bus/tests/library-write.test.mjs

336 lines
23 KiB
JavaScript

// Row 288: broker authority, exact notice and strict Console revert API. Fixture grant rows only.
import { refusalGuidance } from '../src/refusals.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { connect } from 'node:net';
import { startBroker, createLibraryLookup } from '../src/runtime.mjs';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { Client } from '../src/client.mjs';
import { serve } from '../src/server.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
import { LibraryError } from '../../library/src/problems.mjs';
import { grantDecision } from './host-fixtures.mjs';
import { world as bareWorld } from '../../library/tests/write-fixture.mjs';
import { sync } from '../../library/src/sync.mjs';
import { SYNC_STATE_FILE, servedCommit } from '../../library/src/mirror.mjs';
import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../../library/src/write.mjs';
import { git } from '../../library/tests/helpers.mjs';
const SHA = 'a'.repeat(40), NEXT = 'b'.repeat(40), PATH = 'Book One/Notes/Agent.md';
function world(t, options = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-library-write-')), store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const projects = options.projects ?? { books: mappingEntry() }; projects.books.universes.harbor.supporting = ['Book One/Notes/'];
const authority = { withinRole: ['message.send', 'library.write'], crossRole: [] };
const roles = { bean: { definition: 'companion', authority, identity: { name: 'Bean', email: '[email protected]', gitea: { user: 'bean', url: 'https://git.example.invalid' } }, publish: ['books'] },
chief: { definition: 'chief-of-staff', authority }, quill: { definition: 'proofreader', authority: { withinRole: ['message.send'], crossRole: [] } } };
options.changeRole?.(roles.bean);
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true }, projects, roles,
arbiters: { technical: 'chief', delivery: 'chief' }, definitions: { companion: {}, 'chief-of-staff': {}, proofreader: {} } };
const seen = [], held = new Set(), logs = [];
const result = { project: 'books', path: PATH, commit: NEXT };
const library = { LibraryError, log: entry => logs.push(entry), readFile: async () => ({}), status: async () => ({}),
writeDoc: async (context, options) => { seen.push({ context, options }); options.beforePush(); options.onPushed(result); return result; },
saveFile: async context => { seen.push({ context }); return result; },
revert: async context => { seen.push({ context }); return { ...result, reverted: context.commit }; } };
const broker = new Broker({ store, businesses: { acme: business }, library, giteaKeys: { held: (b, a) => held.has(a) } });
const caps = { human: broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }),
tester: broker.bindTester({ business: 'acme' }) };
const p = broker.request(caps.human, { verb: 'project.create', args: { name: 'Library chat', repo: 'books' } }).id;
for (const role of ['bean', 'quill', 'chief']) {
const thread = role === 'chief' ? null : broker.request(caps.human,
{ verb: 'thread.create', args: { project: p, name: role, instance: role } }).id;
caps[role] = broker.bindLaunch({ business: 'acme', role, run: role + '-run', harness: 'pi', ...(thread ? { thread } : {}) });
broker.request(caps[role], { verb: 'role.claim' });
}
const grant = (project = 'books') => {
const decision = grantDecision(store, { business: 'acme', grant: { method: 'gitea', agent: 'bean', target: { projects: [project] }, rotateBy: '2099-01-01' } });
store.run('INSERT OR IGNORE INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(?,?,?,?,?,?)',
'acme', 'bean', 'bean', 7, '0123456789abcdef', decision);
store.run('INSERT OR REPLACE INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?)',
'acme', 'bean', 'bean', 7, 'fixture', JSON.stringify([project]), '2099-01-01', decision);
};
grant();
const call = (who, verb, args = {}) => Promise.resolve().then(() => broker.request(caps[who], { verb, args }));
return { root, store, broker, caps, call, seen, held, business, grant, library, logs };
}
const args = () => ({ universe: 'harbor', path: PATH, base: SHA, content: 'fixture\n' });
test('C2-02: agent verbs require creative holder, action and live unheld project grant', async t => {
const f = world(t);
for (const verb of ['library.doc.write', 'library.doc.delete']) {
const a = args(); if (verb.endsWith('delete')) delete a.content;
for (const [who, code] of [['human', 'library-write-refused'], ['tester', 'tester-refused'], ['chief', 'library-write-refused'], ['quill', 'library-write-refused']])
await assert.rejects(f.call(who, verb, a), { code });
f.held.add('bean'); await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' }); f.held.clear();
f.store.run('DELETE FROM gitea_grants');
await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' });
f.grant('other'); await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' });
f.store.run('DELETE FROM gitea_grants'); f.grant();
}
assert.equal(f.seen.length, 0);
});
test('C2-02: writing instance supplies neither ident nor notice text; broker inserts exact notice', async t => {
const f = world(t);
const result = await f.call('bean', 'library.doc.write', args());
assert.equal(result.commit, NEXT);
const m = f.store.get("SELECT * FROM messages WHERE from_role='bean' AND to_role='human'");
assert.equal(m.body, JSON.stringify({ notice: 'library-write', op: 'write', path: PATH, commit: NEXT }));
assert.equal(m.from_run, 'bean-run');
assert.ok(f.store.get('SELECT 1 FROM message_threads WHERE message=?', m.id));
assert.equal(f.seen[0].options.identity.name, 'Bean');
assert.equal(f.seen[0].options.credential.user, 'bean');
assert.equal(f.seen[0].options.credential.agent, 'bean');
for (const extra of [{ author: 'other' }, { agent: 'chief' }, { credential: { agent: 'chief' } }])
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), ...extra }), { code: 'invalid-request' });
});
test('C2-02 A2: strict human revert args/result and agent human-required refusal', async t => {
const f = world(t);
for (const who of ['bean', 'quill', 'chief', 'tester'])
for (const verb of ['library.revert', 'library.file.save'])
await assert.rejects(f.call(who, verb, {}), { code: 'human-required' });
for (const a of [{ commit: SHA }, { project: 'books' }, { project: 'books', commit: 'A'.repeat(40) },
{ project: 'books', commit: SHA, editor: {} }, { project: 'books', commit: SHA + 'a' }])
await assert.rejects(f.call('human', 'library.revert', a), { code: 'invalid-request' });
assert.deepEqual(await f.call('human', 'library.revert', { project: 'books', commit: SHA }),
{ project: 'books', path: PATH, reverted: SHA, commit: NEXT });
await assert.rejects(f.call('human', 'library.revert', { project: 'missing', commit: SHA }), { code: 'project-not-configured' });
});
test('C2-02: human revert independent of revoked seat and LibraryError codes survive', async t => {
const f = world(t); f.store.run('DELETE FROM gitea_grants'); f.held.add('bean');
assert.equal((await f.call('human', 'library.revert', { project: 'books', commit: SHA })).reverted, SHA);
for (const code of ['library-revert-not-agent', 'stale-base', 'library-mapping-invalid', 'library-human-missing']) {
f.library.revert = async () => { throw new LibraryError(code, 'fixture'); };
await assert.rejects(f.call('human', 'library.revert', { project: 'books', commit: SHA }), { code });
}
});
test('C2-02 transport: human library.file.save exceeds 64 KiB; agent write remains bounded', async t => {
const f = world(t), path = join(f.root, 'bus.sock');
const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }) });
t.after(() => server.close());
const human = new Client({ path, human: { fixture: true } }), agent = new Client({ path, cap: f.caps.bean });
const content = 'x'.repeat(70000);
assert.equal((await human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content })).commit, NEXT);
assert.equal(f.seen.at(-1).context.content, content);
await assert.rejects(agent.call('library.doc.write', { ...args(), content }), { code: 'request-too-large' });
await assert.rejects(human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content: 'x'.repeat(2 * 1024 * 1024) }), { code: 'request-too-large' });
});
test('C2-02 M12 through broker: every refusal preserves remote, served and all mirror refs', async t => {
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.context.dataRoot },
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
for (const path of ['Book One/Chapters/Chapter 01.md', 'Inbox Note.md', 'Book One/Playground/What If.md',
'Book One/Notes/X.canvas', 'Book One/Notes/X.base', 'Book One/Notes/X.png', 'Second World/Chapters/01.md',
'/Book One/Notes/X.md', 'Book One/Notes/../X.md', 'Book One/./Notes/X.md', 'Book One//Notes/X.md',
'Book One/Notes/X\\Y.md', 'Book One/Notes/X\nY.md', 'Book One/Notes/Link.md', 'Book One/Notes/Submodule.md',
'Book One/Notes/agent.md', `Book One/Notes/${'é'.repeat(512)}.md`]) {
for (const verb of ['library.doc.write', 'library.doc.delete']) {
const before = bare.refs(), head = bare.remoteHead(), a = { ...args(), path, base: null };
if (verb.endsWith('delete')) delete a.content;
await assert.rejects(f.call('bean', verb, a), { code: 'library-write-story-path' }, path);
assert.equal(bare.refs(), before); assert.equal(bare.remoteHead(), head);
}
}
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='bean'").n, 0);
});
test('C2-02 broker + bare: exact write/delete notices, then human revert with revoked seat grant', async t => {
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.context.dataRoot },
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
f.library.revert = context => revertLibraryCommit({ ...context, dataRoot: bare.context.dataRoot },
{ credential: { ...bare.credential, kind: 'human' }, command: bare.command });
f.library.saveFile = context => saveLibraryFile({ ...context, dataRoot: bare.context.dataRoot },
{ credential: { ...bare.credential, kind: 'human' }, command: bare.command });
const base = git(bare.mirror, 'rev-parse', `served:${PATH}`);
const saved = await f.call('bean', 'library.doc.write', { ...args(), base });
assert.equal(saved.commit, bare.remoteHead());
f.store.run('DELETE FROM gitea_grants'); f.held.add('bean');
const reverted = await f.call('human', 'library.revert', { project: 'books', commit: saved.commit });
assert.equal(reverted.reverted, saved.commit);
assert.equal(git(bare.remote, 'show', `main:${PATH}`), 'before');
assert.equal(git(bare.remote, 'show', '-s', '--format=%an <%ae>', reverted.commit), 'Human Fixture <[email protected]>');
f.held.clear(); f.grant();
const deleted = await f.call('bean', 'library.doc.delete', { universe: 'harbor', path: PATH, base });
const messages = f.store.all("SELECT body FROM messages WHERE from_role='bean' AND to_role='human' ORDER BY seq");
assert.deepEqual(messages.map(m => JSON.parse(m.body)), [
{ notice: 'library-write', op: 'write', path: PATH, commit: saved.commit },
{ notice: 'library-write', op: 'delete', path: PATH, commit: deleted.commit }]);
assert.equal(git(bare.remote, 'ls-tree', '-r', deleted.commit, '--', PATH), '');
});
test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope', async t => {
const f = world(t), path = join(f.root, 'raw-bus.sock');
const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.caps.human });
t.after(() => server.close());
const request = JSON.stringify({ human: { verb: 'library.file.save' }, verb: 'library.doc.write',
args: { ...args(), content: 'x'.repeat(70000) } }) + '\n';
const response = await new Promise((resolve, reject) => {
const socket = connect(path); let result = '';
socket.on('error', reject); socket.on('connect', () => socket.write(request));
socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } });
});
assert.deepEqual(response, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
assert.equal(f.seen.length, 0);
});
test('C2-02 E2: held grant during fetch refuses at beforePush and preserves every ref', async t => {
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
const before = bare.refs(), head = bare.remoteHead();
const command = (kind, url, options) => {
if (kind === 'fetch') f.held.add('bean');
return bare.command(kind, url, options);
};
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.context.dataRoot },
{ ...options, credential: { ...options.credential, file: bare.file }, command });
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), base: git(bare.mirror, 'rev-parse', `served:${PATH}`) }),
{ code: 'library-write-no-grant' });
assert.equal(bare.refs(), before); assert.equal(bare.remoteHead(), head);
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='bean'").n, 0);
});
test('C2-02 E3: expired, firing, missing publish, wrong grant user and missing ident refuse', async t => {
const cases = [
{ name: 'expired', prepare: f => f.store.run("UPDATE gitea_grants SET rotate_by='2000-01-01'"), code: 'library-write-no-grant' },
{ name: 'firing', prepare: f => f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('fire-bean',?,'acme','action.allowed','chief','run','bean',?)",
new Date().toISOString(), JSON.stringify({ action: 'fire.stopped' })), code: 'library-write-no-grant' },
{ name: 'publish', changeRole: r => { r.publish = []; }, code: 'library-write-no-grant' },
{ name: 'grant user', prepare: f => f.store.run("UPDATE gitea_grants SET user='other-seat'"), code: 'library-write-no-grant' },
{ name: 'identity name', changeRole: r => { delete r.identity.name; }, code: 'library-write-refused' },
{ name: 'identity email', changeRole: r => { delete r.identity.email; }, code: 'library-write-refused' },
];
for (const c of cases) {
const f = world(t, { changeRole: c.changeRole }); c.prepare?.(f);
for (const verb of ['library.doc.write', 'library.doc.delete']) {
const a = args(); if (verb.endsWith('delete')) delete a.content;
await assert.rejects(f.call('bean', verb, a), { code: c.code }, c.name);
}
assert.equal(f.seen.length, 0);
}
});
test('C2-02 F2/E6 runtime: human saves/reverts use editor.user; no login never uses the fetch bot', { timeout: 30000 }, async t => {
for (const user of [undefined, 'her-gitea-login']) {
const bare = await bareWorld(t);
const agent = await writeLibraryDoc(bare.args(), bare.opts);
bare.entry.user = 'fetch-bot';
if (user) bare.entry.editor.user = user;
const configDir = join(bare.root, 'config'), slots = join(configDir, 'secrets', 'acme');
mkdirSync(slots, { recursive: true, mode: 0o700 });
for (const slot of ['library-human', 'library-gitea'])
writeFileSync(join(slots, slot), 'fx288-human\n', { mode: 0o600 });
const pushes = [];
const command = (kind, url, options) => { if (kind === 'push') pushes.push(options.user); return bare.command(kind, url, options); };
const initial = (await bare.state()).lastFetch;
const business = { ...bare.context.business, human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
arbiters: { technical: 'chief', delivery: 'chief' } };
const runtime = await startBroker({ dataRoot: bare.context.dataRoot, businesses: { acme: business },
librarySync: { configDir, command, intervalMs: 60000 } });
try {
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const call = (verb, args) => Promise.resolve().then(() => runtime.broker.request(human, { verb, args }));
// Let the boot fetch finish before inspecting only the human operation's command count.
const deadline = Date.now() + 10000;
while (true) {
const state = await call('library.status', { project: 'books' });
if (state.lastFetch !== initial) break;
if (Date.now() > deadline) throw Error('boot fetch did not complete');
await new Promise(resolve => setTimeout(resolve, 10));
}
const path = 'Book One/Chapters/Chapter 01.md';
const saves = [
['library.file.save', { project: 'books', path, base: git(bare.mirror, 'rev-parse', `served:${path}`), content: 'her edit\n' }],
['library.revert', { project: 'books', commit: agent.commit }],
];
const before = bare.commands.length;
for (const [verb, args] of saves) {
if (user) assert.match((await call(verb, args)).commit, /^[a-f0-9]{40}$/);
else await assert.rejects(call(verb, args), { code: 'library-human-missing' });
}
assert.deepEqual(pushes, user ? [user, user] : []);
if (!user) assert.equal(bare.commands.length, before);
} finally { await runtime.close(); }
}
});
test('C2-02 E4: post-push publication failure stores the exact notice and returns outcome-unknown', async t => {
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
const state = join(bare.mirror, SYNC_STATE_FILE), original = readFileSync(state);
const previous = await servedCommit(bare.mirror), head = bare.remoteHead();
const command = (kind, url, options) => {
// Break only state decoding after push admission; the real remote push still executes.
if (kind === 'push') writeFileSync(state, 'invalid state\n');
return bare.command(kind, url, options);
};
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.context.dataRoot },
{ ...options, credential: { ...options.credential, file: bare.file }, command });
try {
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), base: git(bare.mirror, 'rev-parse', `served:${PATH}`) }),
{ code: 'outcome-unknown' });
assert.notEqual(bare.remoteHead(), head);
assert.equal(git(bare.mirror, 'rev-parse', 'served'), previous);
const messages = f.store.all("SELECT body FROM messages WHERE from_role='bean' AND to_role='human'");
assert.deepEqual(messages.map(m => JSON.parse(m.body)), [
{ notice: 'library-write', op: 'write', path: PATH, commit: bare.remoteHead() }]);
} finally { writeFileSync(state, original); }
const commit = bare.remoteHead();
const human = { credential: { ...bare.credential, kind: 'human' }, command: bare.command };
await assert.rejects(revertLibraryCommit({ ...bare.context, commit }, human), { code: 'library-revert-not-agent' });
await sync(bare.context, { credential: bare.credential, command: bare.command });
assert.equal((await revertLibraryCommit({ ...bare.context, commit }, human)).reverted, commit);
});
test('C2-02 mos-1520: failed notice insert logs metadata and returns outcome-unknown even if refusal evidence fails', async t => {
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
const previous = await servedCommit(bare.mirror), head = bare.remoteHead();
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.context.dataRoot },
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
f.store.run(`CREATE TEMP TRIGGER fail_library_notice BEFORE INSERT ON messages
WHEN NEW.from_role='bean' BEGIN SELECT RAISE(ABORT, 'fixture store failure'); END;`);
f.store.run(`CREATE TEMP TRIGGER fail_refusal_log BEFORE INSERT ON events
WHEN NEW.kind='action.refused' BEGIN SELECT RAISE(ABORT, 'fixture evidence failure'); END;`);
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), base: git(bare.mirror, 'rev-parse', `served:${PATH}`) }),
{ code: 'outcome-unknown' });
assert.notEqual(bare.remoteHead(), head);
assert.equal(await servedCommit(bare.mirror), previous);
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='bean'").n, 0);
assert.deepEqual(f.logs, [{ event: 'library-notice-store-failed', code: 'outcome-unknown', business: 'acme',
instance: 'bean', op: 'write', commit: bare.remoteHead() }]);
});
test('C2-02 + C2-05c: broker get blob feeds write/delete and new served lookups immediately', async t => {
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
f.library.lookup = createLibraryLookup({ dataRoot: bare.context.dataRoot,
sync: context => sync({ ...context, dataRoot: bare.context.dataRoot }, { credential: bare.credential, command: bare.command }) });
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.context.dataRoot },
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
const query = { universe: 'harbor', path: PATH, scope: 'supporting' };
const before = await f.call('bean', 'library.get', query);
assert.equal(before.blob, git(bare.mirror, 'rev-parse', `served:${PATH}`));
const saved = await f.call('bean', 'library.doc.write', { ...args(), base: before.blob, content: 'new served text\n' });
const after = await f.call('bean', 'library.get', query);
assert.equal(after.commit, saved.commit);
assert.equal(after.blob, git(bare.mirror, 'rev-parse', `${saved.commit}:${PATH}`));
assert.notEqual(after.blob, before.blob);
assert.deepEqual(after.lines[0], [1, 'new served text']);
assert.equal(await f.call('bean', 'library.get', { universe: 'harbor', path: PATH }), null);
await f.call('bean', 'library.doc.delete', { universe: 'harbor', path: PATH, base: after.blob });
assert.equal(await f.call('bean', 'library.get', query), null);
});