@bongos/core 1.21.131 → 1.21.133
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 +45 -25
- package/docs/copy-inventory.md +37 -22
- package/docs/copy-registry.json +187 -32
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +5 -2
- package/docs/page-readings.json +150 -149
- package/modules/hall-ui/public/goal-author-lib.js +129 -0
- package/modules/hall-ui/public/goal-author.js +197 -0
- package/modules/hall-ui/public/goals-page.js +3 -0
- package/modules/hall-ui/public/goals.css +13 -0
- package/modules/hall-ui/public/goals.html +4 -0
- package/modules/hall-ui/public/goals.states.json +34 -1
- package/modules/hall-ui/records/goal-authoring.md +10 -0
- package/modules/provisioning/github-setup.js +2 -2
- package/modules/provisioning/pollers/liveness-sweep.js +29 -14
- package/modules/provisioning/tests/liveness.mjs +6 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/github_sign_in_seen.mjs +102 -14
- package/tests/goal_author_ui.mjs +210 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Sign-in used to tick only from the one-click GitHub attempt reaching 'done', so a project
|
|
6
6
|
// whose sign-in was wired some other way — a hand-made OAuth app, or a GitHub tab that never
|
|
7
7
|
// came back — sat at "Waiting on GitHub" for good. Now the liveness sweep reads the project's
|
|
8
|
-
//
|
|
8
|
+
// JSON health answer, records 'github-sign-in-seen' once per attempt, and githubChecklist reads it.
|
|
9
9
|
// The page half (what the founder sees) is tests/setup_status_facts.mjs §6.
|
|
10
10
|
//
|
|
11
11
|
// Pinned here, DB-free over recording fakes and real loopback servers:
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
// * pickUpFor offers nothing to pick up once sign-in works elsewhere;
|
|
15
15
|
// * githubSetupRead reads the fact even when no attempt exists;
|
|
16
16
|
// * recordSignInSeen writes at most one row per attempt;
|
|
17
|
-
// * probeHealthz reports auth_configured only from a small JSON answer saying true
|
|
17
|
+
// * probeHealthz reports auth_configured only from a small JSON answer saying true, read
|
|
18
|
+
// from <API prefix>/healthz — the root /healthz says a plain "ok" (task 1004743), and every
|
|
19
|
+
// server here answers both paths the way a real project does;
|
|
20
|
+
// * a sign-in read that fails, hangs or redirects never turns an up project down;
|
|
18
21
|
// * sweepOnce records the fact only for an up project that says so.
|
|
19
22
|
//
|
|
20
23
|
// Run: node --test tests/github_sign_in_seen.mjs
|
|
@@ -23,6 +26,7 @@ import { test } from 'node:test';
|
|
|
23
26
|
import assert from 'node:assert/strict';
|
|
24
27
|
import http from 'node:http';
|
|
25
28
|
import path from 'node:path';
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
26
30
|
import { createRequire } from 'node:module';
|
|
27
31
|
import { fileURLToPath } from 'node:url';
|
|
28
32
|
|
|
@@ -30,6 +34,8 @@ const require = createRequire(import.meta.url);
|
|
|
30
34
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
35
|
const gs = require(path.join(ROOT, 'modules', 'provisioning', 'github-setup.js'));
|
|
32
36
|
const liveness = require(path.join(ROOT, 'modules', 'provisioning', 'pollers', 'liveness-sweep.js'));
|
|
37
|
+
const { API_PREFIX } = require(path.join(ROOT, 'src', 'bongos', 'api-prefix.js'));
|
|
38
|
+
const AUTH_PATH = `${API_PREFIX}/healthz`;
|
|
33
39
|
|
|
34
40
|
const SEEN = { signInSeenAt: '2026-10-14T10:00:00.000Z' };
|
|
35
41
|
|
|
@@ -100,13 +106,38 @@ test('recordSignInSeen writes at most one row per attempt, from the liveness swe
|
|
|
100
106
|
});
|
|
101
107
|
|
|
102
108
|
// ── the probe and the sweep ────────────────────────────────────────────────────────────
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
// A project as it really answers: the ROOT /healthz is a plain-text "ok", and auth_configured
|
|
110
|
+
// lives only on the JSON answer under the API prefix. `api` handles that one path; without it
|
|
111
|
+
// the path 404s, like a core with no such route. A root mocked as JSON is what hid task 1004743.
|
|
112
|
+
function project(api) {
|
|
113
|
+
const server = http.createServer((req, res) => {
|
|
114
|
+
if (req.url === '/healthz') { res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); res.end('ok'); return; }
|
|
115
|
+
if (req.url === AUTH_PATH && api) { api(req, res); return; }
|
|
116
|
+
res.writeHead(404); res.end();
|
|
117
|
+
});
|
|
118
|
+
return listen(server);
|
|
106
119
|
}
|
|
120
|
+
function json(body, status = 200) {
|
|
121
|
+
return (_req, res) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); res.end(body); };
|
|
122
|
+
}
|
|
123
|
+
function listen(server) { return new Promise((r) => server.listen(0, '127.0.0.1', () => r(server))); }
|
|
107
124
|
function close(server) { if (server.closeAllConnections) server.closeAllConnections(); server.close(); }
|
|
125
|
+
const rootOf = (s) => `http://127.0.0.1:${s.address().port}/healthz`;
|
|
126
|
+
|
|
127
|
+
test('the fixture is the real shape: the root /healthz says a plain-text "ok"', () => {
|
|
128
|
+
const src = readFileSync(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'), 'utf8').replace(/\r\n/g, '\n');
|
|
129
|
+
assert.match(src, /app\.get\('\/healthz', \(_req, res\) => \{\n\s*res\.type\('text\/plain'\)\.send\('ok'\);/,
|
|
130
|
+
'if the root ever answers JSON, project() above must change with it');
|
|
131
|
+
});
|
|
108
132
|
|
|
109
|
-
test('
|
|
133
|
+
test('a project whose root is the only answer is up, never configured, and never crashes the probe', async () => {
|
|
134
|
+
const s = await project(null);
|
|
135
|
+
try {
|
|
136
|
+
assert.deepEqual(await liveness.probeHealthz(rootOf(s), { timeoutMs: 3000 }), { up: true, authConfigured: false });
|
|
137
|
+
} finally { close(s); }
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('probeHealthz says auth_configured only from a small JSON answer under the API prefix saying true', async () => {
|
|
110
141
|
const cases = [
|
|
111
142
|
['{"ok":true,"auth_configured":true}', true],
|
|
112
143
|
['{"ok":true,"auth_configured":false}', false],
|
|
@@ -116,23 +147,80 @@ test('probeHealthz says auth_configured only from a small JSON answer saying tru
|
|
|
116
147
|
[JSON.stringify({ ok: true, auth_configured: true, pad: 'x'.repeat(8000) }), false],
|
|
117
148
|
];
|
|
118
149
|
for (const [body, want] of cases) {
|
|
119
|
-
const s = await
|
|
150
|
+
const s = await project(json(body));
|
|
120
151
|
try {
|
|
121
|
-
const r = await liveness.probeHealthz(
|
|
122
|
-
assert.equal(r.up, true, '
|
|
152
|
+
const r = await liveness.probeHealthz(rootOf(s), { timeoutMs: 3000 });
|
|
153
|
+
assert.equal(r.up, true, 'the root answered, so it is up');
|
|
123
154
|
assert.equal(r.authConfigured, want, body.slice(0, 50));
|
|
124
155
|
} finally { close(s); }
|
|
125
156
|
}
|
|
126
157
|
});
|
|
127
158
|
|
|
159
|
+
test('against the REAL healthz router, the probe reads what the project\'s auth says', async () => {
|
|
160
|
+
const express = require('express');
|
|
161
|
+
const auth = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
|
|
162
|
+
const buildHealthzRouter = require(path.join(ROOT, 'src', 'bongos', 'routes', 'healthz.js'));
|
|
163
|
+
const realAuthConfigured = auth.authConfigured;
|
|
164
|
+
let configured = true;
|
|
165
|
+
let s = null;
|
|
166
|
+
try {
|
|
167
|
+
auth.authConfigured = () => configured;
|
|
168
|
+
const app = express();
|
|
169
|
+
app.get('/healthz', (_req, res) => { res.type('text/plain').send('ok'); });
|
|
170
|
+
app.use(API_PREFIX, buildHealthzRouter());
|
|
171
|
+
s = await listen(http.createServer(app));
|
|
172
|
+
assert.deepEqual(await liveness.probeHealthz(rootOf(s), { timeoutMs: 3000 }), { up: true, authConfigured: true });
|
|
173
|
+
configured = false;
|
|
174
|
+
assert.deepEqual(await liveness.probeHealthz(rootOf(s), { timeoutMs: 3000 }), { up: true, authConfigured: false });
|
|
175
|
+
} finally { auth.authConfigured = realAuthConfigured; if (s) close(s); }
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('a sign-in read that fails, hangs or redirects never turns an up project down, and is never followed', async () => {
|
|
179
|
+
const elsewhere = await project(json('{"ok":true,"auth_configured":true}'));
|
|
180
|
+
const answers = {
|
|
181
|
+
'a 500': json('{"ok":true,"auth_configured":true}', 500),
|
|
182
|
+
'a hang': () => {},
|
|
183
|
+
'a redirect to a yes': (_req, res) => { res.writeHead(302, { location: `http://127.0.0.1:${elsewhere.address().port}${AUTH_PATH}` }); res.end(); },
|
|
184
|
+
};
|
|
185
|
+
try {
|
|
186
|
+
for (const [name, api] of Object.entries(answers)) {
|
|
187
|
+
const s = await project(api);
|
|
188
|
+
try {
|
|
189
|
+
assert.deepEqual(await liveness.probeHealthz(rootOf(s), { timeoutMs: 400 }), { up: true, authConfigured: false }, name);
|
|
190
|
+
} finally { close(s); }
|
|
191
|
+
}
|
|
192
|
+
} finally { close(elsewhere); }
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('the sign-in read rides only a vetted address, and no shape of failure turns it into a down', async () => {
|
|
196
|
+
const asked = [];
|
|
197
|
+
const answering = async (url) => {
|
|
198
|
+
asked.push(String(url));
|
|
199
|
+
return String(url).endsWith(AUTH_PATH) ? new Response('{"ok":true,"auth_configured":true}', { status: 200 }) : new Response('ok', { status: 200 });
|
|
200
|
+
};
|
|
201
|
+
const url = 'https://kestrel.example.com/healthz';
|
|
202
|
+
assert.deepEqual(await liveness.probeHealthz(url, { fetchImpl: answering, pin: { address: '93.184.216.34', family: 4 } }), { up: true, authConfigured: true });
|
|
203
|
+
assert.deepEqual(asked, [url, `https://kestrel.example.com${AUTH_PATH}`], 'the same origin, nowhere else');
|
|
204
|
+
asked.length = 0;
|
|
205
|
+
assert.deepEqual(await liveness.probeHealthz(url, { fetchImpl: answering }), { up: true, authConfigured: false });
|
|
206
|
+
assert.deepEqual(asked, [url], 'an https answer the gate never pinned is asked nothing more');
|
|
207
|
+
|
|
208
|
+
let calls = 0;
|
|
209
|
+
const throwsSecond = (u) => { calls += 1; if (calls > 1) throw new TypeError(`no: ${u}`); return Promise.resolve(new Response('ok', { status: 200 })); };
|
|
210
|
+
assert.deepEqual(await liveness.probeHealthz('http://127.0.0.1:1/healthz', { fetchImpl: throwsSecond }), { up: true, authConfigured: false }, 'a synchronous throw is only "no"');
|
|
211
|
+
const noStatus = await liveness.probeHealthz('http://127.0.0.1:1/healthz', { fetchImpl: async () => ({}) });
|
|
212
|
+
assert.equal(noStatus.up, false, 'an answer with no status is not a 2xx');
|
|
213
|
+
});
|
|
214
|
+
|
|
128
215
|
test('the sweep records the fact for an up project that says so, and for no other', async () => {
|
|
129
|
-
const
|
|
130
|
-
const
|
|
216
|
+
const kestrel = await project(json('{"ok":true,"auth_configured":true}'));
|
|
217
|
+
const tern = await project(json('{"ok":true,"auth_configured":false}'));
|
|
218
|
+
const wren = await project(null);
|
|
131
219
|
const inserts = [];
|
|
132
220
|
const db = {
|
|
133
221
|
query: async (sql, params) => {
|
|
134
222
|
if (/FROM provisioning_instances/.test(sql) && /WHERE status = 'active'/.test(sql)) {
|
|
135
|
-
return { rows: [
|
|
223
|
+
return { rows: [kestrel, tern, wren].map((s, i) => ({ id: i + 1, slug: ['kestrel', 'tern', 'wren'][i], domain: null, port: s.address().port })) };
|
|
136
224
|
}
|
|
137
225
|
if (/^UPDATE provisioning_instances/.test(sql)) return { rows: [{ id: params[0], prev_up: true }] };
|
|
138
226
|
if (/INSERT INTO provisioning_events/.test(sql)) { inserts.push(params); return { rows: [] }; }
|
|
@@ -142,7 +230,7 @@ test('the sweep records the fact for an up project that says so, and for no othe
|
|
|
142
230
|
try {
|
|
143
231
|
liveness.resetNoticeState();
|
|
144
232
|
const out = await liveness.sweepOnce({ pool: db, log: () => {}, timeoutMs: 3000, notify: () => {}, collectRunnerDrift: async () => [] });
|
|
145
|
-
assert.equal(out.up,
|
|
233
|
+
assert.equal(out.up, 3, 'what the sign-in read finds never changes up or down');
|
|
146
234
|
assert.deepEqual(inserts, [[1, 'github-sign-in-seen']]);
|
|
147
|
-
} finally { close(
|
|
235
|
+
} finally { close(kestrel); close(tern); close(wren); }
|
|
148
236
|
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// tests/goal_author_ui.mjs — authoring a goal and a done-when criterion from the
|
|
2
|
+
// hall (task 1003463; ADR 0366 D1.2/D1.3/D2/D3). Three layers:
|
|
3
|
+
// 1. the pure lib (goal-author-lib.js) in a vm — the request BODIES the form
|
|
4
|
+
// sends, the derived slug, and how a refusal is put into words;
|
|
5
|
+
// 2. the page wiring, read as source — the form carries no scope field and no
|
|
6
|
+
// interview-shaped step;
|
|
7
|
+
// 3. the two server walls the form must not soften, driven with the form's OWN
|
|
8
|
+
// request through the assembled routers: a Metic's goal on a protected module
|
|
9
|
+
// is still refused, and a non-member's criterion is still refused.
|
|
10
|
+
//
|
|
11
|
+
// Run: node --test tests/goal_author_ui.mjs
|
|
12
|
+
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import vm from 'node:vm';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
process.env.NODE_ENV = 'test';
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
24
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
25
|
+
|
|
26
|
+
const win = {};
|
|
27
|
+
const sandbox = { window: win, String, Number, Array, Object, JSON, Math, Date, Set, Map };
|
|
28
|
+
vm.createContext(sandbox);
|
|
29
|
+
vm.runInContext(read('goal-author-lib.js'), sandbox, { filename: 'goal-author-lib.js' });
|
|
30
|
+
const lib = win.OTBGoalAuthor;
|
|
31
|
+
// Objects built inside the vm are another realm's; compare their JSON.
|
|
32
|
+
const plain = (v) => JSON.parse(JSON.stringify(v));
|
|
33
|
+
|
|
34
|
+
// ── 1. the pure half ─────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
test('the goal body carries spec_task:true and NO scope, successor or admission', () => {
|
|
37
|
+
const body = lib.buildGoalBody({
|
|
38
|
+
version_id: ' V9 ', title: ' Hall goal ', subtitle: ' what it is for ', description: '', category_id: '3',
|
|
39
|
+
scope_modules: ['kernel'], succeeds_goal_id: 5, admit_reason: 'because reasons',
|
|
40
|
+
});
|
|
41
|
+
assert.deepEqual(plain(body), { version_id: 'V9', title: 'Hall goal', spec_task: true, subtitle: 'what it is for', category_id: 3 });
|
|
42
|
+
for (const k of ['scope_modules', 'succeeds_goal_id', 'admit_reason']) assert.ok(!(k in body), `${k} must not be sendable from this form`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('an empty subtitle, description and category are omitted rather than sent blank', () => {
|
|
46
|
+
const body = lib.buildGoalBody({ version_id: 'V9', title: 'x', subtitle: ' ', description: '', category_id: '' });
|
|
47
|
+
assert.deepEqual(plain(body), { version_id: 'V9', title: 'x', spec_task: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('the goal form asks only for what the server checks first', () => {
|
|
51
|
+
assert.match(lib.goalFormProblem({ version_id: '', title: 'x' }), /version/i);
|
|
52
|
+
assert.match(lib.goalFormProblem({ version_id: 'V9', title: ' ' }), /name/i);
|
|
53
|
+
assert.match(lib.goalFormProblem({ version_id: 'V9', title: 'x'.repeat(lib.TITLE_MAX + 1) }), /under/);
|
|
54
|
+
assert.equal(lib.goalFormProblem({ version_id: 'V9', title: 'x' }), null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('the criterion slug is derived from the outcome text and is never positional', () => {
|
|
58
|
+
assert.equal(lib.criterionSlug('A goal and a criterion can be authored from the hall!'), 'a-goal-and-a-criterion-can-be-authored-from-the');
|
|
59
|
+
assert.equal(lib.criterionSlug('C7'), 'criterion-c7', 'a Cn-shaped slug is refused by the server, so it is never produced');
|
|
60
|
+
assert.equal(lib.criterionSlug('!!!'), '');
|
|
61
|
+
assert.ok(lib.criterionSlug('word '.repeat(40)).length <= 48);
|
|
62
|
+
assert.ok(!lib.criterionSlug('café déjà vu').match(/[^a-z0-9-]/));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('the criterion request is POST /versions/<goal\'s version>/done-when with the goal named', () => {
|
|
66
|
+
const r = lib.buildCriterionRequest({ goal_id: '12', version_id: 'V 9', outcome: ' Hall can author a goal ' });
|
|
67
|
+
assert.equal(r.path, '/versions/V%209/done-when');
|
|
68
|
+
assert.deepEqual(plain(r.body), { criterion_id: 'hall-can-author-a-goal', criterion_md: 'Hall can author a goal', goal_id: 12 });
|
|
69
|
+
assert.match(lib.criterionFormProblem({ goal_id: '', outcome: 'x' }), /goal/i);
|
|
70
|
+
assert.match(lib.criterionFormProblem({ goal_id: '3', outcome: ' ' }), /true/i);
|
|
71
|
+
assert.equal(lib.criterionFormProblem({ goal_id: '3', outcome: 'x' }), null);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a refusal keeps the server\'s own sentence and only fills in a bare code', () => {
|
|
75
|
+
assert.equal(lib.refusalText(403, { error: { code: 'x', message: 'Only an Archon.' } }), 'Only an Archon.');
|
|
76
|
+
assert.match(lib.refusalText(403, { error: 'goal_not_yours', message: 'Ask the owner.' }), /owner/);
|
|
77
|
+
assert.match(lib.refusalText(403, { error: { code: 'archon_required_for_protected_scope', message: 'archon_required_for_protected_scope' } }), /Archon/);
|
|
78
|
+
assert.match(lib.refusalText(409, { error: 'criterion_exists' }), /already exists/);
|
|
79
|
+
assert.match(lib.refusalText(500, null), /did not go through/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('versions are listed by openness only — the rank wall is the server\'s', () => {
|
|
83
|
+
const v = lib.openVersions([{ id: 'A', status: 'shipped' }, { id: 'B', status: 'building' }, { id: 'C', status: 'planning' }, null]);
|
|
84
|
+
assert.deepEqual(plain(v).map((x) => x.id), ['B', 'C']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── 2. the page wiring ───────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
test('the form has no scope field and none of the interview-shaped steps', () => {
|
|
90
|
+
const ui = read('goal-author.js');
|
|
91
|
+
assert.ok(!/scope_modules|ga-scope|succeeds_goal_id|admit_reason/.test(ui), 'no scope / carry-forward / admission control');
|
|
92
|
+
assert.ok(!/\/goals\/\$\{[^}]*\}\/scope/.test(ui), 'the scope is set by the spec task, not here');
|
|
93
|
+
assert.match(ui, /goals\/suggestions\?version=/, 'matching open goals are shown before submit');
|
|
94
|
+
assert.match(ui, /\/goals`, \{ body: lib\.buildGoalBody/, 'POST /goals goes through the pure body builder');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('goals.html loads the lib before the UI, and goals-page.js mounts it behind a guard', () => {
|
|
98
|
+
const html = read('goals.html');
|
|
99
|
+
const at = (f) => html.indexOf(`src="/builders/${f}"`);
|
|
100
|
+
assert.ok(at('goal-author-lib.js') > 0 && at('goal-author-lib.js') < at('goal-author.js'));
|
|
101
|
+
assert.ok(at('goal-author.js') < at(html.match(/src="\/builders\/(goals-page\.js[^"]*)"/)[1]));
|
|
102
|
+
assert.match(html, /id="goal-author-mount"/);
|
|
103
|
+
assert.match(read('goals-page.js'), /window\.OTBGoalAuthorUI\?\.mount\(/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── 3. the walls, driven with the form's own request ─────────────────────────
|
|
107
|
+
|
|
108
|
+
const r2 = createRequire(import.meta.url);
|
|
109
|
+
|
|
110
|
+
async function withRouter(routeFile, rank, setup, fn) {
|
|
111
|
+
const auth = r2(path.join(ROOT, 'src/bongos/auth.js'));
|
|
112
|
+
const api = r2(path.join(ROOT, 'src/module-api.js'));
|
|
113
|
+
const db = r2(path.join(ROOT, 'modules/lifecycle/db.js'));
|
|
114
|
+
const routePath = r2.resolve(path.join(ROOT, routeFile));
|
|
115
|
+
const saved = { rb: auth.requireBuilder, rp: auth.requirePermission, tx: api.withTx };
|
|
116
|
+
const savedDb = {};
|
|
117
|
+
auth.requireBuilder = (req, _res, next) => { req.builder = { id: 7, rank }; next(); };
|
|
118
|
+
auth.requirePermission = () => (_req, _res, next) => next();
|
|
119
|
+
api.withTx = async (f) => f({ query: async () => ({ rows: [] }) });
|
|
120
|
+
const calls = setup(db, (k) => { savedDb[k] = savedDb[k] || db[k]; });
|
|
121
|
+
delete r2.cache[routePath];
|
|
122
|
+
const express = r2('express');
|
|
123
|
+
const { attachFail } = r2(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
124
|
+
const app = express();
|
|
125
|
+
app.use(express.json());
|
|
126
|
+
app.use(attachFail);
|
|
127
|
+
app.use(r2(routePath)());
|
|
128
|
+
const server = app.listen(0);
|
|
129
|
+
await new Promise((res) => server.once('listening', res));
|
|
130
|
+
const post = async (p, body) => {
|
|
131
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`, {
|
|
132
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
133
|
+
});
|
|
134
|
+
return { status: res.status, data: await res.json().catch(() => ({})) };
|
|
135
|
+
};
|
|
136
|
+
try { await fn(post, calls); } finally {
|
|
137
|
+
server.close();
|
|
138
|
+
auth.requireBuilder = saved.rb; auth.requirePermission = saved.rp; api.withTx = saved.tx;
|
|
139
|
+
for (const [k, v] of Object.entries(savedDb)) db[k] = v;
|
|
140
|
+
delete r2.cache[routePath];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const goalSetup = (db, keep) => {
|
|
145
|
+
const calls = { createGoal: 0 };
|
|
146
|
+
for (const k of ['getVersion', 'createGoal', 'createTask']) keep(k);
|
|
147
|
+
db.getVersion = async (id) => ({ id, status: 'planning' });
|
|
148
|
+
db.createGoal = async (f) => { calls.createGoal += 1; return { id: 42, version_id: f.versionId, title: f.title }; };
|
|
149
|
+
db.createTask = async (f) => ({ id: 500, ...f });
|
|
150
|
+
return calls;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
test('wall: the form\'s own goal request succeeds for a Metic and creates the spec task', async () => {
|
|
154
|
+
await withRouter('modules/lifecycle/routes/goals.js', 'metic', goalSetup, async (post, calls) => {
|
|
155
|
+
const body = lib.buildGoalBody({ version_id: 'V9', title: 'Hall goal', subtitle: 'for the hall' });
|
|
156
|
+
const res = await post('/goals', body);
|
|
157
|
+
assert.equal(res.status, 201, JSON.stringify(res.data));
|
|
158
|
+
assert.equal(res.data.spec_task.id, 500);
|
|
159
|
+
assert.equal(calls.createGoal, 1);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('wall: a Metic still cannot create a goal scoped to a protected module', async () => {
|
|
164
|
+
await withRouter('modules/lifecycle/routes/goals.js', 'metic', goalSetup, async (post, calls) => {
|
|
165
|
+
// The hall form cannot send this field; the server must refuse it anyway.
|
|
166
|
+
const res = await post('/goals', { ...lib.buildGoalBody({ version_id: 'V9', title: 'Hall goal' }), scope_modules: ['kernel'] });
|
|
167
|
+
assert.equal(res.status, 403);
|
|
168
|
+
assert.equal(res.data.error && res.data.error.code, 'archon_required_for_protected_scope');
|
|
169
|
+
assert.equal(calls.createGoal, 0);
|
|
170
|
+
assert.match(lib.refusalText(res.status, res.data), /Archon/);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const criterionSetup = (membership) => (db, keep) => {
|
|
175
|
+
const calls = { createCriterion: 0 };
|
|
176
|
+
for (const k of ['getVersion', 'getGoal', 'getGoalMember', 'createCriterion']) keep(k);
|
|
177
|
+
db.getVersion = async (id) => ({ id, status: 'building' });
|
|
178
|
+
db.getGoal = async (id) => ({ id, version_id: 'V9', status: 'open', created_by: 99 });
|
|
179
|
+
db.getGoalMember = async () => membership;
|
|
180
|
+
db.createCriterion = async (f) => { calls.createCriterion += 1; return { criterion_id: f.criterionId }; };
|
|
181
|
+
return calls;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
test('wall: a non-member Metic\'s criterion is refused by the server, with its reason', async () => {
|
|
185
|
+
await withRouter('modules/lifecycle/routes/done-when.js', 'metic', criterionSetup(null), async (post, calls) => {
|
|
186
|
+
const req = lib.buildCriterionRequest({ goal_id: '12', version_id: 'V9', outcome: 'Hall can author a goal' });
|
|
187
|
+
req.body.goal_id = 12;
|
|
188
|
+
const res = await post(req.path, req.body);
|
|
189
|
+
assert.equal(res.status, 403);
|
|
190
|
+
assert.equal(res.data.error, 'goal_not_yours');
|
|
191
|
+
assert.equal(calls.createCriterion, 0);
|
|
192
|
+
assert.match(lib.refusalText(res.status, res.data), /owner or a manager/);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('wall: the goal\'s manager can add the same criterion through the same request', async () => {
|
|
197
|
+
await withRouter('modules/lifecycle/routes/done-when.js', 'metic', criterionSetup({ membership_kind: 'lead' }), async (post, calls) => {
|
|
198
|
+
const req = lib.buildCriterionRequest({ goal_id: '12', version_id: 'V9', outcome: 'Hall can author a goal' });
|
|
199
|
+
const res = await post(req.path, req.body);
|
|
200
|
+
assert.equal(res.status, 201, JSON.stringify(res.data));
|
|
201
|
+
assert.equal(calls.createCriterion, 1);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('wall: both routes still sit behind their permission atoms', () => {
|
|
206
|
+
const g = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/goals.js'), 'utf8');
|
|
207
|
+
const d = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/done-when.js'), 'utf8');
|
|
208
|
+
assert.match(g, /router\.post\('\/goals', auth\.requireBuilder, auth\.requirePermission\('goal\.create'\)/);
|
|
209
|
+
assert.match(d, /router\.post\('\/versions\/:id\/done-when', auth\.requireBuilder, auth\.requirePermission\('criterion\.create'\)/);
|
|
210
|
+
});
|