Files
stack/packages/tasks/tests/world.mjs
T
jason.woltjeandClaude Opus 5.5 0453da7e10 feat(roles): coder drops the task.reassign grant the broker always refuses (#1567, row 73)
roles/coder.json crossRole goes from ["task.reassign", "task.scope.change"]
to ["task.scope.change"]. The broker refuses a non-pm task.reassign at the
writer check before it looks at authority, so the grant could never be
used. A coder's task.reassign now classifies as gated and goes to the
human. Backed by lead decision 90.

Tests: business role and resolve (the narrowing test moves to the pm,
which still holds two cross-role actions), a bus test on the shipped role
files, a harness test that a coder with a tracker gets no task_reassign,
the tasks fixture, and a verbs line showing a coder's task.reassign
refuses field-writer with no action.allowed. mutants.sh is Rocko's packet
rerun script, already committed.

Author: Rocko (packet agents/rocko/work/queue-73/, commit 2137dd7f).
Candidate manifest sha256 bf4e5ed6, base 324c064b, no drift on the
candidate paths through 8a42e819.
Approval: Dewey, round 1, comment 27506 on #1567.
Gate (Sage, /tmp/sage-g73 on 8a42e819, 23:42-23:48Z): tasks 71, bus 81,
business 60, harness 59, cli 113, webui 61, seat 27 node tests, 0 failed;
every scripts/test-*.sh exit 0, 0 failed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:49:09 -05:00

172 lines
6.7 KiB
JavaScript

import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { Credentials } from '../../bus/src/credentials.mjs';
import { FakeVikunja } from '../src/fake.mjs';
import { tasksAdapter } from '../src/adapter.mjs';
// Token scopes as the runbook mints them. The sync token reads; role tokens write but never delete,
// and only the pm's adds labels (addendum B section 2).
export const SCOPES = {
sync: {
projects: ['read_one', 'views_buckets', 'views_buckets_tasks_get'],
projects_views: ['read_all'],
tasks: ['read_all', 'read_one'],
tasks_comments: ['read_all'],
},
pm: {
tasks: ['read_one', 'create', 'update'],
tasks_labels: ['create', 'delete'],
tasks_assignees: ['create', 'delete'],
tasks_relations: ['create', 'delete'],
tasks_comments: ['create'],
projects: ['views_buckets_tasks'],
labels: ['read_all'],
},
worker: { tasks: ['read_one', 'update'], tasks_comments: ['create'], projects: ['views_buckets_tasks'] },
};
export const AUTHORITY = {
pm: {
withinRole: [
'task.create',
'task.assign',
'task.reassign',
'task.schedule',
'task.update.assigned',
'task.close',
'role.launch',
'message.send',
],
crossRole: ['task.priority.change', 'task.scope.change'],
},
coder: {
withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'],
crossRole: ['task.scope.change'],
},
reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] },
cto: {
withinRole: ['review.request', 'task.update.assigned', 'message.send', 'decision.resolve.technical'],
crossRole: ['task.scope.change'],
},
};
export const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
// One business on a fresh fake: owner svc-demo, four bots it owns, the installed project shared
// with them, one allowed label. `edit` adjusts the setup before the adapter starts; `edit.adapter`
// overrides tasksAdapter options. `edit.fake` is FakeVikunja options or an instance.
export async function world(t, edit = {}) {
const root = mkdtempSync(join(tmpdir(), 'tasks-'));
const fake = edit.fake instanceof FakeVikunja ? edit.fake : new FakeVikunja(edit.fake);
const owner = fake.user('svc-demo');
const bots = {};
for (const r of ['sync', 'pm', 'coder', 'reviewer']) bots[r] = fake.user('bot-demo-' + r, { owner });
const project = fake.project('Demo', owner);
for (const r of ['pm', 'coder', 'reviewer']) fake.share(project, bots[r], 1);
fake.share(project, bots.sync, 0);
const installed = edit.install === false ? null : fake.install(project);
const label = fake.label('slice-1', owner);
const scopes = { sync: SCOPES.sync, pm: SCOPES.pm, coder: SCOPES.worker, reviewer: SCOPES.worker, ...edit.scopes };
const env = {};
const tokens = {};
for (const r of Object.keys(bots)) {
tokens[r] = fake.token(bots[r], scopes[r]);
env['VK_' + r.toUpperCase()] = tokens[r];
}
tokens.owner = fake.token(owner);
const expires = { sync: '2099-01-01', pm: '2099-01-01', coder: '2099-01-01', reviewer: '2099-01-01', ...edit.expires };
const ref = (r) => (edit.files?.[r] ? { file: edit.files[r], expires: expires[r] } : { env: 'VK_' + r.toUpperCase(), expires: expires[r] });
if (edit.files)
for (const [r, file] of Object.entries(edit.files)) {
writeFileSync(file, tokens[r] + '\n');
chmodSync(file, 0o600);
}
const roles = {};
for (const r of ['pm', 'coder', 'reviewer', 'cto'])
roles[r] = {
definition: r,
authority: AUTHORITY[r],
...(r === 'cto' ? {} : { tracker: { bot: 'bot-demo-' + r, botId: bots[r] }, credentials: { vikunja: ref(r) } }),
};
const businesses = {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'cto', delivery: 'pm' },
roles,
tracker: {
sync: { bot: 'bot-demo-sync', botId: bots.sync, credentials: { vikunja: ref('sync') } },
labels: { 'slice-1': label, ...edit.labels },
},
},
};
edit.business?.(businesses.demo, { fake, owner, bots, project });
const references = {};
for (const [r, x] of Object.entries(roles)) if (x.credentials) references['demo/' + r] = x.credentials;
if (businesses.demo.tracker.sync.credentials) references['demo/@sync'] = businesses.demo.tracker.sync.credentials;
const credentials = new Credentials({ references, env, dataRoot: root });
const store = new Store(root);
const broker = new Broker({ store, businesses, secretCheck: (v) => credentials.assertClean(v) });
const log = [];
const factory = tasksAdapter({
trackers: { demo: { baseUrl: 'http://vikunja.test', project, ...edit.tracker } },
fetch: edit.fetch ?? fake.fetch,
log: (line) => log.push(line),
autostart: false,
...edit.adapter,
});
const adapter = await factory({ broker, credentials, businesses });
t.after(async () => {
await adapter.close();
store.close();
credentials.close();
rmSync(root, { recursive: true, force: true });
});
const caps = {};
const agent = (role) => {
const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' });
broker.request(cap, { verb: 'role.claim' });
return (caps[role] = cap);
};
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
// A human instruction, the request a task.create cites.
const instruction = () => broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
// The owner acting in the tracker UI, which the poller must notice.
const ui = async (method, path, body) => {
const r = await fake.fetch('http://vikunja.test/api/v2' + path, {
method,
headers: { authorization: 'Bearer ' + tokens.owner, 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: r.status, json: r.status === 204 ? null : await r.json() };
};
const events = (kind) =>
store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) }));
const snapshots = (taskRef) =>
store.all('SELECT * FROM task_snapshots WHERE task_ref=? ORDER BY seq', taskRef).map((s) => ({ ...s, fields: JSON.parse(s.fields) }));
const call = (cap, verb, args) => broker.requestTask(cap, { verb, args }, adapter.handle);
return {
root,
fake,
owner,
bots,
project,
installed,
label,
tokens,
credentials,
businesses,
store,
broker,
adapter,
log,
caps,
agent,
human,
instruction,
ui,
events,
snapshots,
call,
};
}