@bongos/core 1.19.772 → 1.19.774
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 +204 -79
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
- package/docs/adr/0177-goal-membership-kind.md +26 -2
- package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
- package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
- package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
- package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +102 -11
- package/docs/api-reference.md +5 -4
- package/docs/copy-inventory.md +205 -190
- package/docs/copy-registry.json +435 -269
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -1
- package/modules/builder-settings/render-prefs.js +281 -33
- package/modules/builder-settings/routes/settings.js +20 -12
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/dev-box/module.json +4 -0
- package/modules/hall-ui/public/settings-render.js +138 -0
- package/modules/hall-ui/public/settings.html +16 -9
- package/modules/hall-ui/public/settings.js +3 -70
- package/modules/lifecycle/db-tasks.js +7 -1
- package/modules/lifecycle/dead-deps.js +10 -2
- package/modules/lifecycle/ship-card.js +49 -13
- package/modules/lifecycle/task-list-limits.js +44 -0
- package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
- package/modules/platform-identity/platform-identity.js +8 -19
- package/modules/platform-identity/project-catalog.js +24 -0
- package/modules/platform-identity/routes/sso.js +11 -14
- package/modules/platform-identity/tests/platform-identity.mjs +7 -9
- package/modules/provisioning/capacity-gate.js +54 -0
- package/modules/provisioning/catalog-bridge.js +2 -0
- package/modules/provisioning/disconnect.js +35 -0
- package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/routes/disconnect.js +63 -0
- package/modules/provisioning/routes/provisioning.js +6 -6
- package/modules/public-landing/public/projects.html +116 -11
- package/modules/public-landing/public/projects.probes.json +3 -0
- package/modules/ui-design/kit/serve.js +3 -2
- package/modules/ui-design/module.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/address-park-sweep.sh +78 -0
- package/scripts/gds/audit-deps.js +38 -4
- package/scripts/gds/cli-lib.js +37 -0
- package/scripts/gds/fitness-migration-contraction.js +50 -0
- package/scripts/gds/fitness.js +18 -0
- package/scripts/gds/land-watch.js +8 -2
- package/scripts/gds/provision-config.js +4 -4
- package/scripts/gds/provision-disconnect.js +90 -0
- package/scripts/gds/provision-teardown.js +108 -0
- package/scripts/gds/provision.js +6 -65
- package/scripts/gds/seam-wiring-guard.js +273 -0
- package/scripts/gds/ship-finish.js +17 -10
- package/scripts/gds/ship-flow.js +28 -6
- package/scripts/gds/ship-resume.js +32 -4
- package/scripts/gds/start.js +13 -7
- package/src/bongos/auth-admission.js +7 -8
- package/src/bongos/auth-config.js +3 -2
- package/src/bongos/goal-scope-check.js +23 -8
- package/src/bongos/module-scope-map.js +40 -0
- package/src/module-api.js +1 -1
- package/src/module-loader/manifest-schema.js +79 -1
- package/tests/catalog_only_client.mjs +8 -5
- package/tests/fitness.mjs +22 -0
- package/tests/goal_scope_check.mjs +63 -1
- package/tests/hall_settings_world.mjs +57 -1
- package/tests/hub_disconnect_ui.mjs +105 -0
- package/tests/land_watch.mjs +44 -0
- package/tests/membership_kind_lockstep.mjs +86 -27
- package/tests/module-scope-map.mjs +39 -0
- package/tests/module_exports_unique_keys.mjs +70 -0
- package/tests/module_manifest.mjs +85 -0
- package/tests/project_catalog_forget.mjs +83 -0
- package/tests/projects_hub.mjs +10 -4
- package/tests/projects_hub_pre_uat.mjs +5 -3
- package/tests/provision_disconnect.mjs +231 -0
- package/tests/provision_teardown_address.mjs +230 -0
- package/tests/provisioning_capacity_gate.mjs +192 -0
- package/tests/provisioning_disconnect.mjs +231 -0
- package/tests/provisioning_restart.mjs +5 -5
- package/tests/public_landing_projects.mjs +4 -3
- package/tests/render_prefs.mjs +152 -0
- package/tests/seam_wiring_guard.mjs +234 -0
- package/tests/ship_card.mjs +67 -1
- package/tests/ship_resume.mjs +86 -12
- package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
- package/tests/task_list_paging.mjs +178 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// tests/sso_checkin_legacy_field_gone.mjs — the test of the shim's ABSENCE: the
|
|
2
|
+
// legacy `role` field on POST /sso/membership/check-in is gone (task 1003092,
|
|
3
|
+
// ADR 0177 §"the window closed"; the Lineage lesson of ADR 0174).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS FILE EXISTS, AND WHY IT LANDED WITH THE REMOVAL AND NOT BEFORE. Task
|
|
6
|
+
// 1003090 renamed the coarse project membership from `role` to `membership_kind`
|
|
7
|
+
// but kept accepting the legacy field for a cross-version window, because the
|
|
8
|
+
// callers are OTHER instances on their own upgrade schedule. For as long as an
|
|
9
|
+
// un-upgraded instance could still send `role: 'builder'`, the field was
|
|
10
|
+
// load-bearing — dropping it would have silently defaulted that instance's owner
|
|
11
|
+
// to 'member' (the owner flag vanishing from My Projects). Only once every
|
|
12
|
+
// federated instance that can check in was past core 1.19.241 could the hub
|
|
13
|
+
// stop understanding it — and the moment it does, THIS test is what keeps the
|
|
14
|
+
// shim from quietly coming back.
|
|
15
|
+
//
|
|
16
|
+
// Three things are pinned, at three altitudes:
|
|
17
|
+
// 1. ROUTE — a body carrying `role` is refused by the strict schema (400,
|
|
18
|
+
// unknown_field) and touches the database NOT AT ALL: no client lookup, no
|
|
19
|
+
// membership statement. "Must no longer set the membership" is proven by the
|
|
20
|
+
// absence of any SQL, not by inspecting a value it might have written.
|
|
21
|
+
// 2. CONTROL — the same body with `membership_kind` instead still lands, and
|
|
22
|
+
// the UPDATE carries the kind it named. The removal is a wall, not a leak.
|
|
23
|
+
// 3. CONTRACT — the generated OpenAPI + reference no longer list the field, so
|
|
24
|
+
// a typed client regenerated from them cannot offer it.
|
|
25
|
+
//
|
|
26
|
+
// DB-free: a recording fake pool, the REAL sso router, the REAL domain module,
|
|
27
|
+
// core pinned through the doorway (the tests/sso_checkin_identity_spoof.mjs
|
|
28
|
+
// harness). Runs in the default unit gate.
|
|
29
|
+
//
|
|
30
|
+
// Run: node --test tests/sso_checkin_legacy_field_gone.mjs
|
|
31
|
+
|
|
32
|
+
import { strict as assert } from 'node:assert';
|
|
33
|
+
import { test, after } from 'node:test';
|
|
34
|
+
import http from 'node:http';
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { dirname, join } from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const express = require('express');
|
|
42
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
43
|
+
|
|
44
|
+
// ---- the recording fake pool ----------------------------------------------
|
|
45
|
+
let queued = [];
|
|
46
|
+
let calls = [];
|
|
47
|
+
const pool = {
|
|
48
|
+
query: async (text, params) => {
|
|
49
|
+
calls.push({ text: String(text).replace(/\s+/g, ' ').trim(), params: params || [] });
|
|
50
|
+
return queued.shift() || { rows: [], rowCount: 0 };
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
function expect(...responses) { queued = responses; calls = []; }
|
|
54
|
+
const sqlFor = (re) => calls.filter((c) => re.test(c.text));
|
|
55
|
+
|
|
56
|
+
// ---- the real routes over the real domain, with core pinned ----------------
|
|
57
|
+
const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
|
58
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
59
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
60
|
+
require.cache[moduleApiPath] = {
|
|
61
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
62
|
+
exports: {
|
|
63
|
+
pool,
|
|
64
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
65
|
+
resolveSession: async () => null,
|
|
66
|
+
logger: () => noopLog,
|
|
67
|
+
hasProvider: () => true,
|
|
68
|
+
registerProvider: () => {},
|
|
69
|
+
on: () => () => {},
|
|
70
|
+
resolveEnv: () => null,
|
|
71
|
+
userAgent: () => 'cloud-bongos-tests',
|
|
72
|
+
LIVE_RANK_LADDER: ['xenos', 'thetes', 'metic', 'archon'],
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
77
|
+
const buildSsoRoutes = require('../modules/platform-identity/routes/sso.js');
|
|
78
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
79
|
+
|
|
80
|
+
const app = express();
|
|
81
|
+
app.use(express.json());
|
|
82
|
+
app.use(attachFail);
|
|
83
|
+
app.use(buildSsoRoutes());
|
|
84
|
+
const server = app.listen(0, '127.0.0.1');
|
|
85
|
+
await new Promise((r) => server.once('listening', r));
|
|
86
|
+
const port = server.address().port;
|
|
87
|
+
after(() => server.close());
|
|
88
|
+
|
|
89
|
+
function call(method, path, body) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
92
|
+
const req = http.request({
|
|
93
|
+
host: '127.0.0.1', port, method, path,
|
|
94
|
+
headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
|
|
95
|
+
}, (res) => {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
res.on('data', (c) => chunks.push(c));
|
|
98
|
+
res.on('end', () => {
|
|
99
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
100
|
+
let json = null; try { json = JSON.parse(text); } catch { /* non-JSON body */ }
|
|
101
|
+
resolve({ status: res.statusCode, json, text });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
req.on('error', reject);
|
|
105
|
+
req.end(payload);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- fixtures: a legitimate federated instance, checking in its own OWNER -----
|
|
110
|
+
//
|
|
111
|
+
// The owner is the case that made the window load-bearing: a legacy-only sender
|
|
112
|
+
// said `role: 'owner'`, and a hub that ignored the field would have defaulted the
|
|
113
|
+
// row to 'member'. Now that the hub REFUSES the field instead, nothing is written
|
|
114
|
+
// at all — which is the correct failure, because it is loud on the sender's side.
|
|
115
|
+
const CLIENT_ID = 't1003092-instance';
|
|
116
|
+
const CLIENT_SECRET = 't1003092-secret';
|
|
117
|
+
const OWNER = 7100309200;
|
|
118
|
+
|
|
119
|
+
const clientRow = () => ({
|
|
120
|
+
client_id: CLIENT_ID,
|
|
121
|
+
secret_hash: pi.sha256hex(CLIENT_SECRET),
|
|
122
|
+
name: 'Federated Project',
|
|
123
|
+
redirect_uris: ['https://federated.example/api/gds/auth/web/callback'],
|
|
124
|
+
origin: 'https://federated.example',
|
|
125
|
+
owner_github_id: String(OWNER),
|
|
126
|
+
status: 'active',
|
|
127
|
+
});
|
|
128
|
+
const membershipRow = (over = {}) => ({
|
|
129
|
+
id: '5', client_id: CLIENT_ID, github_id: String(OWNER),
|
|
130
|
+
membership_kind: 'owner', joined_at: 'T0', last_active: 'T0',
|
|
131
|
+
signin_verified_at: '2026-09-01T00:00:00.000Z', ...over,
|
|
132
|
+
});
|
|
133
|
+
const baseBody = () => ({
|
|
134
|
+
client_id: CLIENT_ID,
|
|
135
|
+
client_secret: CLIENT_SECRET,
|
|
136
|
+
github_id: OWNER,
|
|
137
|
+
github_login: 'federated-owner',
|
|
138
|
+
display_name: 'Federated Owner',
|
|
139
|
+
avatar_url: 'https://federated.example/av.png',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const errorCode = (r) => r.json?.error?.code ?? r.json?.error;
|
|
143
|
+
const unknownFields = (r) => (r.json?.error?.details ?? r.json?.details ?? [])
|
|
144
|
+
.filter((d) => d.reason === 'unknown_field').map((d) => d.field);
|
|
145
|
+
|
|
146
|
+
// ── 1. ROUTE: the legacy field is refused, and refused BEFORE any SQL ──────────
|
|
147
|
+
|
|
148
|
+
test('a check-in carrying ONLY the legacy field is refused 400 and sets no membership', async () => {
|
|
149
|
+
expect({ rows: [clientRow()] }, { rows: [membershipRow()] });
|
|
150
|
+
const r = await call('POST', '/sso/membership/check-in', { ...baseBody(), role: 'owner' });
|
|
151
|
+
assert.equal(r.status, 400, `legacy-only body must be refused, got ${r.status}: ${r.text}`);
|
|
152
|
+
assert.equal(errorCode(r), 'validation_failed');
|
|
153
|
+
assert.deepEqual(unknownFields(r), ['role'], 'and the refusal names the field, so the sender can see why');
|
|
154
|
+
assert.equal(calls.length, 0,
|
|
155
|
+
'strict validation runs before the client lookup — a legacy body must not even authenticate, let alone write');
|
|
156
|
+
assert.equal(sqlFor(/platform_identity_project_memberships/).length, 0,
|
|
157
|
+
'no membership statement of any kind: this is the "must no longer set the membership" the task asked for');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('a check-in carrying BOTH spellings is refused too — the window admitted the pair, the wall admits neither', async () => {
|
|
161
|
+
// A sender inside the window sent both deliberately (task 1003090). No shipped
|
|
162
|
+
// core does any more (tests/membership_kind_lockstep.mjs pins the sender), so a
|
|
163
|
+
// body with both is by definition an un-upgraded sender, and it is refused rather
|
|
164
|
+
// than half-honoured: honouring `membership_kind` while ignoring `role` would make
|
|
165
|
+
// the legacy field look accepted when it is not.
|
|
166
|
+
expect({ rows: [clientRow()] }, { rows: [membershipRow()] });
|
|
167
|
+
const r = await call('POST', '/sso/membership/check-in', { ...baseBody(), membership_kind: 'owner', role: 'owner' });
|
|
168
|
+
assert.equal(r.status, 400);
|
|
169
|
+
assert.deepEqual(unknownFields(r), ['role']);
|
|
170
|
+
assert.equal(calls.length, 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('the legacy VALUE is no longer a recognised kind: "builder" is clamped like any unknown value, not translated', async () => {
|
|
174
|
+
// The translator that turned 'builder' into 'member' is gone with the field. What
|
|
175
|
+
// remains is the domain's GENERIC clamp — touchMembership writes 'member' for any
|
|
176
|
+
// value outside MEMBERSHIP_KINDS, whether that is the old craft word or a rank
|
|
177
|
+
// name. The two must land identically: if 'builder' were still special-cased
|
|
178
|
+
// anywhere on the path, this is where it would show as a difference.
|
|
179
|
+
const landed = async (kind) => {
|
|
180
|
+
expect({ rows: [clientRow()] }, { rows: [membershipRow()] }, { rows: [{ id: '9', handle: 'federated-owner' }] });
|
|
181
|
+
await call('POST', '/sso/membership/check-in', { ...baseBody(), membership_kind: kind });
|
|
182
|
+
const membership = sqlFor(/platform_identity_project_memberships/);
|
|
183
|
+
assert.equal(membership.length, 1, `one membership statement for kind=${kind}`);
|
|
184
|
+
return membership[0].params[2];
|
|
185
|
+
};
|
|
186
|
+
assert.equal(await landed('builder'), 'member', 'the craft word is not a kind — clamped');
|
|
187
|
+
assert.equal(await landed('archon'), 'member', 'a rank name is not a kind — clamped the same way');
|
|
188
|
+
assert.equal(await landed('visitor'), 'visitor', 'CONTROL: a real kind passes through untouched');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ── 2. CONTROL: the one spelling still lands, and lands the kind it named ──────
|
|
192
|
+
|
|
193
|
+
test('CONTROL: the same check-in with membership_kind lands, and the UPDATE carries the owner kind', async () => {
|
|
194
|
+
expect(
|
|
195
|
+
{ rows: [clientRow()] },
|
|
196
|
+
{ rows: [membershipRow()] },
|
|
197
|
+
{ rows: [{ id: '9', handle: 'federated-owner' }] },
|
|
198
|
+
);
|
|
199
|
+
const r = await call('POST', '/sso/membership/check-in', { ...baseBody(), membership_kind: 'owner' });
|
|
200
|
+
assert.equal(r.status, 200, `control must still pass, got ${r.status}: ${r.text}`);
|
|
201
|
+
const membership = sqlFor(/platform_identity_project_memberships/);
|
|
202
|
+
assert.equal(membership.length, 1, 'exactly one membership statement');
|
|
203
|
+
assert.match(membership[0].text, /^UPDATE platform_identity_project_memberships/);
|
|
204
|
+
assert.ok(membership[0].params.includes('owner'),
|
|
205
|
+
'the kind the sender named is the kind written — the owner flag survives the window closing');
|
|
206
|
+
assert.equal(membership[0].params[0], CLIENT_ID, 'keyed to the AUTHENTICATED client');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('CONTROL: a check-in naming no kind at all still defaults to member (the sender-side default is unchanged)', async () => {
|
|
210
|
+
expect({ rows: [clientRow()] }, { rows: [membershipRow({ membership_kind: 'member' })] }, { rows: [{ id: '9', handle: 'x' }] });
|
|
211
|
+
const r = await call('POST', '/sso/membership/check-in', baseBody());
|
|
212
|
+
assert.equal(r.status, 200);
|
|
213
|
+
const membership = sqlFor(/platform_identity_project_memberships/);
|
|
214
|
+
assert.equal(membership.length, 1);
|
|
215
|
+
assert.ok(membership[0].params.includes('member'), 'no kind → member, exactly as inside the window');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── 3. CONTRACT: the generated API surface no longer offers the field ──────────
|
|
219
|
+
|
|
220
|
+
test('the generated OpenAPI request schema for the check-in has no `role` property', () => {
|
|
221
|
+
const spec = JSON.parse(readFileSync(join(ROOT, 'docs', 'api', 'openapi.json'), 'utf8'));
|
|
222
|
+
const key = Object.keys(spec.paths).find((p) => p.endsWith('/sso/membership/check-in'));
|
|
223
|
+
assert.ok(key, 'the check-in route is in the spec');
|
|
224
|
+
const ref = spec.paths[key].post.requestBody.content['application/json'].schema.$ref;
|
|
225
|
+
const schema = spec.components.schemas[ref.split('/').pop()];
|
|
226
|
+
const props = Object.keys(schema.properties || {});
|
|
227
|
+
assert.ok(props.includes('membership_kind'), `membership_kind is the field (got ${props.join(', ')})`);
|
|
228
|
+
assert.ok(!props.includes('role'),
|
|
229
|
+
'the legacy field is gone from the contract — a client regenerated from this spec cannot offer it');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('the generated API reference row for the check-in lists one spelling', () => {
|
|
233
|
+
const md = readFileSync(join(ROOT, 'docs', 'api-reference.md'), 'utf8');
|
|
234
|
+
const row = md.split('\n').find((l) => l.includes('`/api/bongos/sso/membership/check-in`'));
|
|
235
|
+
assert.ok(row, 'the check-in row is in the reference');
|
|
236
|
+
const fields = row.split('|')[4];
|
|
237
|
+
assert.match(fields, /`membership_kind`/);
|
|
238
|
+
assert.ok(!/`role`/.test(fields), 'the reference is generated from the route schema, so it drops the field with it');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('the route source itself declares no `role` rule (the schema is the wall, so the wall must be in the schema)', () => {
|
|
242
|
+
const src = readFileSync(join(ROOT, 'modules', 'platform-identity', 'routes', 'sso.js'), 'utf8');
|
|
243
|
+
const route = src.slice(src.indexOf("router.post('/sso/membership/check-in'"), src.indexOf("router.post('/sso/activity/rollup'"));
|
|
244
|
+
assert.ok(!/^\s*role:\s*\{/m.test(route), 'no `role:` rule in the check-in schema');
|
|
245
|
+
assert.ok(!/membershipKindFromLegacy/.test(route), 'and no translator call — the mapping left with the field');
|
|
246
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// tests/task_list_paging.mjs — the GET /tasks page bound, and the pager that
|
|
2
|
+
// has to respect it (task 1003752).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG THIS PINS. audit-deps.js --stranded asked for `limit: 3000` against a
|
|
5
|
+
// route that caps `limit` at 1000. Every run 400'd before reading one task, so
|
|
6
|
+
// the ONLY detector for work stranded behind an abandoned dependency had never
|
|
7
|
+
// once produced a result — silently, for months, because a 400 on a read looks
|
|
8
|
+
// exactly like a clean report to anyone not reading the exit code.
|
|
9
|
+
//
|
|
10
|
+
// So this file covers the CLASS, not just the instance:
|
|
11
|
+
// - the cap the route enforces and the page size the client asks for are held
|
|
12
|
+
// equal, so they cannot drift apart again (routes/tasks.js is a NEEDS_TRUST
|
|
13
|
+
// gate surface and the route was never the bug, so the pin lives here);
|
|
14
|
+
// - the fake here enforces the route's real rules (cap, and `offset` must be
|
|
15
|
+
// strictly positive — `offset=0` is a 400, the landmine under every naive
|
|
16
|
+
// `page * size` pager), so a pager that would 400 in production fails here;
|
|
17
|
+
// - the pager is driven with MORE than one page of rows, because a limit bug
|
|
18
|
+
// is invisible against a fixture that fits in a single page.
|
|
19
|
+
//
|
|
20
|
+
// Hermetic: no DB, no API, no network. Run: node tests/task_list_paging.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
|
|
31
|
+
const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../modules/lifecycle/task-list-limits.js');
|
|
32
|
+
const { fetchAllTasks } = require('../scripts/gds/audit-deps.js');
|
|
33
|
+
|
|
34
|
+
let passed = 0, failed = 0;
|
|
35
|
+
const t = async (n, f) => {
|
|
36
|
+
try { await f(); passed++; console.log(` PASS ${n}`); }
|
|
37
|
+
catch (e) { failed++; console.log(` FAIL ${n}\n ${e.message}`); }
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// A stand-in for GET /tasks that refuses everything the real route refuses.
|
|
41
|
+
// `rows` is the whole corpus; the fake slices it the way listTasks does.
|
|
42
|
+
function fakeRoute(rows, { cap = TASK_LIST_MAX_LIMIT } = {}) {
|
|
43
|
+
const calls = [];
|
|
44
|
+
const get = async (query) => {
|
|
45
|
+
calls.push({ ...query });
|
|
46
|
+
const { limit, offset } = query;
|
|
47
|
+
// routes/tasks.js: posInt(limitRaw, 'bad_limit', cap)
|
|
48
|
+
if (limit === undefined || !Number.isInteger(limit) || limit <= 0 || limit > cap) {
|
|
49
|
+
return { ok: false, status: 400, data: { error: { code: 'bad_limit' } } };
|
|
50
|
+
}
|
|
51
|
+
// routes/tasks.js: posInt(offsetRaw, 'bad_offset') — POSITIVE, so 0 is bad.
|
|
52
|
+
if (offset !== undefined && (!Number.isInteger(offset) || offset <= 0)) {
|
|
53
|
+
return { ok: false, status: 400, data: { error: { code: 'bad_offset' } } };
|
|
54
|
+
}
|
|
55
|
+
const start = offset || 0;
|
|
56
|
+
return { ok: true, status: 200, data: { tasks: rows.slice(start, start + limit) } };
|
|
57
|
+
};
|
|
58
|
+
return { get, calls };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const corpus = (n, from = 1) => Array.from({ length: n }, (_, i) => ({ id: from + i, status: 'ready' }));
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------- the cap
|
|
64
|
+
|
|
65
|
+
await t('the shared cap is the number the route actually enforces', () => {
|
|
66
|
+
const routeSrc = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
|
|
67
|
+
|
|
68
|
+
// routes/tasks.js is a NEEDS_TRUST gate surface, so this fix does not edit it
|
|
69
|
+
// — the route was never wrong, the client was. That leaves the cap written in
|
|
70
|
+
// two files, which is what let them drift in the first place, so the drift is
|
|
71
|
+
// pinned HERE instead: change the cap in the route and this test reds until
|
|
72
|
+
// task-list-limits.js is changed to match.
|
|
73
|
+
//
|
|
74
|
+
// Both reads are guarded against going blind. A source scan fails by quietly
|
|
75
|
+
// matching nothing, and a green test over a pattern that no longer matches is
|
|
76
|
+
// a false claim of coverage, not coverage.
|
|
77
|
+
const capM = routeSrc.match(/posInt\(limitRaw, 'bad_limit', (\d+)\)/);
|
|
78
|
+
assert.ok(capM, 'BLIND SCAN: the GET /tasks limit cap no longer matches this pattern — fix the pattern, do not delete the test');
|
|
79
|
+
assert.equal(Number(capM[1]), TASK_LIST_MAX_LIMIT,
|
|
80
|
+
`route caps limit at ${capM && capM[1]} but TASK_LIST_MAX_LIMIT is ${TASK_LIST_MAX_LIMIT} — the pager will 400 or under-read`);
|
|
81
|
+
|
|
82
|
+
// The 400 message is what a human debugging this actually reads, so it has to
|
|
83
|
+
// carry the same number the validator enforces.
|
|
84
|
+
const msgM = routeSrc.match(/limit must be a positive integer up to (\d+)\./);
|
|
85
|
+
assert.ok(msgM, 'BLIND SCAN: the bad_limit message no longer matches this pattern');
|
|
86
|
+
assert.equal(Number(msgM[1]), TASK_LIST_MAX_LIMIT, 'the error message must quote the cap it enforces');
|
|
87
|
+
|
|
88
|
+
// And `offset` must still be the STRICTLY-positive validator the pager is
|
|
89
|
+
// built around — if the route ever starts accepting offset=0, the first-page
|
|
90
|
+
// special case below becomes dead weight rather than a requirement.
|
|
91
|
+
assert.match(routeSrc, /posInt\(offsetRaw, 'bad_offset'\)/,
|
|
92
|
+
'offset is validated with no cap and must stay positive-only — buildTaskPageQuery depends on it');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await t('no page this pager requests can exceed the route cap', async () => {
|
|
96
|
+
// The literal regression: ask for 3000, the number that 400'd for months.
|
|
97
|
+
const q = buildTaskPageQuery({ limit: 3000 });
|
|
98
|
+
assert.ok(q.limit <= TASK_LIST_MAX_LIMIT, `asked ${q.limit}, cap ${TASK_LIST_MAX_LIMIT}`);
|
|
99
|
+
const { get, calls } = fakeRoute(corpus(10));
|
|
100
|
+
await fetchAllTasks(get, { pageSize: 3000 });
|
|
101
|
+
assert.ok(calls.length > 0, 'the fake must actually have been called');
|
|
102
|
+
for (const c of calls) {
|
|
103
|
+
assert.ok(c.limit <= TASK_LIST_MAX_LIMIT, `page asked for limit=${c.limit}`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ------------------------------------------------------- the offset landmine
|
|
108
|
+
|
|
109
|
+
await t('the FIRST page omits offset entirely (offset=0 is a 400)', () => {
|
|
110
|
+
const q = buildTaskPageQuery({ offset: 0 });
|
|
111
|
+
assert.ok(!('offset' in q), 'offset=0 must not be sent — the route rejects it');
|
|
112
|
+
assert.equal(buildTaskPageQuery({ offset: 1000 }).offset, 1000, 'a real offset is sent');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t('a full-corpus read never trips bad_offset', async () => {
|
|
116
|
+
const { get, calls } = fakeRoute(corpus(2500));
|
|
117
|
+
await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
|
|
118
|
+
assert.ok(!('offset' in calls[0]), 'first page must omit offset');
|
|
119
|
+
assert.ok(calls.slice(1).every((c) => c.offset > 0), 'later pages send a positive offset');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// --------------------------------------------------------------- the paging
|
|
123
|
+
|
|
124
|
+
await t('reads EVERY row when the corpus is several pages long', async () => {
|
|
125
|
+
const rows = corpus(2500);
|
|
126
|
+
const { get, calls } = fakeRoute(rows);
|
|
127
|
+
const out = await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
|
|
128
|
+
assert.equal(out.length, 2500, 'every row comes back');
|
|
129
|
+
assert.equal(new Set(out.map((r) => String(r.id))).size, 2500, 'no duplicates');
|
|
130
|
+
assert.equal(calls.length, 3, '1000 + 1000 + 500');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await t('an exact multiple of the page size still terminates', async () => {
|
|
134
|
+
const { get, calls } = fakeRoute(corpus(2000));
|
|
135
|
+
const out = await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
|
|
136
|
+
assert.equal(out.length, 2000);
|
|
137
|
+
assert.equal(calls.length, 3, 'the third page comes back empty and ends the loop');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await t('a duplicated row across pages is collapsed, not double-counted', async () => {
|
|
141
|
+
// What an ordering tie does under LIMIT/OFFSET: one row served twice.
|
|
142
|
+
const rows = [...corpus(1000), ...corpus(1, 1000)];
|
|
143
|
+
const { get } = fakeRoute(rows);
|
|
144
|
+
const out = await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
|
|
145
|
+
assert.equal(out.length, 1000, 'the repeat of id 1000 is deduped by id');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await t('the ORDER BY is a TOTAL order, so pages cannot drop a row', () => {
|
|
149
|
+
const dbSrc = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/db-tasks.js'), 'utf8');
|
|
150
|
+
assert.match(dbSrc, /ORDER BY priority NULLS LAST, created_at, t\.id/,
|
|
151
|
+
'listTasks must tiebreak on id — priority+created_at tie inside one seeding transaction');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ------------------------------------------------------------- the failures
|
|
155
|
+
|
|
156
|
+
await t('a failing page THROWS rather than returning a short corpus', async () => {
|
|
157
|
+
// The original defect dressed as success: a detector that reports "nothing
|
|
158
|
+
// found" because its read failed is worse than one that crashes.
|
|
159
|
+
const get = async () => ({ ok: false, status: 400, data: {} });
|
|
160
|
+
await assert.rejects(() => fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT }), /400/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await t('a mid-corpus failure does not silently truncate', async () => {
|
|
164
|
+
const rows = corpus(2500);
|
|
165
|
+
const real = fakeRoute(rows);
|
|
166
|
+
let n = 0;
|
|
167
|
+
const get = async (q) => (++n === 2 ? { ok: false, status: 500, data: {} } : real.get(q));
|
|
168
|
+
await assert.rejects(() => fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT }), /500/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await t('a route that never returns a short page cannot spin forever', async () => {
|
|
172
|
+
const get = async (q) => ({ ok: true, status: 200, data: { tasks: corpus(q.limit) } });
|
|
173
|
+
await assert.rejects(() => fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT, maxPages: 5 }),
|
|
174
|
+
/refusing to loop forever/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
console.log(`\ntask_list_paging: ${passed} passed, ${failed} failed`);
|
|
178
|
+
process.exit(failed ? 1 : 0);
|