@bongos/core 1.21.94 → 1.21.96
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 +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -3
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +181 -7
- package/docs/api-reference.md +9 -5
- package/docs/copy-inventory.md +367 -358
- package/docs/copy-registry.json +536 -425
- package/docs/design/project-startup-direction.md +17 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +351 -348
- package/modules/provisioning/github-setup.js +27 -5
- package/modules/provisioning/routes/callback-page.js +20 -10
- package/modules/provisioning/routes/github-setup.js +111 -2
- package/modules/provisioning/routes/provisioning.js +29 -4
- package/modules/public-landing/public/projects.html +367 -24
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- 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_handback_any_device.mjs +340 -0
- package/tests/github_org_account.mjs +378 -0
- package/tests/projects_hub.mjs +6 -2
- package/tests/provisioning_callback_page.mjs +3 -2
- package/tests/provisioning_github_app_routes.mjs +15 -10
- package/tests/wizard_draft_resume.mjs +4 -0
- package/tests/wizard_github_feedback.mjs +2 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// tests/github_org_account.mjs — a personal or an organisation GitHub account for the
|
|
2
|
+
// project's repo and its sign-in app (task 1004651).
|
|
3
|
+
//
|
|
4
|
+
// Before: the page always posted `{}` to mint the app, so the server's org support was
|
|
5
|
+
// dead and every app landed on the founder's personal account; and the new repo could only
|
|
6
|
+
// be made on the personal account, so an organisation-owned project needed the manual
|
|
7
|
+
// "create it yourself" fallback.
|
|
8
|
+
//
|
|
9
|
+
// What this pins:
|
|
10
|
+
// 1. the personal path is UNCHANGED: the page posts `{}` to mint the app and `{name}` to
|
|
11
|
+
// make the repo, and GitHub is asked for /settings/apps/new and POST /user/repos;
|
|
12
|
+
// 2. choosing an organisation builds the org manifest URL and creates the repo with
|
|
13
|
+
// POST /orgs/{org}/repos, and the app's settings link follows the organisation;
|
|
14
|
+
// 3. an organisation the founder does not own is shown with the reason and cannot be
|
|
15
|
+
// picked, and the page refuses to start a trip for it;
|
|
16
|
+
// 4. a bad organisation name is refused by the server before it reaches GitHub;
|
|
17
|
+
// 5. GitHub's org refusals never come back as a 403 (the page reads that as "sign in").
|
|
18
|
+
//
|
|
19
|
+
// The whole path runs for real: the REAL routes (core GitHub router + provisioning mint
|
|
20
|
+
// route), over HTTP, with GitHub faked at the fetch boundary, feeding the REAL page code
|
|
21
|
+
// (sliced out of projects.html and run in a vm). DB-free: the token read and the
|
|
22
|
+
// provisioning data-access fns are stubbed on their cached modules.
|
|
23
|
+
//
|
|
24
|
+
// Run: node --test tests/github_org_account.mjs
|
|
25
|
+
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { test, before, after } from 'node:test';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import vm from 'node:vm';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
process.env.BUILDER_SECRET_KEY = process.env.BUILDER_SECRET_KEY || 'ab'.repeat(32);
|
|
35
|
+
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
|
+
// CRLF on a Windows checkout — a vm cannot run a slice with stray \r in it
|
|
39
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
40
|
+
const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
|
|
41
|
+
|
|
42
|
+
// ── fake GitHub, at the fetch boundary ──────────────────────────────────────────────
|
|
43
|
+
const realFetch = globalThis.fetch;
|
|
44
|
+
let gh = {}; // path → (init) => [status, body]
|
|
45
|
+
const ghCalls = [];
|
|
46
|
+
function ghAnswer(url, init = {}) {
|
|
47
|
+
const u = new URL(url);
|
|
48
|
+
const key = `${(init.method || 'GET').toUpperCase()} ${u.pathname}`;
|
|
49
|
+
ghCalls.push({ key, url, body: init.body ? JSON.parse(init.body) : null });
|
|
50
|
+
const h = gh[key];
|
|
51
|
+
const [status, body] = h ? h(init) : [404, { message: 'Not Found' }];
|
|
52
|
+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
53
|
+
}
|
|
54
|
+
globalThis.fetch = async (url, init) => (String(url).startsWith('https://api.github.com/') ? ghAnswer(String(url), init) : realFetch(url, init));
|
|
55
|
+
|
|
56
|
+
// ── the signed-in founder and their stored repo token ───────────────────────────────
|
|
57
|
+
const db = require(path.join(ROOT, 'src/bongos/db.js'));
|
|
58
|
+
db.getGithubToken = async () => ({ token: 'tok', scope: 'read:user,repo' });
|
|
59
|
+
db.deleteGithubToken = async () => {};
|
|
60
|
+
const auth = require(path.join(ROOT, 'src/bongos/auth.js'));
|
|
61
|
+
auth.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'founder' }; next(); };
|
|
62
|
+
const api = require(path.join(ROOT, 'src/module-api.js'));
|
|
63
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'founder' }; next(); };
|
|
64
|
+
|
|
65
|
+
// ── the provisioning world: one project, its attempts recorded ──────────────────────
|
|
66
|
+
const provisioning = require(path.join(ROOT, 'modules/provisioning/provisioning.js'));
|
|
67
|
+
let targetRef = 'founder/mercury';
|
|
68
|
+
const events = [];
|
|
69
|
+
provisioning.getInstanceById = async () => ({ id: 42, owner_builder_id: 7, slug: 'mercury', domain: 'mercury.example.com', target_ref: targetRef, status: 'requested' });
|
|
70
|
+
provisioning.createOAuthManifest = async (_db, row) => ({ id: 99, ...row });
|
|
71
|
+
provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
72
|
+
const branding = require(path.join(ROOT, 'src/branding.js'));
|
|
73
|
+
const realBranding = branding.branding;
|
|
74
|
+
branding.branding = () => ({ ...realBranding(), identity: { ...(realBranding().identity || {}), productName: 'Cloud Bongos' }, domains: { publicOrigin: 'https://cloudbongos.com' } });
|
|
75
|
+
const GS = require(path.join(ROOT, 'modules/provisioning/github-setup.js'));
|
|
76
|
+
const githubRepos = require(path.join(ROOT, 'src/bongos/github-repos.js'));
|
|
77
|
+
|
|
78
|
+
const express = require('express');
|
|
79
|
+
const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
80
|
+
const buildGithubRouter = require(path.join(ROOT, 'src/bongos/routes/github.js'));
|
|
81
|
+
const provisioningRoutes = require(path.join(ROOT, 'modules/provisioning/routes/provisioning.js'));
|
|
82
|
+
let server, origin;
|
|
83
|
+
before(async () => {
|
|
84
|
+
const app = express();
|
|
85
|
+
app.use(express.json());
|
|
86
|
+
app.use(attachFail);
|
|
87
|
+
app.use('/api/bongos', buildGithubRouter());
|
|
88
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
89
|
+
server = app.listen(0);
|
|
90
|
+
await new Promise((r) => server.once('listening', r));
|
|
91
|
+
origin = `http://127.0.0.1:${server.address().port}`;
|
|
92
|
+
});
|
|
93
|
+
after(() => { server.close(); globalThis.fetch = realFetch; });
|
|
94
|
+
|
|
95
|
+
// GitHub's answers for a founder who owns acme-studio and is only a member of bigcorp
|
|
96
|
+
function memberships() {
|
|
97
|
+
return {
|
|
98
|
+
'GET /user/memberships/orgs': () => [200, [
|
|
99
|
+
{ role: 'member', state: 'active', organization: { login: 'bigcorp' } },
|
|
100
|
+
{ role: 'admin', state: 'active', organization: { login: 'acme-studio' } },
|
|
101
|
+
]],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── the page code, sliced (each boundary asserted below) ────────────────────────────
|
|
106
|
+
function region(startMark, endMark) {
|
|
107
|
+
const s = SCRIPT.indexOf(startMark);
|
|
108
|
+
const e = s < 0 ? -1 : SCRIPT.indexOf(endMark, s);
|
|
109
|
+
return s > 0 && e > s ? SCRIPT.slice(s, e) : null;
|
|
110
|
+
}
|
|
111
|
+
const PICKER = region(" /* ── whose GitHub account: mine or an organisation's (task 1004651) ──", " var repoPick = acctPicker('ra', {");
|
|
112
|
+
const ENSURE_REPO = region(' function ensureFreshRepo() {', " $('doCreate').addEventListener('click'");
|
|
113
|
+
const NOTE_ORG = region(" /* the organisation the server says this attempt's app is on: change-only */", ' function startManifest(btn) {');
|
|
114
|
+
const START = region(' function startManifest(btn) {', ' /* the "start again" offer once a wait has clearly gone wrong');
|
|
115
|
+
const SETTINGS = region(' /* the organisation that owns the app, when the founder made it on one (task 1004651) */', ' /* oauth-app — the ADR 0133 one-click GitHub-App manifest flow');
|
|
116
|
+
|
|
117
|
+
test('the page regions are still findable (a rename must fail loudly, not test nothing)', () => {
|
|
118
|
+
for (const [name, r] of Object.entries({ PICKER, ENSURE_REPO, NOTE_ORG, START, SETTINGS })) assert.ok(r, `${name} region not found`);
|
|
119
|
+
assert.match(PICKER, /function acctPicker\(p, opts\)/);
|
|
120
|
+
assert.doesNotMatch(PICKER, /innerHTML/, 'organisation names are DOM text, never markup');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// A miniature DOM: just what the picker, the repo create and the mint touch.
|
|
124
|
+
class El {
|
|
125
|
+
constructor(id, tag = 'div') { this.id = id; this.tagName = tag; this.hidden = false; this.textContent = ''; this.className = ''; this.children = []; this._v = ''; this.listeners = {}; this.disabled = false; }
|
|
126
|
+
addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); }
|
|
127
|
+
fire(ev, extra = {}) { (this.listeners[ev] || []).forEach((fn) => fn({ preventDefault() {}, ...extra })); }
|
|
128
|
+
focus() { this.focused = true; }
|
|
129
|
+
appendChild(c) { this.children.push(c); return c; }
|
|
130
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); }
|
|
131
|
+
get firstChild() { return this.children[0] || null; }
|
|
132
|
+
get value() { return this._v; }
|
|
133
|
+
set value(v) {
|
|
134
|
+
// a select takes only a value one of its options has, as a browser does
|
|
135
|
+
if (this.tagName === 'select') this._v = this.children.some((o) => o.value === v) ? v : '';
|
|
136
|
+
else this._v = v;
|
|
137
|
+
}
|
|
138
|
+
submit() { this.submitted = true; }
|
|
139
|
+
remove() {}
|
|
140
|
+
}
|
|
141
|
+
function opt(value) { const o = new El(null, 'option'); o.value = value; return o; }
|
|
142
|
+
|
|
143
|
+
function page({ state = {}, live = {} } = {}) {
|
|
144
|
+
const els = {};
|
|
145
|
+
const $ = (id) => {
|
|
146
|
+
if (!els[id]) {
|
|
147
|
+
const tag = /(Sel|Org)$/.test(id) ? 'select' : 'div';
|
|
148
|
+
els[id] = new El(id, tag);
|
|
149
|
+
if (/Sel$/.test(id)) { els[id].children.push(opt('personal'), opt('org')); els[id]._v = 'personal'; }
|
|
150
|
+
}
|
|
151
|
+
return els[id];
|
|
152
|
+
};
|
|
153
|
+
const posted = [];
|
|
154
|
+
const forms = [];
|
|
155
|
+
const sandbox = {
|
|
156
|
+
API: '/api/bongos', $, JSON, String, Promise, encodeURIComponent, Error,
|
|
157
|
+
document: {
|
|
158
|
+
body: { appendChild() {} },
|
|
159
|
+
createElement: (tag) => { const e = new El(null, tag); if (tag === 'form') forms.push(e); return e; },
|
|
160
|
+
},
|
|
161
|
+
window: { open: () => ({ close() {} }), location: { assign() {} } },
|
|
162
|
+
fetch: (p, init) => { posted.push({ p, init }); return realFetch(origin + p, { method: (init && init.method) || 'GET', headers: init && init.headers, body: init && init.body }); },
|
|
163
|
+
state: { repo: 'mercury', mode: 'greenfield', owner: 'founder', ...state },
|
|
164
|
+
live: { manifest: null, manifestMsg: '', manifestFailure: null, appSlug: null, appOrg: null, setup: null, installUrl: null, ...live },
|
|
165
|
+
repoAccess: true, instanceId: 42, me: { github_login: 'founder' },
|
|
166
|
+
hopForRepoAccess: () => false, syncHome() {}, saveDraft() {},
|
|
167
|
+
rememberManifest() {}, renderSteps() {}, pollManifest() {},
|
|
168
|
+
};
|
|
169
|
+
sandbox.manifestStartedAt = 0; sandbox.manifestStale = false;
|
|
170
|
+
vm.createContext(sandbox);
|
|
171
|
+
vm.runInContext([PICKER, ENSURE_REPO, NOTE_ORG, START, SETTINGS, "var appPick = acctPicker('aa', { needsOwner: true });"].join('\n'), sandbox);
|
|
172
|
+
return { sb: sandbox, els, posted, forms, settle: () => new Promise((r) => setTimeout(r, 60)) };
|
|
173
|
+
}
|
|
174
|
+
async function settled(p, n = 6) { for (let i = 0; i < n; i++) await p.settle(); }
|
|
175
|
+
|
|
176
|
+
// ── 1. the personal path is unchanged ───────────────────────────────────────────────
|
|
177
|
+
test('personal: the page mints with an empty body, and GitHub is asked for the personal app page', async () => {
|
|
178
|
+
gh = memberships(); events.length = 0;
|
|
179
|
+
const p = page();
|
|
180
|
+
p.sb.appPick.load(null);
|
|
181
|
+
const btn = new El('btn');
|
|
182
|
+
p.sb.startManifest(btn);
|
|
183
|
+
await settled(p);
|
|
184
|
+
const mint = p.posted.find((x) => /\/github-app$/.test(x.p));
|
|
185
|
+
assert.equal(mint.init.body, '{}', 'byte-for-byte what the page always sent');
|
|
186
|
+
assert.equal(p.forms.length, 1);
|
|
187
|
+
assert.match(p.forms[0].action, /^https:\/\/github\.com\/settings\/apps\/new\?state=[0-9a-f]{64}$/);
|
|
188
|
+
assert.ok(!events.some((e) => e.event === GS.SETUP_EVENTS.appOrg), 'no organisation is recorded for a personal app');
|
|
189
|
+
assert.ok(!p.posted.some((x) => /\/github\/orgs/.test(x.p)), 'GitHub is not even asked for organisations');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('personal: the new repo is made with {name} and POST /user/repos, as before', async () => {
|
|
193
|
+
gh = { 'POST /user/repos': () => [201, { full_name: 'founder/mercury', html_url: 'https://github.com/founder/mercury' }] };
|
|
194
|
+
ghCalls.length = 0;
|
|
195
|
+
const p = page();
|
|
196
|
+
await p.sb.ensureFreshRepo();
|
|
197
|
+
const sent = p.posted.find((x) => x.p === '/api/bongos/github/repos');
|
|
198
|
+
assert.equal(sent.init.body, JSON.stringify({ name: 'mercury' }));
|
|
199
|
+
assert.deepEqual(ghCalls.map((c) => c.key), ['POST /user/repos']);
|
|
200
|
+
assert.deepEqual(ghCalls[0].body, { name: 'mercury', private: true, auto_init: false });
|
|
201
|
+
assert.equal(p.sb.state.owner, 'founder');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ── 2. an organisation ──────────────────────────────────────────────────────────────
|
|
205
|
+
test('organisation: listed from GitHub; one the founder only belongs to is shown with the reason and cannot be picked', async () => {
|
|
206
|
+
gh = memberships();
|
|
207
|
+
const p = page();
|
|
208
|
+
p.sb.appPick.load(null);
|
|
209
|
+
p.els.aaSel.value = 'org'; p.els.aaSel.fire('change');
|
|
210
|
+
await settled(p);
|
|
211
|
+
const opts = p.els.aaOrg.children.map((o) => ({ v: o.value, t: o.textContent, off: o.disabled }));
|
|
212
|
+
assert.deepEqual(opts, [
|
|
213
|
+
{ v: 'acme-studio', t: 'acme-studio', off: false },
|
|
214
|
+
{ v: 'bigcorp', t: 'bigcorp (you’re a member, not an owner)', off: true },
|
|
215
|
+
{ v: '__other', t: 'Another organisation…', off: false },
|
|
216
|
+
]);
|
|
217
|
+
assert.equal(p.els.aaOrg.value, 'acme-studio', 'the one they own is picked');
|
|
218
|
+
assert.equal(p.els.aaOrgField.hidden, false);
|
|
219
|
+
// a refused one forced in (an old browser ignoring disabled) still cannot go ahead
|
|
220
|
+
p.els.aaOrg._v = 'bigcorp'; p.els.aaOrg.fire('change');
|
|
221
|
+
assert.equal(p.sb.appPick.value().ok, false);
|
|
222
|
+
assert.match(p.els.aaNote.textContent, /member of bigcorp, not an owner/);
|
|
223
|
+
const btn = new El('btn');
|
|
224
|
+
p.sb.startManifest(btn);
|
|
225
|
+
await settled(p, 2);
|
|
226
|
+
assert.ok(!p.posted.some((x) => /\/github-app$/.test(x.p)), 'no link is minted for an organisation they cannot make apps on');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('organisation: the app is made on it — the org manifest URL, the org recorded, its settings linked there', async () => {
|
|
230
|
+
gh = memberships(); events.length = 0;
|
|
231
|
+
const p = page();
|
|
232
|
+
p.sb.appPick.load('acme-studio');
|
|
233
|
+
await settled(p);
|
|
234
|
+
assert.equal(p.sb.appPick.value().org, 'acme-studio');
|
|
235
|
+
p.sb.startManifest(new El('btn'));
|
|
236
|
+
await settled(p);
|
|
237
|
+
const mint = p.posted.find((x) => /\/github-app$/.test(x.p));
|
|
238
|
+
assert.deepEqual(JSON.parse(mint.init.body), { account: 'org', org: 'acme-studio' });
|
|
239
|
+
assert.match(p.forms[0].action, /^https:\/\/github\.com\/organizations\/acme-studio\/settings\/apps\/new\?state=[0-9a-f]{64}$/);
|
|
240
|
+
assert.deepEqual(events.filter((e) => e.event === GS.SETUP_EVENTS.appOrg).map((e) => e.detail), ['acme-studio']);
|
|
241
|
+
// the page now knows whose app it is, and links its settings under the organisation
|
|
242
|
+
p.sb.live.appSlug = 'mercury-app';
|
|
243
|
+
assert.equal(vm.runInContext('appSettingsUrl()', p.sb), 'https://github.com/organizations/acme-studio/settings/apps/mercury-app');
|
|
244
|
+
p.sb.live.appOrg = null;
|
|
245
|
+
assert.equal(vm.runInContext('appSettingsUrl()', p.sb), 'https://github.com/settings/apps/mercury-app', 'and under the founder for a personal app');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('organisation: the new repo is made under it with POST /orgs/{org}/repos, and the project points at it', async () => {
|
|
249
|
+
gh = { 'POST /orgs/acme-studio/repos': () => [201, { full_name: 'acme-studio/mercury', html_url: 'https://github.com/acme-studio/mercury' }] };
|
|
250
|
+
ghCalls.length = 0;
|
|
251
|
+
const p = page({ state: { repoAcct: 'org', repoOrg: 'acme-studio' } });
|
|
252
|
+
await p.sb.ensureFreshRepo();
|
|
253
|
+
assert.deepEqual(ghCalls.map((c) => c.key), ['POST /orgs/acme-studio/repos']);
|
|
254
|
+
assert.deepEqual(ghCalls[0].body, { name: 'mercury', private: true, auto_init: false });
|
|
255
|
+
assert.equal(p.sb.state.owner, 'acme-studio');
|
|
256
|
+
assert.equal(p.sb.state.repoUrl, 'https://github.com/acme-studio/mercury');
|
|
257
|
+
assert.equal(p.sb.state.repoAutoCreated, true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('organisation: GitHub refusing the org repo comes back with its reason, never as "sign in again"', async () => {
|
|
261
|
+
const cases = [
|
|
262
|
+
[{ message: 'Although you appear to have the correct authorization credentials, the `acme-studio` organization has enabled OAuth App access restrictions' }, 'org_app_restricted', /Third-party access/],
|
|
263
|
+
[{ message: 'You need admin access to the organization before adding a repository to it.' }, 'org_forbidden', /didn't let your account|didn’t let your account/],
|
|
264
|
+
];
|
|
265
|
+
for (const [body, code, words] of cases) {
|
|
266
|
+
gh = { 'POST /orgs/acme-studio/repos': () => [403, body] };
|
|
267
|
+
const res = await realFetch(`${origin}/api/bongos/github/repos`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'mercury', org: 'acme-studio' }) });
|
|
268
|
+
const j = await res.json();
|
|
269
|
+
assert.equal(res.status, 409, code);
|
|
270
|
+
assert.equal(j.error.code, code);
|
|
271
|
+
assert.match(j.error.message, words);
|
|
272
|
+
// the page takes the founder to the Code row with that reason, not to a sign-in hop
|
|
273
|
+
const p = page({ state: { repoAcct: 'org', repoOrg: 'acme-studio' } });
|
|
274
|
+
let hopped = false; p.sb.hopForRepoAccess = () => { hopped = true; return true; };
|
|
275
|
+
await assert.rejects(() => p.sb.ensureFreshRepo(), (e) => e.gotoHome === true && e.homeMsg === j.error.message);
|
|
276
|
+
assert.equal(hopped, false);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ── 3. when GitHub will not show the founder's memberships ──────────────────────────
|
|
281
|
+
test('typed name: GitHub hides the memberships, so the founder types it and it is checked', async () => {
|
|
282
|
+
gh = {
|
|
283
|
+
'GET /user/memberships/orgs': () => [403, { message: 'requires read:org' }],
|
|
284
|
+
'GET /user/repos': () => [403, {}],
|
|
285
|
+
'GET /user/memberships/orgs/acme-studio': () => [403, { message: 'requires read:org' }],
|
|
286
|
+
'GET /orgs/acme-studio': () => [200, { login: 'acme-studio', type: 'Organization' }],
|
|
287
|
+
'GET /orgs/someone': () => [200, { login: 'someone', type: 'User' }],
|
|
288
|
+
};
|
|
289
|
+
const p = page();
|
|
290
|
+
p.sb.appPick.load('');
|
|
291
|
+
await settled(p);
|
|
292
|
+
assert.deepEqual(p.els.aaOrg.children.map((o) => o.value), ['__other']);
|
|
293
|
+
assert.equal(p.els.aaTyped.hidden, false);
|
|
294
|
+
assert.match(p.els.aaNote.textContent, /didn’t show us your organisations/);
|
|
295
|
+
assert.equal(p.sb.appPick.value().ok, false, 'nothing to go ahead with until it is checked');
|
|
296
|
+
p.els.aaName.value = 'acme-studio'; p.els.aaCheck.fire('click');
|
|
297
|
+
await settled(p);
|
|
298
|
+
const v = p.sb.appPick.value();
|
|
299
|
+
assert.equal(v.org, 'acme-studio');
|
|
300
|
+
assert.equal(v.ok, true, 'an organisation GitHub did not give a role for is offered, said as such');
|
|
301
|
+
assert.match(p.els.aaNote.textContent, /didn't tell us your role in acme-studio/);
|
|
302
|
+
// a personal account's login is not an organisation
|
|
303
|
+
p.els.aaName.value = 'someone'; p.els.aaCheck.fire('click');
|
|
304
|
+
await settled(p);
|
|
305
|
+
assert.equal(p.sb.appPick.value().ok, false);
|
|
306
|
+
assert.match(p.els.aaNote.textContent, /an organisation called someone/);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ── 4. bad names never reach GitHub ─────────────────────────────────────────────────
|
|
310
|
+
test('bad organisation names are refused by the server before GitHub is asked', async () => {
|
|
311
|
+
gh = {}; ghCalls.length = 0;
|
|
312
|
+
for (const bad of ['-acme', 'acme-', 'ac--me', 'a/b', 'acme studio', 'x'.repeat(40), '../etc']) {
|
|
313
|
+
const mint = await realFetch(`${origin}/api/bongos/provisioning/instances/42/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ account: 'org', org: bad }) });
|
|
314
|
+
assert.equal(mint.status, 400, `mint: ${bad}`);
|
|
315
|
+
const repo = await realFetch(`${origin}/api/bongos/github/repos`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'mercury', org: bad }) });
|
|
316
|
+
assert.equal(repo.status, 400, `repo: ${bad}`);
|
|
317
|
+
}
|
|
318
|
+
const check = await realFetch(`${origin}/api/bongos/github/orgs/${encodeURIComponent('ac--me')}`);
|
|
319
|
+
assert.equal(check.status, 400);
|
|
320
|
+
assert.deepEqual(ghCalls, [], 'GitHub was never called with any of them');
|
|
321
|
+
for (const bad of ['-acme', 'ac--me', 'acme_', '']) assert.equal(githubRepos.validGithubLogin(bad), false, bad);
|
|
322
|
+
for (const ok of ['a', 'acme-studio', 'A1-b2', 'x'.repeat(39)]) assert.equal(githubRepos.validGithubLogin(ok), true, ok);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('mint: an explicit organisation choice never falls back to the personal account unseen', async () => {
|
|
326
|
+
targetRef = 'founder_x/mercury'; // an owner that is not a login, and nothing typed
|
|
327
|
+
try {
|
|
328
|
+
const res = await realFetch(`${origin}/api/bongos/provisioning/instances/42/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ account: 'org' }) });
|
|
329
|
+
assert.equal(res.status, 400);
|
|
330
|
+
assert.equal((await res.json()).error.code, 'org_required');
|
|
331
|
+
} finally { targetRef = 'founder/mercury'; }
|
|
332
|
+
// with no org typed, account 'org' still means the repo's own organisation, as before
|
|
333
|
+
targetRef = 'acme-studio/mercury';
|
|
334
|
+
try {
|
|
335
|
+
const j = await (await realFetch(`${origin}/api/bongos/provisioning/instances/42/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ account: 'org' }) })).json();
|
|
336
|
+
assert.equal(j.account, 'org');
|
|
337
|
+
assert.match(j.post_url, /^https:\/\/github\.com\/organizations\/acme-studio\/settings\/apps\/new\?state=/);
|
|
338
|
+
} finally { targetRef = 'founder/mercury'; }
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ── 5. the helpers, at unit altitude ────────────────────────────────────────────────
|
|
342
|
+
test('listUserOrgs: roles from the memberships; else the organisations the repos sit in, roles unknown', async () => {
|
|
343
|
+
gh = { 'GET /user/memberships/orgs': () => [200, [{ role: 'admin', state: 'pending', organization: { login: 'newco' } }, { role: 'admin', state: 'active', organization: { login: 'bad name' } }]] };
|
|
344
|
+
let out = await githubRepos.listUserOrgs('tok');
|
|
345
|
+
assert.equal(out.listed, 'memberships');
|
|
346
|
+
assert.deepEqual(out.orgs.map((o) => [o.login, o.can_create_apps]), [['newco', false]], 'a malformed login is dropped; a pending invite cannot');
|
|
347
|
+
assert.match(out.orgs[0].reason, /invitation to newco is still waiting/);
|
|
348
|
+
gh = {
|
|
349
|
+
'GET /user/memberships/orgs': () => [403, {}],
|
|
350
|
+
'GET /user/repos': () => [200, [
|
|
351
|
+
{ owner: { login: 'Zeta', type: 'Organization' } }, { owner: { login: 'founder', type: 'User' } },
|
|
352
|
+
{ owner: { login: 'alpha', type: 'Organization' } }, { owner: { login: 'zeta', type: 'Organization' } },
|
|
353
|
+
]],
|
|
354
|
+
};
|
|
355
|
+
out = await githubRepos.listUserOrgs('tok');
|
|
356
|
+
assert.equal(out.listed, 'repos');
|
|
357
|
+
assert.deepEqual(out.orgs.map((o) => [o.login, o.can_create_apps]), [['alpha', null], ['Zeta', null]]);
|
|
358
|
+
gh = { 'GET /user/memberships/orgs': () => [403, {}], 'GET /user/repos': () => [500, {}] };
|
|
359
|
+
assert.deepEqual(await githubRepos.listUserOrgs('tok'), { orgs: [], listed: 'none' });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('getOrgAccess: the role when GitHub gives it, unknown when it only proves the org exists, missing otherwise', async () => {
|
|
363
|
+
gh = { 'GET /user/memberships/orgs/acme-studio': () => [200, { role: 'admin', state: 'active', organization: { login: 'Acme-Studio' } }] };
|
|
364
|
+
assert.deepEqual(await githubRepos.getOrgAccess('tok', { org: 'acme-studio' }), { login: 'Acme-Studio', role: 'admin', can_create_apps: true, reason: null });
|
|
365
|
+
gh = { 'GET /user/memberships/orgs/ghost': () => [404, {}], 'GET /orgs/ghost': () => [404, {}] };
|
|
366
|
+
await assert.rejects(() => githubRepos.getOrgAccess('tok', { org: 'ghost' }), (e) => e.code === 'org_not_found');
|
|
367
|
+
await assert.rejects(() => githubRepos.getOrgAccess('tok', { org: 'a--b' }), (e) => e.code === 'bad_org');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('github-setup: the organisation recorded at the mint rides the checklist read as app_org', async () => {
|
|
371
|
+
const row = { instance_id: 42, state: 'S1', created_at: '2026-10-08T09:00:00Z', app_slug: 'mercury-app' };
|
|
372
|
+
const said = await GS.githubSetupRead({ query: async () => ({ rows: [{ event: GS.SETUP_EVENTS.appOrg, at: '2026-10-08T09:00:01Z', detail: 'acme-studio' }] }) }, {}, row, 'done');
|
|
373
|
+
assert.equal(said.app_org, 'acme-studio');
|
|
374
|
+
const bad = await GS.githubSetupRead({ query: async () => ({ rows: [{ event: GS.SETUP_EVENTS.appOrg, at: '2026-10-08T09:00:01Z', detail: '<script>' }] }) }, {}, row, 'done');
|
|
375
|
+
assert.equal(bad.app_org, null, 'a recorded value that is not a login never reaches a link');
|
|
376
|
+
const none = await GS.githubSetupRead({ query: async () => ({ rows: [] }) }, {}, row, 'done');
|
|
377
|
+
assert.equal(none.app_org, null, 'nothing recorded = the founder’s own account');
|
|
378
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -355,8 +355,12 @@ test('C8 — the wizard polls stop dead off the new view, like the sky off the m
|
|
|
355
355
|
// guard — merged unguarded they would poll a hidden view for up to 33
|
|
356
356
|
// minutes. Both ticks must consult the same gate the sky consults.
|
|
357
357
|
assert.match(SCRIPT, /function wizPollActive\(\)/, 'the wizard needs its own stage guard');
|
|
358
|
-
assert.match(SCRIPT,
|
|
359
|
-
'the guard is
|
|
358
|
+
assert.match(SCRIPT, /function wizPollActive\(\) \{\s*return currentView === 'new';\s*\}/,
|
|
359
|
+
'the guard is view-scoped');
|
|
360
|
+
// a HIDDEN tab slows the wizard's polls instead of stopping them (task 1004650): that
|
|
361
|
+
// is exactly when the founder is on GitHub. tests/github_handback_any_device.mjs runs it.
|
|
362
|
+
assert.match(SCRIPT, /return document\.hidden \? Math\.max\(ms, HIDDEN_POLL_MS\) : ms;/,
|
|
363
|
+
'a hidden tab keeps checking at the slow pace');
|
|
360
364
|
const gatedTicks = (SCRIPT.match(/if \(!wizPollActive\(\)\)/g) || []).length;
|
|
361
365
|
assert.ok(gatedTicks >= 2, `both wizard ticks (progress + manifest) must check the guard, found ${gatedTicks}`);
|
|
362
366
|
assert.match(SCRIPT, /if \(v === 'new'\) wizard\.enter\(focus\); else wizard\.leave\(\);/,
|
|
@@ -50,8 +50,9 @@ test('the hand-back variant escapes the title + domain it interpolates', () => {
|
|
|
50
50
|
assert.doesNotMatch(html, /Setting up <your> instance/, 'no raw title anywhere on the page');
|
|
51
51
|
assert.doesNotMatch(html, /https:\/\/evil"\.example\.com/, 'no unescaped domain in an href');
|
|
52
52
|
// it IS the hand-back variant (task 1004548): the way back + the config block the client script reads
|
|
53
|
-
|
|
54
|
-
assert.
|
|
53
|
+
// the way back is the return route, which names the project on the server (task 1004650)
|
|
54
|
+
assert.ok(html.includes('id="back" href="/api/bongos/provisioning/github-app/return?state=GOODSTATE"'));
|
|
55
|
+
assert.match(html, /__CB_SETUP__=\{"state":"GOODSTATE","domain":"evil\\"\.example\.com","back":"\/api\/bongos\/provisioning\/github-app\/return\?state=GOODSTATE"\}/);
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
test('the message variant escapes the title but passes server-authored body HTML through', () => {
|
|
@@ -108,7 +108,11 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
|
|
|
108
108
|
t('good state → 200 text/html one-trip page', res.status === 200 && /text\/html/.test(res.headers.get('content-type') || '') && /GitHub app created/.test(html) && /__CB_SETUP__/.test(html) && html.includes('GOODSTATE'), `status=${res.status}`);
|
|
109
109
|
// task 1004649: GitHub's half of the approval is done — the SAME tab goes on to the install
|
|
110
110
|
t('says the next step is installing it on the repo', /Next, install it on your repo/.test(html));
|
|
111
|
-
|
|
111
|
+
// task 1004650: the way back is the return route, which names the project on the server
|
|
112
|
+
t('carries a plain way back to the project (works without script)', html.includes('<a class="btn" id="back" href="/api/bongos/provisioning/github-app/return?state=GOODSTATE">Back to my project'));
|
|
113
|
+
// the script's config is the one the page SERVED, not a copy (task 1004650)
|
|
114
|
+
const servedCfg = JSON.parse((html.match(/window\.__CB_SETUP__=(\{[^<]*?\});/) || [])[1] || '{}');
|
|
115
|
+
t('the served config sends the founder to the return route, never to a bare create page', servedCfg.back === '/api/bongos/provisioning/github-app/return?state=GOODSTATE', JSON.stringify(servedCfg));
|
|
112
116
|
t('no progress stepper or countdown left on the page', !/id="steps"/.test(html) && !/Placing credentials/.test(html) && !/location\.href=target/.test(html));
|
|
113
117
|
|
|
114
118
|
// Run the served INSTALL script (not a copy): it polls the real status shape and goes on.
|
|
@@ -116,11 +120,11 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
|
|
|
116
120
|
t('the one-trip page ships its client script', typeof installScript === 'string' && /installations|install_url/.test(installScript));
|
|
117
121
|
async function runInstall(statuses, repoAnswer) {
|
|
118
122
|
const vm = await import('node:vm');
|
|
119
|
-
const back = { href: '
|
|
123
|
+
const back = { href: '' }, note = { textContent: '' };
|
|
120
124
|
const timers = [], fetched = [];
|
|
121
125
|
const calls = { replaced: null };
|
|
122
126
|
const sandbox = {
|
|
123
|
-
__CB_SETUP__:
|
|
127
|
+
__CB_SETUP__: servedCfg,
|
|
124
128
|
document: { getElementById: (id) => (id === 'back' ? back : id === 'note' ? note : null) },
|
|
125
129
|
location: { replace: (u) => { calls.replaced = u; } },
|
|
126
130
|
setTimeout: (fn) => { timers.push(fn); return timers.length; },
|
|
@@ -154,9 +158,9 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
|
|
|
154
158
|
const plain = await runInstall([named], null);
|
|
155
159
|
t('a repo GitHub will not describe (private) falls back to the plain install page', plain.replaced === 'https://github.com/apps/mercury-app/installations/new?state=GOODSTATE', JSON.stringify(plain));
|
|
156
160
|
const doneAlready = await runInstall([{ ...named, checklist: { ...named.checklist, install: 'done' } }], null);
|
|
157
|
-
t('already installed → straight back to the checklist', doneAlready.replaced === '/
|
|
161
|
+
t('already installed → straight back to the checklist', doneAlready.replaced === '/api/bongos/provisioning/github-app/return?state=GOODSTATE', JSON.stringify(doneAlready));
|
|
158
162
|
const stopped = await runInstall([{ ...named, status: 'error' }], null);
|
|
159
|
-
t('a stopped attempt → back to the checklist, which says what stopped', stopped.replaced === '/
|
|
163
|
+
t('a stopped attempt → back to the checklist, which says what stopped', stopped.replaced === '/api/bongos/provisioning/github-app/return?state=GOODSTATE', JSON.stringify(stopped));
|
|
160
164
|
}
|
|
161
165
|
|
|
162
166
|
console.log('\nGET /provisioning/github-app/setup (the App\'s setup_url — task 1004649):');
|
|
@@ -181,16 +185,17 @@ console.log('\nGET /provisioning/github-app/setup (the App\'s setup_url — task
|
|
|
181
185
|
// the plain hand-back page (now the setup_url's) still behaves as before (task 1004548)
|
|
182
186
|
const callbackPage = require(`${ROOT}/modules/provisioning/routes/callback-page.js`).callbackPage;
|
|
183
187
|
const html = callbackPage({ ok: true, title: 'Installed on GitHub', state: 'GOODSTATE', domain: 'mercury.example.com' });
|
|
184
|
-
t('carries a plain "back to my checklist" link (works without script)',
|
|
188
|
+
t('carries a plain "back to my checklist" link (works without script)', html.includes('<a class="btn" id="back" href="/api/bongos/provisioning/github-app/return?state=GOODSTATE">Back to my checklist'));
|
|
189
|
+
const servedCfg = JSON.parse((html.match(/window\.__CB_SETUP__=(\{[^<]*?\});/) || [])[1] || '{}');
|
|
185
190
|
const script = (html.match(/<script>\s*(\(function\(\)\{[\s\S]*?)<\/script>\s*<\/body>/) || [])[1];
|
|
186
191
|
t('the hand-back page ships the client script', typeof script === 'string' && script.length > 100);
|
|
187
192
|
async function runCallback({ opener }) {
|
|
188
193
|
const vm = await import('node:vm');
|
|
189
|
-
const back = { href: '
|
|
194
|
+
const back = { href: '' };
|
|
190
195
|
const timers = [];
|
|
191
196
|
const calls = { close: 0, focusOpener: 0, replaced: null };
|
|
192
197
|
const sandbox = {
|
|
193
|
-
__CB_SETUP__:
|
|
198
|
+
__CB_SETUP__: servedCfg,
|
|
194
199
|
opener: opener ? { closed: false, focus: () => { calls.focusOpener++; } } : null,
|
|
195
200
|
close: () => { calls.close++; },
|
|
196
201
|
document: { getElementById: (id) => (id === 'back' ? back : null) },
|
|
@@ -204,12 +209,12 @@ console.log('\nGET /provisioning/github-app/setup (the App\'s setup_url — task
|
|
|
204
209
|
while (timers.length) timers.shift()();
|
|
205
210
|
return { ...calls, replacedBeforeTimer, backHref: back.href };
|
|
206
211
|
}
|
|
207
|
-
const want = '/
|
|
212
|
+
const want = '/api/bongos/provisioning/github-app/return?state=GOODSTATE';
|
|
208
213
|
const popup = await runCallback({ opener: true });
|
|
209
214
|
t('as the wizard’s pop-up it focuses the checklist tab and closes itself', popup.focusOpener === 1 && popup.close === 1 && popup.replacedBeforeTimer === null, JSON.stringify(popup));
|
|
210
215
|
t('…and if the browser refuses the close, the pop-up becomes the checklist', popup.replaced === want, JSON.stringify(popup));
|
|
211
216
|
const sameTab = await runCallback({ opener: false });
|
|
212
|
-
t('as a same-tab return it goes straight back
|
|
217
|
+
t('as a same-tab return it goes straight back through the return route, carrying the attempt', sameTab.close === 0 && sameTab.replaced === want && sameTab.backHref === want, JSON.stringify(sameTab));
|
|
213
218
|
}
|
|
214
219
|
{
|
|
215
220
|
const res = await fetch(`${base}/provisioning/github-app/callback?code=X&state=NOPE`);
|
|
@@ -247,6 +247,10 @@ function runEnter({ draftStep, focus = false, resumeIntent = false, created = {
|
|
|
247
247
|
const log = { shown: [], created: [], offer: undefined };
|
|
248
248
|
const sandbox = {
|
|
249
249
|
authKnown: true, me: { github_login: 'probe' }, resumeIntent,
|
|
250
|
+
// the server's resume read (task 1004650) already answered for this page load —
|
|
251
|
+
// this slice is about the record the tab stored (tests/github_handback_any_device.mjs
|
|
252
|
+
// holds the server's half)
|
|
253
|
+
resumeAsked: true, handbackProject: null, askServerResume() { throw new Error('not asked twice'); },
|
|
250
254
|
state: { step: draftStep },
|
|
251
255
|
createdRecord: () => created,
|
|
252
256
|
enterCreated: (rec, f) => log.created.push({ id: rec.id, focus: f }),
|
|
@@ -164,7 +164,8 @@ const SEED = SCRIPT.slice(SS, SE);
|
|
|
164
164
|
test('the reload regions are still findable (a rename must fail loudly)', () => {
|
|
165
165
|
assert.ok(WS > 0 && WE > WS, 'wait-timer boundaries found');
|
|
166
166
|
assert.ok(SS > 0 && SE > SS, 'instance-read seed boundaries found');
|
|
167
|
-
|
|
167
|
+
// the pace slows while the tab is hidden, never stops (task 1004650)
|
|
168
|
+
assert.match(SCRIPT, /if \(!terminal \|\| manifestInFlight\(\) \|\| setupOpen\(\)\) pollTimer = setTimeout\(tick, wizPollDelay\(5000\)\);/,
|
|
168
169
|
'a GitHub leg in flight — or an install not yet verified (task 1004649) — keeps the instance read alive once the project is up');
|
|
169
170
|
});
|
|
170
171
|
|