@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.
@@ -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([
@@ -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 &quot;before&quot; 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