@bongos/core 1.20.38 → 1.20.40

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.
Files changed (33) hide show
  1. package/.bongos-core.json +47 -37
  2. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +1 -0
  3. package/docs/copy-inventory.md +1 -1
  4. package/docs/copy-registry.json +9 -9
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-readings.json +251 -248
  7. package/modules/npm-release/preview/childlog.js +95 -0
  8. package/modules/npm-release/preview/commands.js +46 -5
  9. package/modules/npm-release/preview/divert.js +16 -6
  10. package/modules/npm-release/preview/env.js +18 -7
  11. package/modules/npm-release/preview/proxy.js +4 -1
  12. package/modules/npm-release/preview/runtime.js +43 -9
  13. package/modules/npm-release/preview/supervisor.js +170 -31
  14. package/modules/npm-release/public/work.css +3 -0
  15. package/modules/npm-release/public/work.js +6 -1
  16. package/modules/npm-release/routes/preview.js +7 -5
  17. package/modules/provisioning/render-standup.js +13 -1
  18. package/modules/public-landing/public/projects.html +22 -8
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/release-notes.json +16 -0
  22. package/scripts/gds/provision-render.js +13 -1
  23. package/scripts/gds/render-payload.js +6 -5
  24. package/src/module-api.js +1 -1
  25. package/tests/npm_release_preview_commands.mjs +1 -1
  26. package/tests/npm_release_preview_diagnosis.mjs +376 -0
  27. package/tests/npm_release_preview_divert.mjs +10 -0
  28. package/tests/npm_release_preview_proxy.mjs +26 -0
  29. package/tests/npm_release_preview_routes.mjs +15 -2
  30. package/tests/npm_release_preview_supervisor.mjs +4 -4
  31. package/tests/projects_hub_app_step.mjs +49 -0
  32. package/tests/projects_hub_render_connect.mjs +2 -1
  33. package/tests/provision_render.mjs +23 -2
@@ -0,0 +1,376 @@
1
+ // tests/npm_release_preview_diagnosis.mjs — a preview that cannot serve data must say so (task 1004465).
2
+ //
3
+ // WHAT WENT WRONG LIVE. The first real preview started, answered /healthz, was reported
4
+ // running, and then returned 500 on every page; the child's output went nowhere (stdio
5
+ // 'ignore'), so nobody could say why. Stopping it left the copy database behind, because the
6
+ // child was killed without waiting and the failed dropdb was ignored. These tests pin the four
7
+ // repairs: the child's output is kept and redacted, the health check proves data can be read,
8
+ // a stop waits for the child before dropping, and a drop that fails is reported, never idle.
9
+ // Every spawn, command, probe, clock and file is injected.
10
+ //
11
+ // Run: node --test tests/npm_release_preview_diagnosis.mjs
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import { EventEmitter } from 'node:events';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { createRequire } from 'node:module';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const { createSupervisor, copyInstance } = require('../modules/npm-release/preview/supervisor.js');
22
+ const cmds = require('../modules/npm-release/preview/commands.js');
23
+ const { createChildLog, redact, secretValues } = require('../modules/npm-release/preview/childlog.js');
24
+ const { probeHealth } = require('../modules/npm-release/preview/runtime.js');
25
+ const { PREVIEW_DB, buildPreviewEnv } = require('../modules/npm-release/preview/env.js');
26
+
27
+ const fwd = (x) => String(x).split(String.fromCharCode(92)).join('/');
28
+ // Secret-shaped strings assembled at run time, so no scanner mistakes this file for a leak.
29
+ const FAKE_GH = ['gh', 'p_', 'abcdefghijklmnopqrstuvwxyz0123'].join('');
30
+ const FAKE_URL = ['post', 'gres://svc:pw9999@db/x'].join('');
31
+
32
+ // `sleepMode`: 'instant' returns at once (the health loop's waits); 'never' returns only for
33
+ // waits under a second, so a stop can be released ONLY by the child's real exit.
34
+ function rig({ probe = async () => true, failRun = () => false, killBehaviour = 'exit', sleepMode = 'instant', instanceRoot = null, env, liveDb = 'live_db', copy } = {}) {
35
+ const events = [];
36
+ const calls = [];
37
+ const fake = { spawned: [], rm: [] };
38
+ const sup = createSupervisor({
39
+ run: async (cmd) => {
40
+ calls.push(cmd);
41
+ events.push(cmd.cmd);
42
+ return failRun(cmd) ? { code: 3, stderr: 'boom from ' + cmd.cmd } : { code: 0, stderr: '' };
43
+ },
44
+ spawn: (bin, args, opts) => {
45
+ const child = new EventEmitter();
46
+ child.stdout = new EventEmitter();
47
+ child.stderr = new EventEmitter();
48
+ child.bin = bin; child.args = args; child.opts = opts;
49
+ child.kill = (sig) => {
50
+ events.push('kill:' + sig);
51
+ if (killBehaviour === 'exit') setImmediate(() => { events.push('exit'); child.emit('exit', 0); });
52
+ if (killBehaviour === 'on-sigkill' && sig === 'SIGKILL') setImmediate(() => { events.push('exit'); child.emit('exit', null); });
53
+ };
54
+ fake.spawned.push(child);
55
+ return child;
56
+ },
57
+ probe,
58
+ sleep: (ms) => (sleepMode === 'instant' || ms < 1000 ? Promise.resolve() : sleepMode === 'short' ? new Promise((r) => setTimeout(r, 10)) : new Promise(() => {})),
59
+ now: () => 1_000_000,
60
+ fsImpl: { mkdtempSync: (p) => p + 'XYZ', mkdirSync() {}, rmSync: (p) => fake.rm.push(p), writeFile() {}, existsSync: () => false, readFileSync: () => { throw new Error('none'); } },
61
+ tmpdir: '/tmp', liveDb, cacheRoot: '/cache', port: 3190, instanceRoot, copy,
62
+ env: env || { PATH: '/bin' },
63
+ });
64
+ return { sup, events, calls, fake };
65
+ }
66
+
67
+ // ---- the child's output: kept, capped, redacted ---------------------------------------
68
+
69
+ test('what the child printed is kept and shown when the preview cannot serve data', async () => {
70
+ const r = rig({
71
+ env: { PATH: '/bin', PGPASSWORD: 'hunter2pass', GITHUB_TOKEN: FAKE_GH },
72
+ probe: async () => {
73
+ r.fake.spawned[0].stderr.emit('data', 'error: relation "builders" does not exist\n');
74
+ r.fake.spawned[0].stdout.emit('data', `connecting with password=abc123 to ${FAKE_URL} using hunter2pass and ${FAKE_GH}\n`);
75
+ return { ok: false, reason: 'a database-backed read (/api/bongos/me) answered 500' };
76
+ },
77
+ });
78
+ await r.sup.start('1.19.1000').done;
79
+ const s = r.sup.status();
80
+ assert.equal(s.state, 'failed');
81
+ const tail = s.log_tail.join('\n');
82
+ assert.match(tail, /relation "builders" does not exist/);
83
+ assert.match(s.error, /Its last output:.*relation "builders" does not exist/);
84
+ for (const secret of ['hunter2pass', 'abc123', 'pw9999', FAKE_GH]) {
85
+ assert.ok(!tail.includes(secret), 'the tail leaks a secret');
86
+ assert.ok(!s.error.includes(secret), 'the error leaks a secret');
87
+ }
88
+ });
89
+
90
+ test('the tail is at most twenty lines and each line is short', () => {
91
+ const lg = createChildLog({});
92
+ lg.append(Array.from({ length: 50 }, (_, i) => 'line ' + i).join('\n') + '\n' + 'x'.repeat(1000) + '\n');
93
+ const t = lg.tail();
94
+ assert.equal(t.length, 20);
95
+ assert.ok(t.every((l) => l.length <= 301));
96
+ });
97
+
98
+ test('the log is capped, keeping the newest output', () => {
99
+ const lg = createChildLog({ cap: 100 });
100
+ lg.append('a'.repeat(500));
101
+ lg.append('THE-END');
102
+ assert.ok(lg.size() <= 100);
103
+ assert.match(lg.tail().join('\n'), /THE-END$/);
104
+ });
105
+
106
+ test('redaction takes out listed values and the common secret shapes', () => {
107
+ const secrets = secretValues({ SESSION_SECRET: 'sekrit-value-1', NOT_ONE: 'visible-value', PGPASSWORD: 'p' });
108
+ assert.deepEqual(secrets, ['sekrit-value-1'], 'only secret-named, long-enough values');
109
+ const out = redact(`x sekrit-value-1 y Bearer abc.def-ghi ${FAKE_URL} token=zzz visible-value`, secrets);
110
+ assert.ok(!/sekrit-value-1|abc\.def|pw9999|zzz/.test(out), out);
111
+ assert.match(out, /visible-value/);
112
+ });
113
+
114
+ test('the log is also written to a file in the scratch directory, and a failing disk does not matter', () => {
115
+ const writes = [];
116
+ const lg = createChildLog({ fsImpl: { writeFile: (f, b, cb) => { writes.push([f, b]); cb(); } }, file: '/tmp/x/preview.log', flushMs: 1e6 });
117
+ lg.append('hello');
118
+ lg.flush();
119
+ assert.deepEqual(writes, [['/tmp/x/preview.log', 'hello']]);
120
+ lg.close();
121
+ const broken = createChildLog({ fsImpl: { writeFile() { throw new Error('disk'); } }, file: '/f', flushMs: 1e6 });
122
+ broken.append('still fine');
123
+ assert.doesNotThrow(() => broken.flush());
124
+ broken.close();
125
+ });
126
+
127
+ test('a burst of output costs one asynchronous write, never one write per chunk, and none after close', async () => {
128
+ const writes = [];
129
+ const lg = createChildLog({ fsImpl: { writeFile: (f, b, cb) => { writes.push(b); cb(); } }, file: '/f', flushMs: 5 });
130
+ for (let i = 0; i < 500; i++) lg.append('line ' + i + '\n');
131
+ assert.equal(writes.length, 0, 'nothing is written inside the data handler');
132
+ await new Promise((r) => setTimeout(r, 40));
133
+ assert.equal(writes.length, 1, 'the burst was written once');
134
+ assert.match(writes[0], /line 499/);
135
+ lg.append('after');
136
+ lg.close();
137
+ await new Promise((r) => setTimeout(r, 40));
138
+ assert.equal(writes.length, 1, 'closing cancels a pending write');
139
+ });
140
+
141
+ // ---- ready means it can serve data ----------------------------------------------------
142
+
143
+ test('a probe that sees a database failure fails the preview, says why, and cleans up', async () => {
144
+ const { sup, events, fake, calls } = rig({ probe: async () => ({ ok: false, reason: '/version reports schemaPending null: the database copy could not be read' }) });
145
+ await sup.start('1.19.1000').done;
146
+ const s = sup.status();
147
+ assert.equal(s.state, 'failed');
148
+ assert.match(s.error, /could not serve data/);
149
+ assert.match(s.error, /schemaPending null/);
150
+ assert.ok(events.includes('kill:SIGTERM'), 'the child was stopped');
151
+ assert.deepEqual(calls[calls.length - 1], cmds.dropDbCommand(), 'the copy was dropped');
152
+ assert.equal(fake.rm.length, 1, 'the scratch directory was removed');
153
+ });
154
+
155
+ test('a preview is running only once the probe says it can serve data', async () => {
156
+ const answers = [{ ok: false, reason: 'warming up' }, { ok: false, reason: 'warming up' }, { ok: true }];
157
+ const { sup } = rig({ probe: async () => answers.shift() });
158
+ await sup.start('1.19.1000').done;
159
+ assert.equal(sup.status().state, 'running');
160
+ });
161
+
162
+ test('a bare true from the probe still counts as healthy (older callers)', async () => {
163
+ const { sup } = rig({ probe: async () => true });
164
+ await sup.start('1.19.1000').done;
165
+ assert.equal(sup.status().state, 'running');
166
+ });
167
+
168
+ test('a child that exits while starting fails the start at once, with its output', async () => {
169
+ let probes = 0;
170
+ const r = rig({
171
+ probe: async () => {
172
+ probes += 1;
173
+ r.fake.spawned[0].stderr.emit('data', 'Error: Cannot find module "./nope"\n');
174
+ r.fake.spawned[0].emit('exit', 1);
175
+ return false;
176
+ },
177
+ });
178
+ await r.sup.start('1.19.1000').done;
179
+ assert.equal(probes, 1, 'it did not keep probing a dead child');
180
+ assert.match(r.sup.status().error, /exited \(1\) before it could serve data/);
181
+ assert.match(r.sup.status().error, /Cannot find module/);
182
+ assert.equal(r.sup.status().state, 'failed');
183
+ });
184
+
185
+ // ---- the probe itself ------------------------------------------------------------------
186
+
187
+ function fakeGet(map) {
188
+ return async (port, p) => map[p] || { error: 'refused' };
189
+ }
190
+ const HEALTHZ = { status: 200, body: '{}' };
191
+ const OK = { status: 200, body: '{"coreVersion":"1.19.1000","schemaPending":0}' };
192
+
193
+ test('the probe passes only when healthz, /version and a database-backed read all answer', async () => {
194
+ const r = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': OK, '/api/bongos/me': { status: 401, body: '{}' } }));
195
+ assert.deepEqual(r, { ok: true });
196
+ });
197
+
198
+ test('schemaPending null (the database read failed) fails the probe even though /healthz is 200', async () => {
199
+ const r = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': { status: 200, body: '{"schemaPending":null,"commit":null}' }, '/api/bongos/me': { status: 401, body: '' } }));
200
+ assert.equal(r.ok, false);
201
+ assert.match(r.reason, /schemaPending null/);
202
+ });
203
+
204
+ test('a 500 from a database-backed route fails the probe', async () => {
205
+ const r = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': OK, '/api/bongos/me': { status: 500, body: '' } }));
206
+ assert.equal(r.ok, false);
207
+ assert.match(r.reason, /\/api\/bongos\/me\) answered 500/);
208
+ });
209
+
210
+ test('a version too old to report schemaPending is judged by the database-backed read alone', async () => {
211
+ const old = { status: 200, body: '{"coreVersion":"1.0.1"}' };
212
+ const good = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': old, '/api/bongos/me': { status: 401, body: '' } }));
213
+ assert.equal(good.ok, true);
214
+ const bad = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': old, '/api/bongos/me': { status: 500, body: '' } }));
215
+ assert.equal(bad.ok, false);
216
+ });
217
+
218
+ test('nothing answering, or /healthz not 200, fails the probe', async () => {
219
+ assert.equal((await probeHealth(3190, fakeGet({}))).ok, false);
220
+ assert.equal((await probeHealth(3190, fakeGet({ '/healthz': { status: 503, body: '' } }))).ok, false);
221
+ });
222
+
223
+ // ---- the child's working directory is an instance --------------------------------------
224
+
225
+ test('the child runs in a scratch instance holding the live config, and is told so', async () => {
226
+ const copied = [];
227
+ const { sup, fake, calls } = rig({ instanceRoot: '/live/root', copy: (o) => copied.push([o.from, fwd(o.to)]) });
228
+ await sup.start('1.19.1000').done;
229
+ assert.deepEqual(copied, [['/live/root', '/tmp/bongos-preview-XYZ/instance']]);
230
+ const child = fake.spawned[0];
231
+ assert.equal(fwd(child.opts.cwd), '/tmp/bongos-preview-XYZ/instance');
232
+ assert.equal(fwd(child.opts.env.BONGOS_INSTANCE_ROOT), '/tmp/bongos-preview-XYZ/instance');
233
+ const migrate = calls.find((c) => c.cmd === 'bash');
234
+ assert.equal(fwd(migrate.cwd), '/tmp/bongos-preview-XYZ/instance');
235
+ assert.equal(fwd(migrate.env.INIT_CWD), '/tmp/bongos-preview-XYZ/instance');
236
+ assert.equal(migrate.env.PGDATABASE, PREVIEW_DB);
237
+ });
238
+
239
+ test('without an instance root the child still runs from its install directory', async () => {
240
+ const { sup, fake } = rig();
241
+ await sup.start('1.19.1000').done;
242
+ assert.equal(fwd(fake.spawned[0].opts.cwd), '/cache/1.19.1000');
243
+ assert.equal(fake.spawned[0].opts.env.BONGOS_INSTANCE_ROOT, undefined);
244
+ });
245
+
246
+ test('copying the instance takes the boot packs and instance migrations, and nothing else from config', () => {
247
+ const from = fs.mkdtempSync(path.join(os.tmpdir(), 'pv-from-'));
248
+ const to = fs.mkdtempSync(path.join(os.tmpdir(), 'pv-to-'));
249
+ try {
250
+ fs.mkdirSync(path.join(from, 'config'));
251
+ fs.mkdirSync(path.join(from, 'migrations', 'instance'), { recursive: true });
252
+ fs.writeFileSync(path.join(from, 'config', 'branding.json'), '{"envPrefix":"ACME"}');
253
+ fs.writeFileSync(path.join(from, 'config', 'modules.json'), '{}');
254
+ // files that are not on the allow-list stay behind whatever they are called
255
+ for (const name of ['go-live.json', 'push-token.json', 'apikey.json', 'db-creds.json', 'notes.txt']) fs.writeFileSync(path.join(from, 'config', name), '{}');
256
+ fs.writeFileSync(path.join(from, 'migrations', 'instance', '0001_x.sql'), 'select 1;');
257
+ copyInstance({ fsImpl: fs, from, to });
258
+ assert.deepEqual(fs.readdirSync(path.join(to, 'config')).sort(), ['branding.json', 'modules.json']);
259
+ assert.deepEqual(fs.readdirSync(path.join(to, 'migrations', 'instance')), ['0001_x.sql']);
260
+ // an instance with neither directory is not an error
261
+ copyInstance({ fsImpl: fs, from: path.join(from, 'nowhere'), to });
262
+ } finally {
263
+ fs.rmSync(from, { recursive: true, force: true });
264
+ fs.rmSync(to, { recursive: true, force: true });
265
+ }
266
+ });
267
+
268
+ test('the module switches cover every env prefix the core resolves (a tripwire against drift)', () => {
269
+ const ic = require('../src/instance-config');
270
+ const { MODULES_OFF } = require('../modules/npm-release/preview/env.js');
271
+ for (const pfx of [ic.FALLBACK_ENV_PREFIX, ...ic.LEGACY_ENV_PREFIXES]) {
272
+ assert.ok(MODULES_OFF.includes(pfx + '_MODULE_DISCORD'), 'no switch under the ' + pfx + ' prefix: a prefix the core reads would leave the module on');
273
+ }
274
+ });
275
+
276
+ test('the module switches are set under every prefix, including the copied instance own', () => {
277
+ const env = buildPreviewEnv({ port: 3190, scratchHome: '/h', instanceRoot: '/i', envPrefix: 'ACME' });
278
+ for (const pfx of ['BONGOS', 'CLOUDBONGOS', 'OTB', 'GDS', 'PMS', 'ACME']) {
279
+ assert.equal(env[pfx + '_MODULE_DISCORD'], '0', pfx);
280
+ assert.equal(env[pfx + '_MODULE_AGENTS'], '0', pfx);
281
+ }
282
+ assert.equal(buildPreviewEnv({ port: 1, scratchHome: '/h', envPrefix: 'bad prefix; rm' }).ACME_MODULE_DISCORD, undefined);
283
+ });
284
+
285
+ // ---- stopping really drops the copy ------------------------------------------------------
286
+
287
+ test('stop waits for the child to exit before it drops the copy', async () => {
288
+ // sleepMode "never": nothing but the child's own exit can let the stop go on.
289
+ const { sup, events } = rig({ sleepMode: 'never' });
290
+ await sup.start('1.19.1000').done;
291
+ events.length = 0;
292
+ await sup.stop();
293
+ assert.deepEqual(events, ['kill:SIGTERM', 'exit', 'dropdb']);
294
+ assert.equal(sup.status().state, 'idle');
295
+ });
296
+
297
+ test('a child that ignores SIGTERM is killed with SIGKILL before the drop', async () => {
298
+ const { sup, events } = rig({ killBehaviour: 'on-sigkill', sleepMode: 'short' });
299
+ await sup.start('1.19.1000').done;
300
+ events.length = 0;
301
+ await sup.stop();
302
+ assert.deepEqual(events, ['kill:SIGTERM', 'kill:SIGKILL', 'exit', 'dropdb']);
303
+ });
304
+
305
+ test('a failed dropdb is retried after ending the copy backends, then reported instead of idle', async () => {
306
+ let failing = false;
307
+ const { sup, calls } = rig({ failRun: (c) => failing && c.cmd === 'dropdb' });
308
+ await sup.start('1.19.1000').done;
309
+ failing = true;
310
+ calls.length = 0;
311
+ const s = await sup.stop();
312
+ assert.deepEqual(calls.map((c) => c.cmd), ['dropdb', 'psql', 'dropdb']);
313
+ assert.equal(calls[0].args.includes('--force'), true);
314
+ assert.equal(calls[2].args.includes('--force'), false, 'the retry is plain');
315
+ assert.equal(s.state, 'failed');
316
+ assert.equal(s.stage, 'drop');
317
+ assert.match(s.error, /could not be dropped/);
318
+ assert.equal(sup.status().state, 'failed', 'and it stays reported until a drop works');
319
+ });
320
+
321
+ test('a dropdb that fails only for want of --force is rescued by the fallback', async () => {
322
+ let forceFails = false;
323
+ const { sup } = rig({ failRun: (c) => forceFails && c.cmd === 'dropdb' && c.args.includes('--force') });
324
+ await sup.start('1.19.1000').done;
325
+ forceFails = true;
326
+ const s = await sup.stop();
327
+ assert.equal(s.state, 'idle');
328
+ });
329
+
330
+ test('a failed start whose copy will not drop says both things', async () => {
331
+ let dropsFail = false;
332
+ const { sup } = rig({
333
+ probe: async () => { dropsFail = true; return { ok: false, reason: 'the data path is broken' }; },
334
+ failRun: (c) => dropsFail && c.cmd === 'dropdb',
335
+ });
336
+ await sup.start('1.19.1000').done;
337
+ const s = sup.status();
338
+ assert.equal(s.state, 'failed');
339
+ assert.match(s.error, /the data path is broken/);
340
+ assert.match(s.error, /could not be dropped/);
341
+ });
342
+
343
+ // ---- the drop can never name the live database ---------------------------------------------
344
+
345
+ test('a supervisor whose live database is the preview name cannot be built', () => {
346
+ assert.throws(() => rig({ liveDb: PREVIEW_DB }), /cannot be named bongos_preview/);
347
+ });
348
+
349
+ test('every drop command names the fixed copy, and nothing else passes the guard', () => {
350
+ for (const c of [cmds.dropDbCommand(), cmds.dropDbPlainCommand(), cmds.terminateBackendsCommand()]) {
351
+ assert.equal(cmds.targetsOnlyPreviewDb(c), true, JSON.stringify(c));
352
+ assert.ok(JSON.stringify(c).includes(PREVIEW_DB));
353
+ }
354
+ for (const bad of [
355
+ { cmd: 'dropdb', args: ['--if-exists', 'live_db'] },
356
+ { cmd: 'dropdb', args: ['live_db', PREVIEW_DB] },
357
+ { cmd: 'dropdb', args: ['--if-exists', '--force', 'live_db'] },
358
+ { cmd: 'psql', args: ['-d', 'postgres', '-c', 'DROP DATABASE live_db'] },
359
+ { cmd: 'psql', args: ['-X', '-d', 'postgres', '-tAc', "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'live_db'"] },
360
+ { cmd: 'bash', args: ['-c', 'dropdb ' + PREVIEW_DB] },
361
+ null,
362
+ ]) assert.equal(cmds.targetsOnlyPreviewDb(bad), false, JSON.stringify(bad));
363
+ assert.match(cmds.terminateBackendsCommand().args.join(' '), /datname = 'bongos_preview' AND pid <> pg_backend_pid\(\)/);
364
+ });
365
+
366
+ test('every drop-capable command a start and stop runs is the guarded kind, none naming the live database', async () => {
367
+ const { sup, calls } = rig({ failRun: (c) => c.cmd === 'dropdb' });
368
+ await sup.start('1.19.1000').done;
369
+ await sup.stop();
370
+ const risky = calls.filter((x) => x.cmd === 'dropdb' || x.cmd === 'psql');
371
+ assert.ok(risky.length > 0);
372
+ for (const c of risky) {
373
+ assert.equal(cmds.targetsOnlyPreviewDb(c), true, JSON.stringify(c));
374
+ assert.ok(!JSON.stringify(c.args).includes('live_db'));
375
+ }
376
+ });
@@ -159,6 +159,16 @@ test('a cookie for a preview that is not running is dropped and the live hall an
159
159
  }
160
160
  });
161
161
 
162
+ test('the clearing cookie repeats the set attributes, Secure included (task 1004466)', () => {
163
+ const { setCookie } = require('../modules/npm-release/preview/divert.js');
164
+ for (const secure of [false, true]) {
165
+ const req = { secure };
166
+ const attrs = (c) => c.split('; ').slice(1).filter((a) => !/^Max-Age=/.test(a)).join('; ');
167
+ assert.equal(attrs(clearCookie(req)), attrs(setCookie('1.19.1000', req)), 'secure=' + secure);
168
+ assert.equal(/; Secure$/.test(clearCookie(req)), secure);
169
+ }
170
+ });
171
+
162
172
  test('the clearing cookie expires the steering cookie for the whole site', () => {
163
173
  assert.match(clearCookie(), /^bongos_preview=; Path=\/; Max-Age=0/);
164
174
  });
@@ -137,6 +137,32 @@ test('JSON and other non-page answers pass through untouched, headers included',
137
137
  assert.doesNotMatch(res.text, /banner/);
138
138
  });
139
139
 
140
+ test('a Set-Cookie from the preview never reaches the browser (it would overwrite the live hall cookies), task 1004466', async () => {
141
+ const cookies = ['cb_stealth=child; Path=/', '__Host-sid=child; Path=/; Secure', 'bongos_preview=; Max-Age=0'];
142
+ for (const [type, body] of [['application/json', '{}'], ['text/html; charset=utf-8', '<html><body>x</body></html>']]) {
143
+ const http = fakeHttp(() => ({ headers: { 'content-type': type, 'set-cookie': cookies, 'x-keep': 'yes' }, body }));
144
+ const res = new FakeRes();
145
+ make(http)(request(), res);
146
+ await finish(res);
147
+ assert.equal(Object.keys(res.headers).filter((k) => k.toLowerCase() === 'set-cookie').length, 0, type);
148
+ assert.equal(res.headers['x-keep'], 'yes', 'other headers still pass');
149
+ }
150
+ });
151
+
152
+ test('a streamed answer with a Set-Cookie is still streamed, cookie dropped', async () => {
153
+ const stream = new PassThrough();
154
+ const http = fakeHttp(() => ({ headers: { 'content-type': 'text/event-stream', 'set-cookie': 'cb_stealth=child' }, stream }));
155
+ const res = new FakeRes();
156
+ make(http)(request(), res);
157
+ await tick();
158
+ stream.write('data: one\n\n');
159
+ await tick();
160
+ assert.equal(res.headers['set-cookie'], undefined);
161
+ assert.equal(res.text, 'data: one\n\n');
162
+ stream.end();
163
+ await finish(res);
164
+ });
165
+
140
166
  test('an event stream is streamed, not held: the first chunk arrives before the stream ends', async () => {
141
167
  const stream = new PassThrough();
142
168
  const http = fakeHttp(() => ({ headers: { 'content-type': 'text/event-stream' }, stream }));
@@ -52,6 +52,7 @@ let base;
52
52
  before(async () => {
53
53
  state = { state: 'idle', version: null };
54
54
  const app = express();
55
+ app.set('trust proxy', true);
55
56
  app.use(express.json());
56
57
  app.use('/api/bongos', buildRouter({ supervisor, listPublished: async () => registry, runningVersion: () => '1.19.1040' }));
57
58
  server = http.createServer(app);
@@ -60,10 +61,10 @@ before(async () => {
60
61
  });
61
62
  after(() => new Promise((r) => server.close(r)));
62
63
 
63
- function call(method, url, { cookie = 'sid=good', body } = {}) {
64
+ function call(method, url, { cookie = 'sid=good', body, extra } = {}) {
64
65
  return new Promise((resolve, reject) => {
65
66
  const data = body === undefined ? null : JSON.stringify(body);
66
- const headers = { host: 'builders.example.test' };
67
+ const headers = { host: 'builders.example.test', ...(extra || {}) };
67
68
  if (cookie) headers.cookie = cookie;
68
69
  if (data) { headers['content-type'] = 'application/json'; headers['content-length'] = Buffer.byteLength(data); }
69
70
  const r = http.request({ host: '127.0.0.1', port: base, path: '/api/bongos' + url, method, headers }, (res) => {
@@ -178,6 +179,18 @@ test('enter sets the steering cookie for the running version and goes to the hal
178
179
  assert.match(c, /^bongos_preview=1\.19\.1030; Path=\/; HttpOnly; SameSite=Lax/);
179
180
  });
180
181
 
182
+ test('exit clears with the same attributes enter set, Secure included, and is not cached (task 1004466)', async () => {
183
+ state = { state: 'running', version: '1.19.1030' };
184
+ for (const extra of [undefined, { 'x-forwarded-proto': 'https' }]) {
185
+ const e = await call('GET', '/npm-release/preview/enter?v=1.19.1030', { extra });
186
+ const x = await call('GET', '/npm-release/preview/exit', { extra });
187
+ const attrs = (r) => [].concat(r.headers['set-cookie'])[0].split('; ').slice(1).filter((a) => !/^Max-Age=/.test(a)).join('; ');
188
+ assert.equal(attrs(x), attrs(e));
189
+ assert.equal(/Secure/.test(attrs(x)), Boolean(extra));
190
+ assert.equal(x.headers['cache-control'], 'no-store');
191
+ }
192
+ });
193
+
181
194
  test('enter refuses a preview that is not the one running, and sets no cookie', async () => {
182
195
  for (const [st, v] of [[{ state: 'idle', version: null }, '1.19.1030'], [{ state: 'starting', version: '1.19.1030' }, '1.19.1030'], [{ state: 'running', version: '1.19.1031' }, '1.19.1030'], [{ state: 'running', version: '1.19.1030' }, 'zzz']]) {
183
196
  state = st;
@@ -28,7 +28,7 @@ function rig({ failOn, probeResults, env } = {}) {
28
28
  },
29
29
  spawn: (bin, args, opts) => {
30
30
  const handlers = {};
31
- const child = { bin, args, opts, on: (ev, fn) => { handlers[ev] = fn; }, kill: () => { fake.killed += 1; }, emit: (ev, ...a) => handlers[ev] && handlers[ev](...a) };
31
+ const child = { bin, args, opts, on: (ev, fn) => { handlers[ev] = fn; }, kill: (sig) => { fake.killed += 1; (fake.signals ||= []).push(sig); if (handlers.exit) handlers.exit(0); }, emit: (ev, ...a) => handlers[ev] && handlers[ev](...a) };
32
32
  fake.spawned.push(child);
33
33
  return child;
34
34
  },
@@ -46,7 +46,7 @@ function rig({ failOn, probeResults, env } = {}) {
46
46
  }
47
47
 
48
48
  const fwd = (x) => String(x).split(String.fromCharCode(92)).join('/');
49
- const DROP = { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] };
49
+ const DROP = { cmd: 'dropdb', args: ['--if-exists', '--force', PREVIEW_DB] };
50
50
 
51
51
  test('a start runs install, copy, restore and migrate, in that order, then launches and waits for health', async () => {
52
52
  const { sup, calls, fake } = rig();
@@ -89,9 +89,9 @@ for (const failOn of ['npm', 'dropdb', 'createdb', 'dump', 'pg_restore', 'bash']
89
89
  await sup.start('1.19.1000').done;
90
90
  const s = sup.status();
91
91
  assert.equal(s.state, 'failed');
92
- assert.match(s.error, /failed \(exit 3\)/);
92
+ assert.match(s.error, failOn === 'dropdb' ? /could not be dropped/ : /failed [(]exit 3[)]/);
93
93
  assert.equal(fake.spawned.length, 0, 'no process was started');
94
- assert.deepEqual(calls[calls.length - 1], DROP, 'the copy is dropped last');
94
+ if (failOn !== 'dropdb') assert.deepEqual(calls[calls.length - 1], DROP, 'the copy is dropped last');
95
95
  assert.equal(rmCalls.length, 1, 'the scratch directory is removed');
96
96
  });
97
97
  }
@@ -23,6 +23,8 @@ import fs from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import vm from 'node:vm';
25
25
  import { fileURLToPath } from 'node:url';
26
+ import { createRequire } from 'node:module';
27
+ const require = createRequire(import.meta.url);
26
28
 
27
29
  const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
28
30
  const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
@@ -301,6 +303,53 @@ test('typing a start command clears the "how your app starts" ask at once', asyn
301
303
  assert.doesNotMatch(w.$('h-render').textContent, /how your app starts/);
302
304
  });
303
305
 
306
+ // task 1004464 — Render refused a native app with no build command, live, on project 43.
307
+ test('the build command starts as the runtime\'s usual one, follows the runtime, and yields to the owner\'s own', async () => {
308
+ const w = load({ answers: ANSWERS, draft: { appPlatform: 'render' } });
309
+ w.typeKey(KEY);
310
+ await w.check();
311
+ await w.pickWorkspace(0);
312
+ w.pickService({ value: 'new' });
313
+ assert.equal(w.$('f-renderBuild').value, 'npm install', 'Node.js starts with npm install');
314
+ w.$('f-renderRuntime').value = 'python';
315
+ w.$('renderNew').fire('change', { target: w.$('f-renderRuntime') });
316
+ assert.equal(w.$('f-renderBuild').value, 'pip install -r requirements.txt', 'it follows the runtime');
317
+ w.$('f-renderBuild').value = 'make build';
318
+ w.$('renderNew').fire('input', { target: w.$('f-renderBuild') });
319
+ w.$('f-renderRuntime').value = 'ruby';
320
+ w.$('renderNew').fire('change', { target: w.$('f-renderRuntime') });
321
+ assert.equal(w.$('f-renderBuild').value, 'make build', 'the owner\'s own command is never overwritten');
322
+ w.state.renderStart = 'bundle exec puma';
323
+ assert.equal(w.renderRequestBody().build_command, 'make build');
324
+ });
325
+
326
+ test('an emptied build command is refused before Render ever sees it, and the ask clears on typing', async () => {
327
+ const w = load({ answers: ANSWERS, draft: { appPlatform: 'render' } });
328
+ w.typeKey(KEY);
329
+ await w.check();
330
+ await w.pickWorkspace(0);
331
+ w.pickService({ value: 'new' });
332
+ w.state.renderStart = 'npm start';
333
+ w.$('f-renderBuild').value = '';
334
+ w.$('renderNew').fire('input', { target: w.$('f-renderBuild') });
335
+ assert.equal(w.validateRender(), false);
336
+ assert.match(w.$('h-render').textContent, /how your app is built/);
337
+ w.$('f-renderBuild').value = 'npm ci';
338
+ w.$('renderNew').fire('input', { target: w.$('f-renderBuild') });
339
+ assert.doesNotMatch(w.$('h-render').className, /err/);
340
+ assert.equal(w.validateRender(), true);
341
+ });
342
+
343
+ test('the page\'s build defaults are the runner\'s, key for key', () => {
344
+ const { BUILD_DEFAULTS, RUNTIMES } = require('../modules/provisioning/render-standup.js');
345
+ const src = HUB.replace(/\r\n/g, '\n').match(/var RENDER_BUILD_DEFAULTS = (\{[\s\S]*?\});/);
346
+ assert.ok(src, 'the page carries RENDER_BUILD_DEFAULTS');
347
+ const page = vm.runInNewContext('(' + src[1] + ')');
348
+ assert.deepEqual({ ...page }, { ...BUILD_DEFAULTS });
349
+ assert.deepEqual(Object.keys(page).sort(), RUNTIMES.filter((r) => r !== 'docker').sort());
350
+ assert.doesNotMatch(HUB, /leave empty if there is none/);
351
+ });
352
+
304
353
  test('the empty command boxes read as examples, not as filled-in values', () => {
305
354
  assert.match(HUB, /id="f-renderBuild"[^>]*placeholder="for example: npm ci"/);
306
355
  assert.match(HUB, /id="f-renderStart"[^>]*placeholder="for example: npm start"/);
@@ -250,7 +250,8 @@ test('Connect sends the key once, to this project, forgets it, and the card show
250
250
  const sent = w.calls.filter((c) => c.url === RENDER);
251
251
  assert.equal(sent.length, 1);
252
252
  assert.equal(sent[0].method, 'POST');
253
- assert.deepEqual(sent[0].body, { key: KEY, owner_id: TEA_A, mode: 'greenfield', runtime: 'node', start_command: 'npm start', web_plan: 'starter', database: false });
253
+ // build_command: the runtime's usual one, prefilled — Render refuses a native app without one (task 1004464)
254
+ assert.deepEqual(sent[0].body, { key: KEY, owner_id: TEA_A, mode: 'greenfield', runtime: 'node', start_command: 'npm start', build_command: 'npm install', web_plan: 'starter', database: false });
254
255
  for (const c of w.calls) assert.ok(!c.url.includes(KEY), 'no URL carries the key');
255
256
  assert.equal(w.q('f-renderKey').value, '', 'the field is emptied');
256
257
  assert.equal(w.byId('mAppConnectForm').hidden, true, 'the form closes');
@@ -335,11 +335,32 @@ await ta('a link needs a repository to check the service against (1004350 review
335
335
  assert.equal(out.ok, false);
336
336
  assert.equal(h.cleared(), 1);
337
337
  });
338
- await ta('an app with no build step sends no build command at all (1004350 review)', async () => {
338
+ // The 1004350 review asked for NO build command here; Render refused exactly that live
339
+ // ("buildCommand is required for non-static, non-docker services", task 1004464), so a
340
+ // row saved without one — every row from before the fix included — gets the runtime's
341
+ // default, and a Retry of such a row now works.
342
+ await ta('an app saved with no build command gets its runtime\'s default, as Render requires (task 1004464)', async () => {
339
343
  const h = harness({ host: { buildCommand: undefined } });
340
344
  await h.run();
341
345
  const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
342
- assert.deepEqual(svc.serviceDetails.envSpecificDetails, { startCommand: 'npm start' });
346
+ assert.deepEqual(svc.serviceDetails.envSpecificDetails, { buildCommand: 'npm install', startCommand: 'npm start' });
347
+ });
348
+ await ta('every native runtime has a default build command; docker still sends none (task 1004464)', () => {
349
+ const { RUNTIMES, BUILD_DEFAULTS } = require('../modules/provisioning/render-standup.js');
350
+ const { buildFor } = require('../scripts/gds/render-payload.js');
351
+ for (const rt of RUNTIMES.filter((r) => r !== 'docker')) {
352
+ assert.ok(BUILD_DEFAULTS[rt], `${rt} has a default`);
353
+ assert.equal(buildFor({ runtime: rt, startCommand: 'x' }).buildCommand, BUILD_DEFAULTS[rt]);
354
+ assert.equal(buildFor({ runtime: rt, startCommand: 'x', buildCommand: 'make' }).buildCommand, 'make', 'the owner\'s own wins');
355
+ }
356
+ assert.deepEqual(buildFor({ runtime: 'docker' }), { runtime: 'docker' });
357
+ });
358
+ await ta('a refusal from Render reads as a sentence, with Render\'s own reason kept (task 1004464)', async () => {
359
+ const h = harness({ host: { database: false }, render: { 'POST /services': new Error('buildCommand is required for non-static, non-docker services.') } });
360
+ const out = await h.run();
361
+ assert.equal(out.ok, false);
362
+ assert.equal(h.lastHost().error, 'Render would not set up the app: buildCommand is required for non-static, non-docker services.');
363
+ assert.doesNotMatch(h.lastHost().error, /POST \/services/, 'no raw HTTP line reaches the owner');
343
364
  });
344
365
 
345
366
  console.log('\nteardown forgets, never deletes:');