@bongos/core 1.19.238 → 1.19.240
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 +47 -32
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0177-goal-membership-kind.md +96 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -5
- package/docs/api-reference.md +2 -2
- package/docs/canonical-permissions.md +3 -1
- package/migrations/core_220_goal_members_membership_kind.sql +100 -0
- package/modules/hall-ui/public/goals-page.js +15 -13
- package/modules/hall-ui/public/goals.css +2 -2
- package/modules/lifecycle/db.js +40 -32
- package/modules/lifecycle/goal-authz.js +10 -7
- package/modules/lifecycle/routes/done-when.js +3 -3
- package/modules/lifecycle/routes/goals.js +43 -36
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +68 -0
- package/scripts/gds/run-unit-tests.js +7 -1
- package/src/module-api.js +5 -1
- package/tests/criterion_authoring_gate.mjs +4 -4
- package/tests/goal_conflicts.mjs +3 -3
- package/tests/{goal_member_roles.mjs → goal_member_kinds.mjs} +89 -80
- package/tests/goal_membership_requests.mjs +10 -10
- package/tests/goal_routes.mjs +8 -8
- package/tests/goal_tier.mjs +10 -10
- package/tests/government_live_proof.mjs +72 -20
- package/tests/government_live_proof_shape.mjs +124 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// tests/goal_membership_requests.mjs — private goals: the visibility flag + the
|
|
2
2
|
// invite / join-request queue db layer (task 1951 / ADR 0112). DB-free: the
|
|
3
|
-
// SQL-aware fake pool (the
|
|
3
|
+
// SQL-aware fake pool (the goal_member_kinds.mjs precedent) pins each helper's
|
|
4
4
|
// SQL contract + return shaping. The route authorization gates are task 1952's
|
|
5
5
|
// tests; this file pins the query layer under them.
|
|
6
6
|
//
|
|
@@ -139,7 +139,7 @@ await test('resolveMembershipRequest: a lost race (nothing pending) → null', a
|
|
|
139
139
|
// without a membership.
|
|
140
140
|
await test('acceptMembershipRequestAndAddMember: one atomic CTE; returns { request, member }', async () => {
|
|
141
141
|
const REQ = { ...REQUEST_ROW, status: 'accepted' };
|
|
142
|
-
const MEMBER = { goal_id: 1, builder_id: 2,
|
|
142
|
+
const MEMBER = { goal_id: 1, builder_id: 2, membership_kind: 'member', joined_at: 'T2' };
|
|
143
143
|
const pool = fakePool((s) => (/WITH resolved AS/.test(s) ? { rows: [{ request: REQ, member: MEMBER }] } : null));
|
|
144
144
|
const out = await acceptMembershipRequestAndAddMember({ id: 10, resolvedBy: 5 }, { pool });
|
|
145
145
|
assert.deepEqual(out, { request: REQ, member: MEMBER });
|
|
@@ -179,7 +179,7 @@ await test('listInboxFor: shapes {invitations, joinRequests, myPendingRequests}'
|
|
|
179
179
|
await test('listInboxFor: joinRequests gates on owner (created_by) OR manager (lead)', async () => {
|
|
180
180
|
const pool = fakePool(() => ({ rows: [] }));
|
|
181
181
|
await listInboxFor(95, { pool });
|
|
182
|
-
const jq = find(pool.queries, /r\.kind = 'request'.*g\.created_by = \$1.*
|
|
182
|
+
const jq = find(pool.queries, /r\.kind = 'request'.*g\.created_by = \$1.*membership_kind = 'lead'/);
|
|
183
183
|
assert.ok(jq, 'a join-request only reaches an owner or a manager of the goal');
|
|
184
184
|
// and it must not leak: the same $1 builder id feeds every bucket.
|
|
185
185
|
for (const q of pool.queries) assert.deepEqual(q.params, [95]);
|
|
@@ -207,30 +207,30 @@ await test('listPendingRequestsForGoal: pending both-kinds for one goal', async
|
|
|
207
207
|
// stamped 'rejected' on someone who is in fact a member.
|
|
208
208
|
// =========================================================================
|
|
209
209
|
|
|
210
|
-
const ADD_MEMBER_ROW = { goal_id: 7, builder_id: 42,
|
|
210
|
+
const ADD_MEMBER_ROW = { goal_id: 7, builder_id: 42, membership_kind: 'member', joined_at: 'T0' };
|
|
211
211
|
const addMemberPool = () => fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [ADD_MEMBER_ROW] } : null));
|
|
212
212
|
|
|
213
213
|
// join-cancels-request: the joiner arrives under their own steam.
|
|
214
214
|
await test('addGoalMember: a join cancels the joiner\'s pending ask in ONE statement', async () => {
|
|
215
215
|
const pool = addMemberPool();
|
|
216
|
-
const out = await addGoalMember({ goalId: 7, builderId: 42,
|
|
216
|
+
const out = await addGoalMember({ goalId: 7, builderId: 42, membershipKind: 'member', resolvedBy: 42 }, { pool });
|
|
217
217
|
assert.deepEqual(out, ADD_MEMBER_ROW, 'still returns the member row — the route response shape is unchanged');
|
|
218
218
|
assert.equal(pool.queries.length, 1, 'ONE statement: the cancel commits with the membership write or not at all');
|
|
219
219
|
const q = find(pool.queries, /^WITH added AS/);
|
|
220
|
-
assert.ok(/INSERT INTO goal_members[\s\S]*ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET
|
|
221
|
-
'the membership upsert is the first arm,
|
|
220
|
+
assert.ok(/INSERT INTO goal_members[\s\S]*ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET membership_kind = goal_members\.membership_kind/.test(norm(q.sql)),
|
|
221
|
+
'the membership upsert is the first arm, membership_kind still preserved (task 1002689)');
|
|
222
222
|
assert.ok(/UPDATE goal_membership_requests[\s\S]*SET status = 'cancelled', resolved_by = \$4/.test(norm(q.sql)),
|
|
223
223
|
'the second arm closes the ask as cancelled, attributed to the actor');
|
|
224
224
|
assert.ok(/WHERE goal_id = \$1 AND builder_id = \$2 AND status = 'pending'/.test(norm(q.sql)),
|
|
225
225
|
'scoped to THIS goal+builder, and only a pending row');
|
|
226
|
-
assert.deepEqual(q.params, [7, 42, 'member', 42], 'goal, builder,
|
|
226
|
+
assert.deepEqual(q.params, [7, 42, 'member', 42], 'goal, builder, membership_kind, resolvedBy — in that order');
|
|
227
227
|
});
|
|
228
228
|
|
|
229
229
|
// add-cancels-request: an owner/manager/Archon admits them directly. Same
|
|
230
230
|
// statement; resolvedBy is the ADDER, so the audit trail names who admitted them.
|
|
231
231
|
await test('addGoalMember: a direct member-add cancels the target\'s pending ask, attributed to the adder', async () => {
|
|
232
232
|
const pool = addMemberPool();
|
|
233
|
-
await addGoalMember({ goalId: 7, builderId: 42,
|
|
233
|
+
await addGoalMember({ goalId: 7, builderId: 42, membershipKind: 'lead', resolvedBy: 99 }, { pool });
|
|
234
234
|
const q = find(pool.queries, /^WITH added AS/);
|
|
235
235
|
assert.deepEqual(q.params, [7, 42, 'lead', 99],
|
|
236
236
|
'resolved_by is the ADDER (99), not the target (42) — who admitted them is the audit fact');
|
|
@@ -425,7 +425,7 @@ await test('routes: invitation-respond declines before the goal-state gate', ()
|
|
|
425
425
|
// authorizeJoinRequestResponse — owner|manager|Archon resolves a join-request.
|
|
426
426
|
const OWNER_GOAL = { id: 5, status: 'open', created_by: '1', visibility: 'private' };
|
|
427
427
|
const REQUEST = { id: 8, goal_id: '5', builder_id: '2', kind: 'request', status: 'pending' };
|
|
428
|
-
const mgr = (bid) => ({ goal_id: 5, builder_id: bid,
|
|
428
|
+
const mgr = (bid) => ({ goal_id: 5, builder_id: bid, membership_kind: 'lead' });
|
|
429
429
|
await test('authorizeJoinRequestResponse: non-authority actor → 403 (before any request lookup — no leak)', () => {
|
|
430
430
|
const d = authorizeJoinRequestResponse({ goal: OWNER_GOAL, actorId: 7, actorRank: 'metic', actorMembership: null, request: REQUEST, goalId: 5 });
|
|
431
431
|
assert.equal(d.status, 403); assert.equal(d.body.error, 'not_owner_or_manager');
|
package/tests/goal_routes.mjs
CHANGED
|
@@ -42,10 +42,10 @@ const POST_GOALS_SCHEMA = {
|
|
|
42
42
|
scope_modules: { type: 'array', maxItems: 64, itemsType: 'string' },
|
|
43
43
|
succeeds_goal_id: { type: 'integer', min: 1 }, // BV1.R64 rescope carry-forward
|
|
44
44
|
};
|
|
45
|
-
// /members validates only `
|
|
45
|
+
// /members validates only `membership_kind` declaratively; builder_id is coerced in-handler
|
|
46
46
|
// (accepts a number OR a numeric string — ids round-trip through JSON as strings).
|
|
47
47
|
const POST_MEMBERS_SCHEMA = {
|
|
48
|
-
|
|
48
|
+
membership_kind: { type: 'string' },
|
|
49
49
|
};
|
|
50
50
|
// POST /goals/:id/scope (BV1.R64 ask-to-widen) — `add` is the module list; the
|
|
51
51
|
// empty-array, unknown-key, and protected-module checks are in-handler.
|
|
@@ -122,15 +122,15 @@ await test('POST /goals/:id/scope: a non-string add item is rejected', () => {
|
|
|
122
122
|
// ---- POST /goals/:id/members schema ----
|
|
123
123
|
// builder_id is coerced via Number() + Number.isInteger in-handler (a number or a
|
|
124
124
|
// numeric string passes; a non-numeric string → 400 bad_builder_id), verified
|
|
125
|
-
// live against Postgres. Here we pin the declarative half (
|
|
126
|
-
await test('POST /members:
|
|
125
|
+
// live against Postgres. Here we pin the declarative half (membership_kind).
|
|
126
|
+
await test('POST /members: membership_kind optional, valid when present', () => {
|
|
127
127
|
assert.equal(validate({}, POST_MEMBERS_SCHEMA), null);
|
|
128
|
-
assert.equal(validate({
|
|
128
|
+
assert.equal(validate({ membership_kind: 'lead' }, POST_MEMBERS_SCHEMA), null);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
await test('POST /members: non-string
|
|
132
|
-
const r = validate({
|
|
133
|
-
assert.ok(r && r.details.some((d) => d.field === '
|
|
131
|
+
await test('POST /members: non-string membership_kind → wrong_type', () => {
|
|
132
|
+
const r = validate({ membership_kind: 42 }, POST_MEMBERS_SCHEMA);
|
|
133
|
+
assert.ok(r && r.details.some((d) => d.field === 'membership_kind' && d.reason === 'wrong_type'));
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
await test('builder_id coercion contract: Number() accepts int + numeric string, rejects junk', () => {
|
package/tests/goal_tier.mjs
CHANGED
|
@@ -49,7 +49,7 @@ const GOAL_ROW = {
|
|
|
49
49
|
scope_modules: ['gds-core'], status: 'open', created_by: 42,
|
|
50
50
|
succeeded_by_goal_id: null, sort_order: 0, created_at: 'T0', updated_at: 'T0',
|
|
51
51
|
};
|
|
52
|
-
const MEMBER_ROW = { goal_id: 7, builder_id: 42,
|
|
52
|
+
const MEMBER_ROW = { goal_id: 7, builder_id: 42, membership_kind: 'member', joined_at: 'T0' };
|
|
53
53
|
|
|
54
54
|
// =========================================================================
|
|
55
55
|
// createGoal
|
|
@@ -76,7 +76,7 @@ await test('createGoal: the creator becomes the first lead in the same statement
|
|
|
76
76
|
const pool = fakePool((s) => (/INSERT INTO goals/.test(s) ? { rows: [GOAL_ROW] } : null));
|
|
77
77
|
await createGoal({ versionId: 'V1', title: 'g', createdBy: 42 }, { pool });
|
|
78
78
|
const ins = find(pool.queries, /INSERT INTO goals/);
|
|
79
|
-
assert.match(ins.sql, /INSERT INTO goal_members \(goal_id, builder_id,
|
|
79
|
+
assert.match(ins.sql, /INSERT INTO goal_members \(goal_id, builder_id, membership_kind\)/,
|
|
80
80
|
'the member insert is part of the createGoal statement');
|
|
81
81
|
assert.match(ins.sql, /'lead'/, 'the creator is inserted as lead, not member');
|
|
82
82
|
assert.match(ins.sql, /WHERE \$6::bigint IS NOT NULL/,
|
|
@@ -157,19 +157,19 @@ await test('setGoalStatus: unknown goal returns null', async () => {
|
|
|
157
157
|
// task 1002888 wrapped the bare upsert in a CTE that also cancels any pending
|
|
158
158
|
// membership request, so the statement now STARTS with `WITH added AS (` — the
|
|
159
159
|
// INSERT is the first arm. The pending-ask cancellation itself is pinned in
|
|
160
|
-
// tests/goal_membership_requests.mjs; here we only re-anchor the
|
|
161
|
-
await test('addGoalMember: upsert PRESERVES the existing
|
|
162
|
-
const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [{ ...MEMBER_ROW,
|
|
163
|
-
const out = await addGoalMember({ goalId: 7, builderId: 42,
|
|
164
|
-
assert.equal(out.
|
|
160
|
+
// tests/goal_membership_requests.mjs; here we only re-anchor the membership contract.
|
|
161
|
+
await test('addGoalMember: upsert PRESERVES the existing membership_kind (re-join cannot demote a lead)', async () => {
|
|
162
|
+
const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [{ ...MEMBER_ROW, membership_kind: 'lead' }] } : null));
|
|
163
|
+
const out = await addGoalMember({ goalId: 7, builderId: 42, membershipKind: 'member' }, { pool });
|
|
164
|
+
assert.equal(out.membership_kind, 'lead', 'an existing lead row survives a membership_kind:member re-add');
|
|
165
165
|
const ins = find(pool.queries, /^WITH added AS/);
|
|
166
166
|
assert.deepEqual(ins.params, [7, 42, 'member', null], 'resolvedBy defaults to null when no actor is supplied');
|
|
167
167
|
assert.match(ins.sql, /INSERT INTO goal_members/, 'the membership write is the first CTE arm');
|
|
168
|
-
assert.match(ins.sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET
|
|
169
|
-
'conflict keeps the stored
|
|
168
|
+
assert.match(ins.sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET membership_kind = goal_members\.membership_kind/,
|
|
169
|
+
'conflict keeps the stored membership_kind — changes go through PATCH /goals/:id/members/:builderId only (task 1002689)');
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
-
await test('addGoalMember:
|
|
172
|
+
await test('addGoalMember: membershipKind defaults to member', async () => {
|
|
173
173
|
const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [MEMBER_ROW] } : null));
|
|
174
174
|
await addGoalMember({ goalId: 7, builderId: 42 }, { pool });
|
|
175
175
|
assert.equal(find(pool.queries, /^WITH added AS/).params[2], 'member');
|
|
@@ -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
|
+
});
|