@bongos/core 1.19.813 → 1.19.815
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +74 -39
- package/clients/bongos-client/bongos-client.global.js +1 -1
- package/clients/bongos-client/index.cjs +1 -1
- package/clients/bongos-client/index.d.ts +2 -3
- package/clients/bongos-client/index.mjs +1 -1
- package/docs/adr/0297-a-tasks-creator-may-retire-it.md +128 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -28
- package/docs/api-reference.md +2 -2
- package/docs/canonical-permissions.md +30 -1
- package/docs/copy-inventory.md +49 -45
- package/docs/copy-registry.json +86 -50
- package/docs/module-api-changelog.md +4 -0
- package/modules/agents/lib/validate.js +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +1 -1
- package/modules/discord/discord-channels.js +80 -5
- package/modules/discord/routes/discord.js +14 -2
- package/modules/government/catalog.js +28 -1
- package/modules/government/migrations/government_015_task_abandon_own.sql +64 -0
- package/modules/hall-ui/public/task.js +50 -1
- package/modules/ideas/inbox.js +29 -1
- package/modules/lifecycle/abandon-authz.js +132 -0
- package/modules/lifecycle/cascade.js +134 -19
- package/modules/lifecycle/db-ship.js +59 -5
- package/modules/lifecycle/routes/abandon-handler.js +151 -0
- package/modules/lifecycle/routes/lifecycle.js +18 -0
- package/modules/lifecycle/routes/tasks.js +26 -38
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +12 -1
- package/src/module-api.js +1 -1
- package/tests/abandon_creator_authz.mjs +176 -0
- package/tests/abandon_route.mjs +276 -0
- package/tests/abandon_task.mjs +145 -3
- package/tests/agents_validate.mjs +33 -2
- package/tests/discord_channels.mjs +166 -0
- package/tests/government_ownership_scope.mjs +13 -7
- package/tests/idea_cascade.mjs +211 -0
- package/tests/task_goal_required.mjs +15 -3
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// tests/abandon_route.mjs
|
|
2
|
+
//
|
|
3
|
+
// Regression guard for task 1002443 / ADR 0297 — the COMPOSITION layer of
|
|
4
|
+
// POST /tasks/:id/abandon (modules/lifecycle/routes/abandon-handler.js).
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS FILE EXISTS. The route's authority is assembled from three pieces that
|
|
7
|
+
// each have their own tests and none of which cover the seams between them:
|
|
8
|
+
// • abandon-authz.js — the pure decision (tests/abandon_creator_authz.mjs)
|
|
9
|
+
// • abandonTask — the transaction seam (tests/abandon_task.mjs)
|
|
10
|
+
// • this handler — resolve, compose, map (nothing, until now)
|
|
11
|
+
// A mistake in the wiring — admitting when the government port is absent, mapping
|
|
12
|
+
// a refusal to the wrong status, naming the wrong atom in a denial, passing the
|
|
13
|
+
// wrong flags into the decision — would have passed both of the others. That gap
|
|
14
|
+
// was the grade finding on the first ship of this task; this closes it.
|
|
15
|
+
//
|
|
16
|
+
// No Express, no DB, no live port: the handler takes its collaborators as `deps`,
|
|
17
|
+
// so fakes are enough.
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test tests/abandon_route.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { makeRunner } from './helpers.mjs';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const { makeAbandonHandler } = require('../modules/lifecycle/routes/abandon-handler.js');
|
|
27
|
+
|
|
28
|
+
const { test, summary } = makeRunner();
|
|
29
|
+
|
|
30
|
+
const ME = 7;
|
|
31
|
+
const ANY_KEY = 'task.abandon';
|
|
32
|
+
const OWN_KEY = 'task.abandon.own';
|
|
33
|
+
|
|
34
|
+
// A res that records instead of writing. `fail` mirrors the real envelope's two
|
|
35
|
+
// call shapes: fail(code, status) and fail(code, { status, message, details }).
|
|
36
|
+
function fakeRes() {
|
|
37
|
+
const rec = { json: null, fail: null };
|
|
38
|
+
return {
|
|
39
|
+
rec,
|
|
40
|
+
json(body) { rec.json = body; return this; },
|
|
41
|
+
fail(code, second, third) {
|
|
42
|
+
const opts = (second && typeof second === 'object') ? second : { status: second };
|
|
43
|
+
rec.fail = { code, status: opts.status, message: opts.message, details: opts.details ?? third };
|
|
44
|
+
return this;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A government port with only what the handler actually consults.
|
|
50
|
+
function fakeGovernance({ holds = [], crossOwner = [ANY_KEY], resolveThrows = false, omit = [] } = {}) {
|
|
51
|
+
const port = {
|
|
52
|
+
resolveBuilderPermissions: async () => {
|
|
53
|
+
if (resolveThrows) throw new Error('DB blip');
|
|
54
|
+
return [...holds];
|
|
55
|
+
},
|
|
56
|
+
sameOwner: (a, b) => a != null && b != null && String(a) === String(b),
|
|
57
|
+
crossOwnerKeysFor: () => [...crossOwner],
|
|
58
|
+
permissionFloor: (k) => (k === ANY_KEY ? 'metic' : k === OWN_KEY ? 'xenos' : null),
|
|
59
|
+
};
|
|
60
|
+
for (const k of omit) delete port[k];
|
|
61
|
+
return port;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function harness({ governance = fakeGovernance(), abandon, taskId = 42, body = {} } = {}) {
|
|
65
|
+
const calls = { abandon: null, logged: [] };
|
|
66
|
+
const deps = {
|
|
67
|
+
db: {
|
|
68
|
+
abandonTask: async (args) => {
|
|
69
|
+
calls.abandon = args;
|
|
70
|
+
if (typeof abandon === 'function') return abandon(args);
|
|
71
|
+
return { taskId: args.taskId, taskTitle: 'fixture', taskStatus: 'abandoned', previousStatus: 'ready', alreadyAbandoned: false };
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
seams: { resolveOptional: (name) => (name === 'government' ? governance : null) },
|
|
75
|
+
log: { error: (...a) => calls.logged.push(a) },
|
|
76
|
+
validateOrRespond: () => false, // body is valid
|
|
77
|
+
parseId: () => taskId, // id parses
|
|
78
|
+
};
|
|
79
|
+
const req = { builder: { id: ME }, body, params: { id: String(taskId) } };
|
|
80
|
+
const res = fakeRes();
|
|
81
|
+
return { handler: makeAbandonHandler(deps), req, res, calls };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── the port is the wall, and its absence denies ──────────────────────────────
|
|
85
|
+
await test('a MISSING government port denies fail-closed, and never reaches the db', async () => {
|
|
86
|
+
// Admitting here would make disabling a module a privilege escalation — the
|
|
87
|
+
// inversion ADR 0016 exists to prevent.
|
|
88
|
+
const { handler, req, res, calls } = harness({ governance: null });
|
|
89
|
+
await handler(req, res);
|
|
90
|
+
assert.equal(res.rec.fail.status, 403);
|
|
91
|
+
assert.equal(res.rec.fail.code, 'permission_forbidden');
|
|
92
|
+
assert.equal(res.rec.fail.details.held, null, 'held is null when authority could not be ESTABLISHED');
|
|
93
|
+
assert.equal(calls.abandon, null, 'no transaction may be opened');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('a MALFORMED port (missing a function the decision needs) also denies', async () => {
|
|
97
|
+
for (const missing of ['resolveBuilderPermissions', 'sameOwner']) {
|
|
98
|
+
const { handler, req, res, calls } = harness({ governance: fakeGovernance({ holds: [ANY_KEY], omit: [missing] }) });
|
|
99
|
+
await handler(req, res);
|
|
100
|
+
assert.equal(res.rec.fail.status, 403, `a port without ${missing} must deny`);
|
|
101
|
+
assert.equal(calls.abandon, null);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await test('a THROWING resolver denies rather than surfacing as a 500', async () => {
|
|
106
|
+
const { handler, req, res, calls } = harness({ governance: fakeGovernance({ resolveThrows: true }) });
|
|
107
|
+
await handler(req, res);
|
|
108
|
+
assert.equal(res.rec.fail.status, 403);
|
|
109
|
+
assert.equal(res.rec.fail.details.held, null);
|
|
110
|
+
assert.equal(calls.abandon, null);
|
|
111
|
+
assert.equal(calls.logged.length, 1, 'and it is logged — a DB blip should be visible');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── the denial names the right atoms ──────────────────────────────────────────
|
|
115
|
+
await test('holding neither atom is a 403 naming BOTH ways in, with their floors', async () => {
|
|
116
|
+
const { handler, req, res, calls } = harness({ governance: fakeGovernance({ holds: [] }) });
|
|
117
|
+
await handler(req, res);
|
|
118
|
+
assert.equal(res.rec.fail.status, 403);
|
|
119
|
+
assert.deepEqual(res.rec.fail.details.required, [ANY_KEY, OWN_KEY],
|
|
120
|
+
'a denial listing only the metic key would send a creator after the wrong permission');
|
|
121
|
+
assert.deepEqual(res.rec.fail.details.floors, { [ANY_KEY]: 'metic', [OWN_KEY]: 'xenos' });
|
|
122
|
+
assert.deepEqual(res.rec.fail.details.held, []);
|
|
123
|
+
assert.equal(calls.abandon, null, 'the cheap pre-check must not open a transaction');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await test('floors come from the PORT, so a moved floor cannot leave a lie in the denial', async () => {
|
|
127
|
+
const gov = fakeGovernance({ holds: [] });
|
|
128
|
+
gov.permissionFloor = (k) => (k === ANY_KEY ? 'archon' : null);
|
|
129
|
+
const { handler, req, res } = harness({ governance: gov });
|
|
130
|
+
await handler(req, res);
|
|
131
|
+
assert.deepEqual(res.rec.fail.details.floors, { [ANY_KEY]: 'archon' },
|
|
132
|
+
'the floor is read at deny time, never hardcoded');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test('a port that cannot answer floors still denies, without inventing one', async () => {
|
|
136
|
+
const { handler, req, res } = harness({ governance: fakeGovernance({ holds: [], omit: ['permissionFloor'] }) });
|
|
137
|
+
await handler(req, res);
|
|
138
|
+
assert.equal(res.rec.fail.status, 403);
|
|
139
|
+
assert.deepEqual(res.rec.fail.details.floors, {});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── the escalation path is read from the catalog ──────────────────────────────
|
|
143
|
+
await test('the cross-owner key is DERIVED from the catalog, not hardcoded', async () => {
|
|
144
|
+
// If the declared counterpart ever changes, the route must follow it rather than
|
|
145
|
+
// keep admitting on a stale pairing.
|
|
146
|
+
const { handler, req, res, calls } = harness({
|
|
147
|
+
governance: fakeGovernance({ holds: ['task.manage.any'], crossOwner: ['task.manage.any'] }),
|
|
148
|
+
});
|
|
149
|
+
await handler(req, res);
|
|
150
|
+
assert.equal(res.rec.fail, null, 'the declared counterpart must admit');
|
|
151
|
+
assert.equal(res.rec.json.ok, true);
|
|
152
|
+
assert.equal(calls.abandon.authorize({ createdBy: 999, status: 'completed' }).ok, true,
|
|
153
|
+
'and it must compose as the CROSS-OWNER branch, unbounded by the creator window');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('holding the OLD atom does not admit once the catalog has moved on', async () => {
|
|
157
|
+
const { handler, req, res, calls } = harness({
|
|
158
|
+
governance: fakeGovernance({ holds: [ANY_KEY], crossOwner: ['task.manage.any'] }),
|
|
159
|
+
});
|
|
160
|
+
await handler(req, res);
|
|
161
|
+
assert.equal(res.rec.fail.status, 403, 'a catalog that moved on must not leave this gate wide');
|
|
162
|
+
assert.equal(calls.abandon, null);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await test('a port that cannot name the escalation falls back to the known atom, never to open', async () => {
|
|
166
|
+
const { handler, req, res } = harness({
|
|
167
|
+
governance: fakeGovernance({ holds: [ANY_KEY], omit: ['crossOwnerKeysFor'] }),
|
|
168
|
+
});
|
|
169
|
+
await handler(req, res);
|
|
170
|
+
assert.equal(res.rec.fail, null, 'the fallback keeps the metic path working');
|
|
171
|
+
assert.equal(res.rec.json.ok, true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── composition: what the decision is actually handed ─────────────────────────
|
|
175
|
+
await test('the own-scoped holder reaches the row question, composed correctly', async () => {
|
|
176
|
+
const { handler, req, res, calls } = harness({ governance: fakeGovernance({ holds: [OWN_KEY] }) });
|
|
177
|
+
await handler(req, res);
|
|
178
|
+
assert.ok(calls.abandon, 'an own-holder must be let as far as the transaction');
|
|
179
|
+
const verdict = calls.abandon.authorize;
|
|
180
|
+
assert.equal(verdict({ createdBy: ME, status: 'ready' }).ok, true, 'their own ready task admits');
|
|
181
|
+
assert.equal(verdict({ createdBy: 999, status: 'ready' }).ok, false, 'someone else\'s does not');
|
|
182
|
+
assert.equal(verdict({ createdBy: ME, status: 'completed' }).ok, false, 'and not past the grader');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await test('the callback is handed the CALLER as the owner to compare against', async () => {
|
|
186
|
+
// A wiring slip here (comparing against the task id, or a constant) would make
|
|
187
|
+
// every creator either always or never the owner.
|
|
188
|
+
let sawArgs = null;
|
|
189
|
+
const gov = fakeGovernance({ holds: [OWN_KEY] });
|
|
190
|
+
gov.sameOwner = (a, b) => { sawArgs = [a, b]; return true; };
|
|
191
|
+
const { handler, req, res, calls } = harness({ governance: gov });
|
|
192
|
+
await handler(req, res);
|
|
193
|
+
calls.abandon.authorize({ createdBy: 'abc', status: 'ready' });
|
|
194
|
+
assert.deepEqual(sawArgs, ['abc', ME], 'the row owner and the requesting builder, in that order');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await test('the reason is passed through and clamped to 2000 characters', async () => {
|
|
198
|
+
const { handler, req, res, calls } = harness({
|
|
199
|
+
governance: fakeGovernance({ holds: [ANY_KEY] }),
|
|
200
|
+
body: { reason: 'x'.repeat(2500) },
|
|
201
|
+
});
|
|
202
|
+
await handler(req, res);
|
|
203
|
+
assert.equal(calls.abandon.reason.length, 2000);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ── refusal mapping ──────────────────────────────────────────────────────────
|
|
207
|
+
await test('ABANDON_FORBIDDEN from the locked row maps to 403, carrying its reason', async () => {
|
|
208
|
+
const { handler, req, res } = harness({
|
|
209
|
+
governance: fakeGovernance({ holds: [OWN_KEY] }),
|
|
210
|
+
abandon: async () => { throw Object.assign(new Error('not yours'), { code: 'ABANDON_FORBIDDEN', reason: 'not_task_creator' }); },
|
|
211
|
+
});
|
|
212
|
+
await handler(req, res);
|
|
213
|
+
assert.equal(res.rec.fail.status, 403);
|
|
214
|
+
assert.equal(res.rec.fail.message, 'not yours', 'the row-scoped message reaches the caller');
|
|
215
|
+
assert.equal(res.rec.fail.details.reason, 'not_task_creator');
|
|
216
|
+
assert.deepEqual(res.rec.fail.details.held, [OWN_KEY], 'and it reports what they DO hold');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await test('the three lifecycle guards keep their own status codes', async () => {
|
|
220
|
+
const cases = [
|
|
221
|
+
['TASK_NOT_FOUND', 404, 'task_not_found'],
|
|
222
|
+
['CANNOT_ABANDON_SHIPPED', 409, 'cannot_abandon_shipped'],
|
|
223
|
+
['TASK_HAS_ACTIVE_CLAIM', 409, 'task_has_active_claim'],
|
|
224
|
+
];
|
|
225
|
+
for (const [code, status, envelope] of cases) {
|
|
226
|
+
const { handler, req, res } = harness({
|
|
227
|
+
governance: fakeGovernance({ holds: [ANY_KEY] }),
|
|
228
|
+
abandon: async () => { throw Object.assign(new Error(code), { code }); },
|
|
229
|
+
});
|
|
230
|
+
await handler(req, res);
|
|
231
|
+
assert.equal(res.rec.fail.status, status, `${code} must stay a ${status}`);
|
|
232
|
+
assert.equal(res.rec.fail.code, envelope);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await test('an unexpected error is a 500 and is logged, never a silent 403', async () => {
|
|
237
|
+
const { handler, req, res, calls } = harness({
|
|
238
|
+
governance: fakeGovernance({ holds: [ANY_KEY] }),
|
|
239
|
+
abandon: async () => { throw new Error('something else entirely'); },
|
|
240
|
+
});
|
|
241
|
+
await handler(req, res);
|
|
242
|
+
assert.equal(res.rec.fail.status, 500);
|
|
243
|
+
assert.equal(res.rec.fail.code, 'abandon_failed');
|
|
244
|
+
assert.equal(calls.logged.length, 1);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ── the happy path ───────────────────────────────────────────────────────────
|
|
248
|
+
await test('a successful retire answers ok with the db result spread in', async () => {
|
|
249
|
+
const { handler, req, res } = harness({ governance: fakeGovernance({ holds: [ANY_KEY] }) });
|
|
250
|
+
await handler(req, res);
|
|
251
|
+
assert.equal(res.rec.fail, null);
|
|
252
|
+
assert.equal(res.rec.json.ok, true);
|
|
253
|
+
assert.equal(res.rec.json.taskStatus, 'abandoned');
|
|
254
|
+
assert.equal(res.rec.json.previousStatus, 'ready');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await test('an invalid body or id short-circuits before any authority work', async () => {
|
|
258
|
+
for (const stop of ['body', 'id']) {
|
|
259
|
+
const calls = { abandon: null, resolved: 0 };
|
|
260
|
+
const gov = fakeGovernance({ holds: [ANY_KEY] });
|
|
261
|
+
const realResolve = gov.resolveBuilderPermissions;
|
|
262
|
+
gov.resolveBuilderPermissions = async (...a) => { calls.resolved++; return realResolve(...a); };
|
|
263
|
+
const handler = makeAbandonHandler({
|
|
264
|
+
db: { abandonTask: async () => { calls.abandon = true; } },
|
|
265
|
+
seams: { resolveOptional: () => gov },
|
|
266
|
+
log: { error: () => {} },
|
|
267
|
+
validateOrRespond: () => stop === 'body',
|
|
268
|
+
parseId: () => (stop === 'id' ? null : 42),
|
|
269
|
+
});
|
|
270
|
+
await handler({ builder: { id: ME }, body: {}, params: {} }, fakeRes());
|
|
271
|
+
assert.equal(calls.abandon, null, `a bad ${stop} must not reach the db`);
|
|
272
|
+
assert.equal(calls.resolved, 0, `a bad ${stop} must not even resolve permissions`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
summary();
|
package/tests/abandon_task.mjs
CHANGED
|
@@ -40,10 +40,17 @@ const { test, summary } = makeRunner();
|
|
|
40
40
|
// status → the row's current status
|
|
41
41
|
// hasActiveClaim → the claims lookup returns an unreleased claim
|
|
42
42
|
// updatedStatus → what the UPDATE ... RETURNING reports (restore returns 'backlog')
|
|
43
|
-
|
|
43
|
+
// createdBy (task 1002443) rides the locked SELECT so the ownership predicate and
|
|
44
|
+
// the UPDATE read the same row version; null is the honest default (every task
|
|
45
|
+
// filed before core_227, and every system vector, has no creator).
|
|
46
|
+
function fakePool({ status = 'ready', notFound = false, hasActiveClaim = false, updatedStatus = 'abandoned', createdBy = null }) {
|
|
44
47
|
return makeSqlAwareClient((s, params) => {
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
// Both locked SELECTs, and the optional group is load-bearing: abandonTask
|
|
49
|
+
// reads created_by (task 1002443) and restoreTask does not, so a regex
|
|
50
|
+
// pinned to either one alone silently returns no row for the other — which
|
|
51
|
+
// surfaces as TASK_NOT_FOUND and looks like a code bug, not a fake bug.
|
|
52
|
+
if (/^SELECT id, title, status(, created_by)? FROM tasks WHERE id = \$1 FOR UPDATE/.test(s)) {
|
|
53
|
+
return { rows: notFound ? [] : [{ id: params[0], title: 'fixture task', status, created_by: createdBy }] };
|
|
47
54
|
}
|
|
48
55
|
if (/SELECT id FROM claims WHERE task_id = \$1 AND released_at IS NULL/.test(s)) {
|
|
49
56
|
return { rows: hasActiveClaim ? [{ id: 999 }] : [] };
|
|
@@ -207,5 +214,140 @@ await test('restore: never consults claims (an abandoned task has none by constr
|
|
|
207
214
|
assert.equal(all(pool._client.queries, /SELECT id FROM claims/).length, 0);
|
|
208
215
|
});
|
|
209
216
|
|
|
217
|
+
// =========================================================================
|
|
218
|
+
// task 1002443 / ADR 0297 — the `authorize` seam: the row-scoped veto that lets a
|
|
219
|
+
// task's CREATOR retire their own work without holding the metic-floor
|
|
220
|
+
// task.abandon. The DECISION itself is pinned branch-by-branch in
|
|
221
|
+
// tests/abandon_creator_authz.mjs; what is pinned HERE is the seam's mechanics —
|
|
222
|
+
// what it is handed, when it runs, and that every unhappy path denies.
|
|
223
|
+
|
|
224
|
+
await test('authorize is handed the LOCKED row: created_by and status', async () => {
|
|
225
|
+
// The whole point of doing this inside the transaction. If the callback were fed
|
|
226
|
+
// anything other than the row abandonTask just locked FOR UPDATE, the ownership
|
|
227
|
+
// check would be reading a different version than the UPDATE writes — the TOCTOU
|
|
228
|
+
// window this seam exists to close.
|
|
229
|
+
const pool = fakePool({ status: 'ready', createdBy: 7 });
|
|
230
|
+
let seen = null;
|
|
231
|
+
await abandonTask({
|
|
232
|
+
taskId: TASK,
|
|
233
|
+
reason: 'mine to retire',
|
|
234
|
+
authorize: (row) => { seen = row; return { ok: true }; },
|
|
235
|
+
}, { pool });
|
|
236
|
+
assert.equal(seen.createdBy, 7, 'created_by must reach the predicate');
|
|
237
|
+
assert.equal(seen.status, 'ready', 'and so must the row status');
|
|
238
|
+
assert.equal(seen.taskId, TASK);
|
|
239
|
+
assert.ok(find(pool._client.queries, /^UPDATE tasks/), 'an admit still performs the write');
|
|
240
|
+
assert.ok(find(pool._client.queries, /^COMMIT/));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await test('a refusing authorize throws ABANDON_FORBIDDEN, rolls back, writes nothing', async () => {
|
|
244
|
+
const pool = fakePool({ status: 'ready', createdBy: 8 });
|
|
245
|
+
await assert.rejects(
|
|
246
|
+
() => abandonTask({
|
|
247
|
+
taskId: TASK,
|
|
248
|
+
authorize: () => ({ ok: false, reason: 'not_task_creator', message: 'not yours' }),
|
|
249
|
+
}, { pool }),
|
|
250
|
+
(err) => err.code === 'ABANDON_FORBIDDEN' && err.reason === 'not_task_creator',
|
|
251
|
+
);
|
|
252
|
+
assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0, 'a refusal must never write');
|
|
253
|
+
assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await test('authorize runs BEFORE the already-abandoned idempotent return', async () => {
|
|
257
|
+
// Ordering, and it is a disclosure rule rather than a cosmetic one: if the
|
|
258
|
+
// idempotent branch came first, a builder with no authority over this row could
|
|
259
|
+
// probe it and read `alreadyAbandoned: true` back as a 200. An unauthorized retry
|
|
260
|
+
// is a refusal, not a no-op.
|
|
261
|
+
const pool = fakePool({ status: 'abandoned', createdBy: 8 });
|
|
262
|
+
await assert.rejects(
|
|
263
|
+
() => abandonTask({ taskId: TASK, authorize: () => ({ ok: false, reason: 'not_task_creator' }) }, { pool }),
|
|
264
|
+
(err) => err.code === 'ABANDON_FORBIDDEN',
|
|
265
|
+
);
|
|
266
|
+
assert.ok(!find(pool._client.queries, /^COMMIT/), 'must not COMMIT the idempotent no-op to an unauthorized caller');
|
|
267
|
+
assert.ok(find(pool._client.queries, /^ROLLBACK/));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await test('authorize also precedes the shipped and active-claim guards', async () => {
|
|
271
|
+
// Same reason: the 409s are informative, so an unauthorized caller must not be
|
|
272
|
+
// able to distinguish them. A refusal outranks every status branch below it.
|
|
273
|
+
for (const setup of [{ status: 'shipped' }, { status: 'ready', hasActiveClaim: true }]) {
|
|
274
|
+
const pool = fakePool({ ...setup, createdBy: 8 });
|
|
275
|
+
await assert.rejects(
|
|
276
|
+
() => abandonTask({ taskId: TASK, authorize: () => ({ ok: false }) }, { pool }),
|
|
277
|
+
(err) => err.code === 'ABANDON_FORBIDDEN',
|
|
278
|
+
`an unauthorized caller must get ABANDON_FORBIDDEN, not the ${setup.status} guard`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await test('an authorized creator is STILL stopped by the existing guards', async () => {
|
|
284
|
+
// The widening adds an authority; it removes no guard. A creator who genuinely
|
|
285
|
+
// owns the row still cannot retire shipped history, nor a task someone is
|
|
286
|
+
// actively holding.
|
|
287
|
+
const shipped = fakePool({ status: 'shipped', createdBy: 7 });
|
|
288
|
+
await assert.rejects(
|
|
289
|
+
() => abandonTask({ taskId: TASK, authorize: () => ({ ok: true }) }, { pool: shipped }),
|
|
290
|
+
(err) => err.code === 'CANNOT_ABANDON_SHIPPED',
|
|
291
|
+
);
|
|
292
|
+
const claimed = fakePool({ status: 'ready', createdBy: 7, hasActiveClaim: true });
|
|
293
|
+
await assert.rejects(
|
|
294
|
+
() => abandonTask({ taskId: TASK, authorize: () => ({ ok: true }) }, { pool: claimed }),
|
|
295
|
+
(err) => err.code === 'TASK_HAS_ACTIVE_CLAIM',
|
|
296
|
+
);
|
|
297
|
+
assert.equal(all(claimed._client.queries, /^UPDATE tasks/).length, 0);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await test('the seam is FAIL-CLOSED: anything but an explicit { ok: true } denies', async () => {
|
|
301
|
+
for (const verdict of [undefined, null, false, {}, { ok: 'yes' }, { ok: 1 }, 'ok', { okay: true }]) {
|
|
302
|
+
const pool = fakePool({ status: 'ready', createdBy: 7 });
|
|
303
|
+
await assert.rejects(
|
|
304
|
+
() => abandonTask({ taskId: TASK, authorize: () => verdict }, { pool }),
|
|
305
|
+
(err) => err.code === 'ABANDON_FORBIDDEN',
|
|
306
|
+
`${JSON.stringify(verdict)} must not be read as an admit`,
|
|
307
|
+
);
|
|
308
|
+
assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await test('a THROWING authorize denies rather than escaping as a 500', async () => {
|
|
313
|
+
const pool = fakePool({ status: 'ready', createdBy: 7 });
|
|
314
|
+
await assert.rejects(
|
|
315
|
+
() => abandonTask({ taskId: TASK, authorize: () => { throw new Error('resolver blew up'); } }, { pool }),
|
|
316
|
+
(err) => err.code === 'ABANDON_FORBIDDEN' && err.reason === 'authorize_threw',
|
|
317
|
+
);
|
|
318
|
+
assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
|
|
319
|
+
assert.ok(find(pool._client.queries, /^ROLLBACK/));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await test('authorize never runs for a task that does not exist', async () => {
|
|
323
|
+
const pool = fakePool({ notFound: true });
|
|
324
|
+
let called = false;
|
|
325
|
+
await assert.rejects(
|
|
326
|
+
() => abandonTask({ taskId: 424242, authorize: () => { called = true; return { ok: true }; } }, { pool }),
|
|
327
|
+
(err) => err.code === 'TASK_NOT_FOUND',
|
|
328
|
+
);
|
|
329
|
+
assert.equal(called, false, 'there is no row to be the owner of');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
await test('OMITTING authorize preserves the pre-1002443 behaviour exactly', async () => {
|
|
333
|
+
// Every caller whose route already gated the transition (and the merge path,
|
|
334
|
+
// which mirrors this function) must be untouched by the new parameter.
|
|
335
|
+
const pool = fakePool({ status: 'ready', createdBy: 8 });
|
|
336
|
+
const out = await abandonTask({ taskId: TASK, reason: 'no authorize supplied' }, { pool });
|
|
337
|
+
assert.equal(out.taskStatus, 'abandoned');
|
|
338
|
+
assert.equal(out.previousStatus, 'ready');
|
|
339
|
+
assert.ok(find(pool._client.queries, /^UPDATE tasks/));
|
|
340
|
+
assert.ok(find(pool._client.queries, /^COMMIT/));
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await test('a non-function authorize is ignored, not treated as a refusal', async () => {
|
|
344
|
+
// `authorize: null` is the documented "no row-scoped veto" default; only a real
|
|
345
|
+
// function is consulted. This pins that a caller passing null does not
|
|
346
|
+
// accidentally brick the gated path it already guards in middleware.
|
|
347
|
+
const pool = fakePool({ status: 'ready', createdBy: 8 });
|
|
348
|
+
const out = await abandonTask({ taskId: TASK, authorize: null }, { pool });
|
|
349
|
+
assert.equal(out.taskStatus, 'abandoned');
|
|
350
|
+
});
|
|
351
|
+
|
|
210
352
|
// -------------------------------------------------------------------------
|
|
211
353
|
summary();
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
//
|
|
12
12
|
// Run: node tests/agents_validate.mjs
|
|
13
13
|
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
14
17
|
import { strict as assert } from 'node:assert';
|
|
15
18
|
import { createRequire } from 'node:module';
|
|
16
19
|
|
|
@@ -73,9 +76,37 @@ await test('a failed validation returns NO value — nothing downstream can inse
|
|
|
73
76
|
|
|
74
77
|
// --- THE EVENT CATALOG — the fail-closed rule -------------------------------
|
|
75
78
|
|
|
76
|
-
await test('EVENT_CATALOG is
|
|
77
|
-
assert.deepEqual(EVENT_CATALOG, ['task.shipped']);
|
|
79
|
+
await test('EVENT_CATALOG is frozen, and every entry is REALLY dispatched', () => {
|
|
78
80
|
assert.equal(Object.isFrozen(EVENT_CATALOG), true, 'a caller must not be able to widen the catalog at runtime');
|
|
81
|
+
assert.ok(EVENT_CATALOG.includes('task.shipped'), 'the original wired event stays wired');
|
|
82
|
+
|
|
83
|
+
// The catalog's own comment is the rule: widening it "must land together with
|
|
84
|
+
// something that actually dispatches the new event". Pinned as that rule rather
|
|
85
|
+
// than as a frozen list, because a list cannot tell a legitimate widening from
|
|
86
|
+
// one that ships an entry nothing emits — and the second produces an agent that
|
|
87
|
+
// looks armed and never fires, which is the failure the catalog exists to stop.
|
|
88
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
89
|
+
const files = [];
|
|
90
|
+
const walk = (dir) => {
|
|
91
|
+
let ents = [];
|
|
92
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
93
|
+
for (const e of ents) {
|
|
94
|
+
const full = path.join(dir, e.name);
|
|
95
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules') walk(full); continue; }
|
|
96
|
+
if (e.name.endsWith('.js')) files.push(full);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
for (const d of ['src', 'modules']) walk(path.join(ROOT, d));
|
|
100
|
+
const tree = files.map((f) => fs.readFileSync(f, 'utf8')).join('\n');
|
|
101
|
+
|
|
102
|
+
for (const event of EVENT_CATALOG) {
|
|
103
|
+
// Any quote style, emit or emitAsync. A plain substring scan rather than a
|
|
104
|
+
// built regex: the thing being asserted is "somebody emits this string".
|
|
105
|
+
const dispatched = ['emit(', 'emitAsync('].some((call) =>
|
|
106
|
+
[String.fromCharCode(39), '"', String.fromCharCode(96)].some((q) => tree.includes(call + q + event + q)));
|
|
107
|
+
assert.ok(dispatched,
|
|
108
|
+
`"${event}" is in the catalog but nothing in src/ or modules/ emits it — an agent armed on it would never fire`);
|
|
109
|
+
}
|
|
79
110
|
});
|
|
80
111
|
|
|
81
112
|
await test('an UNWIRED event name is REFUSED, never silently accepted', () => {
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
// Run: node tests/discord_channels.mjs
|
|
25
25
|
|
|
26
26
|
import { strict as assert } from 'node:assert';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import os from 'node:os';
|
|
27
30
|
import { createRequire } from 'node:module';
|
|
28
31
|
const require = createRequire(import.meta.url);
|
|
29
32
|
const ch = require('../modules/discord/discord-channels.js');
|
|
@@ -365,5 +368,168 @@ await t('the real config plans cleanly against an empty guild and never deletes'
|
|
|
365
368
|
assert.ok(!ops.some((o) => String(o.op).startsWith('delete')), 'reconcile must never delete');
|
|
366
369
|
});
|
|
367
370
|
|
|
371
|
+
// ---- seeding channels.json from a live guild (task 1003841) ----------------
|
|
372
|
+
//
|
|
373
|
+
// WHY THESE EXIST. Seeding hangs on one subtle rule, and I read it wrong the
|
|
374
|
+
// first time: an omitted `view` has TWO possible causes that look identical in
|
|
375
|
+
// the config, and only one of them is a problem.
|
|
376
|
+
//
|
|
377
|
+
// • the channel's view EQUALS its category's -> omitted, NO warning (fine)
|
|
378
|
+
// • the channel has no managed VIEW overwrite -> omitted, WITH a warning
|
|
379
|
+
//
|
|
380
|
+
// configFromSnapshot emits `view` only when it differs from the parent, because
|
|
381
|
+
// the planner reads `ch.view || cat.view` and repeating the value is noise that
|
|
382
|
+
// drifts later. So "the field is missing" is NOT evidence that a channel is
|
|
383
|
+
// unmanaged — the WARNING is. These pin that distinction, because acting on the
|
|
384
|
+
// wrong one means writing view values into the file that the module deliberately
|
|
385
|
+
// omits, which is a diff against the guild rather than a record of it.
|
|
386
|
+
|
|
387
|
+
const V_ALLOW = String(ch.VIEW_CHANNEL);
|
|
388
|
+
const allowEveryone = [{ id: EVERYONE, type: 0, allow: V_ALLOW, deny: '0' }];
|
|
389
|
+
const publicCat = (catOverwrites, chOverwrites) => ({
|
|
390
|
+
categories: [{ id: 'CAT_PUB', name: 'PUBLIC', overwrites: catOverwrites }],
|
|
391
|
+
channels: [{ id: 'CH_NEWS', type: 0, name: '📯-ship-news', parentId: 'CAT_PUB', topic: 'Ships land here.', overwrites: chOverwrites }],
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await t('an omitted view means INHERITS when the channel matches its category — and warns nothing (task 1003841)', () => {
|
|
395
|
+
const { config, warnings } = ch.configFromSnapshot(publicCat(allowEveryone, allowEveryone), CTX);
|
|
396
|
+
const news = config.categories[0].channels[0];
|
|
397
|
+
assert.equal(news.view, undefined, 'a view identical to the category is deliberately omitted');
|
|
398
|
+
assert.deepEqual(warnings, [], 'and nothing is wrong, so nothing is warned about');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await t('an omitted view means UNMANAGED when the channel has no overwrite — and that WARNS (task 1003841)', () => {
|
|
402
|
+
// The distinguishing signal. Same missing field, different meaning.
|
|
403
|
+
const { config, warnings } = ch.configFromSnapshot(publicCat(allowEveryone, []), CTX);
|
|
404
|
+
assert.equal(config.categories[0].channels[0].view, undefined, 'also omitted — the config alone cannot tell them apart');
|
|
405
|
+
assert.equal(warnings.length, 1, 'but the warning does');
|
|
406
|
+
assert.match(warnings[0], /no VIEW overwrite/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
await t('the seeded file round-trips to ZERO ops for every channel that exists (task 1003841)', () => {
|
|
410
|
+
// The done-when, demonstrated rather than argued. Feeding the snapshot-derived
|
|
411
|
+
// config back against the same guild must plan to nothing — that is the whole
|
|
412
|
+
// correctness property of configFromSnapshot, and it is what makes committing
|
|
413
|
+
// the snapshot safe: applying it cannot edit a live channel.
|
|
414
|
+
const guild = publicCat(allowEveryone, allowEveryone);
|
|
415
|
+
const { config } = ch.configFromSnapshot(guild, CTX);
|
|
416
|
+
assert.deepEqual(ch.planChannelReconcile(guild, config, CTX).ops, [], 'a faithful snapshot is a no-op plan');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await t('writing view:everyone where the snapshot omitted it would ADD an op — which is why it is not written (task 1003841)', () => {
|
|
420
|
+
// The mistake this file avoids. Spelling out a value identical to the category
|
|
421
|
+
// is harmless to READ, but it is not what the module emits, and on a channel
|
|
422
|
+
// that genuinely lacks an overwrite it turns a record into an edit of the live
|
|
423
|
+
// guild. Pinned so nobody "tidies" the omitted fields back in.
|
|
424
|
+
const guild = publicCat(allowEveryone, []);
|
|
425
|
+
const spelled = {
|
|
426
|
+
categories: [{
|
|
427
|
+
name: 'PUBLIC', view: 'everyone',
|
|
428
|
+
channels: [{ key: 'ship-news', name: '📯-ship-news', view: 'everyone', topic: 'Ships land here.' }],
|
|
429
|
+
}],
|
|
430
|
+
};
|
|
431
|
+
const { ops } = ch.planChannelReconcile(guild, spelled, CTX);
|
|
432
|
+
assert.ok(ops.some((o) => o.op === 'edit-channel'), 'it edits the live channel');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
await t('the committed file is the guild, plus board-room (task 1003841)', () => {
|
|
436
|
+
const cfg = ch.loadConfig();
|
|
437
|
+
const keys = cfg.categories.flatMap((c) => c.channels.map((x) => x.key));
|
|
438
|
+
assert.ok(keys.length >= 15, `expected the seeded guild, got ${keys.length} channels`);
|
|
439
|
+
assert.ok(keys.includes('board-room'), 'board-room stays declared — the bot never deletes');
|
|
440
|
+
for (const cat of cfg.categories) {
|
|
441
|
+
assert.ok(cat.view, `category ${cat.name} needs a view`);
|
|
442
|
+
for (const c of cat.channels) assert.ok(c.key && c.name, 'every channel carries its key and real name');
|
|
443
|
+
}
|
|
444
|
+
// The four PUBLIC channels inherit deliberately; asserting they have no view
|
|
445
|
+
// is what stops a later edit from spelling them out and creating a diff.
|
|
446
|
+
const pub = cfg.categories.find((c) => c.name === 'PUBLIC');
|
|
447
|
+
assert.equal(pub.view, 'everyone');
|
|
448
|
+
for (const c of pub.channels) assert.equal(c.view, undefined, `${c.key} must inherit, not repeat, its category view`);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ---- the config path on a DEPLOYED instance (task 1003973) ---------------
|
|
452
|
+
//
|
|
453
|
+
// channels.json describes ONE guild, so it is instance identity and is NOT
|
|
454
|
+
// published in the neutral core (ADR 0062 §7). The module used to resolve it
|
|
455
|
+
// against its OWN directory, which on a deployed instance is
|
|
456
|
+
// node_modules/@bongos/core/modules/discord/ — the file is not there, readFileSync
|
|
457
|
+
// threw ENOENT, computePlan() threw, and the route returned a bare 500 that was
|
|
458
|
+
// indistinguishable from a bot outage. These pin both halves of the fix.
|
|
459
|
+
|
|
460
|
+
await t('resolveConfigPath prefers the INSTANCE root over the core package', () => {
|
|
461
|
+
// Point the instance root somewhere that is NOT the core checkout and give it
|
|
462
|
+
// its own channels.json. If resolution still lands on the core copy, a deployed
|
|
463
|
+
// instance would read cloudbongos.com's channel names — the thing ADR 0062 §7
|
|
464
|
+
// forbids — so this must fail loudly rather than pass because the two roots
|
|
465
|
+
// happen to coincide in a single checkout.
|
|
466
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-inst-1003973-'));
|
|
467
|
+
const own = path.join(root, 'docs', 'discord', 'channels.json');
|
|
468
|
+
fs.mkdirSync(path.dirname(own), { recursive: true });
|
|
469
|
+
fs.writeFileSync(own, JSON.stringify({ categories: [{ name: 'X', channels: [] }] }));
|
|
470
|
+
const prev = process.env.BONGOS_INSTANCE_ROOT;
|
|
471
|
+
process.env.BONGOS_INSTANCE_ROOT = root;
|
|
472
|
+
try {
|
|
473
|
+
assert.equal(ch.resolveConfigPath(), own,
|
|
474
|
+
'a deployed instance must read its OWN docs/discord/channels.json, not the core package copy');
|
|
475
|
+
assert.notEqual(ch.resolveConfigPath(), ch.CORE_CONFIG_PATH);
|
|
476
|
+
assert.deepEqual(ch.loadConfig().categories.map((c) => c.name), ['X'],
|
|
477
|
+
'loadConfig must follow the same resolution, not a stale module-scope constant');
|
|
478
|
+
} finally {
|
|
479
|
+
if (prev === undefined) delete process.env.BONGOS_INSTANCE_ROOT;
|
|
480
|
+
else process.env.BONGOS_INSTANCE_ROOT = prev;
|
|
481
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
482
|
+
}
|
|
483
|
+
// and with no pointer set, a single checkout still resolves to the repo file
|
|
484
|
+
assert.ok(fs.existsSync(ch.resolveConfigPath()), 'the single-checkout path must still resolve');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
await t('loadConfig on a missing file throws a TAGGED error naming the path', () => {
|
|
488
|
+
const missing = path.join(os.tmpdir(), 'bongos-no-such-channels-1003973.json');
|
|
489
|
+
assert.throws(() => ch.loadConfig(missing), (err) => {
|
|
490
|
+
assert.equal(err.code, 'CONFIG_NOT_FOUND', 'ENOENT must be tagged, not left as a raw fs error');
|
|
491
|
+
assert.equal(err.configPath, missing);
|
|
492
|
+
assert.ok(err.message.includes(missing), 'the message must name the path the operator should fix');
|
|
493
|
+
return true;
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await t('computePlan with no config returns {ok:false, no_config} instead of throwing', () => {
|
|
498
|
+
const missing = path.join(os.tmpdir(), 'bongos-no-such-channels-1003973.json');
|
|
499
|
+
const deps = {
|
|
500
|
+
getGuild: () => ({ id: '1', channels: { cache: new Map() } }),
|
|
501
|
+
loadConfig: () => ch.loadConfig(missing),
|
|
502
|
+
loadRoleMap: () => ({}),
|
|
503
|
+
};
|
|
504
|
+
const r = ch.computePlan(deps);
|
|
505
|
+
assert.equal(r.ok, false);
|
|
506
|
+
assert.equal(r.reason, 'no_config', 'a missing layout file must be its own reason, never a thrown 500');
|
|
507
|
+
assert.equal(r.configPath, missing, 'the caller needs the path to report it');
|
|
508
|
+
assert.deepEqual(r.ops, []);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
await t('reconcileChannels with no config returns {ok:false, no_config} and writes nothing', async () => {
|
|
512
|
+
const missing = path.join(os.tmpdir(), 'bongos-no-such-channels-1003973.json');
|
|
513
|
+
const deps = {
|
|
514
|
+
getGuild: () => ({ id: '1', channels: { cache: new Map() } }),
|
|
515
|
+
loadBotToken: () => 'tok',
|
|
516
|
+
loadConfig: () => ch.loadConfig(missing),
|
|
517
|
+
loadRoleMap: () => ({}),
|
|
518
|
+
};
|
|
519
|
+
const r = await ch.reconcileChannels(deps);
|
|
520
|
+
assert.equal(r.ok, false);
|
|
521
|
+
assert.equal(r.reason, 'no_config');
|
|
522
|
+
assert.equal(r.applied, 0);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await t('a non-ENOENT read failure still propagates — only ENOENT is tagged', () => {
|
|
526
|
+
// A directory reads as EISDIR: a genuine internal error must NOT be laundered
|
|
527
|
+
// into the friendly 'put the file here' path.
|
|
528
|
+
assert.throws(() => ch.loadConfig(os.tmpdir()), (err) => {
|
|
529
|
+
assert.notEqual(err.code, 'CONFIG_NOT_FOUND');
|
|
530
|
+
return true;
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
368
534
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
369
535
|
process.exit(failed ? 1 : 0);
|