@bongos/core 1.20.25 → 1.20.27
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 +92 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
- package/docs/adr/0355-a-soft-limit-is-a-rule-with-a-reason-and-nobody-enforces-it.md +30 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +137 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +34 -34
- package/docs/copy-registry.json +38 -38
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +75 -73
- package/modules/discord/board-broadcast.js +1 -0
- package/modules/government/board.js +261 -2
- package/modules/government/charter.js +232 -0
- package/modules/government/db.js +42 -1
- package/modules/government/migrations/government_020_genesis_stage_subject.sql +55 -0
- package/modules/government/routes/government.js +82 -0
- package/modules/hall-ui/public/board-room.js +5 -0
- package/modules/hall-ui/public/government.js +11 -0
- package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql +65 -0
- package/modules/provisioning/planet-physics.js +154 -0
- package/modules/provisioning/provisioning.js +29 -9
- package/modules/provisioning/routes/body-validators.js +25 -7
- package/modules/provisioning/routes/provisioning.js +8 -3
- package/modules/provisioning/screening.js +62 -0
- package/modules/provisioning/soft-limits.js +146 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/route-rank-check.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/government_board_genesis_stage.mjs +305 -0
- package/tests/government_charter_forms.mjs +283 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/pending_invite_purge_db.mjs +129 -0
- package/tests/provisioning_planet_physics.mjs +377 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// tests/provisioning_planet_physics.mjs
|
|
2
|
+
//
|
|
3
|
+
// The seven planet-physics answers, the screening hook, and the soft-limit rule
|
|
4
|
+
// table (BV2.PS02, task 1004416; goal 1000121, spec
|
|
5
|
+
// docs/specs/bongos-v2-project-startup.md decision D6).
|
|
6
|
+
//
|
|
7
|
+
// Done when: a create request can carry the answers, they read back on the project,
|
|
8
|
+
// and a test pins the vocabulary and the stop path. Four layers:
|
|
9
|
+
// • the vocabulary — every kind of question, stored canonically, refused loudly
|
|
10
|
+
// • soft limits — a reason on every rule, every rule names real answers, and
|
|
11
|
+
// nothing claims a limit is enforced
|
|
12
|
+
// • screening — the stop path, and the honest projects it must let through
|
|
13
|
+
// • the routes — POST /instances carries and reads back the answers; a
|
|
14
|
+
// screened description is stopped BEFORE anything is created;
|
|
15
|
+
// PATCH …/detail un-answers with null
|
|
16
|
+
//
|
|
17
|
+
// DB-free: the data-access functions the routes need are stubbed on the cached
|
|
18
|
+
// module object, over a loopback listener (the provisioning_module_selection.mjs rig).
|
|
19
|
+
//
|
|
20
|
+
// Run: node tests/provisioning_planet_physics.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import http from 'node:http';
|
|
25
|
+
// ESM named imports off the CJS modules on purpose — knip cannot trace a
|
|
26
|
+
// createRequire() import (the tests/provisioning_module_selection.mjs note).
|
|
27
|
+
import { EU_COUNTRIES, COUNTRY_CODES, REGION_CODES, crossAnswerFault }
|
|
28
|
+
from '../modules/provisioning/planet-physics.js';
|
|
29
|
+
import { SOFT_LIMIT_RULES, EFFECTS, softLimitsFor } from '../modules/provisioning/soft-limits.js';
|
|
30
|
+
import { SCREENING_RULES, STOP_MESSAGE, screenProject } from '../modules/provisioning/screening.js';
|
|
31
|
+
import { badProjectDetail, screenedOut } from '../modules/provisioning/routes/body-validators.js';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
35
|
+
|
|
36
|
+
let passed = 0, failed = 0;
|
|
37
|
+
async function t(name, fn) {
|
|
38
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
39
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The Kestrel sample from the canvas, answered in full.
|
|
43
|
+
const KESTREL = Object.freeze({
|
|
44
|
+
project_state: 'existing', existing_work: ['digital-files', 'code'], main_work: 'code',
|
|
45
|
+
home_country: 'US', home_region: 'US-NY', builder_geography: 'global',
|
|
46
|
+
minors_on_board: true, entity: 'for-profit',
|
|
47
|
+
builders_at_start: 3, builders_expected: 8,
|
|
48
|
+
reward_intents: ['future-payouts', 'non-monetary'],
|
|
49
|
+
framing: 'hybrid', services: ['hosting', 'starter-kit', 'guild-access'],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
console.log('\nThe vocabulary:');
|
|
53
|
+
|
|
54
|
+
await t('the seven answers are all questions the detail column knows, beside team_shape', () => {
|
|
55
|
+
const keys = Object.keys(provisioning.PROJECT_DETAIL_QUESTIONS);
|
|
56
|
+
for (const key of ['team_shape', ...Object.keys(KESTREL)]) assert.ok(keys.includes(key), `${key} is a question`);
|
|
57
|
+
assert.deepEqual(provisioning.PROJECT_DETAIL_VOCAB.team_shape, ['solo', 'small-team', 'community'], 'the R15 answer is untouched');
|
|
58
|
+
// Framing is A–D, in that order.
|
|
59
|
+
assert.deepEqual(provisioning.PROJECT_DETAIL_VOCAB.framing, ['managed', 'hybrid', 'local', 'open-source']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await t('every enumerated question has a closed answer list, and every count a range — no defaults anywhere', () => {
|
|
63
|
+
for (const [key, q] of Object.entries(provisioning.PROJECT_DETAIL_QUESTIONS)) {
|
|
64
|
+
if (q.kind === 'count') {
|
|
65
|
+
assert.ok(Number.isInteger(q.min) && q.max > q.min, `${key} has a range`);
|
|
66
|
+
assert.ok(!(key in provisioning.PROJECT_DETAIL_VOCAB), `${key} has no answer list to offer`);
|
|
67
|
+
} else {
|
|
68
|
+
assert.ok(['one', 'many'].includes(q.kind), `${key} is a known kind`);
|
|
69
|
+
assert.ok(Array.isArray(q.values) && q.values.length, `${key} has a closed list`);
|
|
70
|
+
assert.equal(provisioning.PROJECT_DETAIL_VOCAB[key], q.values, `${key}'s list is what options serves`);
|
|
71
|
+
}
|
|
72
|
+
assert.equal(q.default, undefined, `${key} carries no default`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await t('a full set of answers stores canonically and reads back on the project', () => {
|
|
77
|
+
const stored = provisioning.normalizeProjectDetail(KESTREL);
|
|
78
|
+
// `many` answers come back in the vocabulary's order, so two spellings store one value.
|
|
79
|
+
assert.deepEqual(stored.existing_work, ['code', 'digital-files']);
|
|
80
|
+
assert.deepEqual(stored.reward_intents, ['non-monetary', 'future-payouts']);
|
|
81
|
+
assert.equal(stored.minors_on_board, true);
|
|
82
|
+
assert.equal(stored.builders_at_start, 3);
|
|
83
|
+
const read = provisioning.publicInstance({ id: 1, slug: 'kestrel', detail: stored }).detail;
|
|
84
|
+
assert.deepEqual(read, stored, 'the read is the stored answers, nothing added or lost');
|
|
85
|
+
assert.equal(Object.keys(read).length, Object.keys(KESTREL).length);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await t('false and an empty list are real answers, distinct from never answering', () => {
|
|
89
|
+
const stored = provisioning.normalizeProjectDetail({ minors_on_board: false, reward_intents: [] });
|
|
90
|
+
assert.deepEqual(stored, { minors_on_board: false, reward_intents: [] });
|
|
91
|
+
assert.deepEqual(provisioning.normalizeProjectDetail({}), {});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await t('badProjectDetail refuses every kind of wrong answer LOUDLY, naming the question', () => {
|
|
95
|
+
const cases = [
|
|
96
|
+
[{ plannet_state: 'new' }, /Unknown detail answer "plannet_state"/],
|
|
97
|
+
[{ project_state: 'brownfield' }, /"project_state" must be one of: new, existing/],
|
|
98
|
+
[{ existing_work: 'code' }, /"existing_work" must be a list/],
|
|
99
|
+
[{ existing_work: ['code', 'sculpture'] }, /"existing_work" must be a list, each item one of/],
|
|
100
|
+
[{ home_country: 'ZZ' }, /ISO 3166-1 alpha-2/],
|
|
101
|
+
[{ home_country: 'us' }, /ISO 3166-1 alpha-2/],
|
|
102
|
+
[{ home_region: 'US-XX' }, /US state or Canadian province/],
|
|
103
|
+
[{ minors_on_board: 'yes' }, /"minors_on_board" must be one of: false, true/],
|
|
104
|
+
[{ entity: 'llc' }, /"entity" must be one of/],
|
|
105
|
+
[{ builders_at_start: 0 }, /whole number from 1/],
|
|
106
|
+
[{ builders_expected: 2.5 }, /whole number/],
|
|
107
|
+
[{ builders_expected: '8' }, /whole number/],
|
|
108
|
+
[{ framing: 'b' }, /"framing" must be one of: managed, hybrid, local, open-source/],
|
|
109
|
+
[{ services: ['hosting', 'yacht'] }, /"services" must be a list/],
|
|
110
|
+
];
|
|
111
|
+
for (const [detail, re] of cases) {
|
|
112
|
+
const fault = badProjectDetail(detail);
|
|
113
|
+
assert.ok(fault, `${JSON.stringify(detail)} is refused`);
|
|
114
|
+
assert.equal(fault.code, 'bad_detail');
|
|
115
|
+
assert.match(fault.message, re, JSON.stringify(detail));
|
|
116
|
+
}
|
|
117
|
+
assert.equal(badProjectDetail(KESTREL), null, 'the canvas sample is clean');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await t('null un-answers every kind of question — and only null does', () => {
|
|
121
|
+
const nulls = Object.fromEntries(Object.keys(KESTREL).map((k) => [k, null]));
|
|
122
|
+
assert.equal(badProjectDetail(nulls), null, 'null is accepted for every question');
|
|
123
|
+
const { set, clear } = provisioning.projectDetailPatch(nulls);
|
|
124
|
+
assert.deepEqual(set, {});
|
|
125
|
+
assert.deepEqual(clear.sort(), Object.keys(KESTREL).sort());
|
|
126
|
+
assert.ok(badProjectDetail({ existing_work: '' }), 'an empty string is still a typo, not an erase');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await t('answers sent together must agree: main work is one of the existing kinds, the region is in the country', () => {
|
|
130
|
+
assert.match(badProjectDetail({ existing_work: ['code'], main_work: 'physical-work' }).message, /"main_work" must be one of the kinds/);
|
|
131
|
+
assert.match(badProjectDetail({ home_country: 'DE', home_region: 'US-CA' }).message, /not in "home_country" DE/);
|
|
132
|
+
assert.equal(badProjectDetail({ home_country: 'CA', home_region: 'CA-ON' }), null);
|
|
133
|
+
// Across two writes it is not refused: a merge patch may change one half first.
|
|
134
|
+
assert.equal(crossAnswerFault({ main_work: 'physical-work' }), null);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await t('the reference lists are what they claim to be', () => {
|
|
138
|
+
assert.equal(new Set(COUNTRY_CODES).size, COUNTRY_CODES.length, 'no duplicate country');
|
|
139
|
+
assert.equal(COUNTRY_CODES.length, 249, 'the ISO 3166-1 officially assigned set');
|
|
140
|
+
assert.equal(EU_COUNTRIES.length, 27);
|
|
141
|
+
for (const c of EU_COUNTRIES) assert.ok(COUNTRY_CODES.includes(c), `${c} is a country`);
|
|
142
|
+
for (const r of REGION_CODES) assert.ok(COUNTRY_CODES.includes(r.slice(0, 2)), `${r} is inside a country`);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
console.log('\nThe soft-limit table:');
|
|
146
|
+
|
|
147
|
+
// Is `option` a real question, or a real member of one?
|
|
148
|
+
function optionExists(option) {
|
|
149
|
+
const [key, member] = option.split(':');
|
|
150
|
+
const q = provisioning.PROJECT_DETAIL_QUESTIONS[key];
|
|
151
|
+
if (!q) return false;
|
|
152
|
+
return member === undefined || (q.values || []).map(String).includes(member);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await t('every rule carries a reason, a known effect, and names only real questions and answers', () => {
|
|
156
|
+
const ids = new Set();
|
|
157
|
+
for (const rule of SOFT_LIMIT_RULES) {
|
|
158
|
+
assert.ok(!ids.has(rule.id), `${rule.id} is unique`); ids.add(rule.id);
|
|
159
|
+
assert.ok(typeof rule.reason === 'string' && rule.reason.length > 10, `${rule.id} has a reason`);
|
|
160
|
+
assert.ok(EFFECTS.includes(rule.effect), `${rule.id}'s effect is one of ${EFFECTS}`);
|
|
161
|
+
assert.ok(rule.options.length, `${rule.id} names an option`);
|
|
162
|
+
for (const option of rule.options) assert.ok(optionExists(option), `${rule.id}: ${option} exists`);
|
|
163
|
+
for (const [key, test] of Object.entries(rule.when)) {
|
|
164
|
+
const q = provisioning.PROJECT_DETAIL_QUESTIONS[key];
|
|
165
|
+
assert.ok(q, `${rule.id}: ${key} is a question`);
|
|
166
|
+
const named = test.in || [test.has ?? test.lacks];
|
|
167
|
+
for (const v of named) assert.ok(q.values.includes(v), `${rule.id}: ${key}=${v} is a real answer`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await t('nothing claims a limit is enforced — not the resolution, not any reason', () => {
|
|
173
|
+
assert.equal(softLimitsFor(KESTREL).enforced, false);
|
|
174
|
+
assert.equal(softLimitsFor({}).enforced, false);
|
|
175
|
+
for (const rule of SOFT_LIMIT_RULES) {
|
|
176
|
+
assert.doesNotMatch(rule.reason, /enforc|automatic|blocked|prohibit|not permitted|forbidden/i, `${rule.id} words advice as a rule`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await t("the spec's three examples fire, each with its why line", () => {
|
|
181
|
+
const find = (answers, option) => softLimitsFor(answers).limits.filter((l) => l.option === option);
|
|
182
|
+
const equity = find({ entity: 'intent-solo' }, 'reward_intents:equity');
|
|
183
|
+
assert.equal(equity.length, 1); assert.equal(equity[0].effect, 'grey');
|
|
184
|
+
assert.match(equity[0].reason, /until you form a company/);
|
|
185
|
+
assert.match(find({ builder_geography: 'us-eu' }, 'reward_intents:cash')[0].reason, /EU aren't available yet/);
|
|
186
|
+
assert.match(find({ minors_on_board: true }, 'reward_intents:cash')[0].reason, /13 to 15 join non-monetary tracks only/);
|
|
187
|
+
// A formed company is not greyed out of equity.
|
|
188
|
+
assert.equal(find({ entity: 'for-profit' }, 'reward_intents:equity').length, 0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await t('a rule never fires on an answer nobody gave — only the platform-wide facts apply to a skipped interview', () => {
|
|
192
|
+
const skipped = softLimitsFor({});
|
|
193
|
+
for (const limit of skipped.limits) {
|
|
194
|
+
const rule = SOFT_LIMIT_RULES.find((r) => r.id === limit.rule);
|
|
195
|
+
assert.deepEqual(rule.when, {}, `${rule.id} fired with no answers`);
|
|
196
|
+
}
|
|
197
|
+
// `lacks` is "said, and not this" — never "never said".
|
|
198
|
+
assert.ok(!softLimitsFor({}).limits.some((l) => l.rule === 'labor-payment-follows-cash'));
|
|
199
|
+
assert.ok(softLimitsFor({ reward_intents: ['equity'] }).limits.some((l) => l.rule === 'labor-payment-follows-cash'));
|
|
200
|
+
assert.ok(!softLimitsFor({ reward_intents: ['cash'] }).limits.some((l) => l.rule === 'labor-payment-follows-cash'));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await t('the table hides whole questions too: a new planet has no existing work, managed has no services to pick', () => {
|
|
204
|
+
const opts = (answers) => softLimitsFor(answers).limits.filter((l) => l.effect === 'hide').map((l) => l.option);
|
|
205
|
+
assert.deepEqual(opts({ project_state: 'new' }), ['existing_work', 'main_work']);
|
|
206
|
+
assert.ok(opts({ framing: 'managed' }).includes('services'));
|
|
207
|
+
assert.ok(!opts({ framing: 'hybrid' }).includes('services'));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await t('the soft limits read back on every owner read of the project, from the stored answers', () => {
|
|
211
|
+
const view = provisioning.publicInstance({ id: 1, slug: 'kestrel', detail: provisioning.normalizeProjectDetail({ entity: 'nonprofit' }) });
|
|
212
|
+
assert.equal(view.soft_limits.enforced, false);
|
|
213
|
+
assert.ok(view.soft_limits.limits.some((l) => l.rule === 'equity-not-in-a-nonprofit'));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
console.log('\nScreening:');
|
|
217
|
+
|
|
218
|
+
await t('the stop path: each rule stops a description that asks for the thing itself', () => {
|
|
219
|
+
const stops = [
|
|
220
|
+
['A marketplace to sell ransomware to anyone who pays.', 'malware'],
|
|
221
|
+
['We want to build a phishing kit that copies bank logins.', 'phishing'],
|
|
222
|
+
['A site sharing explicit images of minors.', 'child-sexual-abuse'],
|
|
223
|
+
];
|
|
224
|
+
for (const [description, rule] of stops) assert.deepEqual(screenProject({ description }), { stop: true, rule }, description);
|
|
225
|
+
assert.deepEqual(SCREENING_RULES.map((r) => r.id).sort(), stops.map(([, r]) => r).sort(), 'every rule has a stop case here');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await t('honest projects pass — including the ones that talk about the same things', () => {
|
|
229
|
+
for (const description of [
|
|
230
|
+
'A field guide app where birdwatchers log sightings together and build a shared map.',
|
|
231
|
+
'We build a ransomware detector for small clinics.',
|
|
232
|
+
'A phishing awareness course that runs simulated campaigns for our staff.',
|
|
233
|
+
'A child safety app that helps parents block harmful content.',
|
|
234
|
+
'', ' ',
|
|
235
|
+
]) assert.deepEqual(screenProject({ description }), { stop: false }, description);
|
|
236
|
+
assert.deepEqual(screenProject({}), { stop: false }, 'no text is never a stop');
|
|
237
|
+
assert.deepEqual(screenProject({ description: 42 }), { stop: false });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await t('the hook is a seam: a caller can hand it different rules and nothing else changes', () => {
|
|
241
|
+
const rules = [{ id: 'test-only', match: /\bkestrel\b/i, unless: null }];
|
|
242
|
+
assert.deepEqual(screenProject({ description: 'Kestrel' }, rules), { stop: true, rule: 'test-only' });
|
|
243
|
+
assert.deepEqual(screenProject({ description: 'Kestrel' }), { stop: false });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await t('screenedOut answers with the canvas copy and names no rule anywhere in the reply', () => {
|
|
247
|
+
assert.equal(screenedOut('A birdwatching map.'), null);
|
|
248
|
+
assert.equal(screenedOut(undefined), null);
|
|
249
|
+
const out = screenedOut('We will sell ransomware.');
|
|
250
|
+
assert.equal(out.status, 422);
|
|
251
|
+
assert.equal(out.message, STOP_MESSAGE);
|
|
252
|
+
assert.match(out.message, /Nothing has been set up/);
|
|
253
|
+
assert.deepEqual(out.details, { screened: true }, 'naming the rule would teach a caller how to reword around it');
|
|
254
|
+
assert.doesNotMatch(JSON.stringify(out), /malware/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
console.log('\nThe routes:');
|
|
258
|
+
|
|
259
|
+
let lastCreateArgs = null;
|
|
260
|
+
let detailRow = null;
|
|
261
|
+
provisioning.getInstanceBySlug = async () => null;
|
|
262
|
+
provisioning.getInstanceByDomain = async () => null;
|
|
263
|
+
provisioning.getInstanceById = async () => detailRow;
|
|
264
|
+
provisioning.createInstance = async (_db, args) => {
|
|
265
|
+
lastCreateArgs = args;
|
|
266
|
+
return { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'byo-host',
|
|
267
|
+
description: args.description || null, detail: provisioning.normalizeProjectDetail(args.detail) }, created: true };
|
|
268
|
+
};
|
|
269
|
+
provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
|
|
270
|
+
provisioning.getOpenIntent = async () => null;
|
|
271
|
+
provisioning.recordEvent = async () => {};
|
|
272
|
+
// Mirrors the real statement: merge the set answers, drop the un-answered keys.
|
|
273
|
+
provisioning.updateInstanceDetail = async (_db, _id, patch) => {
|
|
274
|
+
const row = { ...detailRow };
|
|
275
|
+
if (patch.description !== undefined) row.description = patch.description;
|
|
276
|
+
if (patch.detail !== undefined) {
|
|
277
|
+
const { set, clear } = provisioning.projectDetailPatch(patch.detail);
|
|
278
|
+
row.detail = { ...(detailRow.detail || {}), ...set };
|
|
279
|
+
for (const key of clear) delete row.detail[key];
|
|
280
|
+
}
|
|
281
|
+
detailRow = row;
|
|
282
|
+
return row;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const api = require('../src/module-api.js');
|
|
286
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
287
|
+
|
|
288
|
+
const express = require('express');
|
|
289
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
290
|
+
const app = express();
|
|
291
|
+
app.use(express.json());
|
|
292
|
+
app.use((_req, res, next) => {
|
|
293
|
+
res.fail = (code, opts, details) => {
|
|
294
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
295
|
+
res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
|
|
296
|
+
};
|
|
297
|
+
next();
|
|
298
|
+
});
|
|
299
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
300
|
+
const server = app.listen(0);
|
|
301
|
+
await new Promise((r) => server.once('listening', r));
|
|
302
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
303
|
+
|
|
304
|
+
function send(method, path, body) {
|
|
305
|
+
return new Promise((resolve, reject) => {
|
|
306
|
+
const payload = JSON.stringify(body);
|
|
307
|
+
const req = http.request(`${base}${path}`, {
|
|
308
|
+
method, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
309
|
+
}, (res) => {
|
|
310
|
+
let d = '';
|
|
311
|
+
res.on('data', (c) => { d += c; });
|
|
312
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
313
|
+
});
|
|
314
|
+
req.on('error', reject);
|
|
315
|
+
req.end(payload);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await t('POST /instances carries all seven answers to storage and reads them back on the project', async () => {
|
|
320
|
+
lastCreateArgs = null;
|
|
321
|
+
const res = await send('POST', '/provisioning/instances', {
|
|
322
|
+
slug: 'kestrel', hosting_shape: 'byo-host', description: 'An inventory tool our team started last year.', detail: KESTREL,
|
|
323
|
+
});
|
|
324
|
+
assert.equal(res.status, 201, res.raw);
|
|
325
|
+
assert.deepEqual(lastCreateArgs.detail, KESTREL, 'the answers reached createInstance');
|
|
326
|
+
assert.deepEqual(res.body.instance.detail, provisioning.normalizeProjectDetail(KESTREL), 'and read back on the project');
|
|
327
|
+
assert.equal(res.body.instance.soft_limits.enforced, false, 'with the soft limits beside them');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await t('POST /instances refuses an unknown answer with a 400 and creates nothing', async () => {
|
|
331
|
+
lastCreateArgs = null;
|
|
332
|
+
const res = await send('POST', '/provisioning/instances', { slug: 'kestrel', hosting_shape: 'byo-host', detail: { framing: 'e' } });
|
|
333
|
+
assert.equal(res.status, 400, res.raw);
|
|
334
|
+
assert.equal(res.body.error.code, 'bad_detail');
|
|
335
|
+
assert.equal(lastCreateArgs, null);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await t('POST /instances: a screened description STOPS creation — 422, nothing created, no intent queued', async () => {
|
|
339
|
+
lastCreateArgs = null;
|
|
340
|
+
let queued = false;
|
|
341
|
+
const enqueue = provisioning.enqueueIntent;
|
|
342
|
+
provisioning.enqueueIntent = async (...a) => { queued = true; return enqueue(...a); };
|
|
343
|
+
const res = await send('POST', '/provisioning/instances', {
|
|
344
|
+
slug: 'kestrel', hosting_shape: 'byo-host', description: 'Build ransomware and sell it on forums.', detail: KESTREL,
|
|
345
|
+
});
|
|
346
|
+
provisioning.enqueueIntent = enqueue;
|
|
347
|
+
assert.equal(res.status, 422, res.raw);
|
|
348
|
+
assert.equal(res.body.error.code, 'screened_out');
|
|
349
|
+
assert.equal(res.body.error.message, STOP_MESSAGE);
|
|
350
|
+
assert.deepEqual(res.body.error.details, { screened: true }, 'the reply names no rule');
|
|
351
|
+
assert.equal(lastCreateArgs, null, 'createInstance never ran');
|
|
352
|
+
assert.equal(queued, false, 'and no provision intent exists');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await t('PATCH …/detail screens a new description too, so the stop cannot be walked around after creation', async () => {
|
|
356
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'kestrel', status: 'active', description: 'A map.', detail: {} };
|
|
357
|
+
const res = await send('PATCH', '/provisioning/instances/41/detail', { description: 'We will build a phishing kit.' });
|
|
358
|
+
assert.equal(res.status, 422, res.raw);
|
|
359
|
+
assert.equal(res.body.error.code, 'screened_out');
|
|
360
|
+
assert.equal(detailRow.description, 'A map.', 'nothing was written');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
await t('PATCH …/detail answers, re-reads, and un-answers with null — soft limits following the stored answers', async () => {
|
|
364
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'kestrel', status: 'active', detail: {} };
|
|
365
|
+
const set = await send('PATCH', '/provisioning/instances/41/detail', { detail: { entity: 'intent-solo', reward_intents: ['equity'] } });
|
|
366
|
+
assert.equal(set.status, 200, set.raw);
|
|
367
|
+
assert.deepEqual(set.body.detail, { entity: 'intent-solo', reward_intents: ['equity'] });
|
|
368
|
+
assert.ok(set.body.soft_limits.limits.some((l) => l.rule === 'equity-needs-a-company'));
|
|
369
|
+
const cleared = await send('PATCH', '/provisioning/instances/41/detail', { detail: { entity: null } });
|
|
370
|
+
assert.equal(cleared.status, 200, cleared.raw);
|
|
371
|
+
assert.deepEqual(cleared.body.detail, { reward_intents: ['equity'] }, 'the entity is un-answered, the rest kept');
|
|
372
|
+
assert.ok(!cleared.body.soft_limits.limits.some((l) => l.rule === 'equity-needs-a-company'), 'and its limit left with it');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
server.close();
|
|
376
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
377
|
+
if (failed) process.exit(1);
|