@bongos/core 1.19.260 → 1.19.262

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.
@@ -0,0 +1,121 @@
1
+ // tests/provisioning_settings_env.mjs
2
+ //
3
+ // The standup leg of the platform->instance settings channel (task 1003139, goal
4
+ // 1000058): a per-project setting the PLATFORM stores must reach the INSTANCE that
5
+ // has to obey it.
6
+ //
7
+ // The gap this closes: PATCH /provisioning/instances/:id/settings wrote
8
+ // platform_visibility / joinability to the platform's jsonb and pushed nothing, and a
9
+ // standalone instance never federates — it holds no hub credential and has no
10
+ // authenticated path back to the platform to pull one. The settings were write-only
11
+ // state. The transport that DOES exist is /etc/<slug>/web.env, read through the
12
+ // branding pack, so the runner now writes them there at standup and src/branding.js
13
+ // resolves them onto project.*.
14
+ //
15
+ // NOT covered here because it is NOT built: pushing a CHANGED setting to an
16
+ // already-running instance (the branding pack is memoized + deep-frozen for the
17
+ // process lifetime, so that needs a re-write + restart — the deferred
18
+ // 'settings-apply' intent). These tests assert the standup leg only, and one of them
19
+ // pins that the generated file does not CLAIM the push exists.
20
+ //
21
+ // DB-free and hermetic: pure builders + a child process with env set. No network.
22
+ //
23
+ // Run: node tests/provisioning_settings_env.mjs
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { createRequire } from 'node:module';
27
+ import { execFileSync } from 'node:child_process';
28
+ import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const provisioning = require('../modules/provisioning/provisioning.js');
34
+
35
+ let passed = 0, failed = 0;
36
+ function t(name, fn) {
37
+ try { fn(); passed++; console.log(` PASS ${name}`); }
38
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
39
+ }
40
+
41
+ const linesOf = (s) => s.split('\n').filter((l) => l && !l.startsWith('#'));
42
+ const varsOf = (s) => Object.fromEntries(linesOf(s).map((l) => l.split('=')));
43
+
44
+ console.log('settingsEnvLines — the platform\'s stored settings, as the instance reads them:');
45
+
46
+ t('a stored setting is carried verbatim under the pack\'s env prefix', () => {
47
+ const v = varsOf(provisioning.settingsEnvLines(
48
+ { settings: { platform_visibility: 'gated', joinability: 'invite_only' } }, 'CLOUDBONGOS'));
49
+ assert.equal(v.CLOUDBONGOS_PLATFORM_VISIBILITY, 'gated');
50
+ assert.equal(v.CLOUDBONGOS_JOINABILITY, 'invite_only');
51
+ });
52
+
53
+ t('a row with NO stored settings emits today\'s behavior EXPLICITLY, never nothing', () => {
54
+ const v = varsOf(provisioning.settingsEnvLines({}, 'CLOUDBONGOS'));
55
+ // an instance must never have to infer a policy from an absent variable
56
+ assert.equal(v.CLOUDBONGOS_PLATFORM_VISIBILITY, 'public');
57
+ assert.equal(v.CLOUDBONGOS_JOINABILITY, 'apply');
58
+ assert.equal(linesOf(provisioning.settingsEnvLines({}, 'X')).length, 2, 'both keys always present');
59
+ });
60
+
61
+ t('an off-vocabulary stored value falls back to the default, never reaches the instance', () => {
62
+ const v = varsOf(provisioning.settingsEnvLines(
63
+ { settings: { platform_visibility: 'sort-of-public', joinability: 'whenever' } }, 'CLOUDBONGOS'));
64
+ assert.equal(v.CLOUDBONGOS_PLATFORM_VISIBILITY, 'public');
65
+ assert.equal(v.CLOUDBONGOS_JOINABILITY, 'apply');
66
+ });
67
+
68
+ t('the prefix is the pack\'s, not a guess — a wrong prefix silently never fires', () => {
69
+ const v = varsOf(provisioning.settingsEnvLines({}, 'MERCURY'));
70
+ assert.ok('MERCURY_PLATFORM_VISIBILITY' in v);
71
+ assert.ok(!('CLOUDBONGOS_PLATFORM_VISIBILITY' in v));
72
+ });
73
+
74
+ t('every emitted key is in SETTINGS_VOCAB — the two lists cannot drift', () => {
75
+ const keys = linesOf(provisioning.settingsEnvLines({}, 'X')).map((l) => l.split('=')[0].replace(/^X_/, '').toLowerCase());
76
+ assert.deepEqual(keys.sort(), Object.keys(provisioning.SETTINGS_VOCAB).sort(),
77
+ 'a key added to the vocabulary must be carried, or the instance never learns it');
78
+ });
79
+
80
+ console.log('\nThe generated file tells the truth about what it is:');
81
+
82
+ t('it does NOT claim a live push exists — that intent is not built', () => {
83
+ const s = provisioning.settingsEnvLines({}, 'X');
84
+ assert.doesNotMatch(s, /settings-apply/,
85
+ 'no comment may name an intent action the runner cannot dispatch');
86
+ assert.match(s, /next provision run/i, 'it says when a change actually takes effect');
87
+ });
88
+
89
+ console.log('\nwebEnvBody — the settings ride the real per-instance env file:');
90
+
91
+ t('the runner writes them into web.env alongside PORT and PGDATABASE', () => {
92
+ const provision = require('../scripts/gds/provision.js');
93
+ const body = provision.webEnvBody(
94
+ { slug: 'spike-harbor', port: 3010, db_name: 'spike-harbor', target_ref: 'jaxri/spike-harbor',
95
+ hosting_shape: 'standalone', settings: { platform_visibility: 'gated' } }, {});
96
+ assert.match(body, /_PLATFORM_VISIBILITY=gated/);
97
+ assert.match(body, /_JOINABILITY=apply/);
98
+ assert.match(body, /PORT=3010/, 'the rest of the file is untouched');
99
+ });
100
+
101
+ console.log('\nsrc/branding.js — the value actually reaches code:');
102
+
103
+ t('the neutral pack ships today\'s behavior, so an un-pushed instance is unchanged', () => {
104
+ const pack = require('../config/branding.neutral.json');
105
+ assert.equal(pack.project.platformVisibility, 'public');
106
+ assert.equal(pack.project.joinability, 'apply');
107
+ });
108
+
109
+ t('the env override resolves onto project.* in a real process', () => {
110
+ const read = (env) => JSON.parse(execFileSync(process.execPath, ['-e',
111
+ "const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({v:r.project.platformVisibility,j:r.project.joinability}))"],
112
+ { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...env } }));
113
+ assert.deepEqual(read({}), { v: 'public', j: 'apply' }, 'no env → the pack default');
114
+ assert.deepEqual(
115
+ read({ CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'invite_only' }),
116
+ { v: 'gated', j: 'invite_only' },
117
+ 'the web.env the runner writes is what the instance reads');
118
+ });
119
+
120
+ console.log(`\nprovisioning_settings_env: ${passed} passed, ${failed} failed`);
121
+ if (failed) process.exit(1);
@@ -0,0 +1,176 @@
1
+ // tests/provisioning_teardown_states.mjs
2
+ //
3
+ // The teardown STATE gate (task 1003042, walk finding N5). Sibling of
4
+ // provisioning_teardown_intent.mjs, which covers the intent-collision mapping;
5
+ // this file covers which STATES may be torn down at all, and how the one
6
+ // remaining refusal reads.
7
+ //
8
+ // The bug: TEARDOWN_FROM was a hand-kept whitelist, ['provisioning','active',
9
+ // 'tearing_down'], that silently omitted 'requested' and 'error'. Because the
10
+ // archon force-teardown route reuses the SAME set, there was no path — owner or
11
+ // archon — to remove a snagged project, so errored rows accumulated forever
12
+ // (instance 15 'swag' from 2026-08-07). Two things are pinned here:
13
+ //
14
+ // • the set is DERIVED from INSTANCE_STATES minus 'torn_down', so a future
15
+ // state is teardownable by default and cannot be silently excluded again;
16
+ // • the surviving refusal is a plain SENTENCE on BOTH routes. force-teardown
17
+ // used the positional res.fail(code, 409, details) form with no message,
18
+ // and envelope()'s `message || code` fallback then put the bare string
19
+ // "not_teardownable" on the wire as the human text (§5.1's plain-sentence
20
+ // rule). This test mounts the REAL attachFail, not a shim, so that exact
21
+ // regression is caught on the wire and not merely in the call shape.
22
+ //
23
+ // DB-free: the pool is never queried — the data-access fns the routes need are
24
+ // stubbed on the cached module object (the provisioning_teardown_intent.mjs
25
+ // pattern). No network beyond a loopback listener.
26
+ //
27
+ // Run: node tests/provisioning_teardown_states.mjs
28
+
29
+ import { strict as assert } from 'node:assert';
30
+ import { createRequire } from 'node:module';
31
+ import http from 'node:http';
32
+
33
+ const require = createRequire(import.meta.url);
34
+ const provisioning = require('../modules/provisioning/provisioning.js');
35
+
36
+ let passed = 0, failed = 0;
37
+ function t(name, fn) {
38
+ try { fn(); passed++; console.log(` PASS ${name}`); }
39
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
40
+ }
41
+ async function ta(name, fn) {
42
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
43
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
44
+ }
45
+
46
+ // ── the constant itself ──────────────────────────────────────────────────────
47
+
48
+ console.log('TEARDOWN_FROM — every state except the one with nothing left:');
49
+
50
+ t('every INSTANCE_STATE except torn_down may be torn down', () => {
51
+ for (const s of provisioning.INSTANCE_STATES) {
52
+ assert.equal(provisioning.TEARDOWN_FROM.has(s), s !== 'torn_down',
53
+ `${s} should ${s === 'torn_down' ? 'NOT ' : ''}be teardownable`);
54
+ }
55
+ });
56
+
57
+ t("'requested' and 'error' are IN — the two the old whitelist dropped (N5)", () => {
58
+ assert.equal(provisioning.TEARDOWN_FROM.has('requested'), true);
59
+ assert.equal(provisioning.TEARDOWN_FROM.has('error'), true);
60
+ });
61
+
62
+ t('the set is derived, not hand-listed — size tracks INSTANCE_STATES', () => {
63
+ assert.equal(provisioning.TEARDOWN_FROM.size, provisioning.INSTANCE_STATES.length - 1,
64
+ 'a state added to INSTANCE_STATES must be teardownable by default, never silently excluded');
65
+ });
66
+
67
+ // ── the routes, over a real mounted router + the REAL error envelope ─────────
68
+
69
+ let currentStatus = 'active';
70
+ let teardownCalls = [];
71
+ provisioning.getInstanceById = async (_db, id) =>
72
+ ({ id, owner_builder_id: 7, slug: 'swag', status: currentStatus });
73
+ provisioning.enqueueTeardown = async (_db, id, requestedBy) => {
74
+ teardownCalls.push({ id, requestedBy, status: currentStatus });
75
+ return { intent: { action: 'teardown', state: 'pending' }, created: true, superseded: false };
76
+ };
77
+ provisioning.recordEvent = async () => {};
78
+
79
+ const api = require('../src/module-api.js');
80
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner', rank: 'archon' }; next(); };
81
+ api.requirePermission = () => (_req, _res, next) => next();
82
+
83
+ const express = require('express');
84
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
85
+ const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
86
+ const app = express();
87
+ app.use(express.json());
88
+ app.use(attachFail); // the REAL envelope — a shim would hide the bare-code bug
89
+ app.use('/api/bongos', provisioningRoutes());
90
+
91
+ const server = app.listen(0);
92
+ await new Promise((r) => server.once('listening', r));
93
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
94
+
95
+ function post(path) {
96
+ return new Promise((resolve, reject) => {
97
+ const req = http.request(`${base}${path}`, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
100
+ }, (res) => {
101
+ let d = '';
102
+ res.on('data', (c) => { d += c; });
103
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
104
+ });
105
+ req.on('error', reject);
106
+ req.end('{}');
107
+ });
108
+ }
109
+
110
+ const OWNER = '/provisioning/instances/15/teardown';
111
+ const FORCE = '/provisioning/instances/15/force-teardown';
112
+
113
+ console.log('\nEvery non-torn-down state is accepted — on BOTH routes:');
114
+
115
+ for (const state of ['requested', 'provisioning', 'active', 'tearing_down', 'error']) {
116
+ await ta(`owner teardown queues from '${state}'`, async () => {
117
+ currentStatus = state; teardownCalls = [];
118
+ const res = await post(OWNER);
119
+ assert.equal(res.status, 200, res.raw);
120
+ assert.equal(res.body.queued, true);
121
+ assert.equal(teardownCalls.length, 1, 'the intent was actually enqueued');
122
+ });
123
+ await ta(`force-teardown queues from '${state}'`, async () => {
124
+ currentStatus = state; teardownCalls = [];
125
+ const res = await post(FORCE);
126
+ assert.equal(res.status, 200, res.raw);
127
+ assert.equal(res.body.queued, true);
128
+ assert.equal(teardownCalls.length, 1);
129
+ });
130
+ }
131
+
132
+ await ta("the live repro: instance 15 'swag' sat at 'error' and could not be removed by ANYONE", async () => {
133
+ currentStatus = 'error';
134
+ teardownCalls = [];
135
+ const owner = await post(OWNER);
136
+ const force = await post(FORCE);
137
+ assert.equal(owner.status, 200, `owner teardown: ${owner.raw}`);
138
+ assert.equal(force.status, 200, `force-teardown: ${force.raw}`);
139
+ assert.equal(teardownCalls.length, 2, 'both roles now have a real path out');
140
+ });
141
+
142
+ console.log("\nOnly 'torn_down' still refuses — and it refuses in a sentence:");
143
+
144
+ for (const [label, path] of [['owner teardown', OWNER], ['force-teardown', FORCE]]) {
145
+ await ta(`${label} refuses a torn-down project with 409 not_teardownable`, async () => {
146
+ currentStatus = 'torn_down'; teardownCalls = [];
147
+ const res = await post(path);
148
+ assert.equal(res.status, 409, res.raw);
149
+ assert.equal(res.body.error.code, 'not_teardownable');
150
+ assert.equal(res.body.error.details.status, 'torn_down', 'the real state is named');
151
+ assert.equal(teardownCalls.length, 0, 'nothing was enqueued');
152
+ });
153
+
154
+ await ta(`${label}'s message is a plain sentence, never the bare code (the N5 §5.1 violation)`, async () => {
155
+ currentStatus = 'torn_down';
156
+ const res = await post(path);
157
+ const m = String(res.body.error.message || '');
158
+ assert.notEqual(m, 'not_teardownable', 'the bare code must never be the human text');
159
+ assert.doesNotMatch(m, /_/, 'no snake_case identifier leaks into owner-facing copy');
160
+ assert.ok(/\s/.test(m) && /\.$/.test(m), `a real sentence, got: ${JSON.stringify(m)}`);
161
+ assert.match(m, /torn down/i, 'it says what is actually true of the project');
162
+ });
163
+ }
164
+
165
+ await ta('both routes give the SAME refusal — the copies cannot drift again', async () => {
166
+ currentStatus = 'torn_down';
167
+ const owner = await post(OWNER);
168
+ const force = await post(FORCE);
169
+ assert.equal(owner.body.error.message, force.body.error.message);
170
+ assert.equal(owner.status, force.status);
171
+ });
172
+
173
+ server.close();
174
+
175
+ console.log(`\nprovisioning_teardown_states: ${passed} passed, ${failed} failed`);
176
+ if (failed) process.exit(1);
@@ -121,6 +121,28 @@ test('the manage view is served: identity block + honest not-yet sections (task
121
121
  assert.doesNotMatch(missing, /permission|allowed|access|owner/i);
122
122
  });
123
123
 
124
+ test('the delete section is served live, and never claims to erase (task 1003042)', async () => {
125
+ const { body } = await request('/projects', 'game.test.local');
126
+ assert.match(body, /id="mDel"/, 'the live section body is served');
127
+ assert.match(body, /id="mDelWhat"/, 'the teardown-vs-delete fold is served');
128
+ assert.match(body, /does <b>not<\/b> erase the project/i,
129
+ 'the distinction survives to the wire, not just the source');
130
+ assert.match(body, /no permanent-delete button/i, 'the absent capability is admitted');
131
+ // the confirmation asks for the project's own name — the heaviest gate on the page
132
+ assert.match(body, /Type the project’s name to confirm/, 'the typed confirmation is served');
133
+ assert.match(body, /data-del-yes disabled/, 'and the act starts unreachable');
134
+ });
135
+
136
+ test('the served teardown copy no longer claims the address goes quiet (task 1003042)', async () => {
137
+ const { body } = await request('/projects', 'game.test.local');
138
+ // teardown has no DNS or Caddy leg: the hostname keeps resolving and 502s, so
139
+ // this sentence was false on the wire. Task 1002897 owns actually releasing it.
140
+ assert.doesNotMatch(body, /the address stops answering/,
141
+ 'no served copy may promise a silence the runner does not deliver');
142
+ assert.match(body, /stays reserved for this project/,
143
+ 'the truthful replacement is what ships');
144
+ });
145
+
124
146
  test('the repo-visibility section is served live, with the private direction refused (task 1003045)', async () => {
125
147
  const { body } = await request('/projects', 'game.test.local');
126
148
  assert.match(body, /id="mRepoVis"/, 'the live section body is served');