@bongos/core 1.21.95 → 1.21.97
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 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +10 -3
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +156 -7
- package/docs/api-reference.md +8 -5
- package/docs/copy-inventory.md +367 -358
- package/docs/copy-registry.json +536 -425
- package/docs/design/project-startup-direction.md +22 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +351 -348
- package/modules/provisioning/github-setup.js +98 -7
- package/modules/provisioning/routes/github-setup.js +44 -1
- package/modules/provisioning/routes/provisioning.js +37 -8
- package/modules/public-landing/public/projects.html +441 -36
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/provision-github-setup.js +29 -9
- package/scripts/gds/provision.js +20 -9
- package/src/bongos/github-repos.js +129 -0
- package/src/bongos/routes/github.js +71 -5
- package/src/module-api.js +1 -1
- package/tests/github_one_trip.mjs +4 -4
- package/tests/github_org_account.mjs +379 -0
- package/tests/github_pick_up.mjs +366 -0
- package/tests/projects_hub_pre_uat.mjs +3 -2
- package/tests/provisioning_github_app_routes.mjs +73 -0
- package/tests/wizard_github_feedback.mjs +95 -29
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// tests/github_pick_up.mjs — "GitHub retry picks up where it stopped, never makes a second
|
|
2
|
+
// app, and every failure has a button that fixes it" (task 1004652).
|
|
3
|
+
//
|
|
4
|
+
// WHY. Retrying the GitHub step used to mint a new link, which makes a SECOND app on the
|
|
5
|
+
// founder's account; "Start again" appeared only after 90 seconds; and two failures ended
|
|
6
|
+
// at "contact us" or "delete the orphan app by hand". Now every state part 03 can reach
|
|
7
|
+
// has one sentence and an action that resumes from that step with the app already made.
|
|
8
|
+
//
|
|
9
|
+
// Four layers, DB-free and network-free:
|
|
10
|
+
// 1. modules/provisioning/github-setup.js — pickUpFor (what the action does, for EVERY
|
|
11
|
+
// status × app × verified-facts shape), mintGuard (reuse or refuse, never a second
|
|
12
|
+
// attempt once GitHub approved one), and the checking window a check request re-opens.
|
|
13
|
+
// 2. the runner — a stopped sign-in is re-asked with the keys already placed and becomes
|
|
14
|
+
// 'done'; a placement that throws after GitHub's conversion is a SPENT code (never
|
|
15
|
+
// re-queued); GitHub's 404 on a conversion drops the dead code.
|
|
16
|
+
// 3. the REAL status route's answer run into the REAL page (seedSetup + the step renderers):
|
|
17
|
+
// every reachable state draws a sentence and an action, none a chore or "contact us".
|
|
18
|
+
// 4. the page's own actions: pick-up posts to the pick-up route and watches again, Reopen
|
|
19
|
+
// GitHub goes through the mint route (which hands back the SAME state), and "Send it to
|
|
20
|
+
// the platform team" files a blocker without a secret.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test tests/github_pick_up.mjs
|
|
23
|
+
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import vm from 'node:vm';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const GS = require(path.join(ROOT, 'modules', 'provisioning', 'github-setup.js'));
|
|
35
|
+
const RUN = require(path.join(ROOT, 'scripts', 'gds', 'provision-github-setup.js'));
|
|
36
|
+
const P = require(path.join(ROOT, 'scripts', 'gds', 'provision.js'));
|
|
37
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
38
|
+
const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
|
|
39
|
+
|
|
40
|
+
const NOW = Date.parse('2026-10-09T12:00:00Z');
|
|
41
|
+
const ago = (min) => new Date(NOW - min * 60000).toISOString();
|
|
42
|
+
const SOON = new Date(NOW + 10 * 60000).toISOString();
|
|
43
|
+
|
|
44
|
+
// ── 1. what picking it up does ──────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
test('pickUpFor: every attempt shape has its action, and none of them is "make another app"', () => {
|
|
47
|
+
const c = (status, slug, facts) => GS.githubChecklist(status, slug, facts);
|
|
48
|
+
const at = (row, status, facts = {}, verifying = false) => GS.pickUpFor(row, status, c(status, row && row.app_slug, facts), verifying, NOW);
|
|
49
|
+
assert.equal(at(null, null), null, 'no attempt: Connect GitHub is the first click, not a pick-up');
|
|
50
|
+
assert.equal(at({ attempts: 0 }, 'pending'), 'reopen', 'waiting on GitHub: post the SAME link again');
|
|
51
|
+
assert.equal(at({ attempts: 0 }, 'expired'), 'fresh', 'lapsed before approval: nothing was made, a new link is safe');
|
|
52
|
+
assert.equal(at({ attempts: 0 }, 'error'), 'fresh', 'the runner never took it up: nothing was made');
|
|
53
|
+
assert.equal(at({ attempts: 1 }, 'code_received'), null, 'in flight: nothing to pick up');
|
|
54
|
+
assert.equal(at({ attempts: 1 }, 'converting'), null);
|
|
55
|
+
// GitHub approved; the conversion stopped
|
|
56
|
+
assert.equal(at({ attempts: 2, github_code: 'C', created_at: ago(20) }, 'error'), 'convert', 'the code is still young: convert it again');
|
|
57
|
+
assert.equal(at({ attempts: 2, github_code: 'C', created_at: ago(61) }, 'error'), 'operator', 'past GitHub\'s hour the code is dead');
|
|
58
|
+
assert.equal(at({ attempts: 2, github_code: null, created_at: ago(5) }, 'error'), 'operator', 'a spent code: the app exists, its keys never reached us');
|
|
59
|
+
// the app exists with its keys placed
|
|
60
|
+
const app = { attempts: 1, app_id: 5, app_slug: 'mercury-app' };
|
|
61
|
+
assert.equal(at(app, 'error'), 'check', 'sign-in unconfirmed, the runner stopped asking: ask again');
|
|
62
|
+
assert.equal(at(app, 'error', {}, true), null, 'still asking: nothing to press');
|
|
63
|
+
assert.equal(at(app, 'done'), 'check', 'install never confirmed and the window closed: check again');
|
|
64
|
+
assert.equal(at(app, 'done', { installedAt: ago(1) }), 'check', 'device flow still off after the window: check again');
|
|
65
|
+
assert.equal(at(app, 'done', { installedAt: ago(1), deviceFlowAt: ago(1) }), null, 'all four ticked: nothing left');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('mintGuard: a waiting link is handed back, and once GitHub approved an app no new link is made', () => {
|
|
69
|
+
assert.equal(GS.mintGuard(null, NOW), null);
|
|
70
|
+
assert.deepEqual(GS.mintGuard({ status: 'pending', state: 'S', expires_at: SOON }, NOW), { reuse: 'S', expiresAt: SOON });
|
|
71
|
+
assert.equal(GS.mintGuard({ status: 'pending', state: 'S', expires_at: ago(1) }, NOW), null, 'lapsed: mint fresh');
|
|
72
|
+
assert.equal(GS.mintGuard({ status: 'error', attempts: 0 }, NOW), null, 'nothing made: mint fresh');
|
|
73
|
+
for (const row of [{ status: 'error', attempts: 1, app_id: 5 }, { status: 'done', attempts: 1, app_id: 5 }, { status: 'error', attempts: 3, app_id: null }]) {
|
|
74
|
+
assert.equal(GS.mintGuard(row, NOW).refuse, 'app_exists', JSON.stringify(row));
|
|
75
|
+
}
|
|
76
|
+
assert.equal(GS.mintGuard({ status: 'converting', attempts: 1 }, NOW).refuse, 'github_in_progress');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a check request re-opens the runner\'s checking window, like an install report', () => {
|
|
80
|
+
const settled = { resolved_at: ago(5 * 60) };
|
|
81
|
+
assert.equal(GS.stillVerifying(settled, {}, NOW), false);
|
|
82
|
+
assert.equal(GS.stillVerifying(settled, { checkRequestedAt: ago(1) }, NOW), true);
|
|
83
|
+
assert.equal(GS.SETUP_EVENTS.checkRequested, 'github-app-check-requested');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── 2. the runner ───────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
const STOPPED = { id: 21, instance_id: 42, created_at: ago(300), requested_by: 7, status: 'error', slug: 'mercury', port: 4100, target_ref: 'acme/mercury', hosting_shape: 'co-tenant', instance_status: 'active' };
|
|
89
|
+
function runnerDeps({ healthz, facts = [] }) {
|
|
90
|
+
const resolved = [], events = [], cmds = [];
|
|
91
|
+
return {
|
|
92
|
+
resolved, events, cmds,
|
|
93
|
+
deps: {
|
|
94
|
+
apply: true, log: () => {}, fetchImpl: async () => ({ status: 500, ok: false, json: async () => ({}) }),
|
|
95
|
+
db: { query: async (sql) => (/WITH latest AS/.test(sql) ? { rows: [STOPPED] } : /FROM provisioning_events/.test(sql) ? { rows: facts } : { rows: [] }) },
|
|
96
|
+
exec: (cmd) => { cmds.push(cmd); return { ok: true, stdout: /healthz/.test(cmd) ? healthz : '' }; },
|
|
97
|
+
provisioning: {
|
|
98
|
+
resolveOAuthManifest: async (_db, id, status, patch) => { resolved.push({ id, status, patch }); },
|
|
99
|
+
recordEvent: async (_db, e) => { events.push(e); },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test('a stopped sign-in is asked again with the keys already placed, and becomes done — the same attempt', async () => {
|
|
106
|
+
const w = runnerDeps({ healthz: '{"ok":true,"auth_configured":true}' });
|
|
107
|
+
const out = await RUN.verifyGithubAppSetups(w.deps);
|
|
108
|
+
assert.equal(out.signIn, 1);
|
|
109
|
+
assert.ok(w.cmds.includes('curl -sS --max-time 5 http://127.0.0.1:4100/api/bongos/healthz'), 'the JSON health read, as the placement uses (authHealthzCmd)');
|
|
110
|
+
assert.equal(w.cmds[0], P.authHealthzCmd({ port: 4100 }), 'byte-for-byte the placement probe');
|
|
111
|
+
assert.deepEqual(w.resolved, [{ id: 21, status: 'done', patch: { github_code: null } }], 'attempt 21 itself — nothing new is made');
|
|
112
|
+
assert.ok(w.events.some((e) => e.event === 'oauth-manifest-done'));
|
|
113
|
+
// still not answering: nothing changes, the next tick asks again
|
|
114
|
+
const no = runnerDeps({ healthz: '{"ok":true,"auth_configured":false}' });
|
|
115
|
+
assert.equal((await RUN.verifyGithubAppSetups(no.deps)).signIn, 0);
|
|
116
|
+
assert.deepEqual(no.resolved, []);
|
|
117
|
+
// an attempt that is 'done' is never re-asked
|
|
118
|
+
const done = runnerDeps({ healthz: '{"auth_configured":true}', facts: [{ event: 'github-app-installed', at: ago(1) }, { event: 'github-app-device-flow', at: ago(1) }] });
|
|
119
|
+
STOPPED.status = 'done';
|
|
120
|
+
try { await RUN.verifyGithubAppSetups(done.deps); } finally { STOPPED.status = 'error'; }
|
|
121
|
+
assert.deepEqual(done.cmds, [], 'a finished attempt costs nothing');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const INST = { id: 1, slug: 'mercury', hosting_shape: 'co-tenant', status: 'active', owner_builder_id: 7, port: 3003, db_name: 'mercury', domain: 'mercury.example.com' };
|
|
125
|
+
const CREDS = { clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 9, appSlug: 'm-app' };
|
|
126
|
+
|
|
127
|
+
test('keys that fail to land after GitHub\'s conversion are a SPENT code: never re-queued, the app named for the platform team', async () => {
|
|
128
|
+
const r = await P.placeManifestCreds(INST, { github_code: 'C', attempts: 1 }, {
|
|
129
|
+
apply: true, log: () => {}, convertManifestCode: async () => CREDS,
|
|
130
|
+
exec: () => ({ ok: true, stdout: '' }), writeFile: () => { throw new Error('ssh: connection reset'); },
|
|
131
|
+
});
|
|
132
|
+
assert.equal(r.ok, false);
|
|
133
|
+
assert.equal(r.codeSpent, true, 'the code is burned: a retry would 404 forever');
|
|
134
|
+
assert.match(r.error, /GitHub made the app m-app, but placing its keys failed: ssh: connection reset/);
|
|
135
|
+
assert.equal(r.appSlug, undefined, 'no slug on the row: a recorded slug reads as "keys placed"');
|
|
136
|
+
// …and the drain records it so: error, code dropped — pickUpFor then says 'operator', not 'convert'
|
|
137
|
+
let claims = 0; const resolved = [];
|
|
138
|
+
await P.drainOAuthManifests({
|
|
139
|
+
apply: true, log: () => {}, db: {}, exec: () => ({ ok: true, stdout: '' }), writeFile: () => { throw new Error('disk full'); },
|
|
140
|
+
convertManifestCode: async () => CREDS,
|
|
141
|
+
provisioning: {
|
|
142
|
+
claimNextOAuthManifest: async () => (claims++ === 0 ? { id: 31, instance_id: 1, github_code: 'C', attempts: 1 } : null),
|
|
143
|
+
getInstanceById: async () => INST, resolveOAuthManifest: async (_db, id, status, patch) => { resolved.push({ id, status, patch }); }, recordEvent: async () => {},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
assert.equal(resolved.length, 1);
|
|
147
|
+
assert.equal(resolved[0].status, 'error');
|
|
148
|
+
assert.equal(resolved[0].patch.github_code, null);
|
|
149
|
+
assert.equal(GS.pickUpFor({ attempts: 1, github_code: null, created_at: ago(1) }, 'error', GS.githubChecklist('error', null), false, NOW), 'operator');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('GitHub\'s 404 on a conversion drops the dead code; any other failure keeps it for a pick-up', async () => {
|
|
153
|
+
const drain = async (message) => {
|
|
154
|
+
let claims = 0; const resolved = [];
|
|
155
|
+
await P.drainOAuthManifests({
|
|
156
|
+
apply: true, log: () => {}, db: {}, exec: () => ({ ok: true }), writeFile: () => ({ ok: true }),
|
|
157
|
+
convertManifestCode: async () => { throw new Error(message); },
|
|
158
|
+
provisioning: {
|
|
159
|
+
claimNextOAuthManifest: async () => (claims++ === 0 ? { id: 32, instance_id: 1, github_code: 'C', attempts: 1 } : null),
|
|
160
|
+
getInstanceById: async () => INST, resolveOAuthManifest: async (_db, id, status, patch) => { resolved.push({ status, patch }); }, recordEvent: async () => {},
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
return resolved[0];
|
|
164
|
+
};
|
|
165
|
+
const dead = await drain('GitHub manifest conversion failed: Not Found');
|
|
166
|
+
assert.equal(dead.status, 'error');
|
|
167
|
+
assert.equal(dead.patch.github_code, null);
|
|
168
|
+
const flaky = await drain('GitHub manifest conversion failed: HTTP 502');
|
|
169
|
+
assert.equal(flaky.status, 'error');
|
|
170
|
+
assert.ok(!('github_code' in flaky.patch), 'the code may still convert: "Pick up where it stopped" re-queues it');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── 3. the REAL status route into the REAL page ─────────────────────────────
|
|
174
|
+
|
|
175
|
+
const provisioning = require(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'));
|
|
176
|
+
let attempt = null;
|
|
177
|
+
provisioning.getOAuthManifestByState = async (_db, state) => (state === 'S1' ? attempt : null);
|
|
178
|
+
provisioning.getInstanceById = async () => ({ id: 42, owner_builder_id: 7, slug: 'mercury', domain: 'mercury.example.com', target_ref: 'acme/mercury', status: 'active' });
|
|
179
|
+
let eventRows = [];
|
|
180
|
+
const realQuery = { query: async () => ({ rows: eventRows }) };
|
|
181
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
182
|
+
api.pool.query = realQuery.query; // the one read the status route makes: the verified facts
|
|
183
|
+
const express = require('express');
|
|
184
|
+
const app = express();
|
|
185
|
+
app.use('/api/bongos', require(path.join(ROOT, 'modules', 'provisioning', 'routes', 'github-setup.js'))());
|
|
186
|
+
|
|
187
|
+
const RS = SCRIPT.indexOf(' /* The four GitHub steps of part 03');
|
|
188
|
+
const RE = SCRIPT.indexOf(' var STEP_RENDERERS = {', RS);
|
|
189
|
+
const SS = SCRIPT.indexOf(' /* the checklist the server derived (task 1004649)');
|
|
190
|
+
const SE = SCRIPT.indexOf(' function setupOpen() {', SS);
|
|
191
|
+
const AS = SCRIPT.indexOf(' /* ── Pick up where it stopped (task 1004652).');
|
|
192
|
+
const AE = SCRIPT.indexOf(' function startManifest(btn) {', AS);
|
|
193
|
+
|
|
194
|
+
test('the page regions are findable (a rename must fail loudly)', () => {
|
|
195
|
+
assert.ok(RS > 0 && RE > RS && SS > 0 && SE > SS && AS > 0 && AE > AS);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const esc = (x) => String(x).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
199
|
+
function page(extra = {}) {
|
|
200
|
+
const sandbox = {
|
|
201
|
+
live: { manifest: null, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug: null, appOrg: null, setup: null, installUrl: null, instStatus: 'active', addrReady: true, pickUp: null, verifying: false, askedPlatform: false },
|
|
202
|
+
state: { name: 'Mercury', slug: 'mercury', owner: 'acme', repo: 'mercury' }, autoOpenArmed: false,
|
|
203
|
+
resolvedDomain: () => 'mercury.example.com', apexBase: () => 'example.com', esc,
|
|
204
|
+
GH_ICON: '', manualOauthDetails: () => '<details>manual</details>', projectPageLink: (t) => t, autoOpenTick: () => '',
|
|
205
|
+
progressMarkup: (a, b, note) => `<prog>${note}</prog>`, encodeURIComponent, JSON,
|
|
206
|
+
...extra,
|
|
207
|
+
};
|
|
208
|
+
vm.createContext(sandbox);
|
|
209
|
+
vm.runInContext(`${SCRIPT.slice(RS, RE)}\n${SCRIPT.slice(SS, SE)}\n${SCRIPT.slice(AS, AE)}`, sandbox);
|
|
210
|
+
return sandbox;
|
|
211
|
+
}
|
|
212
|
+
// what the page's poll does with one answer (pollManifest's tick, minus the timer)
|
|
213
|
+
function seedFrom(sb, d) {
|
|
214
|
+
sb.live.appSlug = d.app_slug || null;
|
|
215
|
+
vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)}, ${JSON.stringify(d)})`, sb);
|
|
216
|
+
sb.live.manifest = d.ready ? 'done' : d.status;
|
|
217
|
+
sb.live.manifestFailure = d.failure || null;
|
|
218
|
+
}
|
|
219
|
+
const ROWS = ['renderStepOauthApp', 'renderStepAppInstall', 'renderStepFirstSignIn', 'renderStepDeviceFlow'];
|
|
220
|
+
const CHORE = /\/contact|Contact us|delete it|Developer settings → GitHub Apps\) so|Try again anyway|Start the setup again|by hand/;
|
|
221
|
+
|
|
222
|
+
// every shape the status derivation can reach, as rows the real route reads
|
|
223
|
+
const SHAPES = [
|
|
224
|
+
['waiting on GitHub', { status: 'pending', attempts: 0, expires_at: SOON }, []],
|
|
225
|
+
['link lapsed', { status: 'pending', attempts: 0, expires_at: ago(1) }, []],
|
|
226
|
+
['runner never took it up', { status: 'error', attempts: 0, expires_at: ago(1) }, []],
|
|
227
|
+
['approved, converting', { status: 'converting', attempts: 1, expires_at: ago(1) }, []],
|
|
228
|
+
['conversion stopped, code young', { status: 'error', attempts: 3, github_code: 'C', created_at: ago(15), expires_at: ago(1), resolved_at: ago(10) }, []],
|
|
229
|
+
['conversion stopped, code spent', { status: 'error', attempts: 1, github_code: null, created_at: ago(15), expires_at: ago(1), resolved_at: ago(10) }, []],
|
|
230
|
+
['sign-in unconfirmed, re-asking', { status: 'error', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(15), resolved_at: ago(10) }, []],
|
|
231
|
+
['sign-in unconfirmed, window over', { status: 'error', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(300), resolved_at: ago(290) }, []],
|
|
232
|
+
['signed in, install unverified, checking', { status: 'done', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(15), resolved_at: ago(10) }, []],
|
|
233
|
+
['signed in, install never confirmed, window over', { status: 'done', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(300), resolved_at: ago(290) }, []],
|
|
234
|
+
['installed, device flow off, window over', { status: 'done', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(300), resolved_at: ago(290) }, [{ event: 'github-app-installed', at: ago(280) }]],
|
|
235
|
+
['all four ticked', { status: 'done', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(300), resolved_at: ago(290) }, [{ event: 'github-app-installed', at: ago(280) }, { event: 'github-app-device-flow', at: ago(280) }]],
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
async function statusSays(row, events) {
|
|
239
|
+
attempt = { id: 99, instance_id: 42, requested_by: 7, state: 'S1', ...row };
|
|
240
|
+
eventRows = events;
|
|
241
|
+
const server = app.listen(0);
|
|
242
|
+
await new Promise((r) => server.once('listening', r));
|
|
243
|
+
try {
|
|
244
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/bongos/provisioning/github-app/status?state=S1`);
|
|
245
|
+
return await res.json();
|
|
246
|
+
} finally { server.close(); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
test('every state the setup can reach: the REAL status answer draws a sentence and an action — never a chore, never "contact us"', async (t) => {
|
|
250
|
+
// the page reads NOW from the real clock; age the rows against it
|
|
251
|
+
const shift = Date.now() - NOW;
|
|
252
|
+
const aged = (row) => Object.fromEntries(Object.entries(row).map(([k, v]) => [k, /_at$/.test(k) && v ? new Date(Date.parse(v) + shift).toISOString() : v]));
|
|
253
|
+
const seen = {};
|
|
254
|
+
for (const [name, row, events] of SHAPES) {
|
|
255
|
+
const d = await statusSays(aged(row), events.map((e) => ({ ...e, at: new Date(Date.parse(e.at) + shift).toISOString() })));
|
|
256
|
+
assert.equal(d.ok, true, name);
|
|
257
|
+
const sb = page();
|
|
258
|
+
seedFrom(sb, d);
|
|
259
|
+
const html = ROWS.map((f) => vm.runInContext(`${f}()`, sb)).join('\n');
|
|
260
|
+
seen[name] = { pick: d.pick_up, html };
|
|
261
|
+
assert.doesNotMatch(html, CHORE, `${name}: no manual chore and no "contact us"`);
|
|
262
|
+
// a stopped step always carries the action that fixes it
|
|
263
|
+
for (const li of html.split('\n').filter((x) => /class="sstopped/.test(x))) {
|
|
264
|
+
assert.match(li, /data-action="(pick-up|manifest-start|ask-platform)"/, `${name}: a stopped step has its button — ${li.slice(0, 120)}`);
|
|
265
|
+
}
|
|
266
|
+
// a pick-up the server offers is on the page
|
|
267
|
+
if (d.pick_up === 'check' || d.pick_up === 'convert') assert.match(html, /data-action="pick-up"/, `${name}: ${d.pick_up} is offered`);
|
|
268
|
+
if (d.pick_up === 'operator') assert.match(html, /data-action="ask-platform"/, `${name}: the platform team is one click away`);
|
|
269
|
+
if (d.pick_up === 'reopen') assert.match(html, /data-action="reopen-github"/, `${name}: the same link can be reopened`);
|
|
270
|
+
// a new link is offered ONLY where nothing was made on GitHub
|
|
271
|
+
if (/data-action="manifest-start"/.test(html)) assert.ok(d.pick_up === 'fresh', `${name}: Connect GitHub is offered only when no app exists (pick_up=${d.pick_up})`);
|
|
272
|
+
}
|
|
273
|
+
assert.equal(seen['waiting on GitHub'].pick, 'reopen');
|
|
274
|
+
assert.equal(seen['link lapsed'].pick, 'fresh');
|
|
275
|
+
assert.equal(seen['conversion stopped, code young'].pick, 'convert');
|
|
276
|
+
assert.equal(seen['conversion stopped, code spent'].pick, 'operator');
|
|
277
|
+
assert.equal(seen['sign-in unconfirmed, re-asking'].pick, null);
|
|
278
|
+
assert.match(seen['sign-in unconfirmed, re-asking'].html, /We’re asking it again/);
|
|
279
|
+
assert.equal(seen['sign-in unconfirmed, window over'].pick, 'check');
|
|
280
|
+
assert.match(seen['signed in, install never confirmed, window over'].html, /GitHub stopped before the app was installed on your repo\. Nothing is lost, and no second app will be made\./, 'the mockup\'s sentence, from the real read');
|
|
281
|
+
assert.match(seen['installed, device flow off, window over'].html, /Turned it on\? Check again/);
|
|
282
|
+
assert.equal(seen['all four ticked'].pick, null);
|
|
283
|
+
assert.doesNotMatch(seen['all four ticked'].html, /sstopped|data-action="pick-up"/);
|
|
284
|
+
t.diagnostic(Object.entries(seen).map(([k, v]) => `${k}: ${v.pick}`).join(' · '));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ── 4. the page's own actions ───────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
function actions({ pickUp, answer, manifestState = null, blockerStatus = 201 }) {
|
|
290
|
+
const calls = { fetched: [], polled: [], progress: [], started: 0, renders: 0 };
|
|
291
|
+
const sb = page({
|
|
292
|
+
API: '/api/bongos', instanceId: 42, manifestState, pollTimer: null, lastStepsHtml: 'x', manifestStartedAt: 1700000000000, ghTab: null,
|
|
293
|
+
fetch: async (url, init) => {
|
|
294
|
+
calls.fetched.push({ url, init });
|
|
295
|
+
if (/\/blockers$/.test(url)) return { ok: blockerStatus < 300, status: blockerStatus, json: async () => ({}) };
|
|
296
|
+
return { ok: true, status: 200, json: async () => answer };
|
|
297
|
+
},
|
|
298
|
+
rememberManifest: () => {}, renderSteps: () => { calls.renders++; },
|
|
299
|
+
pollManifest: (s) => calls.polled.push(s), startProgress: (id) => calls.progress.push(id),
|
|
300
|
+
startManifest: () => { calls.started++; },
|
|
301
|
+
});
|
|
302
|
+
sb.live.pickUp = pickUp;
|
|
303
|
+
return { sb, calls };
|
|
304
|
+
}
|
|
305
|
+
const settle = () => new Promise((r) => setTimeout(r, 20));
|
|
306
|
+
const btn = () => ({ tagName: 'BUTTON', disabled: false, textContent: '' });
|
|
307
|
+
|
|
308
|
+
test('pick-up posts to the pick-up route, seeds its fresh read, and watches again', async () => {
|
|
309
|
+
const answer = { ok: true, action: 'convert', status: 'code_received', failure: null, checklist: GS.githubChecklist('code_received', null), install_url: null, verifying: false, pick_up: null };
|
|
310
|
+
const { sb, calls } = actions({ pickUp: 'convert', answer, manifestState: 'S1' });
|
|
311
|
+
vm.runInContext('pickUpSetup', sb)(btn());
|
|
312
|
+
await settle();
|
|
313
|
+
assert.equal(calls.fetched.length, 1);
|
|
314
|
+
assert.equal(calls.fetched[0].url, '/api/bongos/provisioning/instances/42/github-app/pick-up');
|
|
315
|
+
assert.equal(calls.fetched[0].init.method, 'POST');
|
|
316
|
+
assert.equal(calls.started, 0, 'no new link is minted');
|
|
317
|
+
assert.equal(sb.live.manifest, 'code_received', 'the step moves on at once');
|
|
318
|
+
assert.equal(sb.live.pickUp, null);
|
|
319
|
+
assert.deepEqual(calls.polled, ['S1'], 'the attempt\'s own poll runs again');
|
|
320
|
+
// without the state (a reload, another device) the instance read watches instead
|
|
321
|
+
const other = actions({ pickUp: 'check', answer: { ...answer, action: 'check', status: 'error', verifying: true } });
|
|
322
|
+
vm.runInContext('pickUpSetup', other.sb)(btn());
|
|
323
|
+
await settle();
|
|
324
|
+
assert.deepEqual(other.calls.progress, [42]);
|
|
325
|
+
assert.equal(other.sb.live.verifying, true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('Reopen GitHub and a lapsed link go through the mint route (which hands back the SAME waiting state); a refused pick-up says so', async () => {
|
|
329
|
+
for (const pickUp of ['reopen', 'fresh']) {
|
|
330
|
+
const { sb, calls } = actions({ pickUp, answer: {} });
|
|
331
|
+
vm.runInContext('pickUpSetup', sb)(btn());
|
|
332
|
+
await settle();
|
|
333
|
+
assert.equal(calls.started, 1, pickUp);
|
|
334
|
+
assert.equal(calls.fetched.length, 0, `${pickUp}: the pick-up route is not asked`);
|
|
335
|
+
}
|
|
336
|
+
const r = actions({ pickUp: null, answer: {} });
|
|
337
|
+
vm.runInContext('reopenGithub', r.sb)(btn());
|
|
338
|
+
assert.equal(r.calls.started, 1);
|
|
339
|
+
// a refusal (409) re-enables the button with a plain line
|
|
340
|
+
const refused = actions({ pickUp: 'check', answer: { ok: false } });
|
|
341
|
+
const b = btn();
|
|
342
|
+
vm.runInContext('pickUpSetup', refused.sb)(b);
|
|
343
|
+
await settle();
|
|
344
|
+
assert.equal(b.disabled, false);
|
|
345
|
+
assert.match(b.textContent, /That didn’t go through\. Try again/);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('"Send it to the platform team" files one blocker for the attempt, with no secret, and the step says it was sent', async () => {
|
|
349
|
+
const { sb, calls } = actions({ pickUp: 'operator', answer: {} });
|
|
350
|
+
vm.runInContext('pickUpSetup', sb)(btn());
|
|
351
|
+
await settle();
|
|
352
|
+
const filed = calls.fetched.find((c) => /\/blockers$/.test(c.url));
|
|
353
|
+
assert.ok(filed, 'POST /api/bongos/blockers');
|
|
354
|
+
assert.equal(filed.url, '/api/bongos/blockers');
|
|
355
|
+
const body = JSON.parse(filed.init.body);
|
|
356
|
+
assert.equal(body.source, 'github-setup');
|
|
357
|
+
assert.equal(body.source_ref, 'instance-42-1700000000000', 'one per attempt: a second press is the same blocker');
|
|
358
|
+
assert.match(body.body_md, /Project 42/);
|
|
359
|
+
assert.doesNotMatch(filed.init.body, /state|github_code|secret=|Iv1\./i, 'no token, code or secret');
|
|
360
|
+
assert.equal(sb.live.askedPlatform, true);
|
|
361
|
+
assert.ok(calls.renders > 0);
|
|
362
|
+
const failed = actions({ pickUp: 'operator', answer: {}, blockerStatus: 500 });
|
|
363
|
+
vm.runInContext('askPlatform', failed.sb)(btn());
|
|
364
|
+
await settle();
|
|
365
|
+
assert.equal(failed.sb.live.askedPlatform, 'failed');
|
|
366
|
+
});
|
|
@@ -213,8 +213,9 @@ test('the manifest step acknowledges code_received, offers a restart after a sta
|
|
|
213
213
|
// 'converting' is the same stage — it used to fall through to "waiting for approval" (task 1004572)
|
|
214
214
|
assert.match(SCRIPT, /if \(live\.manifest === 'code_received' \|\| live\.manifest === 'converting'\)/, 'GitHub’s half is acknowledged');
|
|
215
215
|
assert.match(SCRIPT, /Approved on GitHub\. The platform is placing the app’s keys on your project/);
|
|
216
|
-
|
|
217
|
-
assert.match(SCRIPT,
|
|
216
|
+
// the way back is the SAME attempt, on show from the start — never a new link after 90s (task 1004652)
|
|
217
|
+
assert.match(SCRIPT, /data-action="reopen-github">No GitHub tab\? Reopen GitHub/, 'a waiting step can reopen GitHub');
|
|
218
|
+
assert.doesNotMatch(SCRIPT, /manifestStale|> 90000/, 'no 90-second restart door left');
|
|
218
219
|
assert.match(SCRIPT, /function rememberManifest\(stateToken, status, appSlug\)/);
|
|
219
220
|
assert.match(SCRIPT, /rememberManifest\(d\.state, 'pending'\)/, 'the token is remembered when GitHub is opened');
|
|
220
221
|
assert.match(SCRIPT, /if \(created\.manifest\) \{ live\.manifest = created\.manifest; live\.appSlug = created\.app_slug \|\| null; \}/, 'enter() re-seeds the step');
|
|
@@ -29,6 +29,9 @@ provisioning.getOAuthManifestByState = async (_db, state) => (state === 'GOODSTA
|
|
|
29
29
|
? { id: 99, instance_id: 42, requested_by: 7, status: 'pending', expires_at: new Date(Date.now() + 600000).toISOString() }
|
|
30
30
|
: null);
|
|
31
31
|
provisioning.attachManifestCode = async (_db, id, code) => ({ id, status: 'code_received', github_code: code });
|
|
32
|
+
// the latest attempt the mint guard and the pick-up route read (task 1004652); none by default
|
|
33
|
+
let latest = null;
|
|
34
|
+
provisioning.getLatestOAuthManifestForInstance = async () => latest;
|
|
32
35
|
|
|
33
36
|
// A branding with a known publicOrigin so mothershipOrigin() resolves deterministically.
|
|
34
37
|
const branding = require(`${ROOT}/src/branding.js`);
|
|
@@ -281,6 +284,76 @@ console.log('\nGET /provisioning/github-app/status (the page poll → auto-open
|
|
|
281
284
|
t('missing state → 400', res.status === 400, `status=${res.status}`);
|
|
282
285
|
}
|
|
283
286
|
|
|
287
|
+
console.log('\nNever a second app (task 1004652): the mint guard and "Pick up where it stopped":');
|
|
288
|
+
{
|
|
289
|
+
const mint = async () => fetch(`${base}/provisioning/instances/42/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
290
|
+
const soon = new Date(Date.now() + 600000).toISOString();
|
|
291
|
+
// a link still waiting on GitHub is handed back — the SAME state, no new attempt row
|
|
292
|
+
stored = null;
|
|
293
|
+
latest = { id: 70, instance_id: 42, state: 'WAITINGSTATE', status: 'pending', attempts: 0, expires_at: soon, created_at: new Date().toISOString() };
|
|
294
|
+
let res = await mint();
|
|
295
|
+
let j = await res.json();
|
|
296
|
+
t('a waiting link is reopened, not re-minted: same state, same expiry, no new row', res.status === 200 && j.state === 'WAITINGSTATE' && j.expires_at === soon && stored === null && j.post_url === 'https://github.com/settings/apps/new?state=WAITINGSTATE', JSON.stringify(j));
|
|
297
|
+
// GitHub already made the app (keys placed, sign-in unconfirmed): refused, nothing minted
|
|
298
|
+
for (const row of [
|
|
299
|
+
{ id: 71, instance_id: 42, state: 'S71', status: 'error', attempts: 1, app_id: 5, app_slug: 'mercury-app', expires_at: soon },
|
|
300
|
+
{ id: 72, instance_id: 42, state: 'S72', status: 'done', attempts: 1, app_id: 5, app_slug: 'mercury-app', expires_at: soon },
|
|
301
|
+
{ id: 73, instance_id: 42, state: 'S73', status: 'error', attempts: 2, app_id: null, app_slug: null, expires_at: soon },
|
|
302
|
+
]) {
|
|
303
|
+
stored = null; latest = row;
|
|
304
|
+
res = await mint(); j = await res.json();
|
|
305
|
+
t(`an attempt GitHub approved (${row.status}, attempts ${row.attempts}) refuses a new link: 409 app_exists, nothing minted`, res.status === 409 && j.error.code === 'app_exists' && stored === null, JSON.stringify(j));
|
|
306
|
+
}
|
|
307
|
+
stored = null; latest = { id: 74, instance_id: 42, state: 'S74', status: 'converting', attempts: 1, expires_at: soon };
|
|
308
|
+
res = await mint(); j = await res.json();
|
|
309
|
+
t('an attempt the runner is converting refuses a new link: 409 github_in_progress', res.status === 409 && j.error.code === 'github_in_progress' && stored === null, JSON.stringify(j));
|
|
310
|
+
// nothing was made on GitHub: a fresh link is minted
|
|
311
|
+
for (const row of [
|
|
312
|
+
{ id: 75, instance_id: 42, state: 'S75', status: 'pending', attempts: 0, expires_at: new Date(Date.now() - 1000).toISOString() },
|
|
313
|
+
{ id: 76, instance_id: 42, state: 'S76', status: 'error', attempts: 0, expires_at: soon },
|
|
314
|
+
]) {
|
|
315
|
+
stored = null; latest = row;
|
|
316
|
+
res = await mint(); j = await res.json();
|
|
317
|
+
t(`nothing made on GitHub (${row.status}, attempts ${row.attempts}) → a fresh state is minted`, res.status === 200 && stored && stored.state === j.state && j.state !== row.state, JSON.stringify(j));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// the pick-up route: 'convert' re-queues the SAME attempt with the code it holds
|
|
321
|
+
const queries = [];
|
|
322
|
+
const pg = require(`${ROOT}/src/module-api.js`).pool;
|
|
323
|
+
const prevQuery = pg.query;
|
|
324
|
+
pg.query = async (sql, params) => { queries.push({ sql, params }); return { rows: /UPDATE provisioning_oauth_manifests/.test(sql) ? [{ id: params[0] }] : [] }; };
|
|
325
|
+
const pickUp = async () => fetch(`${base}/provisioning/instances/42/github-app/pick-up`, { method: 'POST' });
|
|
326
|
+
latest = { id: 80, instance_id: 42, state: 'S80', status: 'error', attempts: 1, github_code: 'CODE', app_id: null, app_slug: null, created_at: new Date(Date.now() - 10 * 60000).toISOString(), expires_at: soon };
|
|
327
|
+
const before = reported.length;
|
|
328
|
+
stored = null;
|
|
329
|
+
res = await pickUp(); j = await res.json();
|
|
330
|
+
const requeue = queries.find((q) => /UPDATE provisioning_oauth_manifests/.test(q.sql));
|
|
331
|
+
t("pick-up 'convert' re-queues attempt 80 itself (status code_received, attempts reset), mints nothing", res.status === 200 && j.action === 'convert' && requeue && requeue.params[0] === 80 && /status = 'code_received', attempts = 0/.test(requeue.sql) && /github_code IS NOT NULL/.test(requeue.sql) && stored === null, JSON.stringify(j));
|
|
332
|
+
// 'check': keys placed, the runner's window closed → a check request, no new attempt
|
|
333
|
+
queries.length = 0;
|
|
334
|
+
latest = { id: 81, instance_id: 42, state: 'S81', status: 'error', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: new Date(Date.now() - 5 * 3600000).toISOString(), resolved_at: new Date(Date.now() - 4 * 3600000).toISOString(), expires_at: soon };
|
|
335
|
+
res = await pickUp(); j = await res.json();
|
|
336
|
+
const asked = reported.slice(before).filter((e) => e.event === 'github-app-check-requested');
|
|
337
|
+
t("pick-up 'check' records one check request for the project and writes no attempt", res.status === 200 && j.action === 'check' && asked.length === 1 && asked[0].instanceId === 42 && !queries.some((q) => /UPDATE|INSERT/.test(q.sql)) && stored === null, JSON.stringify(j));
|
|
338
|
+
// nothing the route itself can pick up: 409 naming the page's own action
|
|
339
|
+
for (const [row, action] of [
|
|
340
|
+
[{ id: 82, instance_id: 42, state: 'S82', status: 'pending', attempts: 0, expires_at: soon }, 'reopen'],
|
|
341
|
+
[{ id: 83, instance_id: 42, state: 'S83', status: 'error', attempts: 2, github_code: null, app_id: null, created_at: new Date().toISOString(), expires_at: soon }, 'operator'],
|
|
342
|
+
]) {
|
|
343
|
+
latest = row;
|
|
344
|
+
res = await pickUp(); j = await res.json();
|
|
345
|
+
t(`pick-up on '${action}' is the page's own: 409 nothing_to_pick_up, action named`, res.status === 409 && j.error.code === 'nothing_to_pick_up' && j.error.action === action, JSON.stringify(j));
|
|
346
|
+
}
|
|
347
|
+
// another builder's project is a 404, like a missing one
|
|
348
|
+
const prevInst = provisioning.getInstanceById;
|
|
349
|
+
provisioning.getInstanceById = async () => ({ id: 42, owner_builder_id: 8, slug: 'mercury', domain: 'mercury.example.com', status: 'active' });
|
|
350
|
+
res = await pickUp();
|
|
351
|
+
t('pick-up on someone else’s project → 404', res.status === 404, `status=${res.status}`);
|
|
352
|
+
provisioning.getInstanceById = prevInst;
|
|
353
|
+
pg.query = prevQuery;
|
|
354
|
+
latest = null;
|
|
355
|
+
}
|
|
356
|
+
|
|
284
357
|
console.log('\nparseId contract (the (req,res) signature — a bad id is a clean 400, not a 500):');
|
|
285
358
|
{
|
|
286
359
|
const res = await fetch(`${base}/provisioning/instances/not-a-number/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|