Files
stack/packages/tasks/tests/scoped-requirements.test.mjs

160 lines
10 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { scopedFixture } from './scoped-fixture.mjs';
import { world } from './world.mjs';
import { loadRequirements } from '../src/requirements.mjs';
const scope = p => ({ project_key: p.args.project_key, requirement_snapshot: p.snapshot.id });
const created = (f, task) => f.call('pm', 'trail', { subject: task.task_ref }).find(e => e.kind === 'task.created').body;
test('two products on one board share an ID but use exact approved snapshots in discovery, create, filtering and trail', async t => {
const f = await scopedFixture(t), a = await f.product('timer'), b = await f.product('clock');
const tasks = [];
for (const p of [a,b]) {
assert.deepEqual(await f.call('pm', 'requirements', scope(p)), p.snapshot);
assert.deepEqual(await f.bus.broker.requirementsFor(f.caps.pm, { ...scope(p), requirement: p.args.requirement }), p.snapshot);
const task = await f.create(p.args); tasks.push(task);
const body = created(f, task);
assert.deepEqual(body.scope, { ...scope(p), requirement: p.args.requirement });
for (const k of ['prd_version','records_commit','spec_commit']) assert.equal(body[k], p.snapshot[k]);
assert.equal(body.op, p.args.op);
assert.equal(f.fake.task(Number(task.task_ref.split('/')[1])).project, f.child);
const filtered = f.call('pm', 'tasks', { project_key: p.args.project_key, requirement: p.args.requirement });
assert.deepEqual(filtered.tasks.map(t => t.task_ref), [task.task_ref]);
assert.equal(filtered.tasks[0].requirement_snapshot, p.snapshot.id);
}
assert.notEqual(a.snapshot.id, b.snapshot.id);
assert.notEqual(a.snapshot.records_commit, b.snapshot.records_commit);
assert.notEqual(a.snapshot.requirements[0].text, b.snapshot.requirements[0].text); assert.equal(f.posts(), 2);
await f.restart();
assert.deepEqual(await f.call('pm', 'requirements', scope(a)), a.snapshot);
assert.equal((await f.create(a.args)).task_ref, tasks[0].task_ref); assert.equal(f.posts(), 2);
});
test('product calls require all scope fields and cannot borrow another project, board, draft or revoked snapshot', async t => {
const f = await scopedFixture(t), p = await f.product('timer'), other = await f.product('clock'), draft = await f.product('draft', { approve: false });
for (const field of ['project_key','requirement_snapshot','op']) {
const args = { ...p.args }; delete args[field]; await assert.rejects(f.create(args), { code: 'task-scope-required' });
}
await assert.rejects(f.create({ ...p.args, project_key: 'clock' }), { code: 'request-project-refused' });
await assert.rejects(f.create({ ...p.args, requirement_snapshot: other.snapshot.id }));
await assert.rejects(f.create({ ...p.args, project: f.board }), { code: 'request-tracker-refused' });
await assert.rejects(f.create(draft.args));
const state = await f.call('pm', 'request.get', { request: p.request });
await f.call('console', 'prd.revoke.console', { project: 'timer', snapshot: p.snapshot.id, revision: 1, reason: 'Replace', op: 'revoke' });
await assert.rejects(f.create(p.args)); assert.equal(f.posts(), 0);
assert.equal(state.phase, 'planning');
});
test('selection, discovery and ambiguous requests cannot evade product approval by omitting scope', async t => {
const f = await scopedFixture(t);
const request = f.instruction(); await f.call('pm', 'request.open', { request });
await assert.rejects(f.create({ title: 'selection', request, requirement: 'REQ-TASK-1' }), { code: 'task-scope-required' });
const draft = await f.product('draft', { approve: false });
f.call('cto', 'decision.resolve', { id: draft.submission.decision.id, choice: 'no' });
await f.bus.requestLifecycle.resume('demo', draft.request);
await assert.rejects(f.create({ title: 'discovery', request: draft.request, requirement: 'REQ-TASK-1' }), { code: 'task-scope-required' });
await assert.rejects(f.create({ title: 'ambiguous', request: f.instruction(), requirement: 'REQ-TASK-1', project_key: 'draft', requirement_snapshot: 'unknown', op: 'ambiguous' }));
assert.equal(f.posts(), 0);
});
test('admission repeats the catalog and session/authority checks after planning and relation reads', async t => {
const f = await scopedFixture(t), p = await f.product('timer');
const original = f.bus.broker.requirementsFor.bind(f.bus.broker); let calls = 0;
f.bus.broker.requirementsFor = async (...args) => {
calls++; const snapshot = await original(...args);
if (calls === 1) await f.call('console', 'prd.revoke.console', { project: 'timer', snapshot: p.snapshot.id, revision: 1, reason: 'Revoke during planning', op: 'race' });
return snapshot;
};
await assert.rejects(f.create(p.args)); assert.ok(calls >= 2); assert.equal(f.posts(), 0);
});
test('legacy response is unchanged but creation requires an explicitly bound validated Stack project', async t => {
const w = await world(t); await w.adapter.start('demo'); const pm = w.agent('pm');
assert.deepEqual(w.broker.request(pm, { verb: 'requirements' }), loadRequirements());
const result = await w.call(pm, 'task.create', { title: 'legacy', request: w.instruction(), requirement: 'REQ-TASK-1' });
const body = w.events('task.created')[0].body;
assert.equal(body.scope.project_key, 'stack'); assert.equal(body.prd_version, loadRequirements().version);
assert.match(body.catalog_sha256, /^[a-f0-9]{64}$/); assert.equal(body.approval, undefined);
assert.ok(result.task_ref);
const f = await scopedFixture(t);
await assert.rejects(f.create({ title: 'wrong Stack identity', request: f.instruction(), requirement: 'REQ-TASK-1' }), { code: 'requirement-snapshot-required' });
});
test('a product removed from the installed tree after planning cannot create on the old board', async t => {
const f = await scopedFixture(t), p = await f.product('timer');
const original = f.bus.broker.requirementsFor.bind(f.bus.broker); let planned = false;
f.bus.broker.requirementsFor = async (...args) => {
const snapshot = await original(...args);
if (!planned) { planned = true; f.fake.reparent(f.child, 0); }
return snapshot;
};
await assert.rejects(f.create(p.args), { code: 'task-project' }); assert.equal(f.posts(), 0);
});
for (const change of ['authority','session']) test(`create admission rechecks ${change} after planning`, async t => {
const f = await scopedFixture(t), p = await f.product('timer');
const original = f.bus.broker.requirementsFor.bind(f.bus.broker); let planned = false;
f.bus.broker.requirementsFor = async (...args) => {
const snapshot = await original(...args);
if (!planned) {
planned = true;
if (change === 'session') f.call('pm', 'role.release');
else {
const b = structuredClone(f.config.businesses.demo);
b.projects.timer = { root: p.context.business.projects.timer.root };
b.roles.pm.authority.withinRole = b.roles.pm.authority.withinRole.filter(a => a !== 'task.create');
f.bus.broker.reloadBusiness('demo', b);
}
}
return snapshot;
};
await assert.rejects(f.create(p.args), { code: change === 'authority' ? 'decision-required' : 'not-holder' });
assert.equal(f.posts(), 0);
});
test('bootConfig binds only the proven Stack checkout and a scoped caller never borrows its builtin snapshot', async t => {
const { fileURLToPath } = await import('node:url');
const { writeFileSync } = await import('node:fs');
const { fixture, tmp } = await import('../../cli/tests/helpers.mjs');
const { bootConfig, loadSystem } = await import('../../cli/src/config.mjs');
const f = fixture(tmp(t, 'builtin-'), 'demo');
f.doc.projects.stack.root = fileURLToPath(new URL('../../../', import.meta.url));
writeFileSync(f.env.MOSAIC_CONFIG.replace('/config.json','/businesses/demo.json'), JSON.stringify(f.doc));
const config = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'demo', env: f.env });
assert.deepEqual(config.builtinBinding, { business: 'demo', project: 'stack', tracker_project: 19 });
const w = await world(t); await w.adapter.start('demo'); const pm = w.agent('pm');
const builtin = await w.broker.requirementsFor(pm, { request: w.instruction(), requirement: 'REQ-TASK-1' });
await assert.rejects(w.broker.requirementsFor(pm, { project_key: 'stack', requirement_snapshot: builtin.id }), { code: 'requirement-snapshot-required' });
const unbound = await world(t, { builtinBinding: false }); await unbound.adapter.start('demo');
await assert.rejects(unbound.call(unbound.agent('pm'), 'task.create', { title: 'unbound', request: unbound.instruction(), requirement: 'REQ-TASK-1' }), { code: 'requirement-snapshot-required' });
});
test('lost product create response survives Store reopen with exact scope and original attribution', async t => {
const f = await scopedFixture(t), p = await f.product('timer');
f.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true });
await assert.rejects(f.create(p.args), { code: 'task-create-uncertain' }); assert.equal(f.posts(), 1);
assert.ok(f.fake.task(1).description.endsWith(`Mosaic operation: ${p.args.op}; request: ${p.request}; scope: timer; snapshot: ${p.snapshot.id}`));
await f.restart();
const made = await f.create(p.args);
const trail = f.call('pm', 'trail', { subject: made.task_ref }).filter(e => e.kind === 'task.created');
assert.equal(trail.length, 1); assert.equal(trail[0].actor_run, 'pm-run-1');
assert.deepEqual(trail[0].body.scope, { ...scope(p), requirement: p.args.requirement });
assert.equal(trail[0].body.records_commit, p.snapshot.records_commit); assert.equal(f.posts(), 1);
});
test('a product derived from Stack history cannot claim the builtin catalogue as its own', async t => {
const { tmp } = await import('../../cli/tests/helpers.mjs');
const { git } = await import('../../project/tests/prd-fixture.mjs');
const { fileURLToPath } = await import('node:url');
const { writeFileSync } = await import('node:fs');
const { stackBinding } = await import('../src/builtin.mjs');
const root = tmp(t, 'derived-'), clone = root + '/code';
git(root, 'clone', '-q', '--shared', '--no-checkout', fileURLToPath(new URL('../../../', import.meta.url)), clone);
git(clone, 'checkout', 'HEAD', '--', '.mosaic/project.json', 'packages/tasks/requirements.json', 'docs/prd/mosaic-stack.md');
writeFileSync(clone + '/.mosaic/project.json', JSON.stringify({ projectVersion: 1, id: 'timer', vars: { 'tracker.project': 19 } }));
git(clone, 'add', '.mosaic/project.json'); git(clone, 'commit', '-qm', 'Product identity');
assert.equal(stackBinding({ business: 'demo', project: 'timer', root: clone, tracker_project: 19 }), null);
});