PRD 1.20 REQ-TASK-1, REQ-PLUG-1, REQ-WS-8. Each organization keeps its tasks in its own bus through the new task-tracker plug-in kind. The default module is the built-in store; Vikunja is the optional module and a Vikunja organization keeps today's path. - bus schema 3u to 3v (migration 3u-3v.sql): store_tasks, append-only, one row per write, each citing its events row; seven guard triggers. - broker: the eight role verbs on the store with the slice 1 field table checked twice (module, then a before/after field diff); the person's tasks.create/edit/archive/unarchive/delete as human.input events; the tasks read returns store rows in board order (group, position), filtered and paged like tracker rows. - runtime: each business gets its module at start (organization file plugin, else vikunja with a tracker entry, else store); a store business with a Vikunja tracker refuses the boot (tracker-config). - plugins: task-tracker kind with store and vikunja modules. - cli: the launcher's tracker flag covers store organizations; trail prints store writes. - pins: schema.test counts, the bus migration tests, stack-release #811 and T46 (store_tasks on the always list; COUNTED unchanged), budget-supervisor accepts 3v. Rebased onto SYS1 by the lead: SYS1 took 3t-3u, so this step is 3u-3v.
466 lines
29 KiB
JavaScript
466 lines
29 KiB
JavaScript
// Row 206 (193b2, #1704, row 193 design 5.2-5.6): a thread owner's read-only shares. The owner shares
|
|
// a thread with a user, the business human or everyone; #readable lets a sharee read it while the
|
|
// share is in force, the sharee's grant still covers it and a user owner's grant does too. Every
|
|
// write verb keeps the owner check. Mutants 2, 3, 4, 7, 8 and 9 of design section 8 die here.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { Store, schemaDigest } from '../src/store.mjs';
|
|
import { Broker } from '../src/broker.mjs';
|
|
|
|
const shipped = (name) =>
|
|
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
|
const here = (p) => new URL(p, import.meta.url);
|
|
const grant = (over = {}) => ({
|
|
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
|
|
});
|
|
|
|
// As user-principal.test.mjs: the access document is a function each test edits between requests.
|
|
function setup(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-thread-shares-'));
|
|
const store = new Store(root);
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [] }, fail: false };
|
|
const b = new Broker({
|
|
store,
|
|
businesses: {
|
|
demo: {
|
|
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
|
roles: {
|
|
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
},
|
|
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
|
},
|
|
},
|
|
access: () => {
|
|
if (access.fail) throw new Error('access file refused');
|
|
return access.doc;
|
|
},
|
|
});
|
|
const caps = {
|
|
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
|
reader: b.bindReader({ business: 'demo' }),
|
|
tester: b.bindTester({ business: 'demo' }),
|
|
};
|
|
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
|
const code = (who, verb, args = {}) => {
|
|
try {
|
|
call(who, verb, args);
|
|
} catch (e) {
|
|
return e.code;
|
|
}
|
|
return null;
|
|
};
|
|
const p1 = call('human', 'project.create', { name: 'shared' }).id;
|
|
const p2 = call('human', 'project.create', { name: 'private' }).id;
|
|
// alice and bob hold collaborator grants on p1, carol on p2 only.
|
|
const base = () => [grant({ projects: [p1] }), grant({ id: 'g-2', user: 'bob', projects: [p1] }), grant({ id: 'g-3', user: 'carol', projects: [p2] })];
|
|
access.doc.grants = base();
|
|
for (const name of ['alice', 'bob', 'carol']) caps[name] = b.bindUser({ business: 'demo', user: name });
|
|
const A = call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
|
|
const sent = call('alice', 'thread.send', { thread: A, body: 'alice: hello' }).id;
|
|
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
|
|
call('human', 'thread.send', { thread: H, body: 'jason: hello' });
|
|
const rows = () => store.get('SELECT count(*) n FROM thread_shares').n;
|
|
const read = (who, thread) => call(who, 'thread.messages', { thread }).map((m) => m.body);
|
|
return { b, store, access, caps, call, code, p1, p2, base, A, sent, H, rows, read };
|
|
}
|
|
|
|
test('a share lets the sharee read the thread and nothing else: every write verb stays the owner\'s (mutants 7, 9)', (t) => {
|
|
const { call, code, A, p1, rows, read } = setup(t);
|
|
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
|
|
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
|
|
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'share' });
|
|
assert.deepEqual(read('bob', A), ['alice: hello']);
|
|
const [listed] = call('bob', 'thread.list', { shared: true });
|
|
assert.deepEqual(Object.keys(listed), ['id', 'at', 'project', 'instance', 'name', 'owner']);
|
|
assert.deepEqual({ ...listed, at: null }, { id: A, at: null, project: p1, instance: 'assistant', name: 'alice work', owner: 'user:alice' });
|
|
// The plain list keeps its shape and holds only bob's own threads.
|
|
assert.deepEqual(call('bob', 'thread.list'), []);
|
|
assert.deepEqual(Object.keys(call('alice', 'thread.list')[0]), ['id', 'at', 'project', 'instance', 'name']);
|
|
// Mutant 7 (#threadRead for thread.send) and mutant 9 (no owner check on thread.share) die here.
|
|
for (const [verb, args] of [
|
|
['thread.send', { body: 'bob was here' }],
|
|
['thread.open', {}],
|
|
['thread.share', { to: '*' }],
|
|
['thread.share', { to: 'jason' }],
|
|
['thread.unshare', { to: 'user:bob' }],
|
|
['thread.shares', {}],
|
|
]) assert.equal(code('bob', verb, { thread: A, ...args }), 'thread-not-owned', verb);
|
|
assert.equal(rows(), 1);
|
|
assert.deepEqual(read('alice', A), ['alice: hello']);
|
|
// The share is bob's alone: the owner and carol still can't read.
|
|
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
|
|
assert.deepEqual(call('human', 'thread.list', { shared: true }), []);
|
|
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
|
|
});
|
|
|
|
test('the business human shares too, and a `*` share reaches every covered principal but the tester', (t) => {
|
|
const { call, code, A, H, rows, read } = setup(t);
|
|
assert.deepEqual(call('human', 'thread.share', { thread: H, to: 'user:alice' }), { thread: H, to: 'user:alice', op: 'share' });
|
|
assert.deepEqual(read('alice', H), ['jason: hello']);
|
|
assert.equal(code('bob', 'thread.messages', { thread: H }), 'thread-not-owned');
|
|
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: '*' }), { thread: A, to: '*', op: 'share' });
|
|
assert.deepEqual(read('bob', A), ['alice: hello']);
|
|
assert.deepEqual(read('human', A), ['alice: hello']);
|
|
assert.deepEqual(call('human', 'thread.list', { shared: true }).map((x) => x.id), [A]);
|
|
// Rule 2: carol's grant doesn't cover p1, so `*` doesn't reach her. Rule 4: nor the tester.
|
|
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
|
|
assert.deepEqual(call('carol', 'thread.list', { shared: true }), []);
|
|
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
|
|
assert.deepEqual(call('tester', 'thread.list', { shared: true }), []);
|
|
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => [x.to, x.by]), [['*', 'user:alice']]);
|
|
assert.deepEqual(call('human', 'thread.shares', { thread: H }).map((x) => [x.to, x.by]), [['user:alice', 'jason']]);
|
|
assert.equal(rows(), 2);
|
|
});
|
|
|
|
test('mutant 2: a share never widens a grant; when the sharee\'s grant stops covering the thread, its reads end', (t) => {
|
|
const { access, call, code, base, A, p2, read } = setup(t);
|
|
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
|
|
assert.deepEqual(read('bob', A), ['alice: hello']);
|
|
// bob keeps a collaborator grant, so the user gate passes, but it now covers p2 only.
|
|
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, projects: [p2] } : g));
|
|
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
|
|
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
|
|
// A grant on another instance is the same.
|
|
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, instances: ['chief'] } : g));
|
|
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
|
|
access.doc.grants = base();
|
|
assert.deepEqual(read('bob', A), ['alice: hello']);
|
|
// Removed, expired or an unreadable file: the gate refuses bob's next request.
|
|
for (const [what, change] of [
|
|
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'bob'); }],
|
|
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
|
|
['unreadable', () => { access.fail = true; }],
|
|
]) {
|
|
change();
|
|
assert.equal(code('bob', 'thread.messages', { thread: A }), 'access-revoked', what);
|
|
access.doc.grants = base();
|
|
access.fail = false;
|
|
assert.deepEqual(read('bob', A), ['alice: hello'], what);
|
|
}
|
|
});
|
|
|
|
test('mutant 3: a thread whose user owner has lost coverage is hidden from its sharees, the owner\'s board included', (t) => {
|
|
const { access, call, code, base, A, sent, read } = setup(t);
|
|
call('alice', 'thread.share', { thread: A, to: 'jason' });
|
|
assert.deepEqual(read('human', A), ['alice: hello']);
|
|
assert.ok(call('human', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
|
|
assert.ok(call('reader', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
|
|
for (const [what, change] of [
|
|
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'alice'); }],
|
|
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
|
|
['out of scope', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: ['none'] } : g)); }],
|
|
['unreadable', () => { access.fail = true; }],
|
|
]) {
|
|
change();
|
|
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned', what);
|
|
assert.deepEqual(call('human', 'thread.list', { shared: true }), [], what);
|
|
for (const who of ['human', 'reader']) assert.deepEqual(call(who, 'trail', { subject: sent }), [], `${who} ${what}`);
|
|
access.doc.grants = base();
|
|
access.fail = false;
|
|
assert.deepEqual(read('human', A), ['alice: hello'], what);
|
|
}
|
|
// A viewer grant still covers: rule 3 asks for any level.
|
|
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, level: 'viewer' } : g));
|
|
assert.deepEqual(read('human', A), ['alice: hello']);
|
|
});
|
|
|
|
test('mutant 4: a share\'s state is its latest row, so an unshare ends it and a share after it starts it again', (t) => {
|
|
const { store, call, code, A, rows, read } = setup(t);
|
|
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
|
|
assert.equal(code('alice', 'thread.share', { thread: A, to: 'user:bob' }), 'share-exists');
|
|
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'unshare' });
|
|
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
|
|
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
|
|
assert.deepEqual(call('alice', 'thread.shares', { thread: A }), []);
|
|
assert.equal(code('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), 'share-not-found');
|
|
assert.equal(code('alice', 'thread.unshare', { thread: A, to: '*' }), 'share-not-found');
|
|
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
|
|
assert.deepEqual(read('bob', A), ['alice: hello']);
|
|
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => x.to), ['user:bob']);
|
|
assert.deepEqual(store.all('SELECT op,target,by FROM thread_shares ORDER BY seq').map((r) => ({ ...r })), [
|
|
{ op: 'share', target: 'user:bob', by: 'user:alice' },
|
|
{ op: 'unshare', target: 'user:bob', by: 'user:alice' },
|
|
{ op: 'share', target: 'user:bob', by: 'user:alice' },
|
|
]);
|
|
// An unshare of `*` leaves bob's own share in force.
|
|
call('alice', 'thread.share', { thread: A, to: '*' });
|
|
call('alice', 'thread.unshare', { thread: A, to: '*' });
|
|
assert.deepEqual(read('bob', A), ['alice: hello']);
|
|
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
|
|
assert.equal(rows(), 5);
|
|
});
|
|
|
|
test('mutant 8: a user target needs a live grant of any level covering the thread, else share-not-covered', (t) => {
|
|
const { b, caps, access, call, code, base, A, p1, rows, read } = setup(t);
|
|
// carol's grant covers p2 only; dave has none; erin's is expired.
|
|
access.doc.grants = [...base(), grant({ id: 'g-4', user: 'erin', projects: [p1], expires: '2000-01-01T00:00:00Z' })];
|
|
for (const to of ['user:carol', 'user:dave', 'user:erin'])
|
|
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'share-not-covered', to);
|
|
assert.equal(rows(), 0);
|
|
// A viewer grant is enough to be shared with, and the viewer reads the share (row 275).
|
|
access.doc.grants = [...base(), grant({ id: 'g-5', user: 'vic', projects: [p1], level: 'viewer' })];
|
|
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'share' });
|
|
caps.vic = b.bindUser({ business: 'demo', user: 'vic' });
|
|
assert.deepEqual(read('vic', A), ['alice: hello']);
|
|
// An unshare checks no coverage: an uncovered target's share can still be ended.
|
|
access.doc.grants = base();
|
|
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'unshare' });
|
|
assert.equal(rows(), 2);
|
|
});
|
|
|
|
test('a viewer reads a thread shared with it, and share, unshare and shares refuse a viewer access-refused (design 4.3)', (t) => {
|
|
const { access, call, code, base, A, rows, read } = setup(t);
|
|
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
|
|
// bob drops to viewer and still reads the share.
|
|
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, level: 'viewer' } : g));
|
|
assert.deepEqual(read('bob', A), ['alice: hello']);
|
|
assert.deepEqual(call('bob', 'thread.list', { shared: true }).map((x) => x.id), [A]);
|
|
// alice drops to viewer too: she reads her own thread, and each share verb on it refuses.
|
|
access.doc.grants = base().map((g) => ({ ...g, level: 'viewer' }));
|
|
assert.deepEqual(read('alice', A), ['alice: hello']);
|
|
for (const [verb, args] of [['thread.share', { to: '*' }], ['thread.unshare', { to: 'user:bob' }], ['thread.shares', {}]])
|
|
assert.equal(code('alice', verb, { thread: A, ...args }), 'access-refused', verb);
|
|
assert.equal(rows(), 1);
|
|
// Rule 3 asks any level, so alice's viewer grant keeps the thread readable to bob.
|
|
assert.deepEqual(read('bob', A), ['alice: hello']);
|
|
});
|
|
|
|
test('a share target is `user:<id>`, the business human or `*`, and never the sharer', (t) => {
|
|
const { call, code, A, H, rows } = setup(t);
|
|
for (const to of ['user:alice', 'bob', 'tester', 'user:tester', 'user:human', 'user:user', 'user:jason', 'user:', 'user:Bad Name', '', 42, null, ['*']])
|
|
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'invalid-request', JSON.stringify(to));
|
|
for (const to of ['jason', 'user:x y'])
|
|
assert.equal(code('human', 'thread.share', { thread: H, to }), 'invalid-request', to);
|
|
assert.equal(code('alice', 'thread.share', { thread: A }), 'invalid-request');
|
|
assert.equal(code('alice', 'thread.share', { thread: A, to: '*', extra: 1 }), 'invalid-request');
|
|
assert.equal(code('alice', 'thread.shares', { thread: A, to: '*' }), 'invalid-request');
|
|
assert.equal(code('alice', 'thread.share', { thread: randomUUID(), to: '*' }), 'thread-not-found');
|
|
assert.equal(code('alice', 'thread.list', { shared: false }), 'invalid-request');
|
|
assert.equal(code('alice', 'thread.list', { shared: 'yes' }), 'invalid-request');
|
|
assert.equal(rows(), 0);
|
|
// The owner shares its own threads only; a user's thread refuses the business human too.
|
|
assert.equal(code('human', 'thread.share', { thread: A, to: '*' }), 'thread-not-owned');
|
|
assert.equal(code('alice', 'thread.share', { thread: H, to: '*' }), 'thread-not-owned');
|
|
assert.equal(rows(), 0);
|
|
assert.deepEqual(call('human', 'thread.share', { thread: H, to: '*' }).op, 'share');
|
|
});
|
|
|
|
test('a user whose collaborator grant no longer covers its own thread can\'t share, unshare or list its shares', (t) => {
|
|
const { access, call, code, base, A, p2, rows } = setup(t);
|
|
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
|
|
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: [p2] } : g));
|
|
for (const [verb, to] of [['thread.share', '*'], ['thread.unshare', 'user:bob'], ['thread.shares', undefined]])
|
|
assert.equal(code('alice', verb, { thread: A, ...(to ? { to } : {}) }), 'access-refused', verb);
|
|
assert.equal(rows(), 1);
|
|
});
|
|
|
|
test('the tester and a reader session never share: each verb refuses and writes nothing', (t) => {
|
|
const { store, call, code, A, H, rows } = setup(t);
|
|
const refusals = () => store.get("SELECT count(*) n FROM events WHERE kind='action.refused'").n;
|
|
const T = call('tester', 'thread.create', { project: call('human', 'project.list')[0].id, name: 'tester work' }).id;
|
|
const was = refusals();
|
|
for (const verb of ['thread.share', 'thread.unshare', 'thread.shares'])
|
|
for (const thread of [T, H, A]) {
|
|
assert.equal(code('tester', verb, { thread, to: '*' }), 'tester-refused', `${verb} ${thread}`);
|
|
assert.equal(code('reader', verb, { thread, to: '*' }), 'read-only', `${verb} ${thread}`);
|
|
}
|
|
// Each refusal is recorded, as every refused request is.
|
|
assert.equal(refusals() - was, 18);
|
|
assert.equal(rows(), 0);
|
|
// A share with `*` of the owner's thread doesn't reach the tester's reads.
|
|
call('human', 'thread.share', { thread: H, to: '*' });
|
|
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
|
|
});
|
|
|
|
test('the owner\'s trail and replies show a user\'s message only while that user\'s thread is shared with the owner', (t) => {
|
|
const { store, call, code, A, sent } = setup(t);
|
|
// The run's reply to alice, as R3's `to: 'user'` path writes it.
|
|
const reply = randomUUID();
|
|
store.run(
|
|
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
|
reply, new Date().toISOString(), 'demo', 'assistant', 'assistant-run', 'user', 'INFO', sent, null, null, 'hello alice',
|
|
);
|
|
// A new thread's default session has its own id (only a migrated thread's reuses the thread's).
|
|
const { session } = store.get('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq DESC LIMIT 1', A);
|
|
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', reply, A, session);
|
|
for (const who of ['human', 'reader']) {
|
|
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
|
|
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
|
|
}
|
|
call('alice', 'thread.share', { thread: A, to: 'jason' });
|
|
for (const who of ['human', 'reader']) {
|
|
assert.ok(call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent), who);
|
|
assert.deepEqual(call(who, 'replies', { id: sent }).map((m) => m.id), [reply], who);
|
|
}
|
|
// The tester never reads another's thread (rule 4).
|
|
assert.deepEqual(call('tester', 'trail', { subject: sent }), []);
|
|
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
|
|
call('alice', 'thread.unshare', { thread: A, to: 'jason' });
|
|
for (const who of ['human', 'reader']) {
|
|
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
|
|
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
|
|
}
|
|
});
|
|
|
|
test('a share covers every session of the thread', (t) => {
|
|
const { store, call, code, H, read } = setup(t);
|
|
call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' });
|
|
const S = store.get("SELECT id FROM sessions WHERE thread=? AND name='second'", H).id;
|
|
call('human', 'thread.send', { thread: H, session: S, body: 'jason: second' });
|
|
call('human', 'thread.share', { thread: H, to: 'user:alice' });
|
|
assert.deepEqual(read('alice', H), ['jason: hello']);
|
|
assert.deepEqual(call('alice', 'thread.messages', { thread: H, session: S }).map((m) => m.body), ['jason: second']);
|
|
assert.equal(code('alice', 'thread.messages', { thread: H, session: randomUUID() }), 'session-not-found');
|
|
assert.equal(code('bob', 'thread.messages', { thread: H, session: S }), 'thread-not-owned');
|
|
});
|
|
|
|
test('a sharee can\'t list the thread\'s sessions: session.list stays owner-only (Q7, sage-2818 Q1)', (t) => {
|
|
const { call, code, A, H } = setup(t);
|
|
call('alice', 'thread.share', { thread: A, to: 'jason' });
|
|
call('human', 'thread.share', { thread: H, to: '*' });
|
|
assert.equal(code('human', 'session.list', { thread: A }), 'thread-not-owned');
|
|
assert.equal(code('reader', 'session.list', { thread: A }), 'read-only');
|
|
for (const who of ['alice', 'bob']) assert.equal(code(who, 'session.list', { thread: H }), 'access-refused', who);
|
|
// The owners still list their own.
|
|
assert.equal(call('human', 'session.list', { thread: H }).length, 1);
|
|
});
|
|
|
|
test('only a human principal matches a share: an agent bound to the thread and the tester match none (sage-2818 Q2)', (t) => {
|
|
const { b, caps, call, code, A, H, sent } = setup(t);
|
|
// The assistant run serving alice's thread, and a chief run with no thread.
|
|
caps.agent = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: A });
|
|
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
|
for (const who of ['agent', 'chief']) call(who, 'role.claim');
|
|
const seen = (who) => call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent);
|
|
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} before`);
|
|
call('alice', 'thread.share', { thread: A, to: '*' });
|
|
call('alice', 'thread.share', { thread: A, to: 'jason' });
|
|
// The human principals now read it; the agents and the tester see what they saw before.
|
|
for (const who of ['human', 'reader']) assert.equal(seen(who), true, who);
|
|
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} after`);
|
|
for (const who of ['agent', 'chief']) {
|
|
assert.equal(code(who, 'thread.messages', { thread: A }), 'human-required', who);
|
|
assert.equal(code(who, 'replies', { id: sent }), 'human-required', who);
|
|
}
|
|
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
|
|
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
|
|
call('human', 'thread.share', { thread: H, to: '*' });
|
|
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
|
|
});
|
|
|
|
// The 3l→3m step. A 3l store from fixtures/schema-3l.sql, with one row in each thread table.
|
|
const AT = '2026-10-22T00:00:00.000Z';
|
|
const ddl3l = readFileSync(here('./fixtures/schema-3l.sql'), 'utf8');
|
|
function source3l(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'thread-shares-migrate-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
|
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
|
|
assert.equal(createHash('sha256').update(ddl3l).digest('hex'), 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a');
|
|
db.exec(ddl3l);
|
|
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
|
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3l');
|
|
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
|
|
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
|
|
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
|
|
for (const [id, owner] of [['t1', 'jason'], ['t2', 'user:alice']]) {
|
|
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', owner, id);
|
|
run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) VALUES(?,?,?,?,?,?,'default','legacy-3l')", AT, 'demo', id, id, 'assistant', owner);
|
|
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,'migration-3l')", AT, id, id);
|
|
}
|
|
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'm-1', AT, 'demo', 'user', 'user:alice', 'assistant', 'INFO', 'm-1');
|
|
run('INSERT INTO message_threads VALUES(?,?,?)', 'm-1', 't2', 't2');
|
|
run('INSERT INTO thread_opens(seq,at,thread,session,by) VALUES(?,?,?,?,?)', 7, AT, 't2', 't2', 'user:alice');
|
|
db.close();
|
|
chmodSync(path, 0o600);
|
|
return { root, path };
|
|
}
|
|
const open = (path, fn) => {
|
|
const db = new DatabaseSync(path, { readOnly: true });
|
|
try { return fn(db); } finally { db.close(); }
|
|
};
|
|
const master = (path) => open(path, (db) => db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })));
|
|
// Every table's rows but meta's, whose version and digest the step changes.
|
|
const tables = (path) => open(path, (db) => Object.fromEntries(
|
|
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('meta') ORDER BY name").all()
|
|
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all().map((r) => ({ ...r }))]),
|
|
));
|
|
const version = (path) => open(path, (db) => db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value);
|
|
|
|
test('a 3l store migrates to 3m: thread_shares is new and empty, and every other row stays as it was', (t) => {
|
|
const { root, path } = source3l(t);
|
|
const before = tables(path);
|
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
|
new Store(root, { migrateSchema: true }).close();
|
|
assert.equal(version(path), '3v');
|
|
const after = tables(path);
|
|
assert.deepEqual(after.thread_shares, []);
|
|
delete after.thread_shares;
|
|
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
|
|
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
|
|
assert.deepEqual(after[name], [], name);
|
|
delete after[name];
|
|
}
|
|
assert.deepEqual(after, before);
|
|
// The migrated schema is the fresh one, object for object.
|
|
const freshRoot = mkdtempSync(join(tmpdir(), 'thread-shares-fresh-'));
|
|
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
|
new Store(freshRoot).close();
|
|
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
|
|
// No existing thread is shared: each stays private to its owner.
|
|
const store = new Store(root);
|
|
t.after(() => store.close());
|
|
store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'share', 'jason', 'user:alice');
|
|
for (const [sql, args] of [
|
|
["UPDATE thread_shares SET target='*'", []],
|
|
['DELETE FROM thread_shares', []],
|
|
['INSERT OR REPLACE INTO thread_shares(seq,at,business,thread,op,target,by) VALUES(?,?,?,?,?,?,?)', [1, AT, 'demo', 't2', 'unshare', 'jason', 'user:alice']],
|
|
]) assert.throws(() => store.run(sql, ...args), /thread_shares is append-only/, sql);
|
|
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'grant', '*', 'user:alice'), /CHECK/);
|
|
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 'no-such', 'share', '*', 'jason'), /FOREIGN KEY/);
|
|
assert.deepEqual(store.all('SELECT seq,thread,op,target FROM thread_shares').map((r) => ({ ...r })), [{ seq: 1, thread: 't2', op: 'share', target: 'jason' }]);
|
|
});
|
|
|
|
// The 3l engine is store.mjs at this change with the 3l schema and version and no 3l→3m step.
|
|
async function engine3l(root) {
|
|
const moduleRoot = join(root, 'engine-3l');
|
|
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
|
|
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
|
const edited = source
|
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
|
|
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3l';")
|
|
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
|
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
|
|
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
|
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3l);
|
|
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
|
|
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
|
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
|
}
|
|
|
|
test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it was', async (t) => {
|
|
const { root, path } = source3l(t);
|
|
new Store(root, { migrateSchema: true }).close();
|
|
const was = master(path), before = tables(path);
|
|
const Store3l = await engine3l(root);
|
|
assert.throws(() => new Store3l(root), /schema-mismatch/);
|
|
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
|
|
assert.equal(version(path), '3v');
|
|
assert.deepEqual(master(path), was);
|
|
assert.deepEqual(tables(path), before);
|
|
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
|
|
const { root: old } = source3l(t);
|
|
new Store3l(old).close();
|
|
});
|