@bongos/core 1.19.239 → 1.19.241
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 +39 -24
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0177-goal-membership-kind.md +59 -2
- package/docs/api/openapi.json +5 -1
- package/docs/api-reference.md +1 -1
- package/modules/hall-ui/public/my-projects.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_010_membership_kind.sql +106 -0
- package/modules/platform-identity/platform-identity.js +45 -21
- package/modules/platform-identity/routes/my-projects.js +2 -2
- package/modules/platform-identity/routes/sso.js +20 -5
- package/modules/platform-identity/tests/platform-identity.mjs +26 -13
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +26 -11
- package/scripts/gds/run-unit-tests.js +7 -1
- package/src/bongos/auth.js +21 -6
- package/src/module-api.js +5 -1
- package/tests/government_live_proof.mjs +72 -20
- package/tests/government_live_proof_shape.mjs +124 -0
- package/tests/idp_federation.mjs +8 -6
- package/tests/membership_kind_lockstep.mjs +99 -0
|
@@ -358,8 +358,18 @@ module.exports = function ssoRoutes() {
|
|
|
358
358
|
// that a builder builds on its project (ADR 0141 §3). Server-to-server, authed by
|
|
359
359
|
// client_id + client_secret (same constant-time check as /sso/token). Upserts the
|
|
360
360
|
// account (so a CLI-only builder still appears in My Projects) + the membership
|
|
361
|
-
// with a COARSE
|
|
362
|
-
// caller cannot report membership for a project it doesn't hold the
|
|
361
|
+
// with a COARSE membership_kind. The client_id is taken from the AUTHENTICATED
|
|
362
|
+
// client — the caller cannot report membership for a project it doesn't hold the
|
|
363
|
+
// secret for.
|
|
364
|
+
//
|
|
365
|
+
// THE FIELD IS `membership_kind`; `role` IS ACCEPTED FOR THE WINDOW (task 1003090,
|
|
366
|
+
// ADR 0177 §"the fifth use"). ADR 0174 reserves `role` for the three crafts, and
|
|
367
|
+
// the old value 'builder' was a craft name meaning "non-owner member" — so both
|
|
368
|
+
// the field and that value are renamed. Unlike the goal-membership rename, this is
|
|
369
|
+
// a CROSS-VERSION wire contract: the callers are other instances on their own
|
|
370
|
+
// upgrade schedule, so an un-upgraded instance still sends `role: 'builder'` and
|
|
371
|
+
// the hub must keep understanding it. `membership_kind` WINS when both arrive
|
|
372
|
+
// (a sender in the window sends both deliberately).
|
|
363
373
|
//
|
|
364
374
|
// rank: public — authenticated by client_id + client_secret, not a builder rank.
|
|
365
375
|
router.post('/sso/membership/check-in', async (req, res) => {
|
|
@@ -370,9 +380,14 @@ module.exports = function ssoRoutes() {
|
|
|
370
380
|
github_login: { required: true, type: 'string', maxLength: 200 },
|
|
371
381
|
display_name: { type: 'string', maxLength: 400 },
|
|
372
382
|
avatar_url: { type: 'string', maxLength: 2048 },
|
|
383
|
+
membership_kind: { type: 'string', maxLength: 16 },
|
|
384
|
+
// LEGACY, accepted for the cross-version window (task 1003090).
|
|
373
385
|
role: { type: 'string', maxLength: 16 },
|
|
374
386
|
})) return;
|
|
375
|
-
const { client_id, client_secret, github_id, github_login, display_name, avatar_url, role } = req.body || {};
|
|
387
|
+
const { client_id, client_secret, github_id, github_login, display_name, avatar_url, membership_kind, role } = req.body || {};
|
|
388
|
+
const membershipKind = membership_kind
|
|
389
|
+
|| (role ? pi.membershipKindFromLegacy(role) : null)
|
|
390
|
+
|| 'member';
|
|
376
391
|
try {
|
|
377
392
|
const client = await pi.getClient(client_id, { pool });
|
|
378
393
|
if (!client || client.status !== 'active' || !pi.clientSecretValid(client, client_secret)) {
|
|
@@ -381,7 +396,7 @@ module.exports = function ssoRoutes() {
|
|
|
381
396
|
// Upsert the account first so a CLI-only builder is known to the hub, then the
|
|
382
397
|
// membership — keyed to the AUTHENTICATED client_id, never a caller-named one.
|
|
383
398
|
await pi.upsertAccount({ githubId: github_id, githubLogin: github_login, displayName: display_name, avatarUrl: avatar_url }, { pool });
|
|
384
|
-
await pi.upsertMembership({ clientId: client_id, githubId: github_id,
|
|
399
|
+
await pi.upsertMembership({ clientId: client_id, githubId: github_id, membershipKind }, { pool });
|
|
385
400
|
res.json({ ok: true });
|
|
386
401
|
} catch (err) {
|
|
387
402
|
console.error('[platform-identity] /sso/membership/check-in', err);
|
|
@@ -424,7 +439,7 @@ module.exports = function ssoRoutes() {
|
|
|
424
439
|
return res.fail('invalid_github_id', 400);
|
|
425
440
|
}
|
|
426
441
|
const membership = await pi.getMembership(b.client_id, b.github_id, { pool });
|
|
427
|
-
if (!membership || membership.
|
|
442
|
+
if (!membership || membership.membership_kind === 'pending') {
|
|
428
443
|
return res.fail('membership_required', 409);
|
|
429
444
|
}
|
|
430
445
|
await pi.upsertActivity({
|
|
@@ -262,9 +262,9 @@ await ta('registerClient auto-catalogues the project (2nd query) when an origin
|
|
|
262
262
|
|
|
263
263
|
console.log('\nproject memberships (§3 check-in directory):');
|
|
264
264
|
|
|
265
|
-
await ta('upsertMembership upserts on (client_id, github_id) with a coarse
|
|
266
|
-
const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42',
|
|
267
|
-
await P.upsertMembership({ clientId: 'c1', githubId: 42,
|
|
265
|
+
await ta('upsertMembership upserts on (client_id, github_id) with a coarse membership_kind', async () => {
|
|
266
|
+
const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', membership_kind: 'owner' }] }]);
|
|
267
|
+
await P.upsertMembership({ clientId: 'c1', githubId: 42, membershipKind: 'owner' }, { pool });
|
|
268
268
|
const c = pool.calls[0];
|
|
269
269
|
assert.match(c.text, /INSERT INTO platform_identity_project_memberships/);
|
|
270
270
|
assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
|
|
@@ -272,10 +272,23 @@ await ta('upsertMembership upserts on (client_id, github_id) with a coarse role'
|
|
|
272
272
|
assert.deepEqual(c.params, ['c1', 42, 'owner']);
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
-
await ta('upsertMembership clamps an unknown
|
|
275
|
+
await ta('upsertMembership clamps an unknown kind to member (never trusts a caller blindly)', async () => {
|
|
276
276
|
const pool = mockPool([{ rows: [{}] }]);
|
|
277
|
-
await P.upsertMembership({ clientId: 'c1', githubId: 42,
|
|
278
|
-
assert.equal(pool.calls[0].params[2], '
|
|
277
|
+
await P.upsertMembership({ clientId: 'c1', githubId: 42, membershipKind: 'archon' }, { pool });
|
|
278
|
+
assert.equal(pool.calls[0].params[2], 'member', 'a non-coarse membership_kind is clamped to member');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await ta('the legacy wire vocabulary maps both ways (the cross-version window, task 1003090)', async () => {
|
|
282
|
+
// Other instances upgrade on their own schedule, so 'builder' keeps arriving on
|
|
283
|
+
// the wire for a while; 'member' is what the hub stores. Nothing else changes.
|
|
284
|
+
assert.equal(P.membershipKindFromLegacy('builder'), 'member');
|
|
285
|
+
assert.equal(P.legacyRoleFromKind('member'), 'builder');
|
|
286
|
+
for (const same of ['owner', 'visitor', 'pending']) {
|
|
287
|
+
assert.equal(P.membershipKindFromLegacy(same), same);
|
|
288
|
+
assert.equal(P.legacyRoleFromKind(same), same);
|
|
289
|
+
}
|
|
290
|
+
assert.deepEqual(P.MEMBERSHIP_KINDS, ['owner', 'member', 'visitor', 'pending'],
|
|
291
|
+
'the craft name is gone from the coarse vocabulary');
|
|
279
292
|
});
|
|
280
293
|
|
|
281
294
|
await ta('removeMembership deletes the (client_id, github_id) row (self-service leave)', async () => {
|
|
@@ -287,20 +300,20 @@ await ta('removeMembership deletes the (client_id, github_id) row (self-service
|
|
|
287
300
|
});
|
|
288
301
|
|
|
289
302
|
await ta('listMembershipsForAccount joins projects + excludes pending', async () => {
|
|
290
|
-
const pool = mockPool([{ rows: [{ client_id: 'c1',
|
|
303
|
+
const pool = mockPool([{ rows: [{ client_id: 'c1', membership_kind: 'member', project_name: 'Proj' }] }]);
|
|
291
304
|
const out = await P.listMembershipsForAccount(42, { pool });
|
|
292
305
|
assert.equal(out.length, 1);
|
|
293
306
|
const c = pool.calls[0];
|
|
294
307
|
assert.match(c.text, /FROM platform_identity_project_memberships m/);
|
|
295
308
|
assert.match(c.text, /LEFT JOIN platform_identity_sso_clients c/);
|
|
296
|
-
assert.match(c.text, /
|
|
309
|
+
assert.match(c.text, /membership_kind <> 'pending'/);
|
|
297
310
|
assert.deepEqual(c.params, [42]);
|
|
298
311
|
});
|
|
299
312
|
|
|
300
313
|
console.log('\ninvitations (§5 — pre-seed pending membership):');
|
|
301
314
|
|
|
302
315
|
await ta('inviteToProject pre-seeds a pending row, never clobbering an existing member', async () => {
|
|
303
|
-
const pool = mockPool([{ rows: [{ id: '1',
|
|
316
|
+
const pool = mockPool([{ rows: [{ id: '1', membership_kind: 'pending' }] }]);
|
|
304
317
|
const out = await P.inviteToProject({ clientId: 'c1', githubId: 42 }, { pool });
|
|
305
318
|
assert.equal(out.invited, true);
|
|
306
319
|
const c = pool.calls[0];
|
|
@@ -320,15 +333,15 @@ await ta('resolveInvite accept flips ONLY a pending row to builder', async () =>
|
|
|
320
333
|
const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: true }, { pool });
|
|
321
334
|
assert.deepEqual(out, { resolved: true, accepted: true });
|
|
322
335
|
const c = pool.calls[0];
|
|
323
|
-
assert.match(c.text, /UPDATE platform_identity_project_memberships SET
|
|
324
|
-
assert.match(c.text, /AND
|
|
336
|
+
assert.match(c.text, /UPDATE platform_identity_project_memberships SET membership_kind = 'member'/);
|
|
337
|
+
assert.match(c.text, /AND membership_kind = 'pending'/, 'only touches pending — never a real member');
|
|
325
338
|
});
|
|
326
339
|
|
|
327
340
|
await ta('resolveInvite decline deletes ONLY a pending row', async () => {
|
|
328
341
|
const pool = mockPool([{ rowCount: 1 }]);
|
|
329
342
|
const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: false }, { pool });
|
|
330
343
|
assert.deepEqual(out, { resolved: true, accepted: false });
|
|
331
|
-
assert.match(pool.calls[0].text, /DELETE FROM platform_identity_project_memberships[\s\S]*AND
|
|
344
|
+
assert.match(pool.calls[0].text, /DELETE FROM platform_identity_project_memberships[\s\S]*AND membership_kind = 'pending'/);
|
|
332
345
|
});
|
|
333
346
|
|
|
334
347
|
await ta('getAccountByLogin resolves case-insensitively, newest first', async () => {
|
|
@@ -376,7 +389,7 @@ await ta('upsertActivity keeps a legitimate core rank key', async () => {
|
|
|
376
389
|
});
|
|
377
390
|
|
|
378
391
|
await ta('getMembership reads one (client, builder) row — the rollup membership requirement', async () => {
|
|
379
|
-
const row = { id: '9', client_id: 'c1', github_id: '42',
|
|
392
|
+
const row = { id: '9', client_id: 'c1', github_id: '42', membership_kind: 'member' };
|
|
380
393
|
const pool = mockPool([{ rows: [row] }]);
|
|
381
394
|
const out = await P.getMembership('c1', 42, { pool });
|
|
382
395
|
assert.deepEqual(out, row);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.241",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.241",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.241",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1741,7 +1741,7 @@ function vocabFilesToScan() {
|
|
|
1741
1741
|
return out;
|
|
1742
1742
|
}
|
|
1743
1743
|
|
|
1744
|
-
// ── Check 19b — the FOURTH "role": a
|
|
1744
|
+
// ── Check 19b — the FOURTH and FIFTH "role": a membership is not a craft either ─
|
|
1745
1745
|
//
|
|
1746
1746
|
// task 1003013 / ADR 0177. ADR 0174 reserved `role` for the three crafts and R07
|
|
1747
1747
|
// (checkVocabularyWall above) enforces it on the GOVERNMENT surface — deliberately
|
|
@@ -1751,10 +1751,16 @@ function vocabFilesToScan() {
|
|
|
1751
1751
|
// exactly why it survived the rename: the rule was silent on it, as designed.
|
|
1752
1752
|
//
|
|
1753
1753
|
// So this is a SECOND narrow wall rather than a widened first one — same reason the
|
|
1754
|
-
// first was scoped: the honest unit is a SURFACE, not the repo.
|
|
1755
|
-
//
|
|
1756
|
-
//
|
|
1757
|
-
//
|
|
1754
|
+
// first was scoped: the honest unit is a SURFACE, not the repo. Two surfaces carry
|
|
1755
|
+
// a membership, and both now say `membership_kind`:
|
|
1756
|
+
// • GOAL membership (`goal_members`, task 1003013) — the routes and pure gate that
|
|
1757
|
+
// decide it, the hall page that renders it, and (line-scoped) the membership SQL
|
|
1758
|
+
// in modules/lifecycle/db.js;
|
|
1759
|
+
// • PROJECT membership on the hub (`platform_identity_project_memberships`, task
|
|
1760
|
+
// 1003090) — whose old VALUE 'builder' was itself a craft name. Its check-in
|
|
1761
|
+
// route keeps reading a legacy `role` field for the cross-version federation
|
|
1762
|
+
// window (other instances upgrade on their own schedule), so the lines that
|
|
1763
|
+
// handle that legacy field cite the task and are exempt like any other citation.
|
|
1758
1764
|
//
|
|
1759
1765
|
// Naming the rule stays legal — a line that cites ADR 0174 / ADR 0177 / task
|
|
1760
1766
|
// 1003013 is meta-commentary about the ban, the same principle as
|
|
@@ -1767,14 +1773,23 @@ const GOAL_VOCAB_SURFACE = [
|
|
|
1767
1773
|
'modules/hall-ui/public/goals-page.js',
|
|
1768
1774
|
'modules/hall-ui/public/goals.html',
|
|
1769
1775
|
'modules/hall-ui/public/goals.css',
|
|
1776
|
+
// The FIFTH use (task 1003090): the hub's coarse project membership.
|
|
1777
|
+
'modules/platform-identity/platform-identity.js',
|
|
1778
|
+
'modules/platform-identity/routes/my-projects.js',
|
|
1779
|
+
'modules/hall-ui/public/my-projects.js',
|
|
1770
1780
|
];
|
|
1771
1781
|
// modules/lifecycle/db.js is 5k+ lines and legitimately says "role" about ranks
|
|
1772
1782
|
// (the rank-sync port, builders.system_role). Scanning it whole would need an
|
|
1773
1783
|
// allowlist longer than the rule; scanning the LINES THAT NAME THE TABLE catches
|
|
1774
1784
|
// the only thing that can regress here — a membership query reaching for the old
|
|
1775
1785
|
// column — with no false positives to excuse.
|
|
1776
|
-
const GOAL_VOCAB_LINE_SCOPED = [
|
|
1777
|
-
|
|
1786
|
+
const GOAL_VOCAB_LINE_SCOPED = [
|
|
1787
|
+
{ file: 'modules/lifecycle/db.js', mustMention: /goal_members/ },
|
|
1788
|
+
// sso.js is the federation surface: only the lines that name the membership table
|
|
1789
|
+
// or its upsert are in scope, so the file's other server-to-server prose stays out.
|
|
1790
|
+
{ file: 'modules/platform-identity/routes/sso.js', mustMention: /project_memberships|upsertMembership/ },
|
|
1791
|
+
];
|
|
1792
|
+
const GOAL_VOCAB_CITATION = /ADR 017[47]|task 1003013|task 1003090/;
|
|
1778
1793
|
|
|
1779
1794
|
function checkGoalMembershipVocabulary() {
|
|
1780
1795
|
const violations = [];
|
|
@@ -1789,19 +1804,19 @@ function checkGoalMembershipVocabulary() {
|
|
|
1789
1804
|
const scanLine = line.replace(/\srole\s*=\s*("[^"]*"|'[^']*')/g, ' ');
|
|
1790
1805
|
if (!/\brole/i.test(scanLine)) return;
|
|
1791
1806
|
if (GOAL_VOCAB_CITATION.test(scanLine)) return;
|
|
1792
|
-
violations.push(`${rel}:${i + 1}: "role" — a
|
|
1807
|
+
violations.push(`${rel}:${i + 1}: "role" — a membership is a 'membership_kind' (ADR 0174 reserves "role" for the crafts; ADR 0177). → ${line.trim().slice(0, 90)}`);
|
|
1793
1808
|
});
|
|
1794
1809
|
};
|
|
1795
1810
|
for (const rel of GOAL_VOCAB_SURFACE) scanFile(rel);
|
|
1796
1811
|
for (const { file, mustMention } of GOAL_VOCAB_LINE_SCOPED) scanFile(file, mustMention);
|
|
1797
1812
|
return {
|
|
1798
|
-
name: 'a
|
|
1813
|
+
name: 'a membership is a "membership_kind", never a role (ADR 0177)',
|
|
1799
1814
|
ok: violations.length === 0,
|
|
1800
1815
|
hardFail: violations.length > 0,
|
|
1801
1816
|
violations,
|
|
1802
1817
|
warnings: [],
|
|
1803
|
-
note: `${scanned}
|
|
1804
|
-
+ `${GOAL_VOCAB_LINE_SCOPED.length} line-scoped to the lines naming
|
|
1818
|
+
note: `${scanned} membership-surface file(s) scanned (${GOAL_VOCAB_SURFACE.length} whole — goal + project membership — plus `
|
|
1819
|
+
+ `${GOAL_VOCAB_LINE_SCOPED.length} line-scoped to the lines naming the membership tables); no allowlist — `
|
|
1805
1820
|
+ 'a line that CITES the rule is the only exemption.',
|
|
1806
1821
|
};
|
|
1807
1822
|
}
|
|
@@ -74,7 +74,13 @@ const INTEGRATION = new Set([
|
|
|
74
74
|
// .github/workflows/consumer-layout-boot.yml, not this DB-free gate (task 2142).
|
|
75
75
|
'consumer_layout_boot',
|
|
76
76
|
'claim_gate_open_blocker', 'claim_gate_rebase', 'clawback_confirmed_backlog', 'discord_autojoin', 'discord_oauth',
|
|
77
|
-
|
|
77
|
+
// RENAMED by ADR 0174 (goal 1000068) — these entries said governance_* until task
|
|
78
|
+
// 1003091. A curated set keyed by a stem that no longer exists silently stops
|
|
79
|
+
// covering its file: both of these dropped OUT of the integration set and INTO the
|
|
80
|
+
// DB-free lane, where they only "passed" because they self-skip. The pairing guard
|
|
81
|
+
// is in tests/government_live_proof_shape.mjs — every curated entry must name a
|
|
82
|
+
// real test file.
|
|
83
|
+
'government_fold_bench', 'government_live_proof', 'linkify_refs', 'memory_isolation',
|
|
78
84
|
'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent', 'reward_mode_cost_plus_only',
|
|
79
85
|
'scouting_rollup_fanout', 'session_records', 'setup_smoke',
|
|
80
86
|
'status-probe',
|
package/src/bongos/auth.js
CHANGED
|
@@ -173,15 +173,27 @@ function idpConfigured() {
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
// The COARSE membership role to report to the hub (ADR 0141 §3) — owner if this
|
|
176
|
-
// builder is the instance's configured firstAdmin, else
|
|
177
|
-
|
|
176
|
+
// builder is the instance's configured firstAdmin, else a plain member. NEVER a rank.
|
|
177
|
+
//
|
|
178
|
+
// 'member', not 'builder' (task 1003090, ADR 0177 §"the fifth use"): ADR 0174 reserves
|
|
179
|
+
// `role` — and the craft name 'Builder' — for Artist / Ideator / Builder, so the coarse
|
|
180
|
+
// project membership says what it means. The legacy spelling still goes on the wire for
|
|
181
|
+
// the cross-version window; see reportMembershipCheckIn.
|
|
182
|
+
function membershipKind({ githubLogin } = {}) {
|
|
178
183
|
const fa = firstAdminLogin();
|
|
179
184
|
if (fa && String(githubLogin || '').toLowerCase() === fa.toLowerCase()) return 'owner';
|
|
180
|
-
return '
|
|
185
|
+
return 'member';
|
|
181
186
|
}
|
|
182
187
|
|
|
183
188
|
// Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
|
|
184
|
-
// github_id builds on project <client_id>, coarse
|
|
189
|
+
// github_id builds on project <client_id>, coarse membership <membership_kind>".
|
|
190
|
+
//
|
|
191
|
+
// BOTH SPELLINGS GO ON THE WIRE FOR THE WINDOW (task 1003090). The receiver is the
|
|
192
|
+
// HUB, a different instance on its own upgrade schedule: send only the new field and
|
|
193
|
+
// an un-upgraded hub silently defaults every membership to non-owner (it would drop
|
|
194
|
+
// the owner flag); send only the legacy field and an upgraded hub keeps translating
|
|
195
|
+
// forever. Sending both is what makes the two ends independent — the removal task
|
|
196
|
+
// drops `role` once the hub is past the window. Runs on every
|
|
185
197
|
// FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
|
|
186
198
|
// project still appears in the builder's My Projects with no hub browser session.
|
|
187
199
|
// Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
|
|
@@ -189,6 +201,7 @@ function membershipRole({ githubLogin } = {}) {
|
|
|
189
201
|
async function reportMembershipCheckIn({ githubId, githubLogin, displayName, avatarUrl }) {
|
|
190
202
|
const idp = loadIdpConfig();
|
|
191
203
|
if (!idp) return;
|
|
204
|
+
const kind = membershipKind({ githubLogin });
|
|
192
205
|
try {
|
|
193
206
|
await fetch(`${idp.origin}/api/gds/sso/membership/check-in`, {
|
|
194
207
|
method: 'POST',
|
|
@@ -200,7 +213,9 @@ async function reportMembershipCheckIn({ githubId, githubLogin, displayName, ava
|
|
|
200
213
|
github_login: githubLogin,
|
|
201
214
|
display_name: displayName ?? null,
|
|
202
215
|
avatar_url: avatarUrl ?? null,
|
|
203
|
-
|
|
216
|
+
membership_kind: kind,
|
|
217
|
+
// LEGACY (cross-version window): 'member' → 'builder' for an un-upgraded hub.
|
|
218
|
+
role: kind === 'member' ? 'builder' : kind,
|
|
204
219
|
}),
|
|
205
220
|
});
|
|
206
221
|
} catch (err) {
|
|
@@ -1816,7 +1831,7 @@ module.exports = {
|
|
|
1816
1831
|
verifyHubLogoutToken,
|
|
1817
1832
|
logoutTokenJtiSeenAndRecord,
|
|
1818
1833
|
completeDeviceExchange,
|
|
1819
|
-
|
|
1834
|
+
membershipKind,
|
|
1820
1835
|
extractTokens,
|
|
1821
1836
|
resolveSession,
|
|
1822
1837
|
allowBoxScope,
|
package/src/module-api.js
CHANGED
|
@@ -905,8 +905,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
905
905
|
// landed since 1.19.237 with no explicit bump. run 32172843539. (task 1002620)
|
|
906
906
|
// 1.19.239 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
907
907
|
// landed since 1.19.238 with no explicit bump. run 32174983930. (task 1002620)
|
|
908
|
+
// 1.19.240 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
909
|
+
// landed since 1.19.239 with no explicit bump. run 32182178305. (task 1002620)
|
|
910
|
+
// 1.19.241 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
911
|
+
// landed since 1.19.240 with no explicit bump. run 32184475745. (task 1002620)
|
|
908
912
|
// ---------------------------------------------------------------------------
|
|
909
|
-
const CORE_VERSION = '1.19.
|
|
913
|
+
const CORE_VERSION = '1.19.241'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
910
914
|
|
|
911
915
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
912
916
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
// resolver behind the real `governance` kernel port, no stubs, no monkeypatches.
|
|
14
14
|
// The captured sandbox transcript lives in docs/governance/bongos-v1-c6-live-proof.md.
|
|
15
15
|
//
|
|
16
|
+
// REPAIRED 2026-08-18 (task 1003091): between ADR 0174's rename and that task this
|
|
17
|
+
// file was SILENTLY DEAD — see the readiness guard below for what happened and why
|
|
18
|
+
// the guard's shape, not just its table name, was the bug. tests/
|
|
19
|
+
// government_live_proof_shape.mjs now pins both from the DB-free lane.
|
|
20
|
+
//
|
|
16
21
|
// Real-DB integration test (the needs_rebase.mjs class): SELF-SKIPS (exit 0)
|
|
17
22
|
// when DATABASE_URL is unset/unreachable or the governance tables are missing,
|
|
18
23
|
// and is listed in run-unit-tests.js's INTEGRATION set so the DB-free lane
|
|
@@ -38,18 +43,65 @@ if (!process.env.DATABASE_URL) {
|
|
|
38
43
|
|
|
39
44
|
const { Pool } = require('pg');
|
|
40
45
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
46
|
+
|
|
47
|
+
// ── THE READINESS GUARD, AND WHY ITS SHAPE MATTERS (task 1003091) ────────────
|
|
48
|
+
//
|
|
49
|
+
// This guard used to be one `SELECT 1 FROM governance_roles LIMIT 1` inside a
|
|
50
|
+
// catch-all that skipped on ANY error. When ADR 0174's rename landed
|
|
51
|
+
// (government_001), that table became `government_ranks` — so on a HEALTHY,
|
|
52
|
+
// fully migrated database the probe raised 42P01 (undefined_table), the catch
|
|
53
|
+
// read it as "no migrated Postgres", and the proof exited 0 having asserted
|
|
54
|
+
// NOTHING. It stayed that way silently, because a fail-OPEN guard cannot fail
|
|
55
|
+
// and this file lives in run-unit-tests' INTEGRATION set, where nobody reads the
|
|
56
|
+
// skip line.
|
|
57
|
+
//
|
|
58
|
+
// So the two cases are now separated, and only one of them is a skip:
|
|
59
|
+
// • the server is unreachable (no DATABASE_URL, refused, no such DB) → SKIP.
|
|
60
|
+
// That is the "this lane has no Postgres" case the skip exists for.
|
|
61
|
+
// • the server IS reachable but an expected table is MISSING → FAIL, naming
|
|
62
|
+
// the tables. A schema this file cannot recognise is exactly the condition
|
|
63
|
+
// it must shout about: either the proof is stale (as it was) or the database
|
|
64
|
+
// is not what it claims to be.
|
|
65
|
+
const REQUIRED_TABLES = [
|
|
66
|
+
'builders', 'builder_sessions', 'idea_inbox',
|
|
67
|
+
'government_ranks', 'government_builder_ranks', 'government_rank_permissions',
|
|
68
|
+
];
|
|
41
69
|
try {
|
|
42
|
-
await pool.query('SELECT 1
|
|
70
|
+
await pool.query('SELECT 1');
|
|
43
71
|
} catch (e) {
|
|
44
|
-
console.log(`government_live_proof.mjs SKIPPED —
|
|
72
|
+
console.log(`government_live_proof.mjs SKIPPED — Postgres unreachable (${e.code || e.message}).`);
|
|
45
73
|
await pool.end().catch(() => {});
|
|
46
74
|
process.exit(0);
|
|
47
75
|
}
|
|
76
|
+
{
|
|
77
|
+
const { rows } = await pool.query(
|
|
78
|
+
`SELECT table_name FROM information_schema.tables
|
|
79
|
+
WHERE table_schema = 'public' AND table_name = ANY($1::text[])`,
|
|
80
|
+
[REQUIRED_TABLES],
|
|
81
|
+
);
|
|
82
|
+
const present = new Set(rows.map((r) => r.table_name));
|
|
83
|
+
const missing = REQUIRED_TABLES.filter((t) => !present.has(t));
|
|
84
|
+
if (missing.length === REQUIRED_TABLES.length) {
|
|
85
|
+
// Nothing at all: an empty database, not a stale proof. Still a legitimate
|
|
86
|
+
// skip — this is the "reachable server, no instance schema" case.
|
|
87
|
+
console.log('government_live_proof.mjs SKIPPED — reachable Postgres with no instance schema (run scripts/migrate.sh).');
|
|
88
|
+
await pool.end().catch(() => {});
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
if (missing.length) {
|
|
92
|
+
await pool.end().catch(() => {});
|
|
93
|
+
throw new Error(
|
|
94
|
+
`government_live_proof.mjs CANNOT RUN: this database is migrated but is missing ${missing.join(', ')}. `
|
|
95
|
+
+ 'Either the schema was renamed under this proof (the task-1003091 failure — fix the names HERE, do not widen the guard) '
|
|
96
|
+
+ 'or the target is not a Cloud Bongos instance database.'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
48
100
|
|
|
49
101
|
// ── fixtures (high-ID range; cleaned before AND after so reruns are safe) ────
|
|
50
102
|
const ARCHON = 90000701;
|
|
51
103
|
const THETES = 90000702;
|
|
52
|
-
const
|
|
104
|
+
const RANK_KEY = 'glp-demo-triager';
|
|
53
105
|
const OPERATIONAL_KEY = 'idea.triage'; // system:false, floor metic — above thetes
|
|
54
106
|
const SYSTEM_KEY = 'deploy.push_main'; // system:true — substrate-enforced
|
|
55
107
|
const tok = () => 'glp' + crypto.randomBytes(24).toString('hex');
|
|
@@ -59,8 +111,8 @@ const THETES_TOKEN = tok();
|
|
|
59
111
|
const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
|
|
60
112
|
|
|
61
113
|
async function cleanup() {
|
|
62
|
-
await q(`DELETE FROM
|
|
63
|
-
await q(`DELETE FROM
|
|
114
|
+
await q(`DELETE FROM government_ranks WHERE rank_key=$1`, [RANK_KEY]); // grants+assignments cascade
|
|
115
|
+
await q(`DELETE FROM government_builder_ranks WHERE builder_id IN ($1,$2)`, [ARCHON, THETES]);
|
|
64
116
|
await q(`DELETE FROM builder_sessions WHERE builder_id IN ($1,$2)`, [ARCHON, THETES]);
|
|
65
117
|
await q(`DELETE FROM idea_inbox WHERE captured_by IN ($1,$2)`, [ARCHON, THETES]);
|
|
66
118
|
// The audit middleware records the demo's own writes; those rows FK the
|
|
@@ -80,7 +132,7 @@ async function seed() {
|
|
|
80
132
|
// builder mirrors its effect here (one seeded rank-role each, fail-closed
|
|
81
133
|
// otherwise — the archon would resolve ZERO permissions and deny itself).
|
|
82
134
|
await q(
|
|
83
|
-
`INSERT INTO
|
|
135
|
+
`INSERT INTO government_builder_ranks (builder_id, rank_key)
|
|
84
136
|
VALUES ($1,'archon'), ($2,'thetes')`,
|
|
85
137
|
[ARCHON, THETES]
|
|
86
138
|
);
|
|
@@ -143,35 +195,35 @@ try {
|
|
|
143
195
|
|
|
144
196
|
// Act 2 — an Archon creates a custom role and grants the operational permission.
|
|
145
197
|
r = await call(ARCHON_TOKEN, 'POST', '/government/ranks',
|
|
146
|
-
{
|
|
147
|
-
ok(r.status === 201, 'the Archon creates a custom
|
|
148
|
-
r = await call(ARCHON_TOKEN, 'PUT', `/
|
|
198
|
+
{ rank_key: RANK_KEY, label: 'GLP demo triage rank' });
|
|
199
|
+
ok(r.status === 201, 'the Archon creates a custom rank (201)');
|
|
200
|
+
r = await call(ARCHON_TOKEN, 'PUT', `/government/ranks/${RANK_KEY}/permissions/${OPERATIONAL_KEY}`);
|
|
149
201
|
ok(r.status === 200 && r.body.ok === true,
|
|
150
|
-
`the Archon grants operational '${OPERATIONAL_KEY}' to the custom
|
|
202
|
+
`the Archon grants operational '${OPERATIONAL_KEY}' to the custom rank (200)`);
|
|
151
203
|
|
|
152
204
|
// Act 3 — the locked wall: a system permission is refused through the same API.
|
|
153
|
-
r = await call(ARCHON_TOKEN, 'PUT', `/
|
|
205
|
+
r = await call(ARCHON_TOKEN, 'PUT', `/government/ranks/${RANK_KEY}/permissions/${SYSTEM_KEY}`);
|
|
154
206
|
ok(r.status === 400 && r.body.error.code === 'permission_not_grantable',
|
|
155
207
|
`system '${SYSTEM_KEY}' is NOT grantable, even by the Archon (400 permission_not_grantable)`);
|
|
156
|
-
r = await call(ARCHON_TOKEN, 'PUT', `/
|
|
208
|
+
r = await call(ARCHON_TOKEN, 'PUT', `/government/ranks/${RANK_KEY}/permissions/government.manage`);
|
|
157
209
|
ok(r.status === 400 && r.body.error.code === 'permission_not_grantable',
|
|
158
210
|
'the meta-escalation (government.manage) is refused the same way');
|
|
159
211
|
|
|
160
212
|
// Act 4 — assign, then the same builder performs the action: no re-login, no
|
|
161
213
|
// restart — the per-request, uncached resolve (ADR 0016) picks the role up.
|
|
162
214
|
r = await call(ARCHON_TOKEN, 'POST', '/government/assignments',
|
|
163
|
-
{ builder_id: THETES,
|
|
164
|
-
ok(r.status === 201, 'the Archon assigns the custom
|
|
215
|
+
{ builder_id: THETES, rank_key: RANK_KEY });
|
|
216
|
+
ok(r.status === 201, 'the Archon assigns the custom rank to the thetes builder (201)');
|
|
165
217
|
r = await call(THETES_TOKEN, 'PATCH', `/inbox/${ideaId}`, { status: 'discarded' });
|
|
166
|
-
ok(r.status === 200, 'AFTER: the same builder, same session, can now triage (200) — the custom
|
|
218
|
+
ok(r.status === 200, 'AFTER: the same builder, same session, can now triage (200) — the custom rank works');
|
|
167
219
|
|
|
168
220
|
// Act 5 — the wall behind the wall: force the grant row no API path can create,
|
|
169
|
-
// so the
|
|
221
|
+
// so the rank now "has" a system permission in the DB…
|
|
170
222
|
await q(
|
|
171
|
-
`INSERT INTO
|
|
172
|
-
[
|
|
223
|
+
`INSERT INTO government_rank_permissions (rank_key, permission_key) VALUES ($1,$2)`,
|
|
224
|
+
[RANK_KEY, SYSTEM_KEY]
|
|
173
225
|
);
|
|
174
|
-
console.log(` (forced INTO the DB: grant ${
|
|
226
|
+
console.log(` (forced INTO the DB: grant ${RANK_KEY} ← ${SYSTEM_KEY} — no API path can write this row)`);
|
|
175
227
|
|
|
176
228
|
// …the resolver reports it honestly (it is a defined catalog key; matrix V10
|
|
177
229
|
// proves no route consumes it via requirePermission, so it admits nothing
|
|
@@ -193,7 +245,7 @@ try {
|
|
|
193
245
|
ok(sub.hardFail === true && sub.findings.length === 2,
|
|
194
246
|
'the substrate checker still BLOCKS the protected-path attempt (deploy/migration machinery), forced grant and all');
|
|
195
247
|
|
|
196
|
-
console.log(`\
|
|
248
|
+
console.log(`\ngovernment_live_proof: ${pass} assertions passed`);
|
|
197
249
|
} finally {
|
|
198
250
|
await cleanup().catch((e) => console.error('cleanup failed', e.message));
|
|
199
251
|
if (server) await new Promise((resolve) => server.close(resolve));
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// tests/government_live_proof_shape.mjs — the DB-free guard on the C6 live proof
|
|
2
|
+
// (task 1003091). ADR 0151's C6 proof (tests/government_live_proof.mjs) is a
|
|
3
|
+
// REAL-DB integration test, so the DB-free lane never runs it — which is how it
|
|
4
|
+
// came to be silently dead for days.
|
|
5
|
+
//
|
|
6
|
+
// WHAT HAPPENED: its readiness probe was `SELECT 1 FROM governance_roles LIMIT 1`
|
|
7
|
+
// inside a catch-all that skipped on ANY error. ADR 0174's rename
|
|
8
|
+
// (government_001) turned that table into `government_ranks`, so on a healthy,
|
|
9
|
+
// fully migrated database the probe raised 42P01 and the catch reported "no
|
|
10
|
+
// migrated Postgres". The proof exited 0 having asserted nothing, and no signal
|
|
11
|
+
// anywhere said so — a fail-OPEN guard cannot fail.
|
|
12
|
+
//
|
|
13
|
+
// So this file pins the two properties that make that irrecoverable-by-silence
|
|
14
|
+
// state impossible, from the lane that DOES run every PR:
|
|
15
|
+
// 1. the proof names the CURRENT schema + routes, not the pre-rename ones;
|
|
16
|
+
// 2. its guard SEPARATES "unreachable" (skip) from "reachable but missing an
|
|
17
|
+
// expected table" (throw) — and no OTHER integration test reintroduces the
|
|
18
|
+
// old shape by probing a specific table inside its skip guard.
|
|
19
|
+
//
|
|
20
|
+
// Run: node --test tests/government_live_proof_shape.mjs
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const PROOF_REL = 'tests/government_live_proof.mjs';
|
|
29
|
+
const proof = fs.readFileSync(path.join(ROOT, PROOF_REL), 'utf8');
|
|
30
|
+
|
|
31
|
+
// A line that CITES the incident has to be able to name the old spellings —
|
|
32
|
+
// otherwise the file cannot explain its own history. Same principle as
|
|
33
|
+
// fitness.js's VOCAB_HISTORICAL_REF.
|
|
34
|
+
const CITATION = /task[- ]?1003091|used to be|pre-rename|ADR 0174/i;
|
|
35
|
+
const PRE_RENAME = [
|
|
36
|
+
'governance_roles',
|
|
37
|
+
'governance_builder_roles',
|
|
38
|
+
'governance_role_permissions',
|
|
39
|
+
'/governance/roles',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
test('the C6 proof names the CURRENT government schema and routes', () => {
|
|
43
|
+
const offenders = [];
|
|
44
|
+
proof.split('\n').forEach((line, i) => {
|
|
45
|
+
if (CITATION.test(line)) return;
|
|
46
|
+
for (const old of PRE_RENAME) {
|
|
47
|
+
if (line.includes(old)) offenders.push(`${PROOF_REL}:${i + 1}: ${old} → ${line.trim().slice(0, 80)}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
assert.deepEqual(offenders, [],
|
|
51
|
+
'the proof still names pre-rename objects; ADR 0174 renamed them (government_001) and task 1003084 removed the /governance/* alias');
|
|
52
|
+
for (const t of ['government_ranks', 'government_builder_ranks', 'government_rank_permissions']) {
|
|
53
|
+
assert.ok(proof.includes(t), `the proof must exercise ${t}`);
|
|
54
|
+
}
|
|
55
|
+
assert.match(proof, /\/government\/ranks\/\$\{RANK_KEY\}\/permissions\//,
|
|
56
|
+
'the grant route is /government/ranks/:rankKey/permissions/:permissionKey');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('the readiness guard skips on UNREACHABLE and THROWS on a migrated-but-missing schema', () => {
|
|
60
|
+
// Connectivity is probed with a table-free query: a `SELECT 1 FROM <table>`
|
|
61
|
+
// here is precisely the bug — it conflates "no database" with "schema I do not
|
|
62
|
+
// recognise", and the second one must never be silent.
|
|
63
|
+
const guard = proof.slice(proof.indexOf('const REQUIRED_TABLES'), proof.indexOf('// ── fixtures'));
|
|
64
|
+
assert.ok(guard.includes("pool.query('SELECT 1')"),
|
|
65
|
+
'connectivity must be probed WITHOUT naming a table');
|
|
66
|
+
assert.ok(!/pool\.query\('SELECT 1 FROM/.test(guard),
|
|
67
|
+
'no table-named readiness probe may return: that is the task-1003091 failure mode');
|
|
68
|
+
assert.match(guard, /information_schema\.tables/, 'the expected tables are checked explicitly');
|
|
69
|
+
assert.match(guard, /throw new Error\(/, 'a migrated database missing an expected table must THROW');
|
|
70
|
+
assert.match(guard, /CANNOT RUN/, 'and say so loudly enough to read in CI output');
|
|
71
|
+
// The skip branches stay, but only for the two honest cases.
|
|
72
|
+
const skips = guard.match(/SKIPPED/g) || [];
|
|
73
|
+
assert.equal(skips.length, 2,
|
|
74
|
+
'exactly two skip cases: unreachable server, and a reachable server with NO instance schema');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('no other integration test probes a specific table inside its skip guard', () => {
|
|
78
|
+
// The sibling real-DB tests (needs_rebase, handle_machinery, …) already probe
|
|
79
|
+
// `SELECT 1`. This asserts the whole class, so the old shape cannot reappear
|
|
80
|
+
// in a NEW file and rot there unseen.
|
|
81
|
+
const offenders = [];
|
|
82
|
+
const SELF = path.basename(fileURLToPath(import.meta.url)); // the rule is not one of its own subjects
|
|
83
|
+
for (const name of fs.readdirSync(path.join(ROOT, 'tests')).filter((n) => n.endsWith('.mjs') && n !== SELF)) {
|
|
84
|
+
const src = fs.readFileSync(path.join(ROOT, 'tests', name), 'utf8');
|
|
85
|
+
if (!src.includes('SKIPPED')) continue;
|
|
86
|
+
src.split('\n').forEach((line, i) => {
|
|
87
|
+
if (CITATION.test(line)) return;
|
|
88
|
+
// A readiness probe is a LIVE query: `await pool.query('SELECT 1 FROM x')`.
|
|
89
|
+
// Assertions ABOUT sql strings (fakePool matchers, /SELECT 1 FROM …/ regexes)
|
|
90
|
+
// are not queries and are left alone.
|
|
91
|
+
if (/(await\s+)?pool\.query\(\s*['"`]SELECT 1 FROM /.test(line)) {
|
|
92
|
+
offenders.push(`tests/${name}:${i + 1}: ${line.trim().slice(0, 80)}`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
assert.deepEqual(offenders, [],
|
|
97
|
+
'a skip guard that probes a named table skips silently the day that table is renamed (task 1003091)');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('every curated INTEGRATION / KNOWN_FAILING entry names a real test file', () => {
|
|
101
|
+
// The SAME rename miss, one layer out: run-unit-tests.js's curated sets are keyed
|
|
102
|
+
// by filename STEM, and the INTEGRATION set still said `governance_live_proof`
|
|
103
|
+
// after ADR 0174 renamed the file. A stem that matches nothing does not error —
|
|
104
|
+
// the entry just stops applying, so the file quietly moved into the DB-free lane
|
|
105
|
+
// (where it "passed" by self-skipping). Curated lists must point at real files.
|
|
106
|
+
const runner = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'run-unit-tests.js'), 'utf8');
|
|
107
|
+
const setEntries = (name) => {
|
|
108
|
+
const start = runner.indexOf(`const ${name} = new Set([`);
|
|
109
|
+
assert.ok(start > -1, `${name} must exist in run-unit-tests.js`);
|
|
110
|
+
const block = runner.slice(start, runner.indexOf(']);', start));
|
|
111
|
+
return [...block.matchAll(/'([a-z0-9_.-]+)'/gi)].map((m) => m[1]).filter((k) => k !== name);
|
|
112
|
+
};
|
|
113
|
+
const stems = new Set(
|
|
114
|
+
fs.readdirSync(path.join(ROOT, 'tests')).filter((n) => n.endsWith('.mjs')).map((n) => n.replace(/\.mjs$/, ''))
|
|
115
|
+
);
|
|
116
|
+
const missing = [];
|
|
117
|
+
for (const setName of ['INTEGRATION', 'KNOWN_FAILING']) {
|
|
118
|
+
for (const stem of setEntries(setName)) {
|
|
119
|
+
if (!stems.has(stem)) missing.push(`${setName}: '${stem}' matches no tests/*.mjs`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
assert.deepEqual(missing, [],
|
|
123
|
+
'a curated entry whose file was renamed silently stops applying (task 1003091)');
|
|
124
|
+
});
|