@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,379 @@
|
|
|
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.getLatestOAuthManifestForInstance = async () => null; // no earlier attempt (task 1004652's mint guard)
|
|
72
|
+
provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
73
|
+
const branding = require(path.join(ROOT, 'src/branding.js'));
|
|
74
|
+
const realBranding = branding.branding;
|
|
75
|
+
branding.branding = () => ({ ...realBranding(), identity: { ...(realBranding().identity || {}), productName: 'Cloud Bongos' }, domains: { publicOrigin: 'https://cloudbongos.com' } });
|
|
76
|
+
const GS = require(path.join(ROOT, 'modules/provisioning/github-setup.js'));
|
|
77
|
+
const githubRepos = require(path.join(ROOT, 'src/bongos/github-repos.js'));
|
|
78
|
+
|
|
79
|
+
const express = require('express');
|
|
80
|
+
const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
81
|
+
const buildGithubRouter = require(path.join(ROOT, 'src/bongos/routes/github.js'));
|
|
82
|
+
const provisioningRoutes = require(path.join(ROOT, 'modules/provisioning/routes/provisioning.js'));
|
|
83
|
+
let server, origin;
|
|
84
|
+
before(async () => {
|
|
85
|
+
const app = express();
|
|
86
|
+
app.use(express.json());
|
|
87
|
+
app.use(attachFail);
|
|
88
|
+
app.use('/api/bongos', buildGithubRouter());
|
|
89
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
90
|
+
server = app.listen(0);
|
|
91
|
+
await new Promise((r) => server.once('listening', r));
|
|
92
|
+
origin = `http://127.0.0.1:${server.address().port}`;
|
|
93
|
+
});
|
|
94
|
+
after(() => { server.close(); globalThis.fetch = realFetch; });
|
|
95
|
+
|
|
96
|
+
// GitHub's answers for a founder who owns acme-studio and is only a member of bigcorp
|
|
97
|
+
function memberships() {
|
|
98
|
+
return {
|
|
99
|
+
'GET /user/memberships/orgs': () => [200, [
|
|
100
|
+
{ role: 'member', state: 'active', organization: { login: 'bigcorp' } },
|
|
101
|
+
{ role: 'admin', state: 'active', organization: { login: 'acme-studio' } },
|
|
102
|
+
]],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── the page code, sliced (each boundary asserted below) ────────────────────────────
|
|
107
|
+
function region(startMark, endMark) {
|
|
108
|
+
const s = SCRIPT.indexOf(startMark);
|
|
109
|
+
const e = s < 0 ? -1 : SCRIPT.indexOf(endMark, s);
|
|
110
|
+
return s > 0 && e > s ? SCRIPT.slice(s, e) : null;
|
|
111
|
+
}
|
|
112
|
+
const PICKER = region(" /* ── whose GitHub account: mine or an organisation's (task 1004651) ──", " var repoPick = acctPicker('ra', {");
|
|
113
|
+
const ENSURE_REPO = region(' function ensureFreshRepo() {', " $('doCreate').addEventListener('click'");
|
|
114
|
+
const NOTE_ORG = region(" /* the organisation the server says this attempt's app is on: change-only */", ' function startManifest(btn) {');
|
|
115
|
+
const START = region(' function startManifest(btn) {', ' /* the "start again" offer once a wait has clearly gone wrong');
|
|
116
|
+
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');
|
|
117
|
+
|
|
118
|
+
test('the page regions are still findable (a rename must fail loudly, not test nothing)', () => {
|
|
119
|
+
for (const [name, r] of Object.entries({ PICKER, ENSURE_REPO, NOTE_ORG, START, SETTINGS })) assert.ok(r, `${name} region not found`);
|
|
120
|
+
assert.match(PICKER, /function acctPicker\(p, opts\)/);
|
|
121
|
+
assert.doesNotMatch(PICKER, /innerHTML/, 'organisation names are DOM text, never markup');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// A miniature DOM: just what the picker, the repo create and the mint touch.
|
|
125
|
+
class El {
|
|
126
|
+
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; }
|
|
127
|
+
addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); }
|
|
128
|
+
fire(ev, extra = {}) { (this.listeners[ev] || []).forEach((fn) => fn({ preventDefault() {}, ...extra })); }
|
|
129
|
+
focus() { this.focused = true; }
|
|
130
|
+
appendChild(c) { this.children.push(c); return c; }
|
|
131
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); }
|
|
132
|
+
get firstChild() { return this.children[0] || null; }
|
|
133
|
+
get value() { return this._v; }
|
|
134
|
+
set value(v) {
|
|
135
|
+
// a select takes only a value one of its options has, as a browser does
|
|
136
|
+
if (this.tagName === 'select') this._v = this.children.some((o) => o.value === v) ? v : '';
|
|
137
|
+
else this._v = v;
|
|
138
|
+
}
|
|
139
|
+
submit() { this.submitted = true; }
|
|
140
|
+
remove() {}
|
|
141
|
+
}
|
|
142
|
+
function opt(value) { const o = new El(null, 'option'); o.value = value; return o; }
|
|
143
|
+
|
|
144
|
+
function page({ state = {}, live = {} } = {}) {
|
|
145
|
+
const els = {};
|
|
146
|
+
const $ = (id) => {
|
|
147
|
+
if (!els[id]) {
|
|
148
|
+
const tag = /(Sel|Org)$/.test(id) ? 'select' : 'div';
|
|
149
|
+
els[id] = new El(id, tag);
|
|
150
|
+
if (/Sel$/.test(id)) { els[id].children.push(opt('personal'), opt('org')); els[id]._v = 'personal'; }
|
|
151
|
+
}
|
|
152
|
+
return els[id];
|
|
153
|
+
};
|
|
154
|
+
const posted = [];
|
|
155
|
+
const forms = [];
|
|
156
|
+
const sandbox = {
|
|
157
|
+
API: '/api/bongos', $, JSON, String, Promise, encodeURIComponent, Error,
|
|
158
|
+
document: {
|
|
159
|
+
body: { appendChild() {} },
|
|
160
|
+
createElement: (tag) => { const e = new El(null, tag); if (tag === 'form') forms.push(e); return e; },
|
|
161
|
+
},
|
|
162
|
+
window: { open: () => ({ close() {} }), location: { assign() {} } },
|
|
163
|
+
fetch: (p, init) => { posted.push({ p, init }); return realFetch(origin + p, { method: (init && init.method) || 'GET', headers: init && init.headers, body: init && init.body }); },
|
|
164
|
+
state: { repo: 'mercury', mode: 'greenfield', owner: 'founder', ...state },
|
|
165
|
+
live: { manifest: null, manifestMsg: '', manifestFailure: null, appSlug: null, appOrg: null, setup: null, installUrl: null, ...live },
|
|
166
|
+
repoAccess: true, instanceId: 42, me: { github_login: 'founder' },
|
|
167
|
+
hopForRepoAccess: () => false, syncHome() {}, saveDraft() {},
|
|
168
|
+
rememberManifest() {}, renderSteps() {}, pollManifest() {}, watchGhTab() {},
|
|
169
|
+
};
|
|
170
|
+
sandbox.manifestStartedAt = 0;
|
|
171
|
+
vm.createContext(sandbox);
|
|
172
|
+
vm.runInContext([PICKER, ENSURE_REPO, NOTE_ORG, START, SETTINGS, "var appPick = acctPicker('aa', { needsOwner: true });"].join('\n'), sandbox);
|
|
173
|
+
return { sb: sandbox, els, posted, forms, settle: () => new Promise((r) => setTimeout(r, 60)) };
|
|
174
|
+
}
|
|
175
|
+
async function settled(p, n = 6) { for (let i = 0; i < n; i++) await p.settle(); }
|
|
176
|
+
|
|
177
|
+
// ── 1. the personal path is unchanged ───────────────────────────────────────────────
|
|
178
|
+
test('personal: the page mints with an empty body, and GitHub is asked for the personal app page', async () => {
|
|
179
|
+
gh = memberships(); events.length = 0;
|
|
180
|
+
const p = page();
|
|
181
|
+
p.sb.appPick.load(null);
|
|
182
|
+
const btn = new El('btn');
|
|
183
|
+
p.sb.startManifest(btn);
|
|
184
|
+
await settled(p);
|
|
185
|
+
const mint = p.posted.find((x) => /\/github-app$/.test(x.p));
|
|
186
|
+
assert.equal(mint.init.body, '{}', 'byte-for-byte what the page always sent');
|
|
187
|
+
assert.equal(p.forms.length, 1);
|
|
188
|
+
assert.match(p.forms[0].action, /^https:\/\/github\.com\/settings\/apps\/new\?state=[0-9a-f]{64}$/);
|
|
189
|
+
assert.ok(!events.some((e) => e.event === GS.SETUP_EVENTS.appOrg), 'no organisation is recorded for a personal app');
|
|
190
|
+
assert.ok(!p.posted.some((x) => /\/github\/orgs/.test(x.p)), 'GitHub is not even asked for organisations');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('personal: the new repo is made with {name} and POST /user/repos, as before', async () => {
|
|
194
|
+
gh = { 'POST /user/repos': () => [201, { full_name: 'founder/mercury', html_url: 'https://github.com/founder/mercury' }] };
|
|
195
|
+
ghCalls.length = 0;
|
|
196
|
+
const p = page();
|
|
197
|
+
await p.sb.ensureFreshRepo();
|
|
198
|
+
const sent = p.posted.find((x) => x.p === '/api/bongos/github/repos');
|
|
199
|
+
assert.equal(sent.init.body, JSON.stringify({ name: 'mercury' }));
|
|
200
|
+
assert.deepEqual(ghCalls.map((c) => c.key), ['POST /user/repos']);
|
|
201
|
+
assert.deepEqual(ghCalls[0].body, { name: 'mercury', private: true, auto_init: false });
|
|
202
|
+
assert.equal(p.sb.state.owner, 'founder');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ── 2. an organisation ──────────────────────────────────────────────────────────────
|
|
206
|
+
test('organisation: listed from GitHub; one the founder only belongs to is shown with the reason and cannot be picked', async () => {
|
|
207
|
+
gh = memberships();
|
|
208
|
+
const p = page();
|
|
209
|
+
p.sb.appPick.load(null);
|
|
210
|
+
p.els.aaSel.value = 'org'; p.els.aaSel.fire('change');
|
|
211
|
+
await settled(p);
|
|
212
|
+
const opts = p.els.aaOrg.children.map((o) => ({ v: o.value, t: o.textContent, off: o.disabled }));
|
|
213
|
+
assert.deepEqual(opts, [
|
|
214
|
+
{ v: 'acme-studio', t: 'acme-studio', off: false },
|
|
215
|
+
{ v: 'bigcorp', t: 'bigcorp (you’re a member, not an owner)', off: true },
|
|
216
|
+
{ v: '__other', t: 'Another organisation…', off: false },
|
|
217
|
+
]);
|
|
218
|
+
assert.equal(p.els.aaOrg.value, 'acme-studio', 'the one they own is picked');
|
|
219
|
+
assert.equal(p.els.aaOrgField.hidden, false);
|
|
220
|
+
// a refused one forced in (an old browser ignoring disabled) still cannot go ahead
|
|
221
|
+
p.els.aaOrg._v = 'bigcorp'; p.els.aaOrg.fire('change');
|
|
222
|
+
assert.equal(p.sb.appPick.value().ok, false);
|
|
223
|
+
assert.match(p.els.aaNote.textContent, /member of bigcorp, not an owner/);
|
|
224
|
+
const btn = new El('btn');
|
|
225
|
+
p.sb.startManifest(btn);
|
|
226
|
+
await settled(p, 2);
|
|
227
|
+
assert.ok(!p.posted.some((x) => /\/github-app$/.test(x.p)), 'no link is minted for an organisation they cannot make apps on');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('organisation: the app is made on it — the org manifest URL, the org recorded, its settings linked there', async () => {
|
|
231
|
+
gh = memberships(); events.length = 0;
|
|
232
|
+
const p = page();
|
|
233
|
+
p.sb.appPick.load('acme-studio');
|
|
234
|
+
await settled(p);
|
|
235
|
+
assert.equal(p.sb.appPick.value().org, 'acme-studio');
|
|
236
|
+
p.sb.startManifest(new El('btn'));
|
|
237
|
+
await settled(p);
|
|
238
|
+
const mint = p.posted.find((x) => /\/github-app$/.test(x.p));
|
|
239
|
+
assert.deepEqual(JSON.parse(mint.init.body), { account: 'org', org: 'acme-studio' });
|
|
240
|
+
assert.match(p.forms[0].action, /^https:\/\/github\.com\/organizations\/acme-studio\/settings\/apps\/new\?state=[0-9a-f]{64}$/);
|
|
241
|
+
assert.deepEqual(events.filter((e) => e.event === GS.SETUP_EVENTS.appOrg).map((e) => e.detail), ['acme-studio']);
|
|
242
|
+
// the page now knows whose app it is, and links its settings under the organisation
|
|
243
|
+
p.sb.live.appSlug = 'mercury-app';
|
|
244
|
+
assert.equal(vm.runInContext('appSettingsUrl()', p.sb), 'https://github.com/organizations/acme-studio/settings/apps/mercury-app');
|
|
245
|
+
p.sb.live.appOrg = null;
|
|
246
|
+
assert.equal(vm.runInContext('appSettingsUrl()', p.sb), 'https://github.com/settings/apps/mercury-app', 'and under the founder for a personal app');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('organisation: the new repo is made under it with POST /orgs/{org}/repos, and the project points at it', async () => {
|
|
250
|
+
gh = { 'POST /orgs/acme-studio/repos': () => [201, { full_name: 'acme-studio/mercury', html_url: 'https://github.com/acme-studio/mercury' }] };
|
|
251
|
+
ghCalls.length = 0;
|
|
252
|
+
const p = page({ state: { repoAcct: 'org', repoOrg: 'acme-studio' } });
|
|
253
|
+
await p.sb.ensureFreshRepo();
|
|
254
|
+
assert.deepEqual(ghCalls.map((c) => c.key), ['POST /orgs/acme-studio/repos']);
|
|
255
|
+
assert.deepEqual(ghCalls[0].body, { name: 'mercury', private: true, auto_init: false });
|
|
256
|
+
assert.equal(p.sb.state.owner, 'acme-studio');
|
|
257
|
+
assert.equal(p.sb.state.repoUrl, 'https://github.com/acme-studio/mercury');
|
|
258
|
+
assert.equal(p.sb.state.repoAutoCreated, true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('organisation: GitHub refusing the org repo comes back with its reason, never as "sign in again"', async () => {
|
|
262
|
+
const cases = [
|
|
263
|
+
[{ 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/],
|
|
264
|
+
[{ 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/],
|
|
265
|
+
];
|
|
266
|
+
for (const [body, code, words] of cases) {
|
|
267
|
+
gh = { 'POST /orgs/acme-studio/repos': () => [403, body] };
|
|
268
|
+
const res = await realFetch(`${origin}/api/bongos/github/repos`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'mercury', org: 'acme-studio' }) });
|
|
269
|
+
const j = await res.json();
|
|
270
|
+
assert.equal(res.status, 409, code);
|
|
271
|
+
assert.equal(j.error.code, code);
|
|
272
|
+
assert.match(j.error.message, words);
|
|
273
|
+
// the page takes the founder to the Code row with that reason, not to a sign-in hop
|
|
274
|
+
const p = page({ state: { repoAcct: 'org', repoOrg: 'acme-studio' } });
|
|
275
|
+
let hopped = false; p.sb.hopForRepoAccess = () => { hopped = true; return true; };
|
|
276
|
+
await assert.rejects(() => p.sb.ensureFreshRepo(), (e) => e.gotoHome === true && e.homeMsg === j.error.message);
|
|
277
|
+
assert.equal(hopped, false);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── 3. when GitHub will not show the founder's memberships ──────────────────────────
|
|
282
|
+
test('typed name: GitHub hides the memberships, so the founder types it and it is checked', async () => {
|
|
283
|
+
gh = {
|
|
284
|
+
'GET /user/memberships/orgs': () => [403, { message: 'requires read:org' }],
|
|
285
|
+
'GET /user/repos': () => [403, {}],
|
|
286
|
+
'GET /user/memberships/orgs/acme-studio': () => [403, { message: 'requires read:org' }],
|
|
287
|
+
'GET /orgs/acme-studio': () => [200, { login: 'acme-studio', type: 'Organization' }],
|
|
288
|
+
'GET /orgs/someone': () => [200, { login: 'someone', type: 'User' }],
|
|
289
|
+
};
|
|
290
|
+
const p = page();
|
|
291
|
+
p.sb.appPick.load('');
|
|
292
|
+
await settled(p);
|
|
293
|
+
assert.deepEqual(p.els.aaOrg.children.map((o) => o.value), ['__other']);
|
|
294
|
+
assert.equal(p.els.aaTyped.hidden, false);
|
|
295
|
+
assert.match(p.els.aaNote.textContent, /didn’t show us your organisations/);
|
|
296
|
+
assert.equal(p.sb.appPick.value().ok, false, 'nothing to go ahead with until it is checked');
|
|
297
|
+
p.els.aaName.value = 'acme-studio'; p.els.aaCheck.fire('click');
|
|
298
|
+
await settled(p);
|
|
299
|
+
const v = p.sb.appPick.value();
|
|
300
|
+
assert.equal(v.org, 'acme-studio');
|
|
301
|
+
assert.equal(v.ok, true, 'an organisation GitHub did not give a role for is offered, said as such');
|
|
302
|
+
assert.match(p.els.aaNote.textContent, /didn't tell us your role in acme-studio/);
|
|
303
|
+
// a personal account's login is not an organisation
|
|
304
|
+
p.els.aaName.value = 'someone'; p.els.aaCheck.fire('click');
|
|
305
|
+
await settled(p);
|
|
306
|
+
assert.equal(p.sb.appPick.value().ok, false);
|
|
307
|
+
assert.match(p.els.aaNote.textContent, /an organisation called someone/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ── 4. bad names never reach GitHub ─────────────────────────────────────────────────
|
|
311
|
+
test('bad organisation names are refused by the server before GitHub is asked', async () => {
|
|
312
|
+
gh = {}; ghCalls.length = 0;
|
|
313
|
+
for (const bad of ['-acme', 'acme-', 'ac--me', 'a/b', 'acme studio', 'x'.repeat(40), '../etc']) {
|
|
314
|
+
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 }) });
|
|
315
|
+
assert.equal(mint.status, 400, `mint: ${bad}`);
|
|
316
|
+
const repo = await realFetch(`${origin}/api/bongos/github/repos`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'mercury', org: bad }) });
|
|
317
|
+
assert.equal(repo.status, 400, `repo: ${bad}`);
|
|
318
|
+
}
|
|
319
|
+
const check = await realFetch(`${origin}/api/bongos/github/orgs/${encodeURIComponent('ac--me')}`);
|
|
320
|
+
assert.equal(check.status, 400);
|
|
321
|
+
assert.deepEqual(ghCalls, [], 'GitHub was never called with any of them');
|
|
322
|
+
for (const bad of ['-acme', 'ac--me', 'acme_', '']) assert.equal(githubRepos.validGithubLogin(bad), false, bad);
|
|
323
|
+
for (const ok of ['a', 'acme-studio', 'A1-b2', 'x'.repeat(39)]) assert.equal(githubRepos.validGithubLogin(ok), true, ok);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('mint: an explicit organisation choice never falls back to the personal account unseen', async () => {
|
|
327
|
+
targetRef = 'founder_x/mercury'; // an owner that is not a login, and nothing typed
|
|
328
|
+
try {
|
|
329
|
+
const res = await realFetch(`${origin}/api/bongos/provisioning/instances/42/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ account: 'org' }) });
|
|
330
|
+
assert.equal(res.status, 400);
|
|
331
|
+
assert.equal((await res.json()).error.code, 'org_required');
|
|
332
|
+
} finally { targetRef = 'founder/mercury'; }
|
|
333
|
+
// with no org typed, account 'org' still means the repo's own organisation, as before
|
|
334
|
+
targetRef = 'acme-studio/mercury';
|
|
335
|
+
try {
|
|
336
|
+
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();
|
|
337
|
+
assert.equal(j.account, 'org');
|
|
338
|
+
assert.match(j.post_url, /^https:\/\/github\.com\/organizations\/acme-studio\/settings\/apps\/new\?state=/);
|
|
339
|
+
} finally { targetRef = 'founder/mercury'; }
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// ── 5. the helpers, at unit altitude ────────────────────────────────────────────────
|
|
343
|
+
test('listUserOrgs: roles from the memberships; else the organisations the repos sit in, roles unknown', async () => {
|
|
344
|
+
gh = { 'GET /user/memberships/orgs': () => [200, [{ role: 'admin', state: 'pending', organization: { login: 'newco' } }, { role: 'admin', state: 'active', organization: { login: 'bad name' } }]] };
|
|
345
|
+
let out = await githubRepos.listUserOrgs('tok');
|
|
346
|
+
assert.equal(out.listed, 'memberships');
|
|
347
|
+
assert.deepEqual(out.orgs.map((o) => [o.login, o.can_create_apps]), [['newco', false]], 'a malformed login is dropped; a pending invite cannot');
|
|
348
|
+
assert.match(out.orgs[0].reason, /invitation to newco is still waiting/);
|
|
349
|
+
gh = {
|
|
350
|
+
'GET /user/memberships/orgs': () => [403, {}],
|
|
351
|
+
'GET /user/repos': () => [200, [
|
|
352
|
+
{ owner: { login: 'Zeta', type: 'Organization' } }, { owner: { login: 'founder', type: 'User' } },
|
|
353
|
+
{ owner: { login: 'alpha', type: 'Organization' } }, { owner: { login: 'zeta', type: 'Organization' } },
|
|
354
|
+
]],
|
|
355
|
+
};
|
|
356
|
+
out = await githubRepos.listUserOrgs('tok');
|
|
357
|
+
assert.equal(out.listed, 'repos');
|
|
358
|
+
assert.deepEqual(out.orgs.map((o) => [o.login, o.can_create_apps]), [['alpha', null], ['Zeta', null]]);
|
|
359
|
+
gh = { 'GET /user/memberships/orgs': () => [403, {}], 'GET /user/repos': () => [500, {}] };
|
|
360
|
+
assert.deepEqual(await githubRepos.listUserOrgs('tok'), { orgs: [], listed: 'none' });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('getOrgAccess: the role when GitHub gives it, unknown when it only proves the org exists, missing otherwise', async () => {
|
|
364
|
+
gh = { 'GET /user/memberships/orgs/acme-studio': () => [200, { role: 'admin', state: 'active', organization: { login: 'Acme-Studio' } }] };
|
|
365
|
+
assert.deepEqual(await githubRepos.getOrgAccess('tok', { org: 'acme-studio' }), { login: 'Acme-Studio', role: 'admin', can_create_apps: true, reason: null });
|
|
366
|
+
gh = { 'GET /user/memberships/orgs/ghost': () => [404, {}], 'GET /orgs/ghost': () => [404, {}] };
|
|
367
|
+
await assert.rejects(() => githubRepos.getOrgAccess('tok', { org: 'ghost' }), (e) => e.code === 'org_not_found');
|
|
368
|
+
await assert.rejects(() => githubRepos.getOrgAccess('tok', { org: 'a--b' }), (e) => e.code === 'bad_org');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('github-setup: the organisation recorded at the mint rides the checklist read as app_org', async () => {
|
|
372
|
+
const row = { instance_id: 42, state: 'S1', created_at: '2026-10-08T09:00:00Z', app_slug: 'mercury-app' };
|
|
373
|
+
const said = await GS.githubSetupRead({ query: async () => ({ rows: [{ event: GS.SETUP_EVENTS.appOrg, at: '2026-10-08T09:00:01Z', detail: 'acme-studio' }] }) }, {}, row, 'done');
|
|
374
|
+
assert.equal(said.app_org, 'acme-studio');
|
|
375
|
+
const bad = await GS.githubSetupRead({ query: async () => ({ rows: [{ event: GS.SETUP_EVENTS.appOrg, at: '2026-10-08T09:00:01Z', detail: '<script>' }] }) }, {}, row, 'done');
|
|
376
|
+
assert.equal(bad.app_org, null, 'a recorded value that is not a login never reaches a link');
|
|
377
|
+
const none = await GS.githubSetupRead({ query: async () => ({ rows: [] }) }, {}, row, 'done');
|
|
378
|
+
assert.equal(none.app_org, null, 'nothing recorded = the founder’s own account');
|
|
379
|
+
});
|