@bongos/core 1.19.954 → 1.19.956
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 +53 -23
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +334 -3
- package/docs/api-reference.md +6 -2
- package/docs/copy-inventory.md +156 -151
- package/docs/copy-registry.json +238 -192
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/env-manifest.js +215 -0
- package/modules/provisioning/migrations/provisioning_023_env_manifest.sql +67 -0
- package/modules/provisioning/module.json +20 -5
- package/modules/provisioning/pollers/liveness-sweep.js +41 -1
- package/modules/provisioning/pollers/runner-drift-notify.js +203 -0
- package/modules/provisioning/routes/env-manifest.js +201 -0
- package/modules/public-landing/public/projects.html +157 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/provisioning_env_manifest.mjs +435 -0
- package/tests/provisioning_runner_drift_notice.mjs +292 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// tests/provisioning_env_manifest.mjs
|
|
2
|
+
//
|
|
3
|
+
// The project ENV VAR MANIFEST — names and purposes, never values (task 1004062,
|
|
4
|
+
// ADR 0323 decision 6).
|
|
5
|
+
//
|
|
6
|
+
// THE LOAD-BEARING ASSERTION IS AN ABSENCE. The task's own done-when is "no route in this
|
|
7
|
+
// work accepts, stores, logs or returns a secret value, and a test asserts the value field
|
|
8
|
+
// does not exist", so this file proves the negative three ways rather than once: the
|
|
9
|
+
// migration declares no value column, the row projection carries no value key, and a
|
|
10
|
+
// caller who POSTs one is REFUSED — loudly, with nothing written. The third is the one
|
|
11
|
+
// that matters most: a silent strip would hand the caller a 201 and leave them believing
|
|
12
|
+
// this platform had taken custody of a secret, which is worse than storing it, because
|
|
13
|
+
// they may then delete it from the place that really holds it.
|
|
14
|
+
//
|
|
15
|
+
// The "secret" in these fixtures is a marker word, deliberately NOT credential-shaped:
|
|
16
|
+
// the repo's secret gate refuses a realistic connection string in any path, and a test
|
|
17
|
+
// that needs a real-looking one to be meaningful would be testing the wrong thing. What
|
|
18
|
+
// matters is that the marker never appears in a response body, and a marker proves that
|
|
19
|
+
// as well as a plausible URL would.
|
|
20
|
+
//
|
|
21
|
+
// Also pinned: the name rule with its reason-giving refusals, environment normalisation
|
|
22
|
+
// (absent means everywhere, and an unknown environment is refused rather than filtered),
|
|
23
|
+
// re-declaring a name as an EDIT not a duplicate, and the own-or-archon 404 that keeps a
|
|
24
|
+
// guessed id from confirming another project's manifest.
|
|
25
|
+
//
|
|
26
|
+
// DB-free: the pool is a fake, data access is stubbed on the cached module objects, auth
|
|
27
|
+
// is faked — the tests/provisioning_disconnect.mjs harness.
|
|
28
|
+
//
|
|
29
|
+
// Run: node tests/provisioning_env_manifest.mjs
|
|
30
|
+
|
|
31
|
+
import { strict as assert } from 'node:assert';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import { readFileSync } from 'node:fs';
|
|
34
|
+
import http from 'node:http';
|
|
35
|
+
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
38
|
+
const envManifest = require('../modules/provisioning/env-manifest.js');
|
|
39
|
+
|
|
40
|
+
let passed = 0, failed = 0;
|
|
41
|
+
function t(name, fn) {
|
|
42
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
43
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
44
|
+
}
|
|
45
|
+
async function ta(name, fn) {
|
|
46
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
47
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The marker a "value" carries in these fixtures. If it ever appears in a response, a
|
|
51
|
+
// value reached a surface it must never reach.
|
|
52
|
+
const MARKER = 'VALUE-MUST-NOT-APPEAR';
|
|
53
|
+
|
|
54
|
+
const MIGRATION = readFileSync(new URL('../modules/provisioning/migrations/provisioning_023_env_manifest.sql', import.meta.url), 'utf8');
|
|
55
|
+
|
|
56
|
+
console.log('no value, anywhere — the schema:');
|
|
57
|
+
|
|
58
|
+
t('the migration declares NO value column, under any spelling', () => {
|
|
59
|
+
// Read the CREATE TABLE body only, so the header prose (which necessarily talks ABOUT
|
|
60
|
+
// values) cannot make this pass or fail. Comment lines are dropped for the same reason.
|
|
61
|
+
const body = /CREATE TABLE IF NOT EXISTS provisioning_env_vars \(([\s\S]*?)\n\);/.exec(MIGRATION);
|
|
62
|
+
assert.ok(body, 'the table is declared');
|
|
63
|
+
const columns = body[1]
|
|
64
|
+
.split('\n')
|
|
65
|
+
.map((l) => l.trim())
|
|
66
|
+
.filter((l) => l && !l.startsWith('--'))
|
|
67
|
+
.map((l) => l.split(/\s+/)[0]);
|
|
68
|
+
assert.ok(columns.includes('name'), `sanity: the columns were parsed (${columns.join(', ')})`);
|
|
69
|
+
for (const forbidden of envManifest.VALUE_FIELDS) {
|
|
70
|
+
assert.ok(!columns.includes(forbidden),
|
|
71
|
+
`provisioning_env_vars must not have a "${forbidden}" column — the deployment platform is the system of record for values (ADR 0323 D6). Columns: ${columns.join(', ')}`);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
t('the migration self-registers and touches no core table', () => {
|
|
76
|
+
assert.match(MIGRATION, /CREATE TABLE IF NOT EXISTS provisioning_env_vars/, 'additive create');
|
|
77
|
+
assert.doesNotMatch(MIGRATION, /ALTER TABLE (?!provisioning_)/, 'no non-provisioning table is altered (ADR 0083 §5)');
|
|
78
|
+
assert.match(MIGRATION, /UNIQUE INDEX IF NOT EXISTS ux_provisioning_env_vars_instance_name/, 'one declaration per name per project');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
t('the environments CHECK admits exactly the known set, and the code agrees with it', () => {
|
|
82
|
+
const m = /CHECK \(environments <@ ARRAY\[([^\]]*)\]/.exec(MIGRATION);
|
|
83
|
+
assert.ok(m, 'the CHECK is a containment test, so any subset is legal');
|
|
84
|
+
const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
|
|
85
|
+
assert.deepEqual(inSql, [...envManifest.ENVIRONMENTS].sort(),
|
|
86
|
+
'the migration and ENVIRONMENTS carry the same set — a drift here silently refuses a legal row at write time');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
console.log('\nno value, anywhere — the projection:');
|
|
90
|
+
|
|
91
|
+
t('envVarRow returns no value key even when the row it is handed carries one', () => {
|
|
92
|
+
// The projection NAMES its columns rather than spreading the row, so a value column
|
|
93
|
+
// that somehow reached the table still could not leave through it. Handing it a poisoned
|
|
94
|
+
// row is how that is proved rather than asserted.
|
|
95
|
+
const out = envManifest.envVarRow({
|
|
96
|
+
id: 1, instance_id: 2, name: 'DATABASE_URL', description: 'the connection string',
|
|
97
|
+
environments: ['production'], required: true, created_at: 'now', updated_at: 'now',
|
|
98
|
+
value: MARKER, secret: MARKER,
|
|
99
|
+
});
|
|
100
|
+
for (const forbidden of envManifest.VALUE_FIELDS) {
|
|
101
|
+
assert.ok(!(forbidden in out), `the projection leaked "${forbidden}"`);
|
|
102
|
+
}
|
|
103
|
+
assert.equal(JSON.stringify(out).includes(MARKER), false, 'and no value rides along inside another field');
|
|
104
|
+
assert.deepEqual(Object.keys(out).sort(),
|
|
105
|
+
['created_at', 'description', 'environments', 'id', 'instance_id', 'name', 'required', 'updated_at']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
t('rejectsValueField catches every spelling, and is silent on a clean body', () => {
|
|
109
|
+
assert.equal(envManifest.rejectsValueField({ name: 'PORT', description: 'the port' }), null);
|
|
110
|
+
assert.equal(envManifest.rejectsValueField(null), null);
|
|
111
|
+
for (const f of envManifest.VALUE_FIELDS) {
|
|
112
|
+
const r = envManifest.rejectsValueField({ name: 'PORT', [f]: MARKER });
|
|
113
|
+
assert.ok(r, `"${f}" must be refused`);
|
|
114
|
+
assert.equal(r.code, 'value_not_accepted');
|
|
115
|
+
assert.match(r.message, /never values/i, 'the message says what the platform does instead');
|
|
116
|
+
assert.ok(!r.message.includes(MARKER), 'and the refusal does not echo the value back');
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
console.log('\nthe name rule:');
|
|
121
|
+
|
|
122
|
+
t('valid names are accepted and the refusals say WHY', () => {
|
|
123
|
+
for (const ok of ['DATABASE_URL', 'PORT', '_INTERNAL', 'npm_config_registry', 'A1']) {
|
|
124
|
+
assert.ok(envManifest.isValidEnvName(ok), `${ok} is a legal name`);
|
|
125
|
+
assert.equal(envManifest.envNameRefusal(ok), null);
|
|
126
|
+
}
|
|
127
|
+
assert.match(envManifest.envNameRefusal(''), /needs a name/);
|
|
128
|
+
assert.match(envManifest.envNameRefusal('MY VAR'), /space/, 'a space is named as the problem');
|
|
129
|
+
assert.match(envManifest.envNameRefusal('1ST_RUN'), /starts with a digit/);
|
|
130
|
+
assert.match(envManifest.envNameRefusal('MY-VAR'), /letters, digits and underscores/);
|
|
131
|
+
assert.match(envManifest.envNameRefusal('X'.repeat(envManifest.ENV_NAME_MAX + 1)), /the limit is/);
|
|
132
|
+
for (const bad of ['MY VAR', '1ST_RUN', 'MY-VAR', '', 'a'.repeat(129)]) {
|
|
133
|
+
assert.ok(!envManifest.isValidEnvName(bad), `${JSON.stringify(bad)} is not a legal name`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
console.log('\nenvironments:');
|
|
138
|
+
|
|
139
|
+
t('absent or empty means EVERYWHERE, stored explicitly and in canonical order', () => {
|
|
140
|
+
assert.deepEqual(envManifest.normalizeEnvironments(undefined), ['production', 'preview', 'development']);
|
|
141
|
+
assert.deepEqual(envManifest.normalizeEnvironments(null), ['production', 'preview', 'development']);
|
|
142
|
+
assert.deepEqual(envManifest.normalizeEnvironments([]), ['production', 'preview', 'development']);
|
|
143
|
+
// Canonical order, so two callers naming the same set produce equal rows and a diff of
|
|
144
|
+
// two manifests is about content rather than ordering.
|
|
145
|
+
assert.deepEqual(envManifest.normalizeEnvironments(['development', 'production']), ['production', 'development']);
|
|
146
|
+
assert.deepEqual(envManifest.normalizeEnvironments(['PRODUCTION', ' production ']), ['production'], 'case and whitespace folded, duplicates collapsed');
|
|
147
|
+
assert.deepEqual(envManifest.normalizeEnvironments('preview'), ['preview'], 'a bare string is accepted as a one-element list');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
t('an unknown environment is REPORTED, not quietly filtered', () => {
|
|
151
|
+
assert.deepEqual(envManifest.unknownEnvironments(['production', 'staging']), ['staging']);
|
|
152
|
+
assert.deepEqual(envManifest.unknownEnvironments(['production']), []);
|
|
153
|
+
assert.deepEqual(envManifest.unknownEnvironments(undefined), []);
|
|
154
|
+
// The two functions disagree on purpose: normalize DROPS the unknown (the backstop) and
|
|
155
|
+
// the route refuses on unknownEnvironments (the answer). Keeping the remainder silently
|
|
156
|
+
// would tell an owner their variable applies somewhere it does not.
|
|
157
|
+
assert.deepEqual(envManifest.normalizeEnvironments(['production', 'staging']), ['production']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
console.log('\nstorage:');
|
|
161
|
+
|
|
162
|
+
// A fake pool that records the SQL and answers the shapes env-manifest.js expects.
|
|
163
|
+
function fakeDb({ rows = [], rowCount = 1, inserted = true } = {}) {
|
|
164
|
+
const calls = [];
|
|
165
|
+
return {
|
|
166
|
+
calls,
|
|
167
|
+
query: async (sql, params) => {
|
|
168
|
+
calls.push({ sql: sql.replace(/\s+/g, ' ').trim(), params });
|
|
169
|
+
if (/^SELECT \* FROM provisioning_env_vars/.test(sql.trim())) return { rows };
|
|
170
|
+
if (/INSERT INTO provisioning_env_vars/.test(sql)) {
|
|
171
|
+
return { rows: [{ id: 5, instance_id: params[0], name: params[1], description: params[2], environments: params[3], required: params[4], created_at: 'c', updated_at: 'u', inserted }] };
|
|
172
|
+
}
|
|
173
|
+
if (/UPDATE provisioning_env_vars/.test(sql)) {
|
|
174
|
+
return { rows: rows.length ? [rows[0]] : [] };
|
|
175
|
+
}
|
|
176
|
+
if (/DELETE FROM provisioning_env_vars/.test(sql)) return { rowCount, rows: [] };
|
|
177
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await ta('declaring stores the name, the sentence, the environments and required — and nothing else', async () => {
|
|
183
|
+
const db = fakeDb();
|
|
184
|
+
const { row, created } = await envManifest.declareEnvVar(db, {
|
|
185
|
+
instanceId: 40, name: ' DATABASE_URL ', description: 'Postgres connection string', environments: ['production'],
|
|
186
|
+
});
|
|
187
|
+
assert.equal(created, true);
|
|
188
|
+
assert.equal(row.name, 'DATABASE_URL', 'the name is trimmed');
|
|
189
|
+
assert.equal(row.required, true, 'required is the default — an omission stays visible on a setup checklist');
|
|
190
|
+
assert.deepEqual(row.environments, ['production']);
|
|
191
|
+
assert.equal(db.calls.length, 1);
|
|
192
|
+
// Five parameters: instance, name, description, environments, required. A sixth would
|
|
193
|
+
// be the thing this table exists to not have.
|
|
194
|
+
assert.equal(db.calls[0].params.length, 5, `the insert binds exactly the manifest columns: ${JSON.stringify(db.calls[0].params)}`);
|
|
195
|
+
assert.match(db.calls[0].sql, /ON CONFLICT \(instance_id, name\) DO UPDATE/, 're-declaring is an edit, not a duplicate row');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await ta('re-declaring an existing name reports created:false — the same declaration, edited', async () => {
|
|
199
|
+
const db = fakeDb({ inserted: false });
|
|
200
|
+
const { created } = await envManifest.declareEnvVar(db, { instanceId: 40, name: 'PORT' });
|
|
201
|
+
assert.equal(created, false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await ta('a patch touches ONLY the fields it names, and is scoped to the project', async () => {
|
|
205
|
+
const db = fakeDb({ rows: [{ id: 5, instance_id: 40, name: 'PORT', description: 'new text', environments: ['production'], required: false, created_at: 'c', updated_at: 'u' }] });
|
|
206
|
+
const row = await envManifest.updateEnvVar(db, 40, '5', { description: 'new text' });
|
|
207
|
+
assert.equal(row.description, 'new text');
|
|
208
|
+
const sql = db.calls[0].sql;
|
|
209
|
+
assert.match(sql, /WHERE instance_id = \$1 AND id = \$2/, 'a guessed id cannot reach another project');
|
|
210
|
+
assert.match(sql, /SET updated_at = now\(\), description = \$3/, 'environments and required are left alone');
|
|
211
|
+
assert.doesNotMatch(sql, /required =/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await ta('an explicit null description erases it; an omitted one does not', async () => {
|
|
215
|
+
const db = fakeDb({ rows: [{ id: 5, instance_id: 40, name: 'PORT', description: null, environments: [], required: true }] });
|
|
216
|
+
await envManifest.updateEnvVar(db, 40, '5', { description: null });
|
|
217
|
+
assert.equal(db.calls[0].params[2], null, 'null is the un-answer, not a dropped field');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await ta('removing is scoped to the project too, and reports whether a row went', async () => {
|
|
221
|
+
const gone = fakeDb({ rowCount: 1 });
|
|
222
|
+
assert.equal(await envManifest.removeEnvVar(gone, 40, '5'), true);
|
|
223
|
+
assert.match(gone.calls[0].sql, /WHERE instance_id = \$1 AND id = \$2/);
|
|
224
|
+
const missing = fakeDb({ rowCount: 0 });
|
|
225
|
+
assert.equal(await envManifest.removeEnvVar(missing, 40, '999'), false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await ta('the list is name-ordered, so two reads of an unchanged manifest are identical', async () => {
|
|
229
|
+
const db = fakeDb({ rows: [] });
|
|
230
|
+
await envManifest.listEnvVars(db, 40);
|
|
231
|
+
assert.match(db.calls[0].sql, /ORDER BY name ASC/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
console.log('\nthe routes:');
|
|
235
|
+
|
|
236
|
+
let instance = null;
|
|
237
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
238
|
+
|
|
239
|
+
const api = require('../src/module-api.js');
|
|
240
|
+
let builder = { id: 7, rank: 'xenos', github_login: 'owner' };
|
|
241
|
+
api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
|
|
242
|
+
|
|
243
|
+
let store = [];
|
|
244
|
+
envManifest.listEnvVars = async () => store;
|
|
245
|
+
envManifest.declareEnvVar = async (_db, d) => {
|
|
246
|
+
const row = envManifest.envVarRow({
|
|
247
|
+
id: 5, instance_id: 40, name: String(d.name).trim(), description: d.description ?? null,
|
|
248
|
+
environments: envManifest.normalizeEnvironments(d.environments), required: d.required !== false,
|
|
249
|
+
created_at: 'c', updated_at: 'u',
|
|
250
|
+
});
|
|
251
|
+
store = [row];
|
|
252
|
+
return { row, created: true };
|
|
253
|
+
};
|
|
254
|
+
envManifest.updateEnvVar = async (_db, _inst, id) => (id === '5' ? store[0] || null : null);
|
|
255
|
+
envManifest.removeEnvVar = async (_db, _inst, id) => id === '5';
|
|
256
|
+
|
|
257
|
+
const express = require('express');
|
|
258
|
+
const envRoutes = require('../modules/provisioning/routes/env-manifest.js');
|
|
259
|
+
const app = express();
|
|
260
|
+
app.use(express.json());
|
|
261
|
+
app.use((_req, res, next) => {
|
|
262
|
+
res.fail = (code, opts, details) => {
|
|
263
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
264
|
+
const extra = typeof opts === 'object' ? opts : {};
|
|
265
|
+
res.status(status).json({ error: { code, message: extra.message || code, details: details || extra.details } });
|
|
266
|
+
};
|
|
267
|
+
next();
|
|
268
|
+
});
|
|
269
|
+
app.use('/api/bongos', envRoutes());
|
|
270
|
+
const server = app.listen(0);
|
|
271
|
+
await new Promise((r) => server.once('listening', r));
|
|
272
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
273
|
+
|
|
274
|
+
function call(method, path, body) {
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
277
|
+
const headers = payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {};
|
|
278
|
+
const req = http.request(`${base}${path}`, { method, headers }, (res) => {
|
|
279
|
+
let d = '';
|
|
280
|
+
res.on('data', (c) => { d += c; });
|
|
281
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
282
|
+
});
|
|
283
|
+
req.on('error', reject);
|
|
284
|
+
if (payload) req.write(payload);
|
|
285
|
+
req.end();
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const URL_ = '/provisioning/instances/40/env-manifest';
|
|
290
|
+
function reset({ inst = { owner_builder_id: 7, slug: 'lantern', status: 'active', hosting_shape: 'byo-host' }, who = { id: 7, rank: 'xenos', github_login: 'owner' } } = {}) {
|
|
291
|
+
instance = inst; builder = who; store = [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await ta('the owner declares a variable and reads it back, values_held:false on both', async () => {
|
|
295
|
+
reset();
|
|
296
|
+
const created = await call('POST', URL_, { name: 'DATABASE_URL', description: 'Postgres connection string', environments: ['production'] });
|
|
297
|
+
assert.equal(created.status, 201, created.raw);
|
|
298
|
+
assert.equal(created.body.env_var.name, 'DATABASE_URL');
|
|
299
|
+
assert.equal(created.body.values_held, false);
|
|
300
|
+
const read = await call('GET', URL_);
|
|
301
|
+
assert.equal(read.status, 200, read.raw);
|
|
302
|
+
assert.equal(read.body.env_vars.length, 1);
|
|
303
|
+
assert.equal(read.body.values_held, false, 'a reader must not go looking for a value field and think the API is broken');
|
|
304
|
+
assert.deepEqual(read.body.environments, envManifest.ENVIRONMENTS, 'the vocabulary ships with the list');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// THE done-when assertion, at the boundary.
|
|
308
|
+
for (const field of ['value', 'secret', 'secret_value']) {
|
|
309
|
+
await ta(`POSTing a "${field}" is REFUSED with nothing written, and the value is not echoed back`, async () => {
|
|
310
|
+
reset();
|
|
311
|
+
const res = await call('POST', URL_, { name: 'DATABASE_URL', [field]: MARKER });
|
|
312
|
+
assert.equal(res.status, 400, res.raw);
|
|
313
|
+
assert.equal(res.body.error.code, 'value_not_accepted');
|
|
314
|
+
assert.ok(!res.raw.includes(MARKER), 'the refusal does not repeat the value into a response body or a log');
|
|
315
|
+
assert.deepEqual(store, [], 'and nothing was stored — a 201 here would have the caller believe we hold it');
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await ta('a value sent alongside a BAD name is still answered as the value problem first', async () => {
|
|
320
|
+
reset();
|
|
321
|
+
const res = await call('POST', URL_, { name: '1BAD NAME', value: MARKER });
|
|
322
|
+
assert.equal(res.status, 400, res.raw);
|
|
323
|
+
assert.equal(res.body.error.code, 'value_not_accepted', 'the value is the urgent half of that mistake');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await ta('PATCHing a value is refused the same way', async () => {
|
|
327
|
+
reset();
|
|
328
|
+
const res = await call('PATCH', `${URL_}/5`, { value: MARKER });
|
|
329
|
+
assert.equal(res.status, 400, res.raw);
|
|
330
|
+
assert.equal(res.body.error.code, 'value_not_accepted');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await ta('an unknown environment is refused and NAMES both sets', async () => {
|
|
334
|
+
reset();
|
|
335
|
+
const res = await call('POST', URL_, { name: 'PORT', environments: ['production', 'staging'] });
|
|
336
|
+
assert.equal(res.status, 400, res.raw);
|
|
337
|
+
assert.equal(res.body.error.code, 'bad_environment');
|
|
338
|
+
assert.deepEqual(res.body.error.details.unknown, ['staging']);
|
|
339
|
+
assert.deepEqual(res.body.error.details.known, envManifest.ENVIRONMENTS);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await ta('a malformed name is refused with the rule, not a generic 400', async () => {
|
|
343
|
+
reset();
|
|
344
|
+
const res = await call('POST', URL_, { name: 'MY VAR' });
|
|
345
|
+
assert.equal(res.status, 400, res.raw);
|
|
346
|
+
assert.equal(res.body.error.code, 'bad_env_name');
|
|
347
|
+
assert.match(res.body.error.message, /space/);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
await ta("another builder's project is a 404, not a 403 — a guessed id confirms nothing", async () => {
|
|
351
|
+
reset({ who: { id: 99, rank: 'xenos', github_login: 'stranger' } });
|
|
352
|
+
for (const [m, p] of [['GET', URL_], ['POST', URL_], ['PATCH', `${URL_}/5`], ['DELETE', `${URL_}/5`]]) {
|
|
353
|
+
const res = await call(m, p, m === 'GET' || m === 'DELETE' ? undefined : { name: 'PORT' });
|
|
354
|
+
assert.equal(res.status, 404, `${m} ${p} → ${res.raw}`);
|
|
355
|
+
assert.equal(res.body.error.code, 'instance_not_found');
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
await ta('an archon may read and edit any project (fleet support)', async () => {
|
|
360
|
+
reset({ who: { id: 99, rank: 'archon', github_login: 'archon' } });
|
|
361
|
+
const res = await call('GET', URL_);
|
|
362
|
+
assert.equal(res.status, 200, res.raw);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await ta('a missing project is the same 404', async () => {
|
|
366
|
+
reset({ inst: null });
|
|
367
|
+
const res = await call('GET', URL_);
|
|
368
|
+
assert.equal(res.status, 404, res.raw);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
await ta("a declaration id that is not this project's is a 404, and removing says the value was never ours", async () => {
|
|
372
|
+
reset();
|
|
373
|
+
const miss = await call('DELETE', `${URL_}/999`);
|
|
374
|
+
assert.equal(miss.status, 404, miss.raw);
|
|
375
|
+
assert.equal(miss.body.error.code, 'env_var_not_found');
|
|
376
|
+
const hit = await call('DELETE', `${URL_}/5`);
|
|
377
|
+
assert.equal(hit.status, 200, hit.raw);
|
|
378
|
+
assert.match(hit.body.message, /untouched/, 'removing a name must not read as removing the secret');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await ta('a non-numeric declaration id is a 400 naming the field, not a 404', async () => {
|
|
382
|
+
reset();
|
|
383
|
+
const res = await call('DELETE', `${URL_}/abc`);
|
|
384
|
+
assert.equal(res.status, 400, res.raw);
|
|
385
|
+
assert.equal(res.body.error.code, 'bad_id');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
console.log('\nthe owner-facing card:');
|
|
389
|
+
|
|
390
|
+
t('the manage card has NO value input, and says why there is nowhere to type one', () => {
|
|
391
|
+
// The UI is the other place a value field could appear, so the absence is asserted here
|
|
392
|
+
// too rather than left to the API's refusal. A form about environment variables with no
|
|
393
|
+
// value box reads as unfinished unless it explains itself, which is why the copy check
|
|
394
|
+
// is part of the same assertion: removing the explanation to "tidy up" would leave a
|
|
395
|
+
// card that invites the very question the API refuses.
|
|
396
|
+
const page = readFileSync(new URL('../modules/public-landing/public/projects.html', import.meta.url), 'utf8');
|
|
397
|
+
const card = /<div class="card mSec" id="mEnvVars">([\s\S]*?)<\/details>/.exec(page);
|
|
398
|
+
assert.ok(card, 'the env-var card is on the manage page');
|
|
399
|
+
assert.match(card[1], /Why there is nowhere to type the value/, 'the fold answers the question the missing field raises');
|
|
400
|
+
assert.match(card[1], /Vercel|Netlify|Render|your own machine/, 'and names where the value does belong');
|
|
401
|
+
|
|
402
|
+
// The card's own client code: no input whose purpose is a value, and no request body
|
|
403
|
+
// field that could carry one.
|
|
404
|
+
const code = /Its environment variables \(task 1004062([\s\S]*?)function renderDisconnect/.exec(page);
|
|
405
|
+
assert.ok(code, 'the card\'s client code is on the page');
|
|
406
|
+
const inputIds = [...code[1].matchAll(/id="(mEnv[A-Za-z]*)"/g)].map((m) => m[1]);
|
|
407
|
+
assert.deepEqual(inputIds.sort(), ['mEnvAdd', 'mEnvName', 'mEnvNewDesc'],
|
|
408
|
+
`the card's only fields are the name and the purpose: ${inputIds.join(', ')}`);
|
|
409
|
+
for (const forbidden of envManifest.VALUE_FIELDS) {
|
|
410
|
+
assert.ok(!new RegExp(`\\b${forbidden}:`).test(code[1]),
|
|
411
|
+
`the card must not put "${forbidden}" in a request body`);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
t('the card is guarded against the once-a-minute manage tick', () => {
|
|
416
|
+
// renderManage runs on a timer. Every other section there is fetch-free and so is safe
|
|
417
|
+
// to re-render; this one fetches AND holds a form, so a blind re-render would throw away
|
|
418
|
+
// what the owner is typing. The guard is the reason loadEnvManifest is called rather
|
|
419
|
+
// than a renderX, and this pins it.
|
|
420
|
+
const page = readFileSync(new URL('../modules/public-landing/public/projects.html', import.meta.url), 'utf8');
|
|
421
|
+
assert.match(page, /loadEnvManifest\(inst\);/, 'renderManage calls the loader');
|
|
422
|
+
assert.match(page, /if \(mEnvSlug === inst\.slug && mEnvVars && \(mEnvAdding \|\| mEnvEditing\)\) return;/,
|
|
423
|
+
'a tick during an edit leaves the DOM alone');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await ta('the routes file is DECLARED in module.json, or none of the above is reachable in production', async () => {
|
|
427
|
+
const mod = JSON.parse(readFileSync(new URL('../modules/provisioning/module.json', import.meta.url), 'utf8'));
|
|
428
|
+
assert.ok((mod.contributes.routes || []).includes('env-manifest'),
|
|
429
|
+
'a routes file the manifest does not list is a silent no-op (ADR 0289)');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
server.close();
|
|
433
|
+
|
|
434
|
+
console.log(`\nprovisioning_env_manifest: ${passed} passed, ${failed} failed`);
|
|
435
|
+
if (failed) process.exit(1);
|