@bongos/core 1.25.21 → 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 +84 -44
- 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 +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
- package/docs/api/openapi.json +198 -3
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +412 -407
- package/docs/copy-registry.json +548 -503
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +349 -343
- package/modules/lifecycle/kickoff-checklist.js +1 -1
- package/modules/lifecycle/routes/pay-type-gates.js +7 -2
- package/modules/onboarding/project-facts.js +318 -0
- package/modules/onboarding/routes/genesis-work.js +16 -0
- package/modules/provisioning/project-facts.js +55 -0
- package/modules/provisioning/provisioning.js +4 -1
- package/modules/provisioning/render-sandbox.js +6 -0
- package/modules/public-landing/public/projects.html +191 -31
- package/package-lock.json +2 -2
- package/package.json +2 -1
- package/release-notes.json +14 -0
- package/scripts/bongos/genesis.js +38 -2
- package/scripts/bongos/init.js +13 -0
- package/scripts/bongos/package-core.js +2 -0
- package/scripts/bongos/provision-render-sandbox.js +5 -1
- package/scripts/bongos/render-payload.js +5 -2
- 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/folder_upload_project.mjs +396 -0
- package/tests/genesis_work_routes.mjs +25 -1
- 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_settings_env.mjs +2 -2
- package/tests/public_landing_projects.mjs +27 -5
- package/tests/render_sandbox.mjs +7 -4
- package/tests/sandbox_gate.mjs +182 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// tests/project_facts.mjs — the project's own facts, carried from the hub, and each reward
|
|
2
|
+
// option's real implications at the point of choice (task 1005129).
|
|
3
|
+
//
|
|
4
|
+
// Everything RUNS: the hub's real companion line (provisioning.js SETTINGS_COMPANION_ENV →
|
|
5
|
+
// project-facts.js) decoded by the project's real reader, the real soft-limit table carried
|
|
6
|
+
// as data, and the project's facts + options over an in-memory project_settings store and
|
|
7
|
+
// a fake `lifecycle.payTypeGates` port built from the REAL gate table.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --test tests/project_facts.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
17
|
+
const hub = require('../modules/provisioning/project-facts.js');
|
|
18
|
+
const pf = require('../modules/onboarding/project-facts.js');
|
|
19
|
+
const gatesLib = require('../modules/lifecycle/pay-type-gates.js');
|
|
20
|
+
const { PAY_TYPE_LABELS } = require('../modules/lifecycle/pay-terms.js');
|
|
21
|
+
|
|
22
|
+
// The real gate verdicts with nothing on file (or with the given pay types cleared).
|
|
23
|
+
function payGates(cleared = []) {
|
|
24
|
+
return {
|
|
25
|
+
labels: PAY_TYPE_LABELS,
|
|
26
|
+
readGates: async () => {
|
|
27
|
+
const types = [...Object.keys(gatesLib.REQUIREMENTS), 'eu_labour'];
|
|
28
|
+
return types.map((p) => gatesLib.withoutEvidence(gatesLib.gateVerdict(p, cleared.includes(p)
|
|
29
|
+
? gatesLib.requirementIds(p).map((requirement) => ({ pay_type: p, requirement, state: 'cleared' })) : [])));
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function project({ detail, gates = payGates() } = {}) {
|
|
35
|
+
const store = new Map();
|
|
36
|
+
const line = detail ? provisioning.SETTINGS_COMPANION_ENV.PROJECT_FACTS({ detail }) : '';
|
|
37
|
+
return {
|
|
38
|
+
store,
|
|
39
|
+
deps: () => ({
|
|
40
|
+
getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
|
|
41
|
+
setSetting: async (k, v) => { store.set(k, String(v)); },
|
|
42
|
+
factsMarker: pf.factsMarker({ projectFacts: line }),
|
|
43
|
+
payGates: gates,
|
|
44
|
+
now: () => new Date('2026-11-13T12:00:00Z'),
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('the hub carries only the facts a stage reads, and the reward rules as data', () => {
|
|
50
|
+
const line = provisioning.SETTINGS_COMPANION_ENV.PROJECT_FACTS({ detail: {
|
|
51
|
+
home_country: 'DE', builder_geography: 'global', entity: 'nonprofit', minors_on_board: false,
|
|
52
|
+
reward_intents: ['cash', 'equity'], framing: 'managed', project_state: 'new',
|
|
53
|
+
} });
|
|
54
|
+
const m = JSON.parse(Buffer.from(line, 'base64url').toString('utf8'));
|
|
55
|
+
assert.deepEqual(Object.keys(m.facts).sort(), ['builder_geography', 'entity', 'home_country', 'minors_on_board', 'reward_intents']);
|
|
56
|
+
assert.ok(!('framing' in m.facts), 'nothing a stage does not read crosses');
|
|
57
|
+
assert.ok(m.rules.length > 0 && m.rules.every((r) => r.options.some((o) => o.startsWith('reward_intents'))), 'only reward rules ride along');
|
|
58
|
+
assert.deepEqual(m.rules.map((r) => r.id), hub.REWARD_RULES.map((r) => r.id), 'read from the soft-limit table, never rewritten');
|
|
59
|
+
assert.equal(provisioning.SETTINGS_COMPANION_ENV.PROJECT_FACTS({ detail: {} }), '', 'nothing answered: an empty line, not a guess');
|
|
60
|
+
assert.equal(provisioning.SETTINGS_COMPANION_ENV.PROJECT_FACTS({}), '');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the project seeds its own copy once, as carried over; the founder\'s edit wins after', async () => {
|
|
64
|
+
const p = project({ detail: { home_country: 'US', home_region: 'US-CA', builder_geography: 'us-only' } });
|
|
65
|
+
let r = await pf.readFacts(p.deps());
|
|
66
|
+
const geo = r.facts.find((f) => f.key === 'builder_geography');
|
|
67
|
+
assert.equal(geo.value, 'us-only');
|
|
68
|
+
assert.equal(geo.origin, 'carried_over');
|
|
69
|
+
assert.deepEqual(r.unanswered, ['minors_on_board', 'entity', 'reward_intents'], 'never answered reads as not answered');
|
|
70
|
+
const w = await pf.setFacts({ builder_geography: 'global' }, 1, p.deps());
|
|
71
|
+
assert.equal(w.ok, true);
|
|
72
|
+
r = await pf.readFacts(p.deps());
|
|
73
|
+
assert.equal(r.facts.find((f) => f.key === 'builder_geography').value, 'global', 'the copy is editable, not a frozen snapshot');
|
|
74
|
+
assert.equal(r.facts.find((f) => f.key === 'builder_geography').origin, 'founder');
|
|
75
|
+
assert.equal(r.facts.find((f) => f.key === 'home_country').origin, 'carried_over');
|
|
76
|
+
// Un-answering goes back to "not answered".
|
|
77
|
+
await pf.setFacts({ home_region: null }, 1, p.deps());
|
|
78
|
+
assert.ok((await pf.readFacts(p.deps())).unanswered.includes('home_region'));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a project not made through the hub has every fact "not answered" and is not refused', async () => {
|
|
82
|
+
const p = project({});
|
|
83
|
+
const r = await pf.readOptions(p.deps());
|
|
84
|
+
assert.equal(r.unanswered.length, 6);
|
|
85
|
+
assert.equal(r.facts.every((f) => f.value === null), true);
|
|
86
|
+
assert.ok(r.options.length > 0, 'the options are still stated, from the enforced table');
|
|
87
|
+
assert.ok(r.options.every((o) => o.facts.length === 0), 'no fact note fires on an answer nobody gave');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('every option states what the server enforces and the facts beside it, kept apart', async () => {
|
|
91
|
+
const p = project({ detail: { home_country: 'US', builder_geography: 'us-eu', entity: 'nonprofit', minors_on_board: true } });
|
|
92
|
+
const r = await pf.readOptions(p.deps());
|
|
93
|
+
const cash = r.options.find((o) => o.pay_type === 'cash');
|
|
94
|
+
assert.equal(cash.status, 'needs_setup');
|
|
95
|
+
assert.equal(cash.enforced.cleared, false);
|
|
96
|
+
assert.deepEqual(cash.enforced.missing, ['Processor onboarding done', 'Tax details collected', 'Age check passed', 'Sanctions screening done', 'Signed pay rule on file'],
|
|
97
|
+
'the five things cash needs, from the gate table');
|
|
98
|
+
assert.ok(cash.facts.some((n) => /EU aren't available yet/.test(n.reason)), 'builders in the EU: a fact about cash');
|
|
99
|
+
assert.ok(cash.facts.some((n) => /13 to 15/.test(n.reason)), 'minors on board: a fact about cash');
|
|
100
|
+
assert.ok(cash.facts.every((n) => n.enforced === false), 'a soft limit is never shown as enforced');
|
|
101
|
+
const equity = r.options.find((o) => o.pay_type === 'equity');
|
|
102
|
+
assert.ok(equity.facts.some((n) => n.effect === 'grey' && /nonprofit/i.test(n.reason)));
|
|
103
|
+
assert.equal(equity.advised_against, true);
|
|
104
|
+
// A blocked option is SHOWN, with its reason.
|
|
105
|
+
const eu = r.options.find((o) => o.pay_type === 'eu_labour');
|
|
106
|
+
assert.ok(eu, 'EU labour is listed, not hidden');
|
|
107
|
+
assert.equal(eu.status, 'closed');
|
|
108
|
+
assert.equal(eu.enforced.never, true);
|
|
109
|
+
assert.match(eu.enforced.reason, /refuses/);
|
|
110
|
+
assert.equal(eu.label, 'Paying builders in the EU');
|
|
111
|
+
// A cleared gate reads as allowed.
|
|
112
|
+
const q = project({ gates: payGates(['self_managed']) });
|
|
113
|
+
assert.equal((await pf.readOptions(q.deps())).options.find((o) => o.pay_type === 'self_managed').status, 'open');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('changing a fact re-checks every option and says what it changed', async () => {
|
|
117
|
+
const p = project({ detail: { home_country: 'US', builder_geography: 'us-only' } });
|
|
118
|
+
let r = await pf.readOptions(p.deps());
|
|
119
|
+
assert.equal(r.options.find((o) => o.pay_type === 'cash').facts.length, 0);
|
|
120
|
+
const w = await pf.setFacts({ builder_geography: 'global' }, 1, p.deps());
|
|
121
|
+
const c = w.changed.find((x) => x.pay_type === 'cash');
|
|
122
|
+
assert.ok(c, 'cash is named as changed');
|
|
123
|
+
assert.ok(c.new_notes.some((n) => /EU/.test(n)));
|
|
124
|
+
r = await pf.readOptions(p.deps());
|
|
125
|
+
assert.ok(r.options.find((o) => o.pay_type === 'cash').facts.length > 0, 'the read reflects the new fact at once');
|
|
126
|
+
const back = await pf.setFacts({ builder_geography: 'us-only' }, 1, p.deps());
|
|
127
|
+
assert.ok(back.changed.find((x) => x.pay_type === 'cash').gone_notes.length > 0, 'and says when a note no longer applies');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a bad fact is refused with its reason; nothing is written', async () => {
|
|
131
|
+
const p = project({ detail: { home_country: 'US' } });
|
|
132
|
+
await pf.readFacts(p.deps());
|
|
133
|
+
const before = p.store.get(pf.FACTS_KEY);
|
|
134
|
+
assert.equal((await pf.setFacts({ nope: 1 }, 1, p.deps())).reason, 'unknown_fact');
|
|
135
|
+
assert.equal((await pf.setFacts({ builder_geography: 'mars' }, 1, p.deps())).reason, 'bad_fact');
|
|
136
|
+
assert.equal((await pf.setFacts({ home_region: 'CA-ON' }, 1, p.deps())).reason, 'region_not_in_country');
|
|
137
|
+
assert.equal((await pf.setFacts({ reward_intents: ['gold'] }, 1, p.deps())).reason, 'bad_fact');
|
|
138
|
+
assert.equal(p.store.get(pf.FACTS_KEY), before);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('with no pay-rules module nothing is claimed as enforced', async () => {
|
|
142
|
+
const p = project({ detail: { entity: 'nonprofit' }, gates: null });
|
|
143
|
+
const r = await pf.readOptions(p.deps());
|
|
144
|
+
assert.equal(r.rules_module, false);
|
|
145
|
+
assert.ok(r.options.every((o) => o.enforced === null && o.status === 'no_rules_module'));
|
|
146
|
+
assert.ok(r.options.find((o) => o.pay_type === 'equity').facts.length > 0, 'the facts still speak');
|
|
147
|
+
assert.match(pf.optionsMarkdown(r), /nothing below is enforced by the server/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the session read explains the rules and tells it never to add one', async () => {
|
|
151
|
+
const p = project({ detail: { home_country: 'US', builder_geography: 'global' } });
|
|
152
|
+
const md = pf.optionsMarkdown(await pf.readOptions(p.deps()));
|
|
153
|
+
assert.match(md, /never add a rule of your own/);
|
|
154
|
+
assert.match(md, /Closed \(enforced\)/);
|
|
155
|
+
assert.match(md, /Fact, not a rule: /);
|
|
156
|
+
assert.match(md, /Where its builders are: global \(from project creation, not yet confirmed\)/);
|
|
157
|
+
assert.match(md, /not answered/);
|
|
158
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -2240,7 +2240,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
|
|
|
2240
2240
|
assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
|
|
2241
2241
|
assert.match(SCRIPT, /hosting_shape: 'cloud-host',/, 'the shape is unchanged');
|
|
2242
2242
|
assert.match(SCRIPT, /target_ref: state\.owner \+ '\/' \+ state\.repo,/, 'the repo ref is unchanged');
|
|
2243
|
-
assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \? 'adopt' : 'greenfield'/, 'the onboard mode
|
|
2243
|
+
assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \|\| state\.folderUploaded \? 'adopt' : 'greenfield'/, 'the onboard mode: adopt, or a repo made from an uploaded folder (task 1005282)');
|
|
2244
2244
|
// the kit walks every step and files against the stub
|
|
2245
2245
|
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
|
|
2246
2246
|
for (const s of ['named', 'ready', 'demo', 'checks-filled', 'row-code', 'row-extras', 'look', 'create-blocked', 'done']) assert.ok(states['in-new-' + s], `the ${s} state has a kit state`);
|
|
@@ -82,6 +82,7 @@ ${NEUTRAL_PREFIX}_DEMO_HOURS=
|
|
|
82
82
|
${NEUTRAL_PREFIX}_SHIP_MODE=
|
|
83
83
|
${NEUTRAL_PREFIX}_TEST_PROJECT=
|
|
84
84
|
${NEUTRAL_PREFIX}_GUIDE_HANDOFF=
|
|
85
|
+
${NEUTRAL_PREFIX}_PROJECT_FACTS=
|
|
85
86
|
${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
|
|
86
87
|
`;
|
|
87
88
|
|
|
@@ -153,7 +154,7 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
|
|
|
153
154
|
// the birth marker and a demo's box (task 1004506), empty on an un-stamped row
|
|
154
155
|
[`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_FOUNDING_GENESIS`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
|
|
155
156
|
// the wizard's ship mode (task 1004629), empty when none was picked
|
|
156
|
-
[`${NEUTRAL_PREFIX}_SHIP_MODE`]: '', [`${NEUTRAL_PREFIX}_TEST_PROJECT`]: '', [`${NEUTRAL_PREFIX}_GUIDE_HANDOFF`]: '',
|
|
157
|
+
[`${NEUTRAL_PREFIX}_SHIP_MODE`]: '', [`${NEUTRAL_PREFIX}_TEST_PROJECT`]: '', [`${NEUTRAL_PREFIX}_GUIDE_HANDOFF`]: '', [`${NEUTRAL_PREFIX}_PROJECT_FACTS`]: '',
|
|
157
158
|
// where the hall's Update button goes (task 1004608), empty when no hub origin resolves
|
|
158
159
|
[`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
|
|
159
160
|
});
|
|
@@ -128,7 +128,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
|
|
|
128
128
|
// route never stamped, which is every row made before it shipped (spec D8).
|
|
129
129
|
CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_FOUNDING_GENESIS: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
|
|
130
130
|
// The wizard's ship mode (task 1004629): empty when none was picked.
|
|
131
|
-
CLOUDBONGOS_SHIP_MODE: '', CLOUDBONGOS_TEST_PROJECT: '', CLOUDBONGOS_GUIDE_HANDOFF: '',
|
|
131
|
+
CLOUDBONGOS_SHIP_MODE: '', CLOUDBONGOS_TEST_PROJECT: '', CLOUDBONGOS_GUIDE_HANDOFF: '', CLOUDBONGOS_PROJECT_FACTS: '',
|
|
132
132
|
});
|
|
133
133
|
// varsOf splits on '=', so an empty value reads as '' on both sides — the point
|
|
134
134
|
// of the comparison is that the standup lines carry exactly the same key set.
|
|
@@ -137,7 +137,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
|
|
|
137
137
|
X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
|
|
138
138
|
X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
|
|
139
139
|
X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
|
|
140
|
-
X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '', X_TEST_PROJECT: '', X_GUIDE_HANDOFF: '',
|
|
140
|
+
X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '', X_TEST_PROJECT: '', X_GUIDE_HANDOFF: '', X_PROJECT_FACTS: '',
|
|
141
141
|
}, 'defaults, explicitly');
|
|
142
142
|
});
|
|
143
143
|
|
|
@@ -252,12 +252,34 @@ test('the repo is named as text from the server read, never a guessed link (task
|
|
|
252
252
|
});
|
|
253
253
|
|
|
254
254
|
test('the create-repo response keeps the html_url it already returned', async () => {
|
|
255
|
+
// EXECUTED, not read (task 1005282 moved the response handling into repoMade(), shared
|
|
256
|
+
// with the folder upload): the served page's repo create runs in a vm against a stub
|
|
257
|
+
// fetch, and the URL GitHub returned must land on the draft and be saved.
|
|
255
258
|
const { body } = await request('/projects', 'game.test.local');
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
assert.
|
|
260
|
-
|
|
259
|
+
const src = body.replace(/\r\n/g, '\n');
|
|
260
|
+
const s = src.indexOf(' function ensureFreshRepo() {');
|
|
261
|
+
const e = s < 0 ? -1 : src.indexOf(" $('doCreate').addEventListener('click'", s);
|
|
262
|
+
assert.ok(s > 0 && e > s, 'ensureFreshRepo() and its response handler must be extractable');
|
|
263
|
+
const vm = (await import('node:vm')).default;
|
|
264
|
+
let saves = 0;
|
|
265
|
+
const sandbox = {
|
|
266
|
+
API: '/api/bongos', JSON, String, Promise, Error,
|
|
267
|
+
state: { mode: 'greenfield', repo: 'mercury', owner: 'founder' },
|
|
268
|
+
repoAccess: true, hopForRepoAccess: () => false, syncHome() {}, paintFolder() {},
|
|
269
|
+
saveDraft() { saves++; sandbox.savedUrl = sandbox.state.repoUrl; },
|
|
270
|
+
fetch: async () => ({ ok: true, status: 201, json: async () => ({ repo: { full_name: 'founder/mercury', html_url: 'https://github.com/founder/mercury' } }) }),
|
|
271
|
+
};
|
|
272
|
+
vm.createContext(sandbox);
|
|
273
|
+
vm.runInContext(src.slice(s, e), sandbox);
|
|
274
|
+
await sandbox.ensureFreshRepo();
|
|
275
|
+
assert.equal(sandbox.state.repoUrl, 'https://github.com/founder/mercury', 'the URL is read off the response, not dropped');
|
|
276
|
+
assert.equal(sandbox.savedUrl, 'https://github.com/founder/mercury', 'the draft is persisted after the URL lands on state');
|
|
277
|
+
assert.ok(saves >= 1);
|
|
278
|
+
// a URL that is not GitHub's own is never kept
|
|
279
|
+
sandbox.state = { mode: 'greenfield', repo: 'x', owner: 'founder' };
|
|
280
|
+
sandbox.fetch = async () => ({ ok: true, status: 201, json: async () => ({ repo: { full_name: 'founder/x', html_url: 'https://evil.example/x' } }) });
|
|
281
|
+
await sandbox.ensureFreshRepo();
|
|
282
|
+
assert.equal(sandbox.state.repoUrl, undefined);
|
|
261
283
|
});
|
|
262
284
|
|
|
263
285
|
/* ── The address step's three choices (task 1002898 N2 / N8 / N10) ───────────
|
package/tests/render_sandbox.mjs
CHANGED
|
@@ -132,10 +132,13 @@ await ta('a new app with a database: the app, then its sandbox — free, follows
|
|
|
132
132
|
assert.equal(sb.branch, 'main');
|
|
133
133
|
assert.equal(sb.autoDeploy, 'yes', 'the sandbox follows every merge');
|
|
134
134
|
assert.equal(sb.repo, 'https://github.com/example-owner/demo');
|
|
135
|
-
|
|
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'), [
|
|
136
139
|
{ key: 'BONGOS_SANDBOX', value: '1' },
|
|
137
140
|
{ key: 'DATABASE_URL', value: 'postgresql://u:p@dpg-new-a/demo_sandbox' },
|
|
138
|
-
], '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');
|
|
139
142
|
// the second database is made with SQL on the app's server, existence checked first
|
|
140
143
|
assert.equal(h.sqlRan.length, 2);
|
|
141
144
|
assert.match(h.sqlRan[0].statements[0], /FROM pg_database WHERE datname = \$1/);
|
|
@@ -151,7 +154,7 @@ await ta('a new app with a database: the app, then its sandbox — free, follows
|
|
|
151
154
|
await ta('an app with no database: the sandbox has none either, and says so', async () => {
|
|
152
155
|
const h = harness({ host: { database: false } });
|
|
153
156
|
assert.equal((await h.run()).ok, true);
|
|
154
|
-
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' }]);
|
|
155
158
|
assert.equal(h.sqlRan.length, 0);
|
|
156
159
|
assert.equal(h.inst.app_host.sandbox.database, 'none');
|
|
157
160
|
assert.match(h.inst.app_host.sandbox.dbNote, /no database, so its sandbox has none/);
|
|
@@ -173,7 +176,7 @@ await ta('the database stage fails: the sandbox still runs, with no database, an
|
|
|
173
176
|
const h = harness({ sql: async () => { throw new Error('permission denied to create database'); } });
|
|
174
177
|
const out = await h.run();
|
|
175
178
|
assert.equal(out.ok, true);
|
|
176
|
-
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');
|
|
177
180
|
const s = h.inst.app_host.sandbox;
|
|
178
181
|
assert.equal(s.state, 'deploying');
|
|
179
182
|
assert.equal(s.database, 'failed');
|
|
@@ -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);
|