@bongos/core 1.19.663 → 1.19.665
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 +40 -25
- package/docs/copy-inventory.md +28 -17
- package/docs/copy-registry.json +121 -22
- package/docs/module-api-changelog.md +14 -0
- package/modules/hall-ui/public/hall-kit.js +36 -2
- package/modules/hall-ui/public/studio.css +121 -1
- package/modules/hall-ui/public/studio.html +5 -1
- package/modules/hall-ui/public/studio.js +337 -13
- package/modules/hall-ui/public/studio.states.json +1 -1
- package/modules/provisioning/credential-preflight.js +170 -0
- package/modules/provisioning/routes/provisioning.js +16 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +11 -0
- package/scripts/hall-preview/task-visual.js +89 -0
- package/src/module-api.js +15 -1
- package/tests/hall_studio_world.mjs +435 -8
- package/tests/module_api.mjs +1 -0
- package/tests/provisioning_credential_preflight.mjs +153 -0
- package/tests/provisioning_onboard_mode.mjs +13 -0
- package/tests/rank_tier_single_source.mjs +8 -1
- package/tests/task_visuals.mjs +35 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// tests/provisioning_credential_preflight.mjs
|
|
2
|
+
//
|
|
3
|
+
// The create-route credential preflight (task 1002909, 1002898 N3).
|
|
4
|
+
//
|
|
5
|
+
// Before this, POST /provisioning/instances checked that an `adopt` NAMED a repo but
|
|
6
|
+
// never that the caller could read it, so the API admitted standups that were doomed at
|
|
7
|
+
// request time and surfaced ~2 minutes later as raw git stderr on the runner (the
|
|
8
|
+
// task-1002697 acceptance walk). The preflight asks ADR 0176's question one round-trip
|
|
9
|
+
// early: can this builder's credential carry THIS repo's standup?
|
|
10
|
+
//
|
|
11
|
+
// The two properties that matter, and the two ways this can be got wrong:
|
|
12
|
+
// 1. it must never refuse a create that would have SUCCEEDED (greenfield, public repos,
|
|
13
|
+
// admin-on-private, and — deliberately — a GitHub outage), and
|
|
14
|
+
// 2. it must refuse the ones that cannot (no credential, stale public_repo scope on a
|
|
15
|
+
// private repo, read-only access to a private repo).
|
|
16
|
+
// 1002898 carved this task out rather than guess at the definition precisely because a
|
|
17
|
+
// preflight written against the wrong one fails in one of those two directions.
|
|
18
|
+
//
|
|
19
|
+
// DB-free and network-free: readToken and fetchRepo are injected seams.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/provisioning_credential_preflight.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
|
|
28
|
+
const { adoptCredentialFault, parseTargetRef } = require('../modules/provisioning/credential-preflight.js');
|
|
29
|
+
// The REAL scope helper, not a stand-in: its whole point is that `public_repo` contains
|
|
30
|
+
// the substring "repo", so a test double would hide the bug it exists to prevent.
|
|
31
|
+
const { grantedScopeCoversRepos } = require('../src/bongos/auth-config.js');
|
|
32
|
+
|
|
33
|
+
let passed = 0, failed = 0;
|
|
34
|
+
async function ta(name, fn) {
|
|
35
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
36
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A deps bundle with every leg stubbed; each test overrides only what it is about.
|
|
40
|
+
function deps({ configured = true, token = { token: 't', scope: 'repo' }, repo = { private: false, admin: false }, throws = null } = {}) {
|
|
41
|
+
return {
|
|
42
|
+
secretBox: { isConfigured: () => configured },
|
|
43
|
+
grantedScopeCoversRepos,
|
|
44
|
+
userAgent: 'test-agent',
|
|
45
|
+
readToken: async () => token,
|
|
46
|
+
fetchRepo: async () => { if (throws) throw Object.assign(new Error('x'), { code: throws }); return repo; },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const adopt = (targetRef = 'octo-owner/demo') => ({ builderId: 7, onboardMode: 'adopt', targetRef });
|
|
50
|
+
|
|
51
|
+
// ── the exemption that is load-bearing ──────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
await ta('greenfield is EXEMPT — the platform creates that repo, so nothing is checked', async () => {
|
|
54
|
+
// Asserted via the seams: a greenfield create must not even read the token, or a
|
|
55
|
+
// builder with no GitHub connection could no longer make a fresh project at all.
|
|
56
|
+
let touched = false;
|
|
57
|
+
const d = deps();
|
|
58
|
+
d.readToken = async () => { touched = true; return null; };
|
|
59
|
+
assert.equal(await adoptCredentialFault({ builderId: 7, onboardMode: 'greenfield', targetRef: null }, d), null);
|
|
60
|
+
assert.equal(touched, false, 'greenfield must not consult the credential at all');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── the refusals ────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
await ta('no stored credential → 403 repo_scope_required, and the message says what to do', async () => {
|
|
66
|
+
const f = await adoptCredentialFault(adopt(), deps({ token: null }));
|
|
67
|
+
assert.equal(f.code, 'repo_scope_required');
|
|
68
|
+
assert.equal(f.status, 403);
|
|
69
|
+
assert.match(f.message, /Connect your GitHub account/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await ta('secret-box unconfigured → 503, not 403 — the fault is the server config, not the caller', async () => {
|
|
73
|
+
const f = await adoptCredentialFault(adopt(), deps({ configured: false }));
|
|
74
|
+
assert.equal(f.code, 'github_credential_unavailable');
|
|
75
|
+
assert.equal(f.status, 503);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await ta('THE HERMESLINES SHAPE: a stale public_repo token 404ing on a private repo re-prompts for scope', async () => {
|
|
79
|
+
// GitHub answers 404 identically for "absent" and "private and invisible to you", so a
|
|
80
|
+
// pre-ADR-0155 token reaching its owner's own private repo lands here. Asserting the
|
|
81
|
+
// repo does not exist would be a lie; the message must offer both readings.
|
|
82
|
+
const f = await adoptCredentialFault(adopt(), deps({ token: { token: 't', scope: 'public_repo,read:user' }, throws: 'repo_not_found' }));
|
|
83
|
+
assert.equal(f.code, 'repo_scope_required');
|
|
84
|
+
assert.equal(f.status, 403);
|
|
85
|
+
assert.match(f.message, /private/i);
|
|
86
|
+
assert.match(f.message, /reconnect/i);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await ta('a repo-scoped token that still 404s → 404 target_repo_not_found (it really is absent)', async () => {
|
|
90
|
+
const f = await adoptCredentialFault(adopt(), deps({ token: { token: 't', scope: 'repo' }, throws: 'repo_not_found' }));
|
|
91
|
+
assert.equal(f.code, 'target_repo_not_found');
|
|
92
|
+
assert.equal(f.status, 404);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await ta('private repo WITHOUT admin → 403: the runner could read it and then fail registering the deploy key', async () => {
|
|
96
|
+
const f = await adoptCredentialFault(adopt(), deps({ repo: { private: true, admin: false } }));
|
|
97
|
+
assert.equal(f.code, 'repo_admin_required');
|
|
98
|
+
assert.equal(f.status, 403);
|
|
99
|
+
assert.match(f.message, /ADMIN/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await ta('a rejected token → 403 telling the caller to reconnect, not a raw 401', async () => {
|
|
103
|
+
const f = await adoptCredentialFault(adopt(), deps({ throws: 'github_unauthorized' }));
|
|
104
|
+
assert.equal(f.code, 'repo_scope_required');
|
|
105
|
+
assert.match(f.message, /reconnect/i);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await ta('an unparseable target_ref is refused for THAT reason, not admitted unchecked', async () => {
|
|
109
|
+
const f = await adoptCredentialFault(adopt('not a repo ref!!'), deps());
|
|
110
|
+
assert.equal(f.code, 'bad_target_ref');
|
|
111
|
+
assert.equal(f.status, 400);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── the admissions (the half a bad preflight breaks) ────────────────────────────
|
|
115
|
+
|
|
116
|
+
await ta('public repo + any readable credential → ADMITTED, admin irrelevant', async () => {
|
|
117
|
+
// The deploy pull rides the shared SSH alias for a public repo, so no deploy key and
|
|
118
|
+
// therefore no admin is needed. Demanding admin here would refuse valid creates.
|
|
119
|
+
assert.equal(await adoptCredentialFault(adopt(), deps({ repo: { private: false, admin: false } })), null);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await ta('private repo WITH admin → ADMITTED — this is exactly what ADR 0176 needs', async () => {
|
|
123
|
+
assert.equal(await adoptCredentialFault(adopt(), deps({ repo: { private: true, admin: true } })), null);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await ta('GitHub unreachable → ADMITTED, matching the runner: an outage must not stop project creation', async () => {
|
|
127
|
+
// No positive evidence of doom. The runner takes the same posture
|
|
128
|
+
// (ensurePrivateRepoAccess returns skipped:'github_unreachable' rather than throwing),
|
|
129
|
+
// so refusing here would turn a GitHub hiccup into an outage of the create route.
|
|
130
|
+
assert.equal(await adoptCredentialFault(adopt(), deps({ throws: 'github_error' })), null);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── the parse must not drift from the runner's ──────────────────────────────────
|
|
134
|
+
|
|
135
|
+
await ta('parseTargetRef accepts every form the runner accepts, and rejects what it rejects', async () => {
|
|
136
|
+
// A divergence from scripts/gds/provision-repo.js parseTargetRef would be silent: this
|
|
137
|
+
// would admit refs the runner cannot parse, or refuse ones it can.
|
|
138
|
+
for (const ref of ['octo-owner/demo', 'git@github.com:octo-owner/demo.git', 'https://github.com/octo-owner/demo', 'https://github.com/octo-owner/demo.git']) {
|
|
139
|
+
assert.deepEqual(parseTargetRef(ref), { owner: 'octo-owner', name: 'demo' }, `should parse ${ref}`);
|
|
140
|
+
}
|
|
141
|
+
for (const ref of ['', null, 'demo', 'a/b/c', 'not a repo ref!!']) {
|
|
142
|
+
assert.equal(parseTargetRef(ref), null, `should reject ${JSON.stringify(ref)}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await ta('the real scope helper is used, so public_repo never passes as repo', async () => {
|
|
147
|
+
// The trap this guards: 'public_repo'.includes('repo') is true.
|
|
148
|
+
assert.equal(grantedScopeCoversRepos('public_repo,read:user'), false);
|
|
149
|
+
assert.equal(grantedScopeCoversRepos('repo'), true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
console.log(`\nprovisioning_credential_preflight: ${passed} passed, ${failed} failed`);
|
|
153
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -101,6 +101,19 @@ provisioning.getInstanceBySlug = async () => null;
|
|
|
101
101
|
const api = require('../src/module-api.js');
|
|
102
102
|
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
103
103
|
|
|
104
|
+
// An `adopt` create now also passes the credential preflight (task 1002909), which is a
|
|
105
|
+
// REAL precondition and not test scaffolding: the route refuses a standup whose owner
|
|
106
|
+
// holds no usable credential for the repo it is about to queue a push to. So give this
|
|
107
|
+
// harness a passing one — a `repo`-scoped token over a PUBLIC repo, the cheapest shape
|
|
108
|
+
// that admits — and leave the preflight's own branch matrix to
|
|
109
|
+
// tests/provisioning_credential_preflight.mjs, which owns it.
|
|
110
|
+
api.secretBox = { isConfigured: () => true };
|
|
111
|
+
api.pool = {
|
|
112
|
+
query: async () => ({ rows: [{ token_enc: 'enc', scope: 'repo', expires_at: new Date(Date.now() + 3600e3) }] }),
|
|
113
|
+
};
|
|
114
|
+
api.secretBox.decrypt = () => 'tok';
|
|
115
|
+
globalThis.fetch = async () => ({ status: 200, ok: true, json: async () => ({ private: false, permissions: { admin: true } }) });
|
|
116
|
+
|
|
104
117
|
const express = require('express');
|
|
105
118
|
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
106
119
|
const app = express();
|
|
@@ -163,7 +163,12 @@ const HALL_SCRIPTS = ['modules/hall-ui/public/builders.js', 'modules/hall-ui/pub
|
|
|
163
163
|
// it needs both the tier order (its `rank` sort lens) and the known-rank test
|
|
164
164
|
// (the badge variant class). Browser code cannot require db-kernel, so it takes
|
|
165
165
|
// the pinned-copy shape builders.js already takes.
|
|
166
|
-
'modules/hall-ui/public/roster.js'
|
|
166
|
+
'modules/hall-ui/public/roster.js',
|
|
167
|
+
// task 1003829: the Studio's verdict row offers four writes that all floor at
|
|
168
|
+
// METIC server-side, so the room reads the tier order to decide whether to draw
|
|
169
|
+
// them at all. Browser code cannot require db-kernel, so it takes the same
|
|
170
|
+
// pinned-copy shape builders.js and roster.js take.
|
|
171
|
+
'modules/hall-ui/public/studio.js'];
|
|
167
172
|
// Filtered INSIDE the test, not at module scope: extractRankMaps is declared
|
|
168
173
|
// further down, so calling it up here is a temporal-dead-zone crash.
|
|
169
174
|
const staticMapFiles = () => HALL_SCRIPTS.filter(
|
|
@@ -198,6 +203,8 @@ const MAP_ALLOWLIST = new Set([
|
|
|
198
203
|
'modules/hall-ui/public/goals-render.js',
|
|
199
204
|
// task 1003441 — see the HALL_SCRIPTS note above: pinned, not unchecked.
|
|
200
205
|
'modules/hall-ui/public/roster.js',
|
|
206
|
+
// task 1003829 — see the HALL_SCRIPTS note above: pinned, not unchecked.
|
|
207
|
+
'modules/hall-ui/public/studio.js',
|
|
201
208
|
]);
|
|
202
209
|
// Where a rank-ladder array literal is allowed to exist.
|
|
203
210
|
const LADDER_ALLOWLIST = new Set([
|
package/tests/task_visuals.mjs
CHANGED
|
@@ -193,6 +193,41 @@ await t('hall-kit: no visual renders nothing at all — never a placeholder', ()
|
|
|
193
193
|
assert.ok(!html.includes('"before"'));
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
+
// ---- the full-size figure (task 1003829) -----------------------------------
|
|
197
|
+
await t('hall-kit: the full-size figure is the same guard, at reading size', () => {
|
|
198
|
+
// Same no-placeholder rule as the thumbnail: nothing at all without a visual.
|
|
199
|
+
assert.equal(kit.taskVisualFigureHtml({ id: 1 }), '');
|
|
200
|
+
assert.equal(kit.taskVisualFigureHtml({ id: 1, visual_url: null }), '');
|
|
201
|
+
assert.equal(kit.taskVisualFigureHtml(null), '');
|
|
202
|
+
// And the same URL wall — an off-origin src or a javascript: payload is not
|
|
203
|
+
// "rendered without a caption", it is not rendered.
|
|
204
|
+
assert.equal(kit.taskVisualFigureHtml({ id: 1, visual_url: 'https://evil.example/x.png' }), '');
|
|
205
|
+
assert.equal(kit.taskVisualFigureHtml({ id: 1, visual_url: 'javascript:alert(1)' }), '');
|
|
206
|
+
|
|
207
|
+
const url = '/api/gds/task-visuals/task-1-0011223344556677.png';
|
|
208
|
+
const html = kit.taskVisualFigureHtml({ id: 1, visual_url: url, visual_alt: 'a "before" shot' });
|
|
209
|
+
assert.ok(html.includes('<figure class="task-visual">'));
|
|
210
|
+
assert.ok(html.includes('class="task-visual__img"'));
|
|
211
|
+
// The caption is the SHIPPER's text — attacker-adjacent, so escaped in both
|
|
212
|
+
// the caption and the alt.
|
|
213
|
+
assert.ok(html.includes('<figcaption class="task-visual__caption">a "before" shot'));
|
|
214
|
+
assert.ok(!html.includes('"before"'));
|
|
215
|
+
|
|
216
|
+
// The caller owns the size, so it owns the class base; anything but
|
|
217
|
+
// [A-Za-z0-9_-] is stripped rather than interpolated into the attribute.
|
|
218
|
+
const studio = kit.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'studio-plate' });
|
|
219
|
+
assert.ok(studio.includes('<figure class="studio-plate">'));
|
|
220
|
+
assert.ok(studio.includes('class="studio-plate__img"'));
|
|
221
|
+
const hostile = kit.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'x" onload="a' });
|
|
222
|
+
assert.ok(hostile.includes('<figure class="xonloada">'), hostile);
|
|
223
|
+
|
|
224
|
+
// No written alt → no caption at all. The fallback alt is a machine sentence
|
|
225
|
+
// for a screen reader, never a caption shown to a reader.
|
|
226
|
+
const bare = kit.taskVisualFigureHtml({ id: 7, visual_url: url });
|
|
227
|
+
assert.ok(!bare.includes('figcaption'));
|
|
228
|
+
assert.ok(bare.includes('alt="Visual for task 7"'));
|
|
229
|
+
});
|
|
230
|
+
|
|
196
231
|
// ---- cleanup ---------------------------------------------------------------
|
|
197
232
|
await fs.rm(TMP, { recursive: true, force: true });
|
|
198
233
|
|