@bongos/core 1.21.45 → 1.21.46
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 +34 -24
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +2 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +558 -554
- package/modules/provisioning/manifest-truth.js +63 -0
- package/modules/provisioning/provisioning.js +3 -11
- package/modules/provisioning/routes/provisioning.js +3 -1
- package/modules/public-landing/public/projects.html +38 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/provision.js +4 -2
- package/src/bongos/routes/auth.js +15 -2
- package/src/module-api.js +1 -1
- package/tests/projects_hub_pre_uat.mjs +3 -2
- package/tests/provision.mjs +53 -0
- package/tests/provisioning_github_app_routes.mjs +23 -0
- package/tests/provisioning_settings.mjs +6 -4
- package/tests/wizard_github_feedback.mjs +239 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// tests/wizard_github_feedback.mjs — the setup checklist tells the founder the truth
|
|
2
|
+
// at every GitHub sign-in stage (task 1004572).
|
|
3
|
+
//
|
|
4
|
+
// WHY. Live on 2026-10-04, a founder setting up mercury was told, in turn:
|
|
5
|
+
// • "Waiting for your approval on GitHub…" while the platform was already placing
|
|
6
|
+
// the credentials ('converting' fell through to the pending branch);
|
|
7
|
+
// • "GitHub setup didn't start. Nothing was changed on GitHub" after GitHub HAD made
|
|
8
|
+
// an app on their account — so they made another, and another;
|
|
9
|
+
// • and, once a setup link lapsed, nothing at all: no button, forever, because a
|
|
10
|
+
// lapsed 'pending' row was never read as expired and the "start again" offer was
|
|
11
|
+
// timed from a click that a reload forgets;
|
|
12
|
+
// • and on the project itself, a bare "auth not configured".
|
|
13
|
+
//
|
|
14
|
+
// Four layers, all DB-free and network-free:
|
|
15
|
+
// 1. manifest-truth.js — the effective status and how far a failure got (PURE).
|
|
16
|
+
// 2. renderStepOauthApp, EXECUTED by the vm-slice idiom: the words for each stage.
|
|
17
|
+
// 3. the reload path, EXECUTED: the seed from the instance read and the wait timer.
|
|
18
|
+
// 4. the project's own sign-in door with nothing configured: a page in words.
|
|
19
|
+
//
|
|
20
|
+
// Run: node --test tests/wizard_github_feedback.mjs
|
|
21
|
+
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import vm from 'node:vm';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
33
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
34
|
+
const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
|
|
35
|
+
|
|
36
|
+
const truth = require(path.join(ROOT, 'modules', 'provisioning', 'manifest-truth.js'));
|
|
37
|
+
|
|
38
|
+
// ── 1. manifest-truth.js ────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const NOW = Date.parse('2026-10-04T20:30:00Z');
|
|
41
|
+
const LATER = '2026-10-04T20:45:00Z';
|
|
42
|
+
const EARLIER = '2026-10-04T20:15:00Z';
|
|
43
|
+
|
|
44
|
+
test('a pending link past its expiry reads EXPIRED; one still live stays pending', () => {
|
|
45
|
+
assert.equal(truth.effectiveManifestStatus({ status: 'pending', expires_at: EARLIER }, NOW), 'expired');
|
|
46
|
+
assert.equal(truth.effectiveManifestStatus({ status: 'pending', expires_at: LATER }, NOW), 'pending');
|
|
47
|
+
// only 'pending' decays — a finished or failed attempt keeps its word
|
|
48
|
+
for (const s of ['code_received', 'converting', 'done', 'error']) {
|
|
49
|
+
assert.equal(truth.effectiveManifestStatus({ status: s, expires_at: EARLIER }, NOW), s, s);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('how far a failure got is read from the row\'s shape, never from error text', () => {
|
|
54
|
+
assert.equal(truth.manifestFailure({ attempts: 0 }), 'not_approved');
|
|
55
|
+
assert.equal(truth.manifestFailure({ attempts: 1, app_slug: 'mercury-cloudbongos-com' }), 'sign_in_unconfirmed');
|
|
56
|
+
assert.equal(truth.manifestFailure({ attempts: 1, app_slug: null, last_error: 'did not report sign-in ready' }), 'finish_failed',
|
|
57
|
+
'an error message alone claims nothing — only a recorded App does');
|
|
58
|
+
assert.deepEqual([...truth.MANIFEST_FAILURES].sort(), ['finish_failed', 'not_approved', 'sign_in_unconfirmed']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('publicGithubApp: effective status, failure only on error, started_at, and nothing else', () => {
|
|
62
|
+
const row = { status: 'pending', expires_at: EARLIER, created_at: '2026-10-04T19:57:35.018Z', github_code: 'SECRET', state: 'SECRET', last_error: 'SECRET' };
|
|
63
|
+
const pub = truth.publicGithubApp(row, NOW);
|
|
64
|
+
assert.deepEqual(pub, { status: 'expired', app_slug: null, failure: null, started_at: '2026-10-04T19:57:35.018Z' });
|
|
65
|
+
assert.doesNotMatch(JSON.stringify(pub), /SECRET/);
|
|
66
|
+
assert.equal(truth.publicGithubApp({ status: 'error', attempts: 1, app_slug: 'a' }, NOW).failure, 'sign_in_unconfirmed');
|
|
67
|
+
assert.equal(truth.publicGithubApp(null), null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── 2. the words for each stage ─────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const RS = SCRIPT.indexOf(' /* oauth-app — the ADR 0133 one-click GitHub-App manifest flow');
|
|
73
|
+
const RE = SCRIPT.indexOf(' /* device-flow', RS);
|
|
74
|
+
const RENDER = SCRIPT.slice(RS, RE);
|
|
75
|
+
|
|
76
|
+
test('the GitHub step region is still findable (a rename must fail loudly)', () => {
|
|
77
|
+
assert.ok(RS > 0 && RE > RS, 'both slice boundaries found');
|
|
78
|
+
assert.match(RENDER, /function renderStepOauthApp\(\)/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function stage(live, { stale = false } = {}) {
|
|
82
|
+
const sandbox = {
|
|
83
|
+
live: { manifest: null, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug: null, instStatus: 'active', addrReady: true, ...live },
|
|
84
|
+
manifestStale: stale,
|
|
85
|
+
autoOpenArmed: false,
|
|
86
|
+
resolvedDomain: () => 'mercury.example.com',
|
|
87
|
+
apexBase: () => 'example.com',
|
|
88
|
+
esc: (x) => String(x).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])),
|
|
89
|
+
GH_ICON: '',
|
|
90
|
+
manualOauthDetails: () => '<details>manual</details>',
|
|
91
|
+
projectPageLink: (t) => '<a>' + t + '</a>',
|
|
92
|
+
autoOpenTick: () => '<label>auto</label>',
|
|
93
|
+
progressMarkup: (now, done, note, cls) => `<prog now=${now} done=${done} cls="${cls}">${note}</prog>`,
|
|
94
|
+
};
|
|
95
|
+
vm.createContext(sandbox);
|
|
96
|
+
vm.runInContext(`${RENDER}\nvar out = renderStepOauthApp();`, sandbox);
|
|
97
|
+
return sandbox.out;
|
|
98
|
+
}
|
|
99
|
+
const startButton = /data-action="manifest-start"/;
|
|
100
|
+
|
|
101
|
+
test('pending: waiting for approval, and says where a missing GitHub tab went', () => {
|
|
102
|
+
const html = stage({ manifest: 'pending' });
|
|
103
|
+
assert.match(html, /Waiting for your approval on GitHub/);
|
|
104
|
+
assert.match(html, /No GitHub tab\? It may have opened behind this window or in another one/);
|
|
105
|
+
assert.doesNotMatch(html, startButton, 'no restart offer while the wait is young');
|
|
106
|
+
assert.match(stage({ manifest: 'pending' }, { stale: true }), /Start the setup again/, 'offered once the wait has gone wrong');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('converting is the SAME stage as code_received — approved, placing credentials — never "waiting for approval"', () => {
|
|
110
|
+
for (const m of ['code_received', 'converting']) {
|
|
111
|
+
const html = stage({ manifest: m });
|
|
112
|
+
assert.match(html, /GitHub’s part is done/, m);
|
|
113
|
+
assert.match(html, /<prog now=1 done=1/, `${m}: the strip is past the approval stage`);
|
|
114
|
+
assert.doesNotMatch(html, /Waiting for your approval/, m);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a failure AFTER approval says an app now exists, names it, and does not push a second one', () => {
|
|
119
|
+
const html = stage({ manifest: 'error', manifestFailure: 'sign_in_unconfirmed', appSlug: 'mercury-cloudbongos-com' });
|
|
120
|
+
assert.match(html, /GitHub approved the app and the platform placed its credentials/);
|
|
121
|
+
assert.match(html, /href="https:\/\/github\.com\/settings\/apps\/mercury-cloudbongos-com"/);
|
|
122
|
+
assert.match(html, /would make a second app/);
|
|
123
|
+
assert.match(html, /href="\/contact"/);
|
|
124
|
+
assert.match(html, /cls="isErr"/, 'the strip shows where it stopped');
|
|
125
|
+
assert.doesNotMatch(html, /Nothing was changed on GitHub/, 'the old claim was false here');
|
|
126
|
+
assert.doesNotMatch(html, /class="ghbtn"/, 'no big primary "set up" button inviting a second app');
|
|
127
|
+
assert.match(html, /Try again anyway/, '…but the founder is never left with no way forward');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('approved but never finished: delete the app GitHub made first, then try again', () => {
|
|
131
|
+
const html = stage({ manifest: 'error', manifestFailure: 'finish_failed' });
|
|
132
|
+
assert.match(html, /GitHub approved the app, but the platform couldn’t finish/);
|
|
133
|
+
assert.match(html, /delete it first/);
|
|
134
|
+
assert.doesNotMatch(html, /Nothing was changed on GitHub/);
|
|
135
|
+
assert.match(html, startButton);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a failure before GitHub (or a refused start) keeps the honest "nothing changed" line', () => {
|
|
139
|
+
assert.match(stage({ manifest: 'error', manifestFailure: 'not_approved' }), /Nothing was changed on GitHub/);
|
|
140
|
+
const refused = stage({ manifest: 'error', manifestMsg: 'HTTP 500' });
|
|
141
|
+
assert.match(refused, /Nothing was changed on GitHub/);
|
|
142
|
+
assert.match(refused, /Technical details/);
|
|
143
|
+
assert.match(refused, startButton);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('an expired link offers a fresh one', () => {
|
|
147
|
+
const html = stage({ manifest: 'expired' });
|
|
148
|
+
assert.match(html, /That setup link expired/);
|
|
149
|
+
assert.match(html, startButton);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── 3. the reload path ──────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
const WS = SCRIPT.indexOf(' function manifestInFlight() {');
|
|
155
|
+
const WE = SCRIPT.indexOf(' function pollManifest(stateToken) {', WS);
|
|
156
|
+
const WAIT = SCRIPT.slice(WS, WE);
|
|
157
|
+
const SS = SCRIPT.indexOf(' var ga = data.github_app;');
|
|
158
|
+
const SE = SCRIPT.indexOf(' /* the checklist reflects live state (task 1002694)', SS);
|
|
159
|
+
const SEED = SCRIPT.slice(SS, SE);
|
|
160
|
+
|
|
161
|
+
test('the reload regions are still findable (a rename must fail loudly)', () => {
|
|
162
|
+
assert.ok(WS > 0 && WE > WS, 'wait-timer boundaries found');
|
|
163
|
+
assert.ok(SS > 0 && SE > SS, 'instance-read seed boundaries found');
|
|
164
|
+
assert.match(SCRIPT, /if \(!terminal \|\| manifestInFlight\(\)\) pollTimer = setTimeout\(tick, 5000\);/,
|
|
165
|
+
'a GitHub leg in flight keeps the instance read alive once the project is up — after a reload it is the only watcher');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// A page that RELOADED: no click in this tab, no manifest poll, just the instance read.
|
|
169
|
+
function reloaded(githubApp, now) {
|
|
170
|
+
const renders = [];
|
|
171
|
+
const sandbox = {
|
|
172
|
+
data: { github_app: githubApp },
|
|
173
|
+
live: { manifest: null, manifestFailure: null, appSlug: null },
|
|
174
|
+
manifestPollTimer: null, manifestStartedAt: 0, manifestStale: false,
|
|
175
|
+
rememberManifest: () => {}, renderSteps: () => renders.push(1),
|
|
176
|
+
Date: { now: () => now, parse: Date.parse },
|
|
177
|
+
};
|
|
178
|
+
vm.createContext(sandbox);
|
|
179
|
+
vm.runInContext(`${WAIT}\n${SEED}`, sandbox);
|
|
180
|
+
return { sandbox, renders, tick: () => vm.runInContext(SEED, sandbox) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
test('a reloaded page times the wait from when the link was minted, and still reaches "start again"', () => {
|
|
184
|
+
const minted = '2026-10-04T20:20:00Z';
|
|
185
|
+
const young = reloaded({ status: 'pending', app_slug: null, failure: null, started_at: minted }, Date.parse(minted) + 30000);
|
|
186
|
+
assert.equal(young.sandbox.live.manifest, 'pending');
|
|
187
|
+
assert.equal(young.sandbox.manifestStartedAt, Date.parse(minted), 'seeded from the server');
|
|
188
|
+
assert.equal(young.sandbox.manifestStale, false);
|
|
189
|
+
|
|
190
|
+
const old = reloaded({ status: 'pending', app_slug: null, failure: null, started_at: minted }, Date.parse(minted) + 120000);
|
|
191
|
+
old.tick(); // the next 5s read: same status, so the wait timer decides
|
|
192
|
+
assert.equal(old.sandbox.manifestStale, true, 'the offer appears — it used to be never after a reload');
|
|
193
|
+
assert.equal(vm.runInContext('manifestInFlight()', old.sandbox), true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('a reloaded page learns HOW a failure happened, not just that it did', () => {
|
|
197
|
+
const r = reloaded({ status: 'error', app_slug: 'mercury-cloudbongos-com', failure: 'sign_in_unconfirmed', started_at: null }, NOW);
|
|
198
|
+
assert.equal(r.sandbox.live.manifest, 'error');
|
|
199
|
+
assert.equal(r.sandbox.live.manifestFailure, 'sign_in_unconfirmed');
|
|
200
|
+
assert.equal(r.sandbox.live.appSlug, 'mercury-cloudbongos-com');
|
|
201
|
+
assert.equal(vm.runInContext('manifestInFlight()', r.sandbox), false, 'a finished attempt stops the watch');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('the reset literal and the initial literal still agree, failure field included', () => {
|
|
205
|
+
const lits = SCRIPT.match(/live = \{ instStatus: null[^}]*\}/g) || [];
|
|
206
|
+
assert.equal(lits.length, 2);
|
|
207
|
+
assert.equal(lits[0].replace(/^var /, ''), lits[1]);
|
|
208
|
+
assert.match(lits[0], /manifestFailure: null/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── 4. the project's own door with nothing configured ───────────────────────
|
|
212
|
+
|
|
213
|
+
test('a project with no sign-in configured answers a page in words, not a bare "auth not configured"', async () => {
|
|
214
|
+
const auth = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
|
|
215
|
+
const prev = { a: auth.authConfigured, i: auth.idpConfigured };
|
|
216
|
+
auth.authConfigured = () => false;
|
|
217
|
+
auth.idpConfigured = () => false;
|
|
218
|
+
const express = require('express');
|
|
219
|
+
const buildAuthRouter = require(path.join(ROOT, 'src', 'bongos', 'routes', 'auth.js'));
|
|
220
|
+
const app = express();
|
|
221
|
+
app.use('/api/bongos', buildAuthRouter());
|
|
222
|
+
const server = app.listen(0);
|
|
223
|
+
try {
|
|
224
|
+
await new Promise((r) => server.once('listening', r));
|
|
225
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
226
|
+
for (const p of ['/auth/web/start', '/auth/web/callback']) {
|
|
227
|
+
const res = await fetch(base + p);
|
|
228
|
+
const html = await res.text();
|
|
229
|
+
assert.equal(res.status, 503, p);
|
|
230
|
+
assert.match(res.headers.get('content-type') || '', /text\/html/, p);
|
|
231
|
+
assert.match(html, /Sign-in isn't set up on this project yet|Sign-in isn't set up on this project yet/, p);
|
|
232
|
+
assert.match(html, /Wire GitHub sign-in/, `${p}: points the owner at the step that fixes it`);
|
|
233
|
+
assert.doesNotMatch(html, /^auth not configured$/, p);
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
server.close();
|
|
237
|
+
auth.authConfigured = prev.a; auth.idpConfigured = prev.i;
|
|
238
|
+
}
|
|
239
|
+
});
|