@bongos/core 1.25.20 → 1.25.22
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 +213 -78
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +18 -0
- package/clients/bongos-client/index.cjs +18 -0
- package/clients/bongos-client/index.d.ts +34 -8
- package/clients/bongos-client/index.mjs +18 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +18 -0
- package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
- package/docs/api/openapi.json +693 -125
- package/docs/api-reference.md +13 -4
- package/docs/copy-inventory.md +432 -427
- package/docs/copy-registry.json +571 -526
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +350 -344
- package/modules/government/board.js +8 -7
- package/modules/government/charter-advice.js +10 -3
- package/modules/government/charter.js +88 -4
- package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
- package/modules/government/routes/government.js +12 -12
- package/modules/government/sole-member.js +75 -0
- package/modules/hall-ui/public/genesis-home.js +12 -5
- package/modules/lifecycle/kickoff-checklist.js +1 -1
- package/modules/lifecycle/routes/pay-type-gates.js +7 -2
- package/modules/onboarding/genesis-home.js +14 -2
- package/modules/onboarding/project-facts.js +318 -0
- package/modules/onboarding/routes/genesis-work.js +16 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-standing.js +49 -0
- package/modules/platform-identity/routes/my-projects.js +8 -0
- package/modules/provisioning/grants.js +126 -0
- package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
- package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/project-facts.js +55 -0
- package/modules/provisioning/project-refusal.js +107 -0
- package/modules/provisioning/provisioning.js +6 -3
- package/modules/provisioning/render-lookup.js +36 -1
- package/modules/provisioning/render-promote.js +80 -0
- package/modules/provisioning/render-sandbox.js +6 -0
- package/modules/provisioning/route-helpers.js +70 -16
- package/modules/provisioning/routes/env-manifest.js +21 -5
- package/modules/provisioning/routes/grants.js +141 -0
- package/modules/provisioning/routes/provisioning.js +8 -7
- package/modules/provisioning/routes/render-standup.js +82 -3
- package/modules/public-landing/public/projects.html +290 -32
- package/package-lock.json +2 -2
- package/package.json +2 -1
- package/release-notes.json +32 -0
- package/scripts/bongos/genesis.js +49 -3
- package/scripts/bongos/government-walk-sole.js +41 -0
- package/scripts/bongos/government-walk.js +15 -16
- package/scripts/bongos/init.js +13 -0
- package/scripts/bongos/package-core.js +2 -0
- package/scripts/bongos/provision-render-common.js +48 -0
- package/scripts/bongos/provision-render-promote.js +93 -0
- package/scripts/bongos/provision-render-sandbox.js +19 -3
- package/scripts/bongos/provision-render.js +4 -36
- package/scripts/bongos/provision.js +1 -0
- package/scripts/bongos/render-api.js +17 -2
- package/scripts/bongos/render-payload.js +5 -2
- package/scripts/gds/government-walk-sole.js +6 -0
- package/scripts/gds/provision-render-common.js +6 -0
- package/scripts/gds/provision-render-promote.js +6 -0
- package/src/bongos/folder-upload.js +243 -0
- package/src/bongos/routes/github.js +109 -16
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +4 -0
- package/src/module-api.js +1 -1
- package/src/sandbox/gate.js +164 -0
- package/src/sandbox/index.js +144 -0
- package/tests/fleet_cost_view.mjs +2 -0
- package/tests/folder_upload_project.mjs +396 -0
- package/tests/genesis_government.mjs +5 -1
- package/tests/genesis_home.mjs +17 -0
- package/tests/genesis_work_routes.mjs +25 -1
- package/tests/government_charter_advice.mjs +49 -0
- package/tests/government_charter_forms.mjs +14 -3
- package/tests/government_seed.mjs +1 -0
- package/tests/government_sole_member.mjs +153 -0
- package/tests/project_catalog_routes.mjs +2 -0
- package/tests/project_facts.mjs +158 -0
- package/tests/projects_hub.mjs +1 -1
- package/tests/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_env_manifest.mjs +4 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
- package/tests/provisioning_project_grants.mjs +458 -0
- package/tests/provisioning_project_owner_gate.mjs +3 -1
- package/tests/provisioning_project_refusal.mjs +316 -0
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +2 -0
- package/tests/provisioning_settings.mjs +2 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_settings_env.mjs +2 -2
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/public_landing_projects.mjs +27 -5
- package/tests/render_promote.mjs +309 -0
- package/tests/render_sandbox.mjs +16 -8
- package/tests/sandbox_gate.mjs +182 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// tests/render_promote.mjs — a person promotes what ran on the sandbox to the live app
|
|
2
|
+
// (task 1005290; ADR 0406 §2). The runner leg (scripts/bongos/provision-render-promote.js)
|
|
3
|
+
// is driven over the REAL Render client and a recording fake of Render's API; the web
|
|
4
|
+
// tier's look (render-lookup.js lookupPromotion) over a fake fetch; the routes over
|
|
5
|
+
// express with the module's seams stubbed. No network, no database, no spend.
|
|
6
|
+
//
|
|
7
|
+
// The task's DONE WHEN:
|
|
8
|
+
// • a shipped change is on the sandbox while the main site is unchanged — the sandbox
|
|
9
|
+
// setup switches the live service to manual deploys (tests/render_sandbox.mjs asserts
|
|
10
|
+
// the PATCH), and the look lists what ran on the sandbox since live's change;
|
|
11
|
+
// • promoting changes the main site — the live service is deployed at EXACTLY the chosen
|
|
12
|
+
// commit;
|
|
13
|
+
// • nothing reaches the main site without a recorded promotion — the record is written
|
|
14
|
+
// BEFORE Render is asked, a refusal sends nothing, and only the owner may press.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/render_promote.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { RenderApi } = require('../scripts/bongos/render-api.js');
|
|
24
|
+
const { promoteInstance } = require('../scripts/bongos/provision-render-promote.js');
|
|
25
|
+
const { lookupPromotion } = require('../modules/provisioning/render-lookup.js');
|
|
26
|
+
const renderPromote = require('../modules/provisioning/render-promote.js');
|
|
27
|
+
|
|
28
|
+
let passed = 0, failed = 0;
|
|
29
|
+
async function ta(name, fn) {
|
|
30
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
31
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OURS = 'tea-ours00000001';
|
|
35
|
+
const OLD = 'aaaaaaa1111111111111111111111111111111aa';
|
|
36
|
+
const MID = 'bbbbbbb2222222222222222222222222222222bb';
|
|
37
|
+
const NEW = 'ccccccc3333333333333333333333333333333cc';
|
|
38
|
+
const BAD = 'ddddddd4444444444444444444444444444444dd';
|
|
39
|
+
const dep = (id, commit, status, message = `task 100 - change ${commit.slice(0, 3)}`) => ({ deploy: { id, status, commit: { id: commit, message, createdAt: '2026-11-13T10:00:00Z' } } });
|
|
40
|
+
|
|
41
|
+
function fakeRender(over = {}) {
|
|
42
|
+
const calls = [];
|
|
43
|
+
const answers = {
|
|
44
|
+
'GET /owners?limit=100': [{ owner: { id: OURS } }],
|
|
45
|
+
'GET /services/srv-live0001': { id: 'srv-live0001', name: 'demo', ownerId: OURS },
|
|
46
|
+
'GET /services/srv-sbx00001': { id: 'srv-sbx00001', name: 'demo-sandbox', ownerId: OURS },
|
|
47
|
+
'GET /services/srv-sbx00001/deploys?limit=50': [dep('dep-s4', BAD, 'build_failed'), dep('dep-s3', NEW, 'live'), dep('dep-s2', MID, 'deactivated'), dep('dep-s1', OLD, 'deactivated')],
|
|
48
|
+
'GET /services/srv-live0001/deploys?limit=10': [dep('dep-l1', OLD, 'live')],
|
|
49
|
+
'PATCH /services/srv-live0001': { id: 'srv-live0001', ownerId: OURS, autoDeploy: 'no' },
|
|
50
|
+
'POST /services/srv-live0001/deploys': { id: 'dep-live-new' },
|
|
51
|
+
...over,
|
|
52
|
+
};
|
|
53
|
+
const fetchImpl = async (url, init) => {
|
|
54
|
+
const path = url.replace('https://api.render.com/v1', '');
|
|
55
|
+
const body = init.body ? JSON.parse(init.body) : null;
|
|
56
|
+
calls.push({ method: init.method, path, body });
|
|
57
|
+
const a = answers[`${init.method} ${path}`];
|
|
58
|
+
if (a === undefined) return { ok: false, status: 404, text: async () => '{"message":"not found"}' };
|
|
59
|
+
if (a instanceof Error) return { ok: false, status: 500, text: async () => JSON.stringify({ message: a.message }) };
|
|
60
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(a) };
|
|
61
|
+
};
|
|
62
|
+
return { calls, fetchImpl };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function harness({ ask = {}, host = {}, render = {}, intent = {} } = {}) {
|
|
66
|
+
const r = fakeRender(render);
|
|
67
|
+
const order = [], promotions = [], cleared = [], logs = [];
|
|
68
|
+
let nextId = 1;
|
|
69
|
+
const inst = { id: 18, slug: 'demo', app_host: { platform: 'render', ownerId: OURS, serviceId: 'srv-live0001', state: 'live',
|
|
70
|
+
sandbox: { state: 'live', serviceId: 'srv-sbx00001' }, promote: { state: 'requested', commitId: NEW, expectLiveCommit: OLD, requestedBy: '7', ...ask }, ...host } };
|
|
71
|
+
const db = { query: async (text, params) => {
|
|
72
|
+
const t = text.replace(/\s+/g, ' ');
|
|
73
|
+
if (/INSERT INTO provisioning_promotions/.test(t)) {
|
|
74
|
+
order.push('record');
|
|
75
|
+
const row = { id: nextId++, commit_id: params[1], commit_message: params[2], from_commit_id: params[3], sandbox_deploy_id: params[4], requested_by: params[5], state: 'started' };
|
|
76
|
+
promotions.push(row);
|
|
77
|
+
return { rows: [{ id: row.id }] };
|
|
78
|
+
}
|
|
79
|
+
if (/UPDATE provisioning_promotions SET state = 'sent'/.test(t)) { Object.assign(promotions.find((p) => p.id === params[0]), { state: 'sent', render_deploy_id: params[1] }); return { rows: [] }; }
|
|
80
|
+
if (/UPDATE provisioning_promotions SET state = 'failed'/.test(t)) { Object.assign(promotions.find((p) => p.id === params[0]), { state: 'failed', error: params[1] }); return { rows: [] }; }
|
|
81
|
+
if (/SET app_host = COALESCE\(app_host, '\{\}'::jsonb\) \|\| \$2::jsonb/.test(t)) { inst.app_host = { ...inst.app_host, ...JSON.parse(params[1]) }; return { rows: [] }; }
|
|
82
|
+
if (/borrowed_key_enc = NULL/.test(t)) { cleared.push(params[0]); return { rows: [] }; }
|
|
83
|
+
return { rows: [] };
|
|
84
|
+
} };
|
|
85
|
+
const wrapped = { calls: r.calls, fetchImpl: async (url, init) => { if (init.method !== 'GET') order.push(`${init.method} ${url.replace('https://api.render.com/v1', '')}`); return r.fetchImpl(url, init); } };
|
|
86
|
+
const deps = { db, log: (l) => logs.push(l), secretBox: { decrypt: () => 'rnd_borrowed' },
|
|
87
|
+
renderApiFactory: ({ token, ownerId }) => new RenderApi({ token, ownerId, dryRun: false, fetchImpl: wrapped.fetchImpl }) };
|
|
88
|
+
const it = { id: 61, action: 'render-promote', borrowed_key_enc: 'v1.enc', created_at: new Date().toISOString(), ...intent };
|
|
89
|
+
return { r, inst, order, promotions, cleared, run: () => promoteInstance(inst, deps, it) };
|
|
90
|
+
}
|
|
91
|
+
const writes = (h) => h.r.calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path}`);
|
|
92
|
+
|
|
93
|
+
console.log('the runner — promote:');
|
|
94
|
+
await ta('promoting deploys EXACTLY the chosen commit to the live app, recorded first, live switched to manual', async () => {
|
|
95
|
+
const h = harness();
|
|
96
|
+
const out = await h.run();
|
|
97
|
+
assert.equal(out.ok, true, out.error);
|
|
98
|
+
assert.deepEqual(h.order, ['record', 'PATCH /services/srv-live0001', 'POST /services/srv-live0001/deploys'], 'the record comes before anything is sent');
|
|
99
|
+
const deploy = h.r.calls.find((c) => c.method === 'POST');
|
|
100
|
+
assert.deepEqual(deploy.body, { commitId: NEW }, 'that commit, not whatever main is now');
|
|
101
|
+
assert.deepEqual(h.r.calls.find((c) => c.method === 'PATCH').body, { autoDeploy: 'no' });
|
|
102
|
+
assert.equal(h.promotions.length, 1);
|
|
103
|
+
assert.deepEqual([h.promotions[0].commit_id, h.promotions[0].from_commit_id, h.promotions[0].requested_by, h.promotions[0].state, h.promotions[0].render_deploy_id],
|
|
104
|
+
[NEW, OLD, '7', 'sent', 'dep-live-new'], 'who, which change, from which, and what Render answered');
|
|
105
|
+
assert.equal(h.inst.app_host.liveAutoDeploy, 'off');
|
|
106
|
+
assert.equal(h.inst.app_host.promote.state, 'sent');
|
|
107
|
+
assert.deepEqual(h.cleared, [61], 'the key is cleared');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await ta('an older change that ran on the sandbox can be promoted too', async () => {
|
|
111
|
+
const h = harness({ ask: { commitId: MID } });
|
|
112
|
+
assert.equal((await h.run()).ok, true);
|
|
113
|
+
assert.deepEqual(h.r.calls.find((c) => c.method === 'POST').body, { commitId: MID });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await ta('a change that never ran on the sandbox is refused, and nothing is sent or recorded', async () => {
|
|
117
|
+
for (const commitId of [BAD, 'eeeeeee5555555555555555555555555555555ee']) {
|
|
118
|
+
const h = harness({ ask: { commitId } });
|
|
119
|
+
const out = await h.run();
|
|
120
|
+
assert.equal(out.ok, false);
|
|
121
|
+
assert.equal(out.terminal, true);
|
|
122
|
+
assert.match(out.error, /has not run on the sandbox/);
|
|
123
|
+
assert.deepEqual(writes(h), [], 'no write to Render');
|
|
124
|
+
assert.equal(h.promotions.length, 0);
|
|
125
|
+
assert.deepEqual(h.cleared, [61]);
|
|
126
|
+
assert.equal(h.inst.app_host.promote.state, 'failed');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await ta('if the live app moved since the owner looked, nothing is done', async () => {
|
|
131
|
+
const h = harness({ ask: { expectLiveCommit: MID } });
|
|
132
|
+
const out = await h.run();
|
|
133
|
+
assert.equal(out.ok, false);
|
|
134
|
+
assert.match(out.error, /changed since you looked/);
|
|
135
|
+
assert.deepEqual(writes(h), []);
|
|
136
|
+
assert.equal(h.promotions.length, 0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await ta('a change already live is refused', async () => {
|
|
140
|
+
const h = harness({ ask: { commitId: OLD } });
|
|
141
|
+
const out = await h.run();
|
|
142
|
+
assert.match(out.error, /already on your live app/);
|
|
143
|
+
assert.deepEqual(writes(h), []);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await ta('Render refusing the deploy leaves a failed record, said plainly', async () => {
|
|
147
|
+
const h = harness({ render: { 'POST /services/srv-live0001/deploys': new Error('build minutes exhausted') } });
|
|
148
|
+
const out = await h.run();
|
|
149
|
+
assert.equal(out.ok, false);
|
|
150
|
+
assert.match(out.error, /build minutes exhausted/);
|
|
151
|
+
assert.equal(h.promotions[0].state, 'failed');
|
|
152
|
+
assert.match(h.promotions[0].error, /build minutes exhausted/);
|
|
153
|
+
assert.deepEqual(h.cleared, [61]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await ta('no key, a stale key, no sandbox or no app: refused before Render is called', async () => {
|
|
157
|
+
const cases = [
|
|
158
|
+
[{ intent: { borrowed_key_enc: null } }, /no Render key/],
|
|
159
|
+
[{ intent: { created_at: new Date(Date.now() - 3600_000).toISOString() } }, /waited too long/],
|
|
160
|
+
[{ host: { sandbox: {} } }, /no sandbox/],
|
|
161
|
+
[{ host: { serviceId: null } }, /no app on Render/],
|
|
162
|
+
[{ ask: { commitId: 'not-a-commit' } }, /no change was chosen/],
|
|
163
|
+
];
|
|
164
|
+
for (const [opts, re] of cases) {
|
|
165
|
+
const h = harness(opts);
|
|
166
|
+
const out = await h.run();
|
|
167
|
+
assert.equal(out.ok, false);
|
|
168
|
+
assert.match(out.error, re);
|
|
169
|
+
assert.equal(h.r.calls.length, 0, `no call to Render for ${re}`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
console.log('\nthe look — what is waiting:');
|
|
174
|
+
function lookFetch(over = {}) {
|
|
175
|
+
const answers = {
|
|
176
|
+
'/services/srv-live0001': { id: 'srv-live0001', ownerId: OURS },
|
|
177
|
+
'/services/srv-sbx00001': { id: 'srv-sbx00001', ownerId: OURS },
|
|
178
|
+
'/services/srv-live0001/deploys?limit=10': [dep('dep-l1', OLD, 'live')],
|
|
179
|
+
'/services/srv-sbx00001/deploys?limit=30': [dep('dep-s4', BAD, 'build_failed'), dep('dep-s3', NEW, 'live'), dep('dep-s2', MID, 'deactivated'), dep('dep-s1', OLD, 'deactivated')],
|
|
180
|
+
...over,
|
|
181
|
+
};
|
|
182
|
+
const seen = [];
|
|
183
|
+
const fetchImpl = async (url, init) => {
|
|
184
|
+
const path = url.replace('https://api.render.com/v1', '');
|
|
185
|
+
seen.push({ method: init.method, path });
|
|
186
|
+
const a = answers[path];
|
|
187
|
+
return { ok: a !== undefined, status: a !== undefined ? 200 : 404, json: async () => a };
|
|
188
|
+
};
|
|
189
|
+
return { seen, fetchImpl };
|
|
190
|
+
}
|
|
191
|
+
await ta('lists what ran on the sandbox since the change live runs, newest first, read-only', async () => {
|
|
192
|
+
const f = lookFetch();
|
|
193
|
+
const out = await lookupPromotion('rnd_k', { ownerId: OURS, liveServiceId: 'srv-live0001', sandboxServiceId: 'srv-sbx00001', fetchImpl: f.fetchImpl });
|
|
194
|
+
assert.equal(out.live.commit_id, OLD);
|
|
195
|
+
assert.deepEqual(out.waiting.map((w) => w.commit_id), [NEW, MID], 'the failed build is not waiting, and live’s own change is not');
|
|
196
|
+
assert.equal(out.waiting[0].short, NEW.slice(0, 7));
|
|
197
|
+
assert.ok(f.seen.every((c) => c.method === 'GET'), 'GET only');
|
|
198
|
+
});
|
|
199
|
+
await ta('a service from another workspace stops the look', async () => {
|
|
200
|
+
const f = lookFetch({ '/services/srv-sbx00001': { id: 'srv-sbx00001', ownerId: 'tea-theirs000001' } });
|
|
201
|
+
await assert.rejects(lookupPromotion('rnd_k', { ownerId: OURS, liveServiceId: 'srv-live0001', sandboxServiceId: 'srv-sbx00001', fetchImpl: f.fetchImpl }), /another workspace/);
|
|
202
|
+
});
|
|
203
|
+
await ta('live in step with the sandbox: nothing waits', async () => {
|
|
204
|
+
const f = lookFetch({ '/services/srv-live0001/deploys?limit=10': [dep('dep-l2', NEW, 'live')] });
|
|
205
|
+
const out = await lookupPromotion('rnd_k', { ownerId: OURS, liveServiceId: 'srv-live0001', sandboxServiceId: 'srv-sbx00001', fetchImpl: f.fetchImpl });
|
|
206
|
+
assert.deepEqual(out.waiting, []);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
console.log('\nthe web tier:');
|
|
210
|
+
await ta('enqueuePromote: the intent with the key and the chosen change on the row, in one statement', async () => {
|
|
211
|
+
const guard = require('../modules/provisioning/control-plane-guard.js');
|
|
212
|
+
const orig = guard.assertIntentAllowed;
|
|
213
|
+
guard.assertIntentAllowed = async () => {};
|
|
214
|
+
try {
|
|
215
|
+
const seen = [];
|
|
216
|
+
const db = { query: async (t, p) => { seen.push({ t: t.replace(/\s+/g, ' '), p }); return { rows: [{ id: 5 }] }; } };
|
|
217
|
+
await renderPromote.enqueuePromote(db, 18, { keyEnc: 'v1.enc', commitId: NEW, expectLiveCommit: OLD, requestedBy: 7 });
|
|
218
|
+
assert.equal(seen.length, 1);
|
|
219
|
+
assert.match(seen[0].t, /'render-promote'/);
|
|
220
|
+
const ask = JSON.parse(seen[0].p[3]);
|
|
221
|
+
assert.deepEqual([ask.state, ask.commitId, ask.expectLiveCommit, ask.requestedBy], ['requested', NEW, OLD, '7']);
|
|
222
|
+
} finally { guard.assertIntentAllowed = orig; }
|
|
223
|
+
});
|
|
224
|
+
await ta('promoteView: whether the owner can, and whether live still follows main', () => {
|
|
225
|
+
const ok = renderPromote.promoteView({ status: 'active', app_host: { platform: 'render', serviceId: 'srv-live0001', liveAutoDeploy: 'off', sandbox: { state: 'live', serviceId: 'srv-sbx00001' } } });
|
|
226
|
+
assert.deepEqual([ok.can, ok.live_follows_main], [true, false]);
|
|
227
|
+
const no = renderPromote.promoteView({ status: 'active', app_host: { platform: 'render', serviceId: 'srv-live0001' } });
|
|
228
|
+
assert.equal(no.can, false);
|
|
229
|
+
assert.match(no.reason, /no sandbox/);
|
|
230
|
+
assert.equal(no.live_follows_main, true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ---- the routes ----
|
|
234
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
235
|
+
const secretBox = require('../src/bongos/secret-box.js');
|
|
236
|
+
const kapi = require('../src/module-api.js');
|
|
237
|
+
let instance = null, enq = [], builder = { id: 7 };
|
|
238
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
239
|
+
provisioning.recordEvent = async () => {};
|
|
240
|
+
renderPromote.enqueuePromote = async (_db, id, args) => { enq.push({ id, ...args }); return { intent: { id: 9 } }; };
|
|
241
|
+
secretBox.isConfigured = () => true;
|
|
242
|
+
secretBox.encrypt = (plain) => `v1.sealed(${plain.length})`;
|
|
243
|
+
kapi.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
|
|
244
|
+
kapi.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
|
|
245
|
+
Object.defineProperty(kapi, 'pool', { configurable: true, get: () => ({ query: async () => ({ rows: [] }) }) });
|
|
246
|
+
require('../modules/provisioning/env-manifest.js').listEnvVars = async () => [];
|
|
247
|
+
const f = lookFetch();
|
|
248
|
+
const express = require('express');
|
|
249
|
+
const app = express();
|
|
250
|
+
app.use(express.json());
|
|
251
|
+
app.use((_req, res, next) => { res.fail = (code, o) => res.status(typeof o === 'number' ? o : (o && o.status) || 400).json({ error: { code, ...(typeof o === 'object' ? o : {}) } }); next(); });
|
|
252
|
+
app.use('/api/bongos', require('../modules/provisioning/routes/render-standup.js')({ fetchImpl: f.fetchImpl }));
|
|
253
|
+
const server = app.listen(0);
|
|
254
|
+
await new Promise((r) => server.once('listening', r));
|
|
255
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
256
|
+
function call(method, path, body) {
|
|
257
|
+
return new Promise((resolve, reject) => {
|
|
258
|
+
const req = http.request(`${base}${path}`, { method, headers: { 'Content-Type': 'application/json' } }, (res) => {
|
|
259
|
+
let d = ''; res.on('data', (c) => { d += c; });
|
|
260
|
+
res.on('end', () => resolve({ status: res.statusCode, body: d ? JSON.parse(d) : null, raw: d }));
|
|
261
|
+
});
|
|
262
|
+
req.on('error', reject);
|
|
263
|
+
req.end(body ? JSON.stringify(body) : undefined);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
const KEY = 'rnd_abcdefghijklmnopqrstuvwx';
|
|
267
|
+
const READY = { owner_builder_id: 7, slug: 'demo', status: 'active', app_host: { platform: 'render', ownerId: OURS, serviceId: 'srv-live0001', state: 'live', sandbox: { state: 'live', serviceId: 'srv-sbx00001' } } };
|
|
268
|
+
|
|
269
|
+
await ta('POST …/render/promote/look by the owner: what is waiting, the key never sent back', async () => {
|
|
270
|
+
instance = READY;
|
|
271
|
+
const r = await call('POST', '/provisioning/instances/18/render/promote/look', { key: KEY });
|
|
272
|
+
assert.equal(r.status, 200, r.raw);
|
|
273
|
+
assert.deepEqual(r.body.waiting.map((w) => w.commit_id), [NEW, MID]);
|
|
274
|
+
assert.ok(!r.raw.includes(KEY));
|
|
275
|
+
});
|
|
276
|
+
await ta('POST …/render/promote by the owner: queued with ciphertext, the change and what live ran', async () => {
|
|
277
|
+
instance = READY; enq = [];
|
|
278
|
+
const r = await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: NEW, expect_live_commit: OLD });
|
|
279
|
+
assert.equal(r.status, 200, r.raw);
|
|
280
|
+
assert.deepEqual(enq, [{ id: 18, keyEnc: `v1.sealed(${KEY.length})`, commitId: NEW, expectLiveCommit: OLD, requestedBy: 7 }]);
|
|
281
|
+
assert.ok(!r.raw.includes(KEY));
|
|
282
|
+
});
|
|
283
|
+
await ta('nobody but the owner can promote or look (the stranger’s 404)', async () => {
|
|
284
|
+
instance = { ...READY, owner_builder_id: 99 }; enq = [];
|
|
285
|
+
assert.equal((await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: NEW })).status, 404);
|
|
286
|
+
assert.equal((await call('POST', '/provisioning/instances/18/render/promote/look', { key: KEY })).status, 404);
|
|
287
|
+
assert.equal(enq.length, 0);
|
|
288
|
+
});
|
|
289
|
+
await ta('no sandbox, or no chosen change: refused, nothing queued', async () => {
|
|
290
|
+
instance = { ...READY, app_host: { ...READY.app_host, sandbox: null } }; enq = [];
|
|
291
|
+
let r = await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: NEW });
|
|
292
|
+
assert.equal(r.status, 409);
|
|
293
|
+
assert.match(r.body.error.message, /no sandbox/);
|
|
294
|
+
instance = READY;
|
|
295
|
+
r = await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: 'main' });
|
|
296
|
+
assert.equal(r.status, 400);
|
|
297
|
+
assert.equal(enq.length, 0);
|
|
298
|
+
});
|
|
299
|
+
await ta('GET …/render carries whether the owner can promote', async () => {
|
|
300
|
+
instance = READY;
|
|
301
|
+
const r = await call('GET', '/provisioning/instances/18/render');
|
|
302
|
+
assert.equal(r.status, 200, r.raw);
|
|
303
|
+
assert.equal(r.body.promote.can, true);
|
|
304
|
+
assert.deepEqual(r.body.promote.recent, []);
|
|
305
|
+
});
|
|
306
|
+
server.close();
|
|
307
|
+
|
|
308
|
+
console.log(`\nrender_promote: ${passed} passed, ${failed} failed`);
|
|
309
|
+
process.exit(failed ? 1 : 0);
|
package/tests/render_sandbox.mjs
CHANGED
|
@@ -59,6 +59,7 @@ function fakeRender(over = {}) {
|
|
|
59
59
|
serviceDetails: { url: LIVE_URL, plan: 'starter', runtime: 'python', region: 'oregon', envSpecificDetails: { buildCommand: 'pip install -r requirements.txt', startCommand: 'gunicorn app:app' } } },
|
|
60
60
|
'GET /services/srv-sbx00001': { id: 'srv-sbx00001', name: 'demo-sandbox', ownerId: OURS, serviceDetails: { url: SB_URL, plan: 'free' } },
|
|
61
61
|
'DELETE /services/srv-sbx00001': null,
|
|
62
|
+
'PATCH /services/srv-live0001': { id: 'srv-live0001', ownerId: OURS, autoDeploy: 'no' },
|
|
62
63
|
[SB_NAME_Q]: [],
|
|
63
64
|
...over,
|
|
64
65
|
};
|
|
@@ -122,16 +123,22 @@ await ta('a new app with a database: the app, then its sandbox — free, follows
|
|
|
122
123
|
const h = harness();
|
|
123
124
|
const out = await h.run();
|
|
124
125
|
assert.equal(out.ok, true, out.error);
|
|
125
|
-
assert.deepEqual(writesTo(h.r.calls), ['POST /postgres', 'POST /services', 'POST /services'
|
|
126
|
+
assert.deepEqual(writesTo(h.r.calls), ['POST /postgres', 'POST /services', 'POST /services', 'PATCH /services/srv-live0001'],
|
|
127
|
+
'database, live app, sandbox, then the live app switched to manual deploys (task 1005290) — and nothing else written');
|
|
128
|
+
assert.deepEqual(h.r.calls.find((c) => c.method === 'PATCH').body, { autoDeploy: 'no' }, 'from now on only a promotion moves the live app');
|
|
129
|
+
assert.equal(h.inst.app_host.liveAutoDeploy, 'off');
|
|
126
130
|
const sb = created(h, 'demo-sandbox').body;
|
|
127
131
|
assert.equal(sb.serviceDetails.plan, 'free');
|
|
128
132
|
assert.equal(sb.branch, 'main');
|
|
129
133
|
assert.equal(sb.autoDeploy, 'yes', 'the sandbox follows every merge');
|
|
130
134
|
assert.equal(sb.repo, 'https://github.com/example-owner/demo');
|
|
131
|
-
|
|
135
|
+
const pageKey = sb.envVars.find((v) => v.key === 'BONGOS_SANDBOX_PAGE_KEY');
|
|
136
|
+
assert.match(pageKey && pageKey.value, /^[0-9a-f]{48}$/, 'its own page key (task 1005291)');
|
|
137
|
+
assert.equal(h.inst.app_host.sandbox.pageKey, pageKey.value, 'kept on the row for the owner’s link');
|
|
138
|
+
assert.deepEqual(sb.envVars.filter((v) => v.key !== 'BONGOS_SANDBOX_PAGE_KEY'), [
|
|
132
139
|
{ key: 'BONGOS_SANDBOX', value: '1' },
|
|
133
140
|
{ key: 'DATABASE_URL', value: 'postgresql://u:p@dpg-new-a/demo_sandbox' },
|
|
134
|
-
], 'its own database on the same server — never the live one');
|
|
141
|
+
], 'its own database on the same server — never the live one, and no other key');
|
|
135
142
|
// the second database is made with SQL on the app's server, existence checked first
|
|
136
143
|
assert.equal(h.sqlRan.length, 2);
|
|
137
144
|
assert.match(h.sqlRan[0].statements[0], /FROM pg_database WHERE datname = \$1/);
|
|
@@ -147,17 +154,17 @@ await ta('a new app with a database: the app, then its sandbox — free, follows
|
|
|
147
154
|
await ta('an app with no database: the sandbox has none either, and says so', async () => {
|
|
148
155
|
const h = harness({ host: { database: false } });
|
|
149
156
|
assert.equal((await h.run()).ok, true);
|
|
150
|
-
assert.deepEqual(created(h, 'demo-sandbox').body.envVars, [{ key: 'BONGOS_SANDBOX', value: '1' }]);
|
|
157
|
+
assert.deepEqual(created(h, 'demo-sandbox').body.envVars.filter((v) => v.key !== 'BONGOS_SANDBOX_PAGE_KEY'), [{ key: 'BONGOS_SANDBOX', value: '1' }]);
|
|
151
158
|
assert.equal(h.sqlRan.length, 0);
|
|
152
159
|
assert.equal(h.inst.app_host.sandbox.database, 'none');
|
|
153
160
|
assert.match(h.inst.app_host.sandbox.dbNote, /no database, so its sandbox has none/);
|
|
154
161
|
});
|
|
155
162
|
|
|
156
|
-
await ta('a linked (adopted) app: the sandbox copies its build, and the live service
|
|
163
|
+
await ta('a linked (adopted) app: the sandbox copies its build, and the live service only stops auto-deploying', async () => {
|
|
157
164
|
const h = harness({ host: { mode: 'adopt', serviceId: 'srv-live0001', runtime: undefined, buildCommand: undefined, startCommand: undefined, database: undefined } });
|
|
158
165
|
const out = await h.run();
|
|
159
166
|
assert.equal(out.ok, true, out.error);
|
|
160
|
-
assert.deepEqual(writesTo(h.r.calls), ['POST /services'], 'the
|
|
167
|
+
assert.deepEqual(writesTo(h.r.calls), ['POST /services', 'PATCH /services/srv-live0001'], 'the sandbox, and live switched to manual deploys');
|
|
161
168
|
const sb = created(h, 'demo-sandbox').body;
|
|
162
169
|
assert.equal(sb.serviceDetails.runtime, 'python');
|
|
163
170
|
assert.equal(sb.serviceDetails.region, 'oregon');
|
|
@@ -169,7 +176,7 @@ await ta('the database stage fails: the sandbox still runs, with no database, an
|
|
|
169
176
|
const h = harness({ sql: async () => { throw new Error('permission denied to create database'); } });
|
|
170
177
|
const out = await h.run();
|
|
171
178
|
assert.equal(out.ok, true);
|
|
172
|
-
assert.deepEqual(created(h, 'demo-sandbox').body.envVars, [{ key: 'BONGOS_SANDBOX', value: '1' }], 'no DATABASE_URL at all — never the live one');
|
|
179
|
+
assert.deepEqual(created(h, 'demo-sandbox').body.envVars.filter((v) => v.key !== 'BONGOS_SANDBOX_PAGE_KEY'), [{ key: 'BONGOS_SANDBOX', value: '1' }], 'no DATABASE_URL at all — never the live one');
|
|
173
180
|
const s = h.inst.app_host.sandbox;
|
|
174
181
|
assert.equal(s.state, 'deploying');
|
|
175
182
|
assert.equal(s.database, 'failed');
|
|
@@ -186,6 +193,7 @@ await ta('the sandbox failing never fails the app, and the key is still cleared'
|
|
|
186
193
|
assert.equal(h.inst.app_host.state, 'deploying');
|
|
187
194
|
assert.equal(h.inst.app_host.sandbox.state, 'failed');
|
|
188
195
|
assert.match(h.inst.app_host.sandbox.error, /free instance limit reached/);
|
|
196
|
+
assert.ok(!h.r.calls.some((c) => c.method === 'PATCH'), 'with no sandbox, live keeps deploying every merge — work always has somewhere to land');
|
|
189
197
|
assert.deepEqual(h.intentWrites, [44], 'the key is cleared');
|
|
190
198
|
});
|
|
191
199
|
|
|
@@ -210,7 +218,7 @@ await ta('an app already live adds its sandbox: the live app is not created, rea
|
|
|
210
218
|
const h = harness({ host: { postgresId: 'dpg-new', serviceId: 'srv-live0001', state: 'live', sandbox: { state: 'requested' } } });
|
|
211
219
|
const out = await h.run();
|
|
212
220
|
assert.equal(out.ok, true, out.error);
|
|
213
|
-
assert.deepEqual(writesTo(h.r.calls), ['POST /services'
|
|
221
|
+
assert.deepEqual(writesTo(h.r.calls), ['POST /services', 'PATCH /services/srv-live0001'], 'the sandbox, then live switched to manual deploys — never redeployed');
|
|
214
222
|
assert.ok(created(h, 'demo-sandbox'));
|
|
215
223
|
assert.equal(h.inst.app_host.state, 'live', 'the app stays live — not knocked back to building');
|
|
216
224
|
assert.equal(h.inst.app_host.sandbox.state, 'deploying');
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// tests/sandbox_gate.mjs — the sandbox's outbound gate and band (task 1005291; ADR 0406 §5).
|
|
2
|
+
//
|
|
3
|
+
// src/sandbox/ driven with fake fetch and fake http/https modules: no network, no provider,
|
|
4
|
+
// no spend. The task's DONE WHEN, as written: with real keys lent, a read succeeds, and a
|
|
5
|
+
// send, a charge and a write are each refused and logged. Plus: nothing changes outside a
|
|
6
|
+
// sandbox; a sandbox page is visibly marked; the owner can see what was refused and which
|
|
7
|
+
// real keys are lent (by name, never value); and the page says plainly what it cannot catch.
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/sandbox_gate.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { EventEmitter } from 'node:events';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const { decide } = require('../src/sandbox/gate.js');
|
|
17
|
+
const { install, isSandbox, pageHtml, PAGE } = require('../src/sandbox/index.js');
|
|
18
|
+
|
|
19
|
+
let passed = 0, failed = 0;
|
|
20
|
+
async function ta(name, fn) {
|
|
21
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
22
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LIVE = 'sk_live_51Habcdefghijklmnopqrstuv';
|
|
26
|
+
const auth = (k) => ({ authorization: `Bearer ${k}` });
|
|
27
|
+
|
|
28
|
+
console.log('the policy (decide):');
|
|
29
|
+
await ta('a read to a known provider passes; a charge, a send and a write are refused, named by what they would do', () => {
|
|
30
|
+
assert.equal(decide({ method: 'GET', host: 'api.stripe.com', path: '/v1/customers', headers: auth(LIVE) }).allow, true);
|
|
31
|
+
const charge = decide({ method: 'POST', host: 'api.stripe.com', path: '/v1/payment_intents', headers: auth(LIVE) });
|
|
32
|
+
assert.deepEqual([charge.allow, charge.provider, charge.kind], [false, 'Stripe', 'charge']);
|
|
33
|
+
assert.match(charge.reason, /take or move a payment for real/);
|
|
34
|
+
const send = decide({ method: 'POST', host: 'api.sendgrid.com', path: '/v3/mail/send' });
|
|
35
|
+
assert.deepEqual([send.allow, send.kind], [false, 'send']);
|
|
36
|
+
const write = decide({ method: 'PATCH', host: 'api.github.com', path: '/repos/o/r/issues/1' });
|
|
37
|
+
assert.deepEqual([write.allow, write.kind], [false, 'write']);
|
|
38
|
+
});
|
|
39
|
+
await ta('a test-mode Stripe key is let through: it cannot move real money', () => {
|
|
40
|
+
assert.equal(decide({ method: 'POST', host: 'api.stripe.com', path: '/v1/charges', headers: auth('sk_test_abc') }).allow, true);
|
|
41
|
+
assert.equal(decide({ method: 'POST', host: 'api.stripe.com', path: '/v1/charges', headers: auth('rk_live_abc') }).allow, false, 'a restricted LIVE key is still refused writes');
|
|
42
|
+
});
|
|
43
|
+
await ta('a read some providers make by POST passes; their other writes do not', () => {
|
|
44
|
+
assert.equal(decide({ method: 'POST', host: 'api.notion.com', path: '/v1/databases/abc/query' }).allow, true);
|
|
45
|
+
assert.equal(decide({ method: 'POST', host: 'api.notion.com', path: '/v1/pages' }).allow, false);
|
|
46
|
+
});
|
|
47
|
+
await ta('a host the gate does not know passes — the gate covers what it knows, and the page says so', () => {
|
|
48
|
+
assert.equal(decide({ method: 'POST', host: 'example.org', path: '/x' }).allow, true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
console.log('\noutside a sandbox:');
|
|
52
|
+
await ta('install() changes nothing unless BONGOS_SANDBOX=1', async () => {
|
|
53
|
+
const realFetch = async () => 'real';
|
|
54
|
+
const host = { fetch: realFetch };
|
|
55
|
+
const http = { request: () => 'r', get: () => 'g' };
|
|
56
|
+
assert.equal(isSandbox({}), false);
|
|
57
|
+
assert.equal(isSandbox({ BONGOS_SANDBOX: '1' }), true);
|
|
58
|
+
const out = install({ env: {}, fetchHost: host, http, https: http });
|
|
59
|
+
assert.equal(out.active, false);
|
|
60
|
+
assert.equal(host.fetch, realFetch, 'fetch untouched');
|
|
61
|
+
assert.equal(http.request(), 'r', 'http untouched');
|
|
62
|
+
let nexted = false;
|
|
63
|
+
out.middleware({}, {}, () => { nexted = true; });
|
|
64
|
+
assert.equal(nexted, true, 'the middleware passes straight through');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
console.log('\nin a sandbox, with a real key lent:');
|
|
68
|
+
const sent = [], lines = [];
|
|
69
|
+
const fetchHost = { fetch: async (input, init = {}) => { sent.push({ via: 'fetch', url: String(input), method: init.method || 'GET' }); return { status: 200, ok: true }; } };
|
|
70
|
+
function fakeHttp(name) {
|
|
71
|
+
return {
|
|
72
|
+
request(...args) { sent.push({ via: name, args }); const r = new EventEmitter(); r.end = () => r; return r; },
|
|
73
|
+
get(...args) { sent.push({ via: `${name}.get`, args }); const r = new EventEmitter(); r.end = () => r; return r; },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const http = fakeHttp('http'), https = fakeHttp('https');
|
|
77
|
+
const env = { BONGOS_SANDBOX: '1', STRIPE_SECRET_KEY: LIVE, STRIPE_READ_KEY: 'rk_live_51Hrestrictedabcdefg', SENDGRID_API_KEY: 'SG.abcdefghijklmnop.qrstuvwxyz', PORT: '3000' };
|
|
78
|
+
const sb = install({ env, fetchHost, http, https, write: (l) => lines.push(l) });
|
|
79
|
+
|
|
80
|
+
await ta('a read succeeds', async () => {
|
|
81
|
+
sent.length = 0;
|
|
82
|
+
const r = await fetchHost.fetch('https://api.stripe.com/v1/customers?limit=3', { headers: auth(LIVE) });
|
|
83
|
+
assert.equal(r.status, 200);
|
|
84
|
+
assert.equal(sent.length, 1, 'the read went out');
|
|
85
|
+
});
|
|
86
|
+
await ta('a charge is refused (403, never sent) and logged', async () => {
|
|
87
|
+
sent.length = 0; lines.length = 0;
|
|
88
|
+
const r = await fetchHost.fetch('https://api.stripe.com/v1/payment_intents', { method: 'POST', headers: auth(LIVE), body: 'amount=500' });
|
|
89
|
+
assert.equal(r.status, 403);
|
|
90
|
+
const body = await r.json();
|
|
91
|
+
assert.equal(body.error.type, 'bongos_sandbox_refused');
|
|
92
|
+
assert.equal(sent.length, 0, 'nothing left the sandbox');
|
|
93
|
+
assert.match(lines[0], /^\[bongos-sandbox\] refused charge to Stripe: POST api\.stripe\.com\/v1\/payment_intents$/);
|
|
94
|
+
});
|
|
95
|
+
await ta('a send through an SDK’s https.request is refused with an error, never connects, and is logged', async () => {
|
|
96
|
+
sent.length = 0; lines.length = 0;
|
|
97
|
+
const req = https.request({ hostname: 'api.sendgrid.com', method: 'POST', path: '/v3/mail/send', headers: { authorization: 'Bearer SG.x' } });
|
|
98
|
+
const err = await new Promise((resolve) => { req.on('error', resolve); req.end('{}'); });
|
|
99
|
+
assert.equal(err.code, 'BONGOS_SANDBOX_REFUSED');
|
|
100
|
+
assert.equal(err.kind, 'send');
|
|
101
|
+
assert.equal(sent.length, 0, 'the real request was never made');
|
|
102
|
+
assert.match(lines[0], /refused send to SendGrid/);
|
|
103
|
+
});
|
|
104
|
+
await ta('a write is refused and logged; https.get (a read) still goes out', async () => {
|
|
105
|
+
sent.length = 0; lines.length = 0;
|
|
106
|
+
const r = await fetchHost.fetch(new URL('https://api.github.com/repos/o/r/issues'), { method: 'POST' });
|
|
107
|
+
assert.equal(r.status, 403);
|
|
108
|
+
assert.match(lines[0], /refused write to GitHub/);
|
|
109
|
+
https.get('https://api.github.com/repos/o/r');
|
|
110
|
+
assert.deepEqual(sent.map((s) => s.via), ['https.get']);
|
|
111
|
+
});
|
|
112
|
+
await ta('every refusal is kept, newest first, for the owner to see', () => {
|
|
113
|
+
assert.deepEqual(sb.refusals.slice(0, 3).map((r) => r.kind), ['write', 'send', 'charge']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
console.log('\nthe band and the page:');
|
|
117
|
+
function fakeRes() {
|
|
118
|
+
const headers = { 'content-type': 'text/html; charset=utf-8' };
|
|
119
|
+
const res = { statusCode: 200, out: '', getHeader: (k) => headers[k.toLowerCase()], setHeader: (k, v) => { headers[k.toLowerCase()] = v; }, removeHeader: (k) => { delete headers[k.toLowerCase()]; } };
|
|
120
|
+
res.write = (c) => { res.out += c; return true; };
|
|
121
|
+
res.end = (c) => { if (c) res.out += c; res.ended = true; };
|
|
122
|
+
return { res, headers };
|
|
123
|
+
}
|
|
124
|
+
await ta('every HTML page carries the "Sandbox" band, with the count refused and a link', () => {
|
|
125
|
+
const { res } = fakeRes();
|
|
126
|
+
sb.middleware({ method: 'GET', url: '/' }, res, () => {});
|
|
127
|
+
res.end('<html><body><h1>Shop</h1></body></html>');
|
|
128
|
+
assert.match(res.out, /<body><div id="bongos-sandbox-band"/);
|
|
129
|
+
assert.match(res.out, /Sandbox — a test copy of this site\. Nothing here reaches real customers\./);
|
|
130
|
+
assert.doesNotMatch(res.out, /refused/, 'no count for passers-by');
|
|
131
|
+
assert.match(res.out, /href="\/__bongos\/sandbox"/);
|
|
132
|
+
});
|
|
133
|
+
await ta('a non-HTML answer is left alone', () => {
|
|
134
|
+
const { res, headers } = fakeRes();
|
|
135
|
+
headers['content-type'] = 'application/json';
|
|
136
|
+
sb.middleware({ method: 'GET', url: '/api' }, res, () => {});
|
|
137
|
+
res.end('{"a":1}');
|
|
138
|
+
assert.equal(res.out, '{"a":1}');
|
|
139
|
+
});
|
|
140
|
+
await ta('the sandbox page, for the owner: what was refused, which real keys are lent (names only), and what it cannot catch', () => {
|
|
141
|
+
const html = pageHtml({ items: sb.refusals }, env, { owner: true });
|
|
142
|
+
assert.match(html, /would take or move a payment/);
|
|
143
|
+
assert.match(html, /<code>STRIPE_SECRET_KEY<\/code>: Stripe — a full live secret key\. Prefer a restricted key with read access only\./);
|
|
144
|
+
assert.match(html, /<code>STRIPE_READ_KEY<\/code>: Stripe — a restricted live key/);
|
|
145
|
+
assert.ok(!html.includes(LIVE) && !html.includes('SG.abcdefghijklmnop'), 'never a key value');
|
|
146
|
+
assert.match(html, /Code that goes around them, or a provider it does not know, is not stopped\. A read-only key is the real protection/);
|
|
147
|
+
});
|
|
148
|
+
await ta('without the owner’s page key a visitor sees what a sandbox is — never the refusals or the lent keys', () => {
|
|
149
|
+
const KEY = 'a'.repeat(48);
|
|
150
|
+
env.BONGOS_SANDBOX_PAGE_KEY = KEY; // the env install() was given — the platform sets it on the sandbox
|
|
151
|
+
try {
|
|
152
|
+
for (const url of ['/__bongos/sandbox', '/__bongos/sandbox?key=wrong', `/__bongos/sandbox?key=${'b'.repeat(48)}`]) {
|
|
153
|
+
const { res } = fakeRes();
|
|
154
|
+
let nexted = false;
|
|
155
|
+
sb.middleware({ method: 'GET', url }, res, () => { nexted = true; });
|
|
156
|
+
assert.equal(nexted, false);
|
|
157
|
+
assert.match(res.out, /<h1>This is a sandbox<\/h1>/);
|
|
158
|
+
assert.match(res.out, /is not stopped/, 'the limits are public');
|
|
159
|
+
assert.doesNotMatch(res.out, /Refused calls|STRIPE_SECRET_KEY|payment_intents/, `${url} shows nothing private`);
|
|
160
|
+
}
|
|
161
|
+
const { res } = fakeRes();
|
|
162
|
+
sb.middleware({ method: 'GET', url: `/__bongos/sandbox?key=${KEY}` }, res, () => {});
|
|
163
|
+
assert.match(res.out, /Refused calls/);
|
|
164
|
+
assert.match(res.out, /STRIPE_SECRET_KEY/);
|
|
165
|
+
} finally { delete env.BONGOS_SANDBOX_PAGE_KEY; }
|
|
166
|
+
});
|
|
167
|
+
await ta('the owner page path is spelled the same in core and in the provisioning module', () => {
|
|
168
|
+
assert.equal(PAGE, '/__bongos/sandbox');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
console.log('\nthe owner’s project page:');
|
|
172
|
+
await ta('the owner’s sandbox status links to the sandbox’s own page with its page key', () => {
|
|
173
|
+
const rs = require('../modules/provisioning/render-sandbox.js');
|
|
174
|
+
const KEY = 'c'.repeat(48);
|
|
175
|
+
const host = (sb) => ({ id: 1, status: 'active', app_host: { platform: 'render', state: 'live', serviceId: 'srv-live0001', sandboxWanted: true,
|
|
176
|
+
sandbox: { state: 'live', serviceId: 'srv-sbx00001', serviceUrl: 'https://demo-sandbox.onrender.com', ...sb } } });
|
|
177
|
+
assert.equal(rs.sandboxStatus(host({ pageKey: KEY })).safety_page, `https://demo-sandbox.onrender.com${PAGE}?key=${KEY}`);
|
|
178
|
+
assert.equal(rs.sandboxStatus(host({})).safety_page, `https://demo-sandbox.onrender.com${PAGE}`, 'an older sandbox has no key: the public page');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
console.log(`\nsandbox_gate: ${passed} passed, ${failed} failed`);
|
|
182
|
+
process.exit(failed ? 1 : 0);
|