@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
|
@@ -252,3 +252,42 @@ test('methodology joined the roster (task 1002989) — the instruction surfaces
|
|
|
252
252
|
assert.equal(covers('scripts/gds/fitness.js'), false, 'the fitness tool stayed with devsecops (task 1003124)');
|
|
253
253
|
assert.equal(covers('scripts/gds/ship.js'), false, 'the ship pipeline belongs to lifecycle');
|
|
254
254
|
});
|
|
255
|
+
|
|
256
|
+
test('SHARED_GLOBS: the ownerless floor every goal wall includes (task 1003655, ADR 0288)', () => {
|
|
257
|
+
// `tests/` is the whole floor and the bar for joining it is high: no module can
|
|
258
|
+
// honestly own the path AND every task legitimately writes it.
|
|
259
|
+
assert.deepEqual([...scope.SHARED_GLOBS], ['tests/']);
|
|
260
|
+
// It is SHARED, not OWNED — no module key may claim it, or the floor would be a
|
|
261
|
+
// second list drifting against the roster.
|
|
262
|
+
for (const k of scope.moduleKeys()) {
|
|
263
|
+
const globs = scope.scopeForModule(k).globs;
|
|
264
|
+
assert.ok(!globs.includes('tests/'), `module "${k}" must not own the shared root tests/`);
|
|
265
|
+
}
|
|
266
|
+
// Module-owned suites keep their own module's key — they are not part of the floor.
|
|
267
|
+
assert.equal(gsc.scopeSubset(['lifecycle'], ['modules/lifecycle/tests/ship.mjs']).ok, true);
|
|
268
|
+
assert.equal(gsc.scopeSubset(['art-pipeline'], ['modules/lifecycle/tests/ship.mjs']).ok, false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('SHARED_GLOBS is DERIVED against PROTECTED_GLOBS, never a trusted literal', () => {
|
|
272
|
+
// Same contract as `protected` (ADR 0086 §7): a protected path listed here would
|
|
273
|
+
// widen EVERY goal's wall into the trust core in one line, so the export filters
|
|
274
|
+
// its own literal rather than trusting it.
|
|
275
|
+
for (const g of scope.SHARED_GLOBS) {
|
|
276
|
+
assert.equal(scope.isProtected([g]), false, `shared glob ${g} overlaps the protected core`);
|
|
277
|
+
}
|
|
278
|
+
assert.deepEqual(
|
|
279
|
+
[...scope.SHARED_GLOBS],
|
|
280
|
+
scope.SHARED_GLOBS_RAW.filter((g) => !scope.isProtected([g])),
|
|
281
|
+
'the exported floor must be the faithful filter of its literal',
|
|
282
|
+
);
|
|
283
|
+
// Prove the filter actually filters, rather than passing because the literal is clean.
|
|
284
|
+
assert.deepEqual(['tests/', 'migrations/'].filter((g) => !scope.isProtected([g])), ['tests/']);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('the gap this closed: an honest task declaring its own test is now authorizable', () => {
|
|
288
|
+
// Before task 1003655 this was refused out_of_scope under EVERY goal, because
|
|
289
|
+
// tests/ resolved to no module — so the wall was only passable by under-declaring
|
|
290
|
+
// touches[] (idea 1000816). Asserted through the real consumer.
|
|
291
|
+
const r = gsc.check(['ideas'], ['modules/ideas/inbox.js', 'tests/idea_routing.mjs'], 'thetes');
|
|
292
|
+
assert.equal(r.ok, true, r.reason);
|
|
293
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// tests/module_exports_unique_keys.mjs
|
|
2
|
+
//
|
|
3
|
+
// No `module.exports = { … }` shorthand literal may name the same key twice (task 1003922).
|
|
4
|
+
//
|
|
5
|
+
// modules/provisioning/provisioning.js carried two adjacent export lines that repeated
|
|
6
|
+
// createInstance, setInstanceStatus, updateInstanceDomain and updateInstanceSettings, a
|
|
7
|
+
// merge residue. JavaScript accepts it silently, which is the hazard: a later edit that
|
|
8
|
+
// removes or renames a key on ONE line leaves the other copy exporting it, so the edit
|
|
9
|
+
// looks applied and is not. This scans every server-side .js file's trailing shorthand
|
|
10
|
+
// export literal, so the next one fails here instead of in review.
|
|
11
|
+
//
|
|
12
|
+
// Run: node tests/module_exports_unique_keys.mjs
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const SKIP_DIRS = new Set(['node_modules', 'vendor', '.git', 'public']);
|
|
21
|
+
|
|
22
|
+
function jsFiles(dir, out = []) {
|
|
23
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
24
|
+
if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) jsFiles(path.join(dir, entry.name), out); }
|
|
25
|
+
else if (entry.name.endsWith('.js')) out.push(path.join(dir, entry.name));
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The trailing `module.exports = { … };` — only when it is pure shorthand (no `key: value`,
|
|
31
|
+
// calls, spreads or strings), since that is the form a duplicated line hides in.
|
|
32
|
+
function duplicateExportKeys(src) {
|
|
33
|
+
const at = src.lastIndexOf('module.exports = {');
|
|
34
|
+
if (at < 0) return [];
|
|
35
|
+
const end = src.indexOf('\n};', at);
|
|
36
|
+
if (end < 0) return [];
|
|
37
|
+
const body = src.slice(at + 'module.exports = {'.length, end)
|
|
38
|
+
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
|
|
39
|
+
if (/[:(){}[\]'"`.]/.test(body)) return [];
|
|
40
|
+
const counts = new Map();
|
|
41
|
+
for (const key of body.split(',').map((s) => s.trim()).filter(Boolean)) counts.set(key, (counts.get(key) || 0) + 1);
|
|
42
|
+
return [...counts].filter(([, n]) => n > 1).map(([key]) => key);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let passed = 0, failed = 0;
|
|
46
|
+
function t(name, fn) {
|
|
47
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
48
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
t('the detector finds a repeated key across two export lines, and ignores a clean or non-shorthand literal', () => {
|
|
52
|
+
assert.deepEqual(duplicateExportKeys('module.exports = {\n a, b,\n a, c,\n};\n'), ['a']);
|
|
53
|
+
assert.deepEqual(duplicateExportKeys('module.exports = {\n a, b, // a\n c,\n};\n'), []);
|
|
54
|
+
assert.deepEqual(duplicateExportKeys('module.exports = {\n a: 1, a: 2,\n};\n'), []);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
t('no server-side module.exports shorthand literal repeats a key', () => {
|
|
58
|
+
const files = ['src', 'modules', 'scripts'].flatMap((d) => jsFiles(path.join(ROOT, d)));
|
|
59
|
+
files.push(path.join(ROOT, 'server.js'));
|
|
60
|
+
assert.ok(files.length > 100, `scanned too few files (${files.length}) — the walk is broken`);
|
|
61
|
+
const offenders = files
|
|
62
|
+
.filter((f) => fs.existsSync(f))
|
|
63
|
+
.map((f) => [path.relative(ROOT, f).replace(/\\/g, '/'), duplicateExportKeys(fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n'))])
|
|
64
|
+
.filter(([, keys]) => keys.length)
|
|
65
|
+
.map(([f, keys]) => `${f}: ${keys.join(', ')}`);
|
|
66
|
+
assert.deepEqual(offenders, [], `a key exported twice is a merge hazard — merge the lines:\n${offenders.join('\n')}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
console.log(`\nmodule_exports_unique_keys: ${passed} passed, ${failed} failed`);
|
|
70
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -276,3 +276,88 @@ test('every bundled module declares a maintenance posture (the catalog has no un
|
|
|
276
276
|
});
|
|
277
277
|
assert.deepEqual(undeclared, [], `these modules declare no maintenance.status: ${undeclared.join(', ')}`);
|
|
278
278
|
});
|
|
279
|
+
|
|
280
|
+
// --- declarativeSeams: the seam-wiring guard's exemption list (task 1003819) ---
|
|
281
|
+
//
|
|
282
|
+
// The exemption is validated AGAINST `contributes` because this is the one place
|
|
283
|
+
// holding both. A typo'd exemption that validated clean would exempt nothing while
|
|
284
|
+
// reading as though it exempted something — the failure mode in the direction that
|
|
285
|
+
// HIDES a defect, which is exactly what the guard exists to catch.
|
|
286
|
+
|
|
287
|
+
test('declarativeSeams: a valid exemption naming a declared seam + entry passes', () => {
|
|
288
|
+
const r = validateManifest({
|
|
289
|
+
...baseManifest(),
|
|
290
|
+
declarativeSeams: { pollers: ['box-lifecycle'], uiSections: ['boxes'] },
|
|
291
|
+
});
|
|
292
|
+
assert.equal(r.valid, true, r.errors.join('; '));
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('declarativeSeams: an exemption for a seam the module does not declare is an ERROR', () => {
|
|
296
|
+
const r = validateManifest({
|
|
297
|
+
...baseManifest(),
|
|
298
|
+
contributes: { routes: ['box'] }, // no pollers declared
|
|
299
|
+
declarativeSeams: { pollers: ['box-lifecycle'] },
|
|
300
|
+
});
|
|
301
|
+
assert.equal(r.valid, false);
|
|
302
|
+
assert.ok(r.errors.some((e) => /does not declare contributes\.pollers/.test(e)), r.errors.join('; '));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('declarativeSeams: an exemption naming an ENTRY the module does not list is an ERROR (the typo case)', () => {
|
|
306
|
+
const r = validateManifest({
|
|
307
|
+
...baseManifest(),
|
|
308
|
+
declarativeSeams: { pollers: ['box-lifecyle'] }, // transposed letters
|
|
309
|
+
});
|
|
310
|
+
assert.equal(r.valid, false);
|
|
311
|
+
assert.ok(r.errors.some((e) => /"box-lifecyle" is not in contributes\.pollers/.test(e)), r.errors.join('; '));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('declarativeSeams: the per-entry check spans EVERY seam, not just bare string arrays', () => {
|
|
315
|
+
// webSurfaces declares OBJECTS and `migrations: true` is implicit, so an earlier cut
|
|
316
|
+
// that compared exemptions against raw strings bailed on both (`if (!isStrArray…)
|
|
317
|
+
// continue`) and let a bogus exemption validate clean — 2 of the 8 allowed keys
|
|
318
|
+
// silently outside acceptance criterion 3. The comparison is against the LABELS now.
|
|
319
|
+
const base = { ...baseManifest(), contributes: { webSurfaces: [{ host: 'status.', dir: 'public' }] } };
|
|
320
|
+
const bad = validateManifest({ ...base, declarativeSeams: { webSurfaces: ['bogus'] } });
|
|
321
|
+
assert.equal(bad.valid, false, 'a bogus webSurfaces exemption must not validate');
|
|
322
|
+
assert.ok(bad.errors.some((e) => /"bogus" is not in contributes\.webSurfaces/.test(e)), bad.errors.join('; '));
|
|
323
|
+
assert.equal(validateManifest({ ...base, declarativeSeams: { webSurfaces: ['status.(public)'] } }).valid, true);
|
|
324
|
+
|
|
325
|
+
const mig = { ...baseManifest(), contributes: { migrations: true } };
|
|
326
|
+
assert.equal(validateManifest({ ...mig, declarativeSeams: { migrations: ['bogus'] } }).valid, false);
|
|
327
|
+
assert.equal(validateManifest({ ...mig, declarativeSeams: { migrations: ['migrations/'] } }).valid, true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('declarativeSeams: an unknown seam key is an ERROR, not a silent no-op', () => {
|
|
331
|
+
const r = validateManifest({ ...baseManifest(), declarativeSeams: { pollerz: ['x'] } });
|
|
332
|
+
assert.equal(r.valid, false);
|
|
333
|
+
assert.ok(r.errors.some((e) => /declarativeSeams\.pollerz: unknown seam/.test(e)), r.errors.join('; '));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('declarativeSeams: shape errors — non-object block, non-string-array entries', () => {
|
|
337
|
+
assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: ['pollers'] }).valid, false);
|
|
338
|
+
assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: { pollers: 'box-lifecycle' } }).valid, false);
|
|
339
|
+
assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: { pollers: [''] } }).valid, false);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('declarativeSeams is OPTIONAL — a manifest without it still validates', () => {
|
|
343
|
+
const m = baseManifest();
|
|
344
|
+
assert.ok(!('declarativeSeams' in m));
|
|
345
|
+
assert.equal(validateManifest(m).valid, true);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('every bundled module with a declarativeSeams block validates against its own contributes', async () => {
|
|
349
|
+
// The live manifests are the real regression surface: an exemption that drifts from
|
|
350
|
+
// the contributes block it exempts is the defect this field is here to prevent.
|
|
351
|
+
const fsm = (await import('node:fs')).default;
|
|
352
|
+
const modulesDir = path.join(ROOT, 'modules');
|
|
353
|
+
const bad = [];
|
|
354
|
+
for (const k of fsm.readdirSync(modulesDir)) {
|
|
355
|
+
const mp = path.join(modulesDir, k, 'module.json');
|
|
356
|
+
if (!fsm.existsSync(mp)) continue;
|
|
357
|
+
const m = JSON.parse(fsm.readFileSync(mp, 'utf8'));
|
|
358
|
+
if (!m.declarativeSeams) continue;
|
|
359
|
+
const r = validateManifest(m);
|
|
360
|
+
if (!r.valid) bad.push(`${k}: ${r.errors.join('; ')}`);
|
|
361
|
+
}
|
|
362
|
+
assert.deepEqual(bad, [], bad.join('\n'));
|
|
363
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// tests/project_catalog_forget.mjs
|
|
2
|
+
//
|
|
3
|
+
// The catalog's half of "Disconnect from Bongos entirely" (task 1003249, ADR 0290): a
|
|
4
|
+
// disconnected project leaves the map for good — its catalog row, its catalog client and the
|
|
5
|
+
// membership rows keyed on that client are DELETED, not hidden.
|
|
6
|
+
//
|
|
7
|
+
// It writes rows keyed on values an owner supplies (a slug, an address), so it keeps the two
|
|
8
|
+
// claim rules the publish path keeps (project-catalog.js header):
|
|
9
|
+
// 1. the PROJECT row goes only when its owner is this instance's owner;
|
|
10
|
+
// 2. the CLIENT (and its memberships) goes only when it is bound to this origin or was never
|
|
11
|
+
// credentialed — a slug that collides with a real, credentialed client elsewhere is not
|
|
12
|
+
// ours to delete.
|
|
13
|
+
//
|
|
14
|
+
// DB-free: a recording pool answers by statement shape.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/project_catalog_forget.mjs
|
|
17
|
+
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const catalog = require('../modules/platform-identity/project-catalog.js');
|
|
24
|
+
|
|
25
|
+
function pool({ client = null } = {}) {
|
|
26
|
+
const calls = [];
|
|
27
|
+
return {
|
|
28
|
+
calls,
|
|
29
|
+
query: async (sql, params) => {
|
|
30
|
+
calls.push({ sql: sql.replace(/\s+/g, ' ').trim(), params });
|
|
31
|
+
if (/^SELECT .* FROM platform_identity_sso_clients/.test(sql.replace(/\s+/g, ' ').trim())) return { rows: client ? [client] : [] };
|
|
32
|
+
if (/^DELETE FROM platform_identity_projects/.test(sql.replace(/\s+/g, ' ').trim())) return { rowCount: 1, rows: [] };
|
|
33
|
+
if (/^DELETE FROM platform_identity_project_memberships/.test(sql.replace(/\s+/g, ' ').trim())) return { rowCount: 3, rows: [] };
|
|
34
|
+
if (/^DELETE FROM platform_identity_sso_clients/.test(sql.replace(/\s+/g, ' ').trim())) return { rowCount: 1, rows: [] };
|
|
35
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const TARGET = { clientId: 'lantern', origin: 'https://lantern.example.com/', ownerGithubId: 4242 };
|
|
40
|
+
const deletes = (p) => p.calls.filter((c) => /^DELETE/.test(c.sql));
|
|
41
|
+
|
|
42
|
+
test('an uncredentialed catalog-only client: the project row, the client and its memberships all go', async () => {
|
|
43
|
+
const p = pool({ client: { origin: 'https://lantern.example.com', secret_hash: '' } });
|
|
44
|
+
const out = await catalog.forgetOwnedProject(TARGET, { pool: p });
|
|
45
|
+
assert.deepEqual(out, { project: 1, client: 1, memberships: 3 });
|
|
46
|
+
const d = deletes(p);
|
|
47
|
+
assert.match(d[0].sql, /DELETE FROM platform_identity_projects WHERE origin = \$1 AND owner_github_id = \$2/);
|
|
48
|
+
assert.deepEqual(d[0].params, ['https://lantern.example.com', 4242], 'origin normalised, owner-scoped (claim rule 2)');
|
|
49
|
+
assert.deepEqual(d[1].params, ['lantern']);
|
|
50
|
+
assert.match(d[1].sql, /platform_identity_project_memberships/);
|
|
51
|
+
assert.match(d[2].sql, /platform_identity_sso_clients/, 'memberships go before the client they key on');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a credentialed client bound to THIS origin is this project\'s — it goes too', async () => {
|
|
55
|
+
const p = pool({ client: { origin: 'https://lantern.example.com', secret_hash: 'abc123' } });
|
|
56
|
+
const out = await catalog.forgetOwnedProject(TARGET, { pool: p });
|
|
57
|
+
assert.equal(out.client, 1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a credentialed client at ANOTHER origin shares only the slug — it and its memberships are left alone (claim rule 1)', async () => {
|
|
61
|
+
const p = pool({ client: { origin: 'https://someone-else.example.org', secret_hash: 'abc123' } });
|
|
62
|
+
const out = await catalog.forgetOwnedProject(TARGET, { pool: p });
|
|
63
|
+
assert.deepEqual(out, { project: 1, client: 0, memberships: 0 });
|
|
64
|
+
assert.equal(deletes(p).length, 1, 'only the owner-scoped project row');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('no client row: the project row still goes', async () => {
|
|
68
|
+
const p = pool();
|
|
69
|
+
assert.deepEqual(await catalog.forgetOwnedProject(TARGET, { pool: p }), { project: 1, client: 0, memberships: 0 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('no address or no owner identity: nothing is touched', async () => {
|
|
73
|
+
for (const t of [{ ...TARGET, origin: '' }, { ...TARGET, ownerGithubId: null }, { ...TARGET, clientId: '' }]) {
|
|
74
|
+
const p = pool({ client: { origin: 'https://lantern.example.com', secret_hash: '' } });
|
|
75
|
+
assert.deepEqual(await catalog.forgetOwnedProject(t, { pool: p }), { project: 0, client: 0, memberships: 0 });
|
|
76
|
+
assert.equal(p.calls.length, 0);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('the kernel port exposes it as `forget`, beside publish and catalogued', () => {
|
|
81
|
+
assert.equal(typeof catalog.projectCatalogPort.forget, 'function');
|
|
82
|
+
assert.deepEqual(Object.keys(catalog.projectCatalogPort).sort(), ['catalogued', 'forget', 'publish']);
|
|
83
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -1305,7 +1305,8 @@ test('"delete" never claims to erase — the fold states what is kept', () => {
|
|
|
1305
1305
|
assert.match(fold, /does <b>not<\/b> erase the project/i, 'the distinction is stated, not implied');
|
|
1306
1306
|
assert.match(fold, /torn down/, 'it names the state the project actually lands in');
|
|
1307
1307
|
assert.match(fold, /repository on GitHub is untouched/, 'and what survives');
|
|
1308
|
-
|
|
1308
|
+
// the true delete exists since task 1003249 — the fold points at it rather than admitting its absence
|
|
1309
|
+
assert.match(fold, /use <b>Disconnect from Bongos entirely<\/b> below/, 'the erase lives in its own card, and the fold says where');
|
|
1309
1310
|
});
|
|
1310
1311
|
|
|
1311
1312
|
test('tearing a project down asks for its NAME — the page\'s heaviest control', () => {
|
|
@@ -1350,12 +1351,17 @@ test('the delete section mirrors the server gate — every state but torn_down',
|
|
|
1350
1351
|
'and the sentence is dropped where no such button rendered');
|
|
1351
1352
|
});
|
|
1352
1353
|
|
|
1353
|
-
test('a teardown
|
|
1354
|
+
test('a teardown keeps the address reserved and says nothing answers there — the copy says what the runner does', () => {
|
|
1354
1355
|
const fn = (SCRIPT.match(/function teardownTruth\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
1355
1356
|
assert.ok(fn, 'teardownTruth() must be extractable');
|
|
1356
|
-
// the runner
|
|
1357
|
+
// the runner parks the hostname and removes its Caddy block (task 1002897), but the
|
|
1358
|
+
// row keeps the address, so the copy says "reserved", never "goes away"
|
|
1357
1359
|
assert.doesNotMatch(fn, /the address stops answering/,
|
|
1358
|
-
'the
|
|
1360
|
+
'the address is held for a revive, not released');
|
|
1361
|
+
assert.match(fn, /nothing answers there until you bring it back/,
|
|
1362
|
+
'what a visitor now meets at the parked address');
|
|
1363
|
+
assert.doesNotMatch(fn, /shows an error page/,
|
|
1364
|
+
'the pre-park 502 promise is gone');
|
|
1359
1365
|
assert.match(fn, /stays reserved for this project/,
|
|
1360
1366
|
'the honest word, and the accurate one — a revive re-uses exactly this hostname');
|
|
1361
1367
|
assert.match(fn, /repository is untouched/, 'what survives is still stated in both shapes');
|
|
@@ -63,7 +63,8 @@ test('addressRow branches by state before it trusts the domain — torn down and
|
|
|
63
63
|
const stateFirst = body.indexOf("inst.status === 'torn_down' || inst.status === 'tearing_down'");
|
|
64
64
|
const domainTest = body.indexOf('if (inst.domain)');
|
|
65
65
|
assert.ok(stateFirst >= 0 && stateFirst < domainTest, 'the torn-down test comes before the domain test');
|
|
66
|
-
assert.match(body, /stays reserved for this project —
|
|
66
|
+
assert.match(body, /stays reserved for this project — nothing answers there until you bring it back/, 'torn down: the address is reserved, and teardown parked it (task 1002897)');
|
|
67
|
+
assert.doesNotMatch(body, /shows an error page/, 'the pre-park promise is gone — teardown removes the proxy block that served it');
|
|
67
68
|
assert.match(body, /if \(inst\.status === 'error'\) return 'Address <code>/, 'snagged has its own line');
|
|
68
69
|
assert.match(body, /nothing is answering there yet/);
|
|
69
70
|
assert.equal((body.match(/— it answers there once the instance is live\.'/g) || []).length, 1, 'the standup promise survives only for queued / standing-up');
|
|
@@ -428,13 +429,14 @@ test('the minute tick keeps a half-typed domain typed, and its receipt on the pa
|
|
|
428
429
|
});
|
|
429
430
|
|
|
430
431
|
/* ── the owner's five calls of 2026-08-24 (task 1003250) ── */
|
|
431
|
-
test('decision 6 — the manage section is "Take it offline", and the teardown truth ends by saying
|
|
432
|
+
test('decision 6 — the manage section is "Take it offline", and the teardown truth ends by saying taking it offline erases nothing', () => {
|
|
432
433
|
const del = (HUB.match(/<div class="card mSec" id="mDelete">[\s\S]*?<\/details>\s*<\/div>/) || [''])[0];
|
|
433
434
|
assert.ok(del, '#mDelete exists');
|
|
434
435
|
assert.match(del, /<h2[^>]*>Take it offline<\/h2>/, 'the heading says what the control does');
|
|
435
436
|
assert.doesNotMatch(HUB, /Delete the project/, 'the word "Delete" no longer promises an erase anywhere on the page');
|
|
436
437
|
const truth = fn('teardownTruth');
|
|
437
|
-
|
|
438
|
+
// scoped to THIS control since task 1003249: the page now also carries the true delete
|
|
439
|
+
assert.equal((truth.match(/until you bring it back\. Taking it offline erases nothing\.'/g) || []).length, 2, 'both hosting shapes end on the same sentence');
|
|
438
440
|
});
|
|
439
441
|
|
|
440
442
|
test('decision 5 — the Type step says where the type can be changed (task 1003504)', () => {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// tests/provision_disconnect.mjs
|
|
2
|
+
//
|
|
3
|
+
// "Disconnect from Bongos entirely" — the runner leg (task 1003249, ADR 0290).
|
|
4
|
+
//
|
|
5
|
+
// The web tier only queues a 'disconnect' intent (tests/provisioning_disconnect.mjs). This is
|
|
6
|
+
// where the project actually goes: the teardown legs first (units, Caddy block, parked DNS,
|
|
7
|
+
// deploy key), then the erase a teardown deliberately never does — the database, its role, its
|
|
8
|
+
// nightly dumps, its env file, state dir, checkout and unix account — then its catalog,
|
|
9
|
+
// membership and client rows through the `project.catalog` port, and LAST the provisioning row,
|
|
10
|
+
// which releases the name, the port and the address.
|
|
11
|
+
//
|
|
12
|
+
// What these tests pin:
|
|
13
|
+
// • ORDER: nothing is erased until the teardown ran; the row is deleted only after every
|
|
14
|
+
// erase and the catalog forget succeeded, so a failure leaves a row the runner can retry
|
|
15
|
+
// • the dump erase matches the nightly filename EXACTLY, so `test` never erases `test-nk`
|
|
16
|
+
// • each shape erases where its data lives (standalone: control plane; co-tenant: the
|
|
17
|
+
// co-hosting box; dedicated: nothing — its droplet went whole)
|
|
18
|
+
// • a dry run erases nothing and deletes nothing; an invalid slug is refused before any command
|
|
19
|
+
//
|
|
20
|
+
// Hermetic: recorded executors, a fake db, a fake catalog port. No shell, no Postgres.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/provision_disconnect.mjs
|
|
23
|
+
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const P = require('../scripts/gds/provision.js');
|
|
29
|
+
const D = require('../scripts/gds/provision-disconnect.js');
|
|
30
|
+
|
|
31
|
+
let passed = 0, failed = 0;
|
|
32
|
+
function t(name, fn) {
|
|
33
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
34
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
35
|
+
}
|
|
36
|
+
async function ta(name, fn) {
|
|
37
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
38
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const lantern = { id: 40, slug: 'lantern', hosting_shape: 'standalone', status: 'torn_down', owner_builder_id: 7, port: 3021, db_name: 'lantern', domain: 'lantern.cloudbongos.com', target_ref: null };
|
|
42
|
+
|
|
43
|
+
function rig({ apply = true, privileged = false, failOn = null, catalogThrows = false, catalog = true } = {}) {
|
|
44
|
+
const events = [], logs = [];
|
|
45
|
+
const record = (where) => (cmd, opts) => {
|
|
46
|
+
events.push({ where, cmd, env: opts && opts.env });
|
|
47
|
+
if (failOn && failOn.test(cmd)) throw new Error(`Command failed: ${cmd}`);
|
|
48
|
+
return { ok: true, stdout: '' };
|
|
49
|
+
};
|
|
50
|
+
const deps = {
|
|
51
|
+
apply, privileged, log: (m) => logs.push(String(m)),
|
|
52
|
+
db: {
|
|
53
|
+
query: async (sql, params) => {
|
|
54
|
+
if (/SELECT github_id FROM builders/.test(sql)) return { rows: [{ github_id: 4242 }] };
|
|
55
|
+
if (/DELETE FROM provisioning_instances/.test(sql)) { events.push({ where: 'db', cmd: 'delete-row', params }); return { rowCount: 1, rows: [] }; }
|
|
56
|
+
return { rows: [] };
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
provisioning: {
|
|
60
|
+
setInstanceStatus: async (_db, _id, status) => { events.push({ where: 'status', cmd: status }); },
|
|
61
|
+
recordEvent: async () => {},
|
|
62
|
+
},
|
|
63
|
+
exec: record('box'), controlExec: record('control'),
|
|
64
|
+
writeFile: () => ({ ok: true }), controlWriteFile: () => ({ ok: true }),
|
|
65
|
+
doApi: { deleteDroplet: async (id) => { events.push({ where: 'do', cmd: `delete-droplet ${id}` }); } },
|
|
66
|
+
projectCatalog: catalog ? {
|
|
67
|
+
forgetOwnedProject: async (target, opts) => {
|
|
68
|
+
events.push({ where: 'catalog', cmd: 'forget', target, pool: opts && opts.pool });
|
|
69
|
+
if (catalogThrows) throw new Error('catalog down');
|
|
70
|
+
return { project: 1, client: 1, memberships: 2 };
|
|
71
|
+
},
|
|
72
|
+
} : null,
|
|
73
|
+
};
|
|
74
|
+
const at = (pred) => events.findIndex(pred);
|
|
75
|
+
const cmds = (where) => events.filter((e) => e.where === where).map((e) => e.cmd);
|
|
76
|
+
return { deps, events, logs, at, cmds };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const saved = { hook: P.CONFIG.dnsHook };
|
|
80
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
81
|
+
|
|
82
|
+
console.log('the erase, in order:');
|
|
83
|
+
|
|
84
|
+
await ta('standalone: teardown legs, then the database, its role, the dumps, env + state, checkout, account — then forget, then the row', async () => {
|
|
85
|
+
const r = rig();
|
|
86
|
+
const res = await D.disconnectInstance(lantern, r.deps);
|
|
87
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
88
|
+
const c = r.cmds('control');
|
|
89
|
+
const idx = (re) => r.at((e) => e.where === 'control' && re.test(e.cmd));
|
|
90
|
+
const order = [
|
|
91
|
+
/systemctl disable --now lantern$/,
|
|
92
|
+
/rm -f \/etc\/caddy\/sites\/lantern\.caddy/,
|
|
93
|
+
/^dropdb --if-exists --force lantern$/,
|
|
94
|
+
/^psql -v ON_ERROR_STOP=1 -d postgres -c 'DROP ROLE IF EXISTS "bongos-lantern"'$/,
|
|
95
|
+
/^find \/var\/backups\/gds -maxdepth 1 -type f -name 'lantern-\[0-9\]/,
|
|
96
|
+
/^rm -rf \/etc\/lantern \/var\/lib\/bongos-lantern$/,
|
|
97
|
+
/^rm -rf \/srv\/cloudbongos\/lantern$/,
|
|
98
|
+
/userdel bongos-lantern/,
|
|
99
|
+
];
|
|
100
|
+
const positions = order.map((re) => idx(re));
|
|
101
|
+
positions.forEach((p, i) => assert.ok(p >= 0, `missing ${order[i]} in:\n${c.join('\n')}`));
|
|
102
|
+
for (let i = 1; i < positions.length; i++) assert.ok(positions[i - 1] < positions[i], `${order[i - 1]} before ${order[i]}`);
|
|
103
|
+
const forget = r.at((e) => e.cmd === 'forget');
|
|
104
|
+
const del = r.at((e) => e.cmd === 'delete-row');
|
|
105
|
+
assert.ok(positions[positions.length - 1] < forget && forget < del, 'erase, then forget, then the row — last');
|
|
106
|
+
assert.deepEqual(r.events[forget].target, { clientId: 'lantern', origin: 'https://lantern.cloudbongos.com', ownerGithubId: 4242 });
|
|
107
|
+
assert.equal(r.events[forget].pool, r.deps.db, 'the runner\'s own pool, not the web tier\'s');
|
|
108
|
+
assert.deepEqual(r.events[del].params, [40]);
|
|
109
|
+
assert.equal(r.cmds('box').length, 0, 'a standalone erases where it ran — the control plane');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await ta('privileged: postgres commands run as postgres, filesystem and account removal are sudo', async () => {
|
|
113
|
+
const r = rig({ privileged: true });
|
|
114
|
+
await D.disconnectInstance(lantern, r.deps);
|
|
115
|
+
const c = r.cmds('control');
|
|
116
|
+
assert.ok(c.includes('sudo -u postgres dropdb --if-exists --force lantern'), c.join('\n'));
|
|
117
|
+
assert.ok(c.some((x) => /^sudo -u postgres psql .*DROP ROLE IF EXISTS "bongos-lantern"/.test(x)));
|
|
118
|
+
assert.ok(c.some((x) => /^sudo find \/var\/backups\/gds /.test(x)));
|
|
119
|
+
assert.ok(c.includes('sudo rm -rf /etc/lantern /var/lib/bongos-lantern'));
|
|
120
|
+
assert.ok(c.includes('sudo rm -rf /srv/cloudbongos/lantern'));
|
|
121
|
+
assert.ok(c.some((x) => /sudo userdel bongos-lantern/.test(x)));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await ta('co-tenant: the erase runs on the co-hosting box, there is no per-project checkout to remove, DNS stays on the control plane', async () => {
|
|
125
|
+
const r = rig();
|
|
126
|
+
await D.disconnectInstance({ ...lantern, hosting_shape: 'co-tenant' }, r.deps);
|
|
127
|
+
const box = r.cmds('box');
|
|
128
|
+
assert.ok(box.includes('dropdb --if-exists --force lantern'), box.join('\n'));
|
|
129
|
+
assert.ok(!box.some((x) => /\/srv\/cloudbongos/.test(x)), 'a co-tenant runs from the shared checkout — never rm it');
|
|
130
|
+
assert.ok(!r.cmds('control').some((x) => /dropdb/.test(x)));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await ta('dedicated: its droplet went whole, so nothing is erased by command — the catalog and the row still go', async () => {
|
|
134
|
+
const r = rig();
|
|
135
|
+
const res = await D.disconnectInstance({ ...lantern, hosting_shape: 'dedicated', droplet_id: 9 }, r.deps);
|
|
136
|
+
assert.equal(res.ok, true);
|
|
137
|
+
assert.ok(!r.events.some((e) => /dropdb|rm -rf|userdel|find /.test(e.cmd || '')), r.events.map((e) => e.cmd).join('\n'));
|
|
138
|
+
assert.ok(r.at((e) => e.cmd === 'forget') >= 0 && r.at((e) => e.cmd === 'delete-row') >= 0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
console.log('\nthe dump erase is exact:');
|
|
142
|
+
|
|
143
|
+
t('the nightly filename matches, and a project whose name merely STARTS with this one\'s does not', () => {
|
|
144
|
+
const glob = D.dumpGlob({ slug: 'test', db_name: 'test' });
|
|
145
|
+
const re = new RegExp(`^${glob.replace(/\./g, '\\.').replace(/\[0-9\]/g, '[0-9]')}$`);
|
|
146
|
+
assert.ok(re.test('test-2026-09-16T031500Z.sql.gz'), 'the name db-backup-nightly.sh writes');
|
|
147
|
+
assert.ok(!re.test('test-nk-2026-09-16T031500Z.sql.gz'), 'another project\'s dump');
|
|
148
|
+
assert.ok(!re.test('test-2-2026-09-16T031500Z.sql.gz'), 'a project named test-2');
|
|
149
|
+
assert.ok(!re.test('test-2026-09-16T031500Z.sql'), 'not a finished dump');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
console.log('\nfailure leaves a row to retry:');
|
|
153
|
+
|
|
154
|
+
await ta('a failed database drop returns a retryable failure: no forget, and the row is NOT deleted', async () => {
|
|
155
|
+
const r = rig({ failOn: /dropdb/ });
|
|
156
|
+
const res = await D.disconnectInstance(lantern, r.deps);
|
|
157
|
+
assert.equal(res.ok, false);
|
|
158
|
+
assert.match(res.error, /dropdb|database/);
|
|
159
|
+
assert.equal(r.at((e) => e.cmd === 'forget'), -1);
|
|
160
|
+
assert.equal(r.at((e) => e.cmd === 'delete-row'), -1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await ta('a teardown that throws erases nothing, forgets nothing and keeps the row', async () => {
|
|
164
|
+
const r = rig();
|
|
165
|
+
r.deps.provisioning.setInstanceStatus = async () => { throw new Error('db down'); };
|
|
166
|
+
await assert.rejects(D.disconnectInstance(lantern, r.deps), /db down/);
|
|
167
|
+
assert.equal(r.at((e) => /dropdb|DROP ROLE|userdel|rm -rf/.test(e.cmd)), -1, r.events.map((e) => e.cmd).join(' | '));
|
|
168
|
+
assert.equal(r.at((e) => e.cmd === 'forget'), -1);
|
|
169
|
+
assert.equal(r.at((e) => e.cmd === 'delete-row'), -1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await ta('a failed catalog forget also keeps the row — deleting it would orphan the map entry for good', async () => {
|
|
173
|
+
const r = rig({ catalogThrows: true });
|
|
174
|
+
const res = await D.disconnectInstance(lantern, r.deps);
|
|
175
|
+
assert.equal(res.ok, false);
|
|
176
|
+
assert.equal(r.at((e) => e.cmd === 'delete-row'), -1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await ta('no catalog on this control plane (platform-identity off): nothing to forget, the row still goes', async () => {
|
|
180
|
+
const r = rig({ catalog: false });
|
|
181
|
+
const res = await D.disconnectInstance(lantern, r.deps);
|
|
182
|
+
assert.equal(res.ok, true);
|
|
183
|
+
assert.ok(r.at((e) => e.cmd === 'delete-row') >= 0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await ta('no address: never catalogued, so nothing is forgotten', async () => {
|
|
187
|
+
const r = rig();
|
|
188
|
+
await D.disconnectInstance({ ...lantern, domain: null }, r.deps);
|
|
189
|
+
assert.equal(r.at((e) => e.cmd === 'forget'), -1);
|
|
190
|
+
assert.ok(r.at((e) => e.cmd === 'delete-row') >= 0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
console.log('\nguards:');
|
|
194
|
+
|
|
195
|
+
await ta('a dry run plans the erase and deletes nothing', async () => {
|
|
196
|
+
const r = rig({ apply: false });
|
|
197
|
+
const res = await D.disconnectInstance(lantern, r.deps);
|
|
198
|
+
assert.equal(res.ok, true);
|
|
199
|
+
assert.equal(r.at((e) => e.cmd === 'forget'), -1);
|
|
200
|
+
assert.equal(r.at((e) => e.cmd === 'delete-row'), -1);
|
|
201
|
+
assert.ok(r.logs.some((l) => /would delete the provisioning row/.test(l)), r.logs.join('\n'));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await ta('an invalid slug is refused before a single command runs — no rm -rf is ever built from it', async () => {
|
|
205
|
+
for (const slug of ['', '..', 'a/b', 'Lantern', '-x']) {
|
|
206
|
+
const r = rig();
|
|
207
|
+
const res = await D.disconnectInstance({ ...lantern, slug }, r.deps);
|
|
208
|
+
assert.equal(res.ok, false, slug);
|
|
209
|
+
assert.equal(r.events.length, 0, `${JSON.stringify(slug)} ran: ${r.events.map((e) => e.cmd).join(' | ')}`);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await ta('the drain loop dispatches a disconnect intent to this leg', async () => {
|
|
214
|
+
const r = rig();
|
|
215
|
+
let claims = 0, resolved = null;
|
|
216
|
+
r.deps.provisioning = {
|
|
217
|
+
...r.deps.provisioning,
|
|
218
|
+
claimNextIntent: async () => (claims++ === 0 ? { id: 3, instance_id: 40, action: 'disconnect', attempts: 1 } : null),
|
|
219
|
+
getInstanceById: async () => ({ ...lantern }),
|
|
220
|
+
resolveIntent: async (_db, id, state) => { resolved = [id, state]; },
|
|
221
|
+
claimNextOAuthManifest: async () => null,
|
|
222
|
+
};
|
|
223
|
+
const out = await P.cmdRunIntents(r.deps);
|
|
224
|
+
assert.equal(out.ok, 1, JSON.stringify(out));
|
|
225
|
+
assert.deepEqual(resolved, [3, 'done'], 'resolving the (now cascaded) intent is a harmless no-op');
|
|
226
|
+
assert.ok(r.at((e) => e.cmd === 'delete-row') >= 0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
P.CONFIG.dnsHook = saved.hook;
|
|
230
|
+
console.log(`\nprovision_disconnect: ${passed} passed, ${failed} failed`);
|
|
231
|
+
process.exit(failed ? 1 : 0);
|