265 lines
21 KiB
JavaScript
265 lines
21 KiB
JavaScript
// U1 (#1873, PRD 1.20 REQ-WS-1..8): the cross-organization read behind GET /api/workspace.
|
|
// Merge, membership, partial failure, timeout, sort and filter, the copied decide command, the
|
|
// route through startServer, and the assistant-read stop. Every bus here is a fake options
|
|
// object or a real org registry in this test's own scratch; no test reaches a live bus.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import * as fs from 'node:fs/promises';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { createOrg, showOrg, templateRender } from '../../organization/src/org-service.mjs';
|
|
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
|
|
import { organizationConsole } from '../src/organizations.mjs';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { createDevOwner } from '../src/dev-owner.mjs';
|
|
import { parseWorkspaceQuery, readWorkspace, workspaceHTTP, WorkspaceQueryError, WORKSPACE_POLL_MS } from '../src/workspace-read.mjs';
|
|
import * as broker from '../../bus/src/broker.mjs';
|
|
|
|
const principal = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
|
const NOW = Date.parse('2026-11-09T13:00:00.000Z');
|
|
const now = () => NOW;
|
|
const never = () => new Promise(() => {});
|
|
|
|
// One org's bus as the org pages see it: options().bus and options().chat. `over` replaces reads.
|
|
function options(data, over = {}) {
|
|
const value = name => (over[name] ? over[name]() : Promise.resolve(data[name] ?? (['usage', 'limits'].includes(name) ? {} : [])));
|
|
return {
|
|
bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'),
|
|
usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') },
|
|
chat: { threads: () => value('threads'), projects: () => value('projects') },
|
|
};
|
|
}
|
|
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
|
|
function member(id, data, { status = healthy, over = {}, doc = {} } = {}) {
|
|
return { id, doc: { appearance: { theme: `${id}-theme` }, ...doc }, status, options: () => options(data, over) };
|
|
}
|
|
const organizationsOf = (...members) => ({ members: async () => members, list: async () => [], resolve: async () => null });
|
|
|
|
const ACME = {
|
|
projects: [
|
|
{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: '2026-11-09T10:00:00.000Z' },
|
|
{ id: 'p-old', repo: 'old', name: 'Old brochure', archived: true, at: '2026-10-01T00:00:00.000Z' },
|
|
],
|
|
tasks: [
|
|
{ task_ref: 'vk:1', project_key: 'site', priority: 2, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Hero copy', done: false, assignees: [7, 99], bucket: 'Doing', labels: ['web'] } },
|
|
{ task_ref: 'vk:2', project_key: 'site', at: '2026-11-08T00:00:00.000Z', fields: { title: 'Logo', done: true } },
|
|
{ task_ref: 'vk:3', project_key: 'site', at: '2026-11-08T00:00:00.000Z', fields: { title: 'Removed', gone: true } },
|
|
],
|
|
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'waiting', at: '2026-11-09T09:00:00.000Z', inputNeeded: [{ task_ref: 'vk:1', at: '2026-11-09T12:00:00.000Z' }], stranded: [] }],
|
|
claims: [{ thread: 't-1', instance: 'assistant', holder_run: 'run-2', harness: 'claude', at: '2026-11-09T08:00:00.000Z' }],
|
|
threads: [{ id: 't-1', project: 'p-site' }],
|
|
inbox: [{ id: 'd1234567', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'vk:1', at: '2026-11-09T12:00:00.000Z', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] }],
|
|
usage: { total: { tokens: 1200 } },
|
|
limits: { tokens: [{ scope: 'org', used: 1200, cap: 5000 }] },
|
|
decisions: [{ at: '2026-11-09T00:00:00.000Z', closed: { op: 'resolved', at: '2026-11-09T00:10:00.000Z' } }],
|
|
};
|
|
const ACME_DOC = { tracker: { kind: 'vikunja' }, roles: { coder: { tracker: { botId: 7 } }, assistant: {} } };
|
|
const BETA = {
|
|
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: '2026-11-07T00:00:00.000Z' }],
|
|
tasks: [{ task_ref: 'local:1', project_key: 'shop', at: '2026-11-07T00:00:00.000Z', fields: { title: 'Price list', done: false } }],
|
|
};
|
|
|
|
test('merge: every item from two buses carries its org, and the snapshot names both orgs', async () => {
|
|
const body = await readWorkspace({ organizations: organizationsOf(member('acme', ACME, { doc: ACME_DOC }), member('beta', BETA)), session: principal, now });
|
|
assert.equal(body.pollMs, WORKSPACE_POLL_MS);
|
|
assert.deepEqual(body.summary, { read: 2, of: 2 });
|
|
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker, o.theme]), [['acme', 'ok', 'vikunja', 'acme-theme'], ['beta', 'ok', 'built-in', 'beta-theme']]);
|
|
assert.deepEqual(body.orgs[0].roles, ['assistant', 'coder']);
|
|
for (const key of ['projects', 'sessions', 'inbox', 'tasks']) for (const row of body[key]) assert.ok(['acme', 'beta'].includes(row.org), `${key} row without org`);
|
|
assert.deepEqual(body.metrics.orgs.map(m => m.org), ['acme', 'beta']);
|
|
// Archived projects are hidden by default; the default sort is newest activity first.
|
|
assert.deepEqual(body.projects.map(p => `${p.org}/${p.key}`), ['acme/site', 'beta/shop']);
|
|
const site = body.projects[0];
|
|
assert.deepEqual({ ...site }, { org: 'acme', id: 'p-site', key: 'site', name: 'Website', status: 'active', state: 'working', git: null,
|
|
sessions: { working: 1, waiting: 1, idle: 0 }, openTasks: 1, needsYou: 1, lastActivity: '2026-11-09T12:00:00.000Z' });
|
|
const role = body.sessions.find(s => s.kind === 'role'), thread = body.sessions.find(s => s.kind === 'thread');
|
|
assert.equal(role.org, 'acme'); assert.equal(role.role, 'coder'); assert.equal(role.state, 'waiting'); assert.equal(role.inputNeeded, 1);
|
|
assert.deepEqual(role.task, { ref: 'vk:1', title: 'Hero copy' }); assert.equal(role.project.key, 'site'); assert.equal(role.projectFrom, 'task');
|
|
assert.equal(thread.thread, 't-1'); assert.equal(thread.project.key, 'site'); assert.equal(thread.projectFrom, 'thread');
|
|
const ask = body.inbox[0];
|
|
assert.equal(ask.org, 'acme'); assert.equal(ask.raisedBy, 'coder'); assert.deepEqual(ask.session, { role: 'coder', run: 'run-1' }); assert.equal(ask.project.key, 'site');
|
|
// The gone task is dropped; only bot ids from the org file become role names.
|
|
assert.deepEqual(body.tasks.map(t => `${t.org}/${t.ref}`), ['acme/vk:1', 'acme/vk:2', 'beta/local:1']);
|
|
assert.deepEqual(body.tasks[0].assignees, ['coder']); assert.equal(body.tasks[0].bucket, 'Doing'); assert.equal(body.tasks[2].tracker, 'built-in');
|
|
assert.deepEqual(body.metrics.orgs[0], { org: 'acme', needsYou: 1, oldestNeedsYouAt: '2026-11-09T12:00:00.000Z', sessions: { working: 0, waiting: 1, idle: 0, live: 1 },
|
|
openTasks: 1, doneThisWeek: 1, projects: { active: 1, archived: 1 }, usage: { tokens: 1200 }, limits: [{ scope: 'org', used: 1200, cap: 5000 }], decisionWaitMedianMs: 600000 });
|
|
assert.deepEqual(body.metrics.totals, { needsYou: 1, oldestNeedsYouAt: '2026-11-09T12:00:00.000Z', sessions: { working: 0, waiting: 1, idle: 0, live: 1 }, openTasks: 2, doneThisWeek: 1, projects: { active: 2, archived: 1 } });
|
|
});
|
|
|
|
test('sections: only the asked sections are read and returned', async () => {
|
|
const asked = [];
|
|
const tracked = member('acme', ACME, { over: Object.fromEntries(['inbox', 'agents', 'claims', 'threads', 'tasks', 'projects', 'usage', 'limits', 'decisions'].map(n => [n, () => { asked.push(n); return Promise.resolve(ACME[n] ?? (['usage', 'limits'].includes(n) ? {} : [])); }])) });
|
|
const body = await readWorkspace({ organizations: organizationsOf(tracked), session: principal, query: parseWorkspaceQuery('sections=tasks'), now });
|
|
assert.deepEqual(Object.keys(body).filter(k => ['projects', 'sessions', 'inbox', 'tasks', 'metrics'].includes(k)), ['tasks']);
|
|
assert.deepEqual(asked.sort(), ['inbox', 'projects', 'tasks']);
|
|
});
|
|
|
|
test('membership: a foreign org is never read or named; a broken own org shows as invalid; options get the timeout', async t => {
|
|
const root = await fs.mkdtemp(join(tmpdir(), 'wsr-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal };
|
|
await fs.mkdir(ctx.configDir, { mode: 0o700 }); await fs.mkdir(ctx.dataRoot, { mode: 0o700 });
|
|
for (const id of ['alpha', 'beta', 'private-tenant']) await createOrg(ctx, id, await templateRender(id));
|
|
await fs.writeFile(join(ctx.dataRoot, 'org-runtime', 'private-tenant', 'owner.json'), JSON.stringify({ ...principal, sub: 'different-owner' }));
|
|
const statusAsked = [], optionsAsked = [];
|
|
const orgs = organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPaths: () => ({}),
|
|
busStatus: async (_ctx, id) => { statusAsked.push(id); return id === 'beta' ? { healthy: false, pid: null, boot: { ok: false, at: '2026-11-09T12:59:00.000Z', reason: `config missing at ${ctx.dataRoot}/org-runtime/beta/x.json` } } : { healthy: true, pid: 7 }; },
|
|
options: o => { optionsAsked.push({ id: o.id, timeoutMs: o.timeoutMs }); return options(o.id === 'alpha' ? BETA : {}); } });
|
|
const body = await readWorkspace({ organizations: orgs, session: principal, timeoutMs: 1500, now, ctx });
|
|
assert.deepEqual(body.orgs.map(o => [o.org, o.state]), [['alpha', 'ok'], ['beta', 'down']]);
|
|
assert.equal(JSON.stringify(body).includes('private-tenant'), false);
|
|
assert.deepEqual(statusAsked.sort(), ['alpha', 'beta']);
|
|
assert.deepEqual(optionsAsked, [{ id: 'alpha', timeoutMs: 1500 }]);
|
|
// The boot refusal is shown with the data root made relative.
|
|
assert.equal(body.orgs[1].reason, 'the bus refused to start: config missing at <data root>/org-runtime/beta/x.json');
|
|
assert.equal(JSON.stringify(body).includes(root), false);
|
|
// A different principal sees none of these orgs.
|
|
const outsider = await readWorkspace({ organizations: orgs, session: { ...principal, sub: 'outsider' }, now, ctx });
|
|
assert.deepEqual(outsider.orgs, []);
|
|
// The owner of a broken org sees it as invalid, with no host path.
|
|
await fs.unlink(join(ctx.configDir, 'businesses', 'alpha.json'));
|
|
const broken = await readWorkspace({ organizations: orgs, session: principal, now, ctx });
|
|
assert.equal(broken.orgs[0].org, 'alpha'); assert.equal(broken.orgs[0].state, 'invalid');
|
|
assert.equal(broken.orgs[0].reason.includes(ctx.configDir), false);
|
|
assert.deepEqual(broken.summary, { read: 0, of: 2 });
|
|
});
|
|
|
|
test('partial failure: down, starting and a status that throws each get a state and a time; the others return', async () => {
|
|
const ctx = { dataRoot: '/srv/fake-data', configDir: '/srv/fake-config' };
|
|
const body = await readWorkspace({ now, ctx, session: principal, organizations: organizationsOf(
|
|
member('acme', ACME, { doc: ACME_DOC }),
|
|
member('down', BETA, { status: async () => ({ healthy: false, pid: null, boot: { ok: true, at: '2026-11-09T12:00:00.000Z' } }) }),
|
|
member('starting', BETA, { status: async () => ({ healthy: false, pid: 991, boot: null }) }),
|
|
member('stale', BETA, { status: async () => { throw new Error('host state names /srv/fake-data/org-runtime/stale/bus-host and /etc/secret-place'); } }),
|
|
member('reads-fail', BETA, { over: { tasks: () => Promise.reject(Object.assign(new Error('nope'), { code: 'bus-unavailable' })), projects: () => Promise.reject(new Error('raw text /etc/x')) } }),
|
|
) });
|
|
const byOrg = Object.fromEntries(body.orgs.map(o => [o.org, o]));
|
|
assert.equal(byOrg.acme.state, 'ok');
|
|
assert.equal(byOrg.down.state, 'down'); assert.equal(byOrg.down.reason, 'the bus is not running');
|
|
assert.equal(byOrg.starting.state, 'starting'); assert.equal(byOrg.starting.reason, 'the bus host is running but does not answer yet');
|
|
assert.equal(byOrg.stale.state, 'down');
|
|
assert.equal(byOrg.stale.reason, 'host state names <data root>/org-runtime/stale/bus-host and <host path>');
|
|
// A read that fails inside an answering bus is that read's error code, never its message.
|
|
assert.equal(byOrg['reads-fail'].state, 'ok');
|
|
assert.deepEqual(byOrg['reads-fail'].errors, { tasks: 'bus-unavailable', projects: 'read-failed' });
|
|
for (const o of body.orgs) { assert.equal(o.at, new Date(NOW).toISOString()); assert.equal(typeof o.readMs, 'number'); }
|
|
assert.deepEqual(body.summary, { read: 2, of: 5 });
|
|
assert.ok(body.projects.every(p => p.org === 'acme'));
|
|
});
|
|
|
|
test('timeout: a bus that never answers, or whose state is never read, is marked timeout within the bound', async () => {
|
|
const started = Date.now();
|
|
const body = await readWorkspace({ timeoutMs: 150, session: principal, organizations: organizationsOf(
|
|
member('acme', ACME, { doc: ACME_DOC }),
|
|
member('slow', BETA, { over: { tasks: never } }),
|
|
member('stuck', BETA, { status: never }),
|
|
) });
|
|
const elapsed = Date.now() - started;
|
|
assert.ok(elapsed < 1500, `took ${elapsed} ms`);
|
|
const byOrg = Object.fromEntries(body.orgs.map(o => [o.org, o]));
|
|
assert.equal(byOrg.acme.state, 'ok');
|
|
assert.equal(byOrg.slow.state, 'timeout'); assert.equal(byOrg.slow.reason, 'the bus did not answer within 150 ms');
|
|
assert.equal(byOrg.stuck.state, 'timeout'); assert.equal(byOrg.stuck.reason, 'the bus state was not read within 150 ms');
|
|
assert.equal(body.timeoutMs, 150);
|
|
assert.ok(body.tasks.every(t => t.org === 'acme'));
|
|
});
|
|
|
|
test('query: strict keys and values', () => {
|
|
const q = parseWorkspaceQuery('?sections=projects,tasks&org=acme,beta&status=active,archived&state=working&name=Web&sort=name&project=acme:site');
|
|
assert.deepEqual([...q.sections], ['projects', 'tasks']); assert.deepEqual([...q.orgs], ['acme', 'beta']);
|
|
assert.deepEqual([...q.status], ['active', 'archived']); assert.deepEqual([...q.state], ['working']);
|
|
assert.equal(q.name, 'web'); assert.equal(q.sort, 'name'); assert.equal(q.order, 'asc'); assert.deepEqual(q.project, { org: 'acme', key: 'site' });
|
|
assert.equal(parseWorkspaceQuery('').order, 'desc');
|
|
for (const bad of ['secton=projects', 'org=acme&org=beta', 'sections=', 'sections=chat', 'org=Bad Org', 'status=paused', 'state=busy', `name=${'x'.repeat(201)}`, 'name=%20', 'sort=size', 'order=up', 'project=acme', 'project=acme:a/b'])
|
|
assert.throws(() => parseWorkspaceQuery(bad), WorkspaceQueryError, bad);
|
|
});
|
|
|
|
test('sort and filter: projects by org, status, state, name and activity; tasks by org and project', async () => {
|
|
const organizations = organizationsOf(member('acme', ACME, { doc: ACME_DOC }), member('beta', BETA));
|
|
const read = search => readWorkspace({ organizations, session: principal, now, query: parseWorkspaceQuery(search) });
|
|
const keys = rows => rows.map(p => `${p.org}/${p.key}`);
|
|
assert.deepEqual(keys((await read('status=active,archived')).projects), ['acme/site', 'beta/shop', 'acme/old']);
|
|
assert.deepEqual(keys((await read('status=active,archived&sort=activity&order=asc')).projects), ['acme/old', 'beta/shop', 'acme/site']);
|
|
assert.deepEqual(keys((await read('status=active,archived&sort=name')).projects), ['acme/old', 'beta/shop', 'acme/site']);
|
|
assert.deepEqual(keys((await read('status=active,archived&sort=org&order=desc')).projects), ['beta/shop', 'acme/site', 'acme/old']);
|
|
assert.deepEqual(keys((await read('status=active,archived&sort=status')).projects), ['acme/site', 'beta/shop', 'acme/old']);
|
|
assert.deepEqual(keys((await read('status=archived')).projects), ['acme/old']);
|
|
assert.deepEqual(keys((await read('state=none')).projects), ['beta/shop']);
|
|
assert.deepEqual(keys((await read('name=SHO')).projects), ['beta/shop']);
|
|
const onlyBeta = await read('org=beta');
|
|
assert.deepEqual(onlyBeta.orgs.map(o => o.org), ['beta']); assert.deepEqual(keys(onlyBeta.projects), ['beta/shop']);
|
|
// An org filter naming an org the viewer is not in returns nothing, not an error.
|
|
assert.deepEqual((await read('org=nobody')).orgs, []);
|
|
assert.deepEqual((await read('project=acme:site')).tasks.map(t => t.ref), ['vk:1', 'vk:2']);
|
|
assert.deepEqual((await read('project=acme:p-site')).tasks.map(t => t.ref), ['vk:1', 'vk:2']);
|
|
assert.deepEqual((await read('project=beta:site')).tasks, []);
|
|
});
|
|
|
|
test('decide: a copied command names the org; an unsafe id or key gets none', async () => {
|
|
const data = { ...ACME, inbox: [...ACME.inbox, { id: 'd99 ; rm -rf ~', question: 'x', options: [{ key: 'ok', text: 'ok' }] }, { id: 'e7654321', question: 'y', options: [{ key: 'a b', text: 'a b' }] }] };
|
|
const body = await readWorkspace({ organizations: organizationsOf(member('acme', data, { doc: ACME_DOC })), session: principal, now, query: parseWorkspaceQuery('sections=inbox') });
|
|
const byId = Object.fromEntries(body.inbox.map(d => [d.id, d]));
|
|
assert.deepEqual(byId.d1234567.decide, [{ key: 'yes', command: 'mosaic decide d1234567 yes --org acme' }, { key: 'no', command: 'mosaic decide d1234567 no --org acme' }]);
|
|
assert.deepEqual(byId['d99 ; rm -rf ~'].decide, [{ key: 'ok', command: null }]);
|
|
assert.deepEqual(byId.e7654321.decide, [{ key: 'a b', command: null }]);
|
|
// Gated first, then oldest first.
|
|
assert.equal(body.inbox[0].id, 'd1234567');
|
|
});
|
|
|
|
function fakeRes() {
|
|
return { status: null, body: null, writeHead(status) { this.status = status; }, end(text) { this.body = JSON.parse(text); } };
|
|
}
|
|
test('handler: non-GET, no registry, non-owner, bad query and an unreadable registry', async () => {
|
|
const organizations = organizationsOf(member('acme', ACME));
|
|
const run = async (opts, method = 'GET') => { const res = fakeRes(); await workspaceHTTP({ method }, res, { session: principal, owner: true, organizations, ...opts }); return res; };
|
|
assert.equal((await run({}, 'POST')).status, 405);
|
|
assert.deepEqual((await run({ organizations: null })).body, { error: 'org-unavailable' });
|
|
// A granted user or the tester reaches the hook with owner false and no owner session.
|
|
for (const who of [{ owner: false }, { session: null }]) {
|
|
const res = await run(who);
|
|
assert.equal(res.status, 403); assert.deepEqual(res.body, { error: 'owner-only', message: 'the cross-organization view is the owner\'s' });
|
|
}
|
|
const bad = await run({ search: '?org=acme&org=acme' });
|
|
assert.equal(bad.status, 400); assert.equal(bad.body.error, 'invalid-request');
|
|
const logged = [];
|
|
const down = await run({ organizations: { members: async () => { throw new Error('/srv/x/org-runtime.json: Unexpected token'); } }, log: line => logged.push(line) });
|
|
assert.equal(down.status, 503); assert.deepEqual(down.body, { error: 'org-unavailable', message: 'Organization registry could not be read' });
|
|
assert.deepEqual(logged, ['workspace: organization registry could not be read']);
|
|
});
|
|
|
|
async function serve(t, organizations, sso = createDevOwner()) {
|
|
const s = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, organizations, log: () => {}, workspaceTimeoutMs: 200 });
|
|
t.after(() => new Promise(r => { s.close(r); s.closeIdleConnections(); }));
|
|
return `http://127.0.0.1:${s.address().port}`;
|
|
}
|
|
test('route: the dev owner reads GET /api/workspace; a granted user gets owner-only; the timeout option applies', async t => {
|
|
const seen = [];
|
|
const organizations = { list: async () => [], resolve: async () => null, members: async (session, opts) => { seen.push({ sub: session.sub, timeoutMs: opts.timeoutMs }); return [member('acme', ACME, { doc: ACME_DOC }), member('slow', BETA, { status: never })]; } };
|
|
const base = await serve(t, organizations);
|
|
const response = await fetch(`${base}/api/workspace?sections=projects,metrics`), body = await response.json();
|
|
assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store');
|
|
assert.deepEqual(body.orgs.map(o => [o.org, o.state]), [['acme', 'ok'], ['slow', 'timeout']]);
|
|
assert.equal(body.timeoutMs, 200); assert.deepEqual(seen, [{ sub: 'local-dev-owner', timeoutMs: 200 }]);
|
|
assert.equal((await fetch(`${base}/api/workspace?bogus=1`)).status, 400);
|
|
// POST has no route here: with no Origin the dev fence refuses it, and with the page's Origin the
|
|
// organizations fence answers as for any unknown /api path.
|
|
assert.equal((await fetch(`${base}/api/workspace`, { method: 'POST' })).status, 403);
|
|
const post = await fetch(`${base}/api/workspace`, { method: 'POST', headers: { origin: base, 'content-type': 'application/json' }, body: '{}' });
|
|
assert.equal(post.status, 404); assert.deepEqual(await post.json(), { error: 'org-unavailable' });
|
|
// A granted user's session (session.user set) is fenced before any org is read.
|
|
const granted = { ...createDevOwner(), session: () => ({ ...principal, sub: 'alice-sub', user: 'alice', name: 'Alice' }) };
|
|
const userBase = await serve(t, organizations, granted);
|
|
const refused = await fetch(`${userBase}/api/workspace`);
|
|
assert.equal(refused.status, 403); assert.equal((await refused.json()).error, 'owner-only');
|
|
assert.equal(seen.length, 1);
|
|
});
|
|
|
|
test('assistant read is designed, not built: no workspace broker verb, assistant authority unchanged', () => {
|
|
const role = JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url), 'utf8'));
|
|
assert.deepEqual(role.authority, { withinRole: ['message.send', 'record.write'], crossRole: [] });
|
|
const verbs = new Set(Object.values(broker).filter(Array.isArray).flat().filter(v => typeof v === 'string'));
|
|
assert.ok(verbs.has('inbox'), 'the verb lists were read');
|
|
assert.deepEqual([...verbs].filter(v => /^(workspace|orgs?)\b/.test(v)), []);
|
|
});
|