@bongos/core 1.19.981 → 1.19.982

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,238 @@
1
+ // tests/projects_hub_repo_private.mjs
2
+ //
3
+ // The manage page's "Make it private" (split (c) of task 1003078): the page's OWN functions,
4
+ // lifted out of projects.html and run in a sandbox with a fake DOM and fetch — so what is
5
+ // proved is behaviour, not the shape of the source.
6
+ //
7
+ // The load-bearing assertions:
8
+ // • the door shows only where the route would accept it, and every other case says why
9
+ // • the POST reply is a RECEIPT: it lands in the stable slot, and nothing claims the
10
+ // repository is private until GitHub is asked again after the run settles
11
+ // • the in-flight line is fetch-free, repaints only when the run's state MOVES (the
12
+ // minute tick never flickers it), and the settle triggers exactly ONE GitHub re-read
13
+ // • a failed make-private reads as a failed make-private on the card, not a failed standup
14
+ //
15
+ // Run: node tests/projects_hub_repo_private.mjs
16
+
17
+ import { strict as assert } from 'node:assert';
18
+ import { readFileSync } from 'node:fs';
19
+ import vm from 'node:vm';
20
+
21
+ const HUB = readFileSync(new URL('../modules/public-landing/public/projects.html', import.meta.url), 'utf8').replace(/\r\n/g, '\n');
22
+
23
+ let passed = 0, failed = 0;
24
+ async function ta(name, fn) {
25
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
26
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
27
+ }
28
+
29
+ // A top-level (2-space) declaration, up to its own closing brace at that indent.
30
+ function grab(head) {
31
+ const at = HUB.indexOf(`\n ${head}`);
32
+ assert.ok(at >= 0, `${head} must exist`);
33
+ const end = HUB.indexOf('\n }', at + 1);
34
+ const close = HUB.slice(end, end + 5) === '\n };' ? end + 5 : end + 4;
35
+ return HUB.slice(at, close);
36
+ }
37
+ const SOURCES = [
38
+ 'function esc(', 'var FLOWS = {', 'function stripMarkup(', 'function stripFor(', 'function errCategory(',
39
+ 'function repoPrivOpen(', 'function repoPrivDoor(', 'function repoVisNote(', 'function renderRepoVis(',
40
+ 'function renderRepoVisState(', 'function askRepoPrivate(', 'function doRepoPrivate(',
41
+ ].map(grab).join('\n') + "\nfunction repoVisMsg(html) { $('mRepoVis').innerHTML = html; }\n";
42
+
43
+ const RECEIPT = { status: 200, body: { ok: true, queued: true, message: 'Queued <b>ok</b>.' } };
44
+ function world({ reply = RECEIPT } = {}) {
45
+ const els = { mRepoVis: { innerHTML: '' }, mRepoVisMsg: { className: 'projMsg', innerHTML: '' } };
46
+ const calls = { fetch: [], loadManage: [], reread: 0 };
47
+ let settle;
48
+ const done = new Promise((r) => { settle = r; });
49
+ const ctx = {
50
+ API: '/api/bongos', manageId: '18', manageInst: null, manageRepo: null,
51
+ mRepoPrivSeen: false, mRepoPrivState: '',
52
+ $: (id) => els[id],
53
+ document: { querySelector: () => null },
54
+ jsonOrEmpty: (res) => res.json(),
55
+ apiError: (data) => (data && data.error) || {},
56
+ signInAgain: () => 'SIGN-IN',
57
+ justLive: () => false,
58
+ loadRepoVisibility: () => { calls.reread++; },
59
+ loadManage: (...a) => { calls.loadManage.push(a); settle(); },
60
+ fetch: (url, opts) => {
61
+ calls.fetch.push({ url, opts });
62
+ return Promise.resolve({ ok: reply.status < 300, status: reply.status, json: async () => reply.body });
63
+ },
64
+ };
65
+ vm.createContext(ctx);
66
+ vm.runInContext(SOURCES, ctx, { filename: 'projects.html#repo-private' });
67
+ return { ctx, els, calls, done };
68
+ }
69
+
70
+ const LIVE = { id: 18, status: 'active', hosting_shape: 'standalone', target_ref: 'o/demo', open_intent: null };
71
+ const PUBLIC = { private: false, admin: true };
72
+
73
+ console.log('the door:');
74
+
75
+ await ta('a live Cloud-host project whose owner is a repo admin gets the button', async () => {
76
+ const { ctx } = world();
77
+ assert.match(ctx.repoPrivDoor(LIVE, PUBLIC), /data-repo-private>Make it private</);
78
+ });
79
+
80
+ for (const [label, inst, repo, why] of [
81
+ ['own host', { ...LIVE, hosting_shape: 'byo-host' }, PUBLIC, /on GitHub directly/],
82
+ ['another shape', { ...LIVE, hosting_shape: 'dedicated' }, PUBLIC, /Cloud host/],
83
+ ['not running', { ...LIVE, status: 'error' }, PUBLIC, /once the project is running/],
84
+ ['no admin rights', LIVE, { private: false, admin: false }, /can’t change this repository’s settings/],
85
+ ]) {
86
+ await ta(`${label}: no button, and a sentence saying why`, async () => {
87
+ const { ctx } = world();
88
+ const html = ctx.repoPrivDoor(inst, repo);
89
+ assert.doesNotMatch(html, /data-repo-private/);
90
+ assert.match(html, why);
91
+ });
92
+ }
93
+
94
+ console.log('\nthe request — a receipt, never a new state:');
95
+
96
+ await ta('Yes POSTs to the project’s repo-private route with NO body', async () => {
97
+ const w = world();
98
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
99
+ w.ctx.doRepoPrivate();
100
+ await w.done;
101
+ assert.equal(w.calls.fetch.length, 1);
102
+ assert.equal(w.calls.fetch[0].url, '/api/bongos/provisioning/instances/18/repo-private');
103
+ assert.equal(w.calls.fetch[0].opts.method, 'POST');
104
+ assert.equal(w.calls.fetch[0].opts.body, undefined, 'the route takes no fields');
105
+ });
106
+
107
+ await ta('a 200 puts the server’s sentence (escaped) in the stable slot, arms the re-read, and re-reads the row', async () => {
108
+ const w = world();
109
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
110
+ w.ctx.doRepoPrivate();
111
+ await w.done;
112
+ assert.equal(w.els.mRepoVisMsg.className, 'projMsg ok');
113
+ assert.equal(w.els.mRepoVisMsg.innerHTML, 'Queued &lt;b&gt;ok&lt;/b&gt;.');
114
+ assert.equal(w.ctx.mRepoPrivSeen, true, 'a run that settles before the re-read lands is still followed up');
115
+ assert.equal(JSON.stringify(w.ctx.manageInst.open_intent), JSON.stringify({ action: 'repo-private', state: 'pending' }),
116
+ 'the receipt proves a run is open, so the row says so — and the 5s watch follows it');
117
+ assert.match(w.els.mRepoVis.innerHTML, /Making it private is queued/, 'the door gives way to the in-flight line at once');
118
+ assert.deepEqual(w.calls.loadManage, [[true, true]], 'quiet, keep the repo read');
119
+ assert.doesNotMatch(w.els.mRepoVis.innerHTML, /<b>private<\/b>/, 'nothing claims the flip happened');
120
+ });
121
+
122
+ await ta('a repaint from the row right after the click (the minute tick) keeps the run and its receipt', async () => {
123
+ const w = world();
124
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
125
+ w.ctx.doRepoPrivate();
126
+ await w.done;
127
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
128
+ assert.equal(w.calls.reread, 0, 'no settle is read into a run that has not been re-read yet');
129
+ assert.match(w.els.mRepoVisMsg.innerHTML, /Queued/);
130
+ assert.doesNotMatch(w.els.mRepoVis.innerHTML, /data-repo-private/, 'the button does not come back');
131
+ });
132
+
133
+ await ta('a 404 (an archon on someone else’s project) says the rule plainly', async () => {
134
+ const w = world({ reply: { status: 404, body: { error: { code: 'instance_not_found' } } } });
135
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
136
+ w.ctx.doRepoPrivate();
137
+ await w.done;
138
+ assert.equal(w.els.mRepoVisMsg.className, 'projMsg err');
139
+ assert.match(w.els.mRepoVisMsg.innerHTML, /Only the project’s owner can make its repository private/);
140
+ assert.equal(w.ctx.mRepoPrivSeen, false);
141
+ });
142
+
143
+ await ta('a 409 shows the server’s own sentence, escaped', async () => {
144
+ const w = world({ reply: { status: 409, body: { error: { code: 'intent_conflict', message: "A 'restart' run is <i>running</i>" } } } });
145
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
146
+ w.ctx.doRepoPrivate();
147
+ await w.done;
148
+ assert.equal(w.els.mRepoVisMsg.className, 'projMsg err');
149
+ assert.match(w.els.mRepoVisMsg.innerHTML, /restart.* run is &lt;i&gt;running&lt;\/i&gt;/);
150
+ assert.doesNotMatch(w.els.mRepoVisMsg.innerHTML, /<i>/);
151
+ });
152
+
153
+ console.log('\nthe in-flight line and the settle:');
154
+
155
+ await ta('the receipt slot is painted INSIDE #mRepoVis, never a sibling of it', async () => {
156
+ // every .mSec>div takes the same grid cell (cosmos.css), so a sibling would sit on top
157
+ const card = (HUB.match(/<div class="card mSec" id="mRepoVisibility">[\s\S]*?\n <\/div>/) || [''])[0];
158
+ assert.ok(card, 'the card markup is extractable');
159
+ assert.doesNotMatch(card, /id="mRepoVisMsg"/, 'no second grid child in the card markup');
160
+ const w = world();
161
+ w.ctx.manageInst = { ...LIVE };
162
+ w.ctx.renderRepoVis(PUBLIC);
163
+ assert.match(w.els.mRepoVis.innerHTML, /<div class="projMsg" id="mRepoVisMsg" role="status" aria-live="polite"><\/div>$/);
164
+ });
165
+
166
+ await ta('an open run replaces the door with the in-flight line, worded by its state', async () => {
167
+ const w = world();
168
+ w.ctx.manageRepo = PUBLIC;
169
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'repo-private', state: 'pending' } };
170
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
171
+ assert.match(w.els.mRepoVis.innerHTML, /Making it private is queued/);
172
+ assert.doesNotMatch(w.els.mRepoVis.innerHTML, /data-repo-private/);
173
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'repo-private', state: 'running' } };
174
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
175
+ assert.match(w.els.mRepoVis.innerHTML, /the platform is giving this project its own deploy key first/);
176
+ });
177
+
178
+ await ta('the minute tick with an unchanged state does not repaint', async () => {
179
+ const w = world();
180
+ w.ctx.manageRepo = PUBLIC;
181
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'repo-private', state: 'running' } };
182
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
183
+ w.els.mRepoVis.innerHTML = 'SENTINEL';
184
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
185
+ assert.equal(w.els.mRepoVis.innerHTML, 'SENTINEL');
186
+ assert.equal(w.calls.reread, 0, 'and asks GitHub nothing while it runs');
187
+ });
188
+
189
+ await ta('the settle asks GitHub exactly once and clears the receipt', async () => {
190
+ const w = world();
191
+ w.ctx.manageRepo = PUBLIC;
192
+ w.ctx.renderRepoVisState({ ...LIVE, open_intent: { action: 'repo-private', state: 'running' } });
193
+ w.els.mRepoVisMsg.innerHTML = 'Queued.';
194
+ w.ctx.renderRepoVisState({ ...LIVE, open_intent: null });
195
+ w.ctx.renderRepoVisState({ ...LIVE, open_intent: null });
196
+ assert.equal(w.calls.reread, 1, 'only GitHub can say the flip took — asked once');
197
+ assert.equal(w.els.mRepoVisMsg.innerHTML, '');
198
+ });
199
+
200
+ await ta('another intent (a restart) is not mistaken for a make-private run', async () => {
201
+ const w = world();
202
+ w.ctx.manageRepo = PUBLIC;
203
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'restart', state: 'running' } };
204
+ w.ctx.renderRepoVis(PUBLIC);
205
+ assert.match(w.els.mRepoVis.innerHTML, /data-repo-private/);
206
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
207
+ assert.equal(w.ctx.mRepoPrivSeen, false);
208
+ });
209
+
210
+ console.log('\nthe card:');
211
+
212
+ await ta('the mine-card strip names the make-private flow and its running half', async () => {
213
+ const { ctx } = world();
214
+ const run = ctx.stripFor({ ...LIVE, open_intent: { action: 'repo-private', state: 'running' } });
215
+ assert.match(run, /Setting up its deploy key/);
216
+ assert.match(run, /setting up the project’s own deploy key first/);
217
+ const queued = ctx.stripFor({ ...LIVE, open_intent: { action: 'repo-private', state: 'pending' } });
218
+ assert.match(queued, /Making the repository private is queued/);
219
+ assert.doesNotMatch(queued, /repo-private run is queued/, 'not the generic fallback');
220
+ });
221
+
222
+ await ta('a retired make-private reads as that, not as a standup that stopped', async () => {
223
+ const { ctx } = world();
224
+ for (const note of [
225
+ 'o/demo is still public: its deploy key could not be set up on the server. Nothing on your side caused this, and trying again is safe.',
226
+ 'o/demo cannot be made private right now: GitHub could not be reached to set up its deploy key (fetch failed). Nothing has been changed — try again.',
227
+ 'The deploy key for o/demo could not be set up, so the repository was left public.',
228
+ ]) assert.match(ctx.errCategory(note), /Making the repository private didn’t finish/, note);
229
+ for (const note of [
230
+ "o/demo cannot be made private: there is no usable GitHub credential stored for this project, and without one its deploy key cannot be registered. Reconnect the project's GitHub access, then try again.",
231
+ "o/demo was not made private: the project's GitHub credential could not be read. Its deploy key is already in place, so reconnect GitHub and try again.",
232
+ ]) assert.match(ctx.errCategory(note), /needs reconnecting/, 'the one fix the owner can make is not folded away');
233
+ assert.match(ctx.errCategory('deploy-key setup failed for o/demo: GitHub deploy-key add failed (500).'), /deploy key on GitHub didn’t finish/);
234
+ assert.match(ctx.errCategory('repository not found: GitHub answered 404'), /couldn’t find the project’s repository/, 'the older categories are untouched');
235
+ });
236
+
237
+ console.log(`\nprojects_hub_repo_private: ${passed} passed, ${failed} failed`);
238
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,224 @@
1
+ // tests/provisioning_repo_private_route.mjs
2
+ //
3
+ // Split (c) of task 1003078: POST /provisioning/instances/:id/repo-private — the owner's
4
+ // "make this project's repository private" request. It only ENQUEUES the `repo-private`
5
+ // intent (task 1004193); the runner wires the project's own deploy key and only then flips.
6
+ //
7
+ // The load-bearing assertions:
8
+ // • OWNER ONLY (owner decision, task 1004194): the runner flips with the OWNER's stored
9
+ // GitHub token whoever asked, so an archon on someone else's project gets the same 404
10
+ // as a stranger — and nothing is queued or recorded
11
+ // • the reply is a RECEIPT, never the new state: the repository is still public when it
12
+ // is sent, and only GitHub can say when that changed
13
+ // • a project the runner would refuse is refused HERE, in plain words, before anything is
14
+ // queued — not left to fail its retries into an error note
15
+ // • the body is strict: this route takes no fields, so any field is a 400
16
+ //
17
+ // DB-free: data access is stubbed on the cached module objects (the provisioning_restart.mjs
18
+ // pattern), over a real mounted router. No network beyond localhost.
19
+ //
20
+ // Run: node tests/provisioning_repo_private_route.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { createRequire } from 'node:module';
24
+ import { readFileSync } from 'node:fs';
25
+ import http from 'node:http';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const provisioning = require('../modules/provisioning/provisioning.js');
29
+ const repoPrivate = require('../modules/provisioning/repo-private.js');
30
+
31
+ let passed = 0, failed = 0;
32
+ async function ta(name, fn) {
33
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
34
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
35
+ }
36
+
37
+ let instance = null;
38
+ let enqueueCalls = [];
39
+ let enqueueResult = null;
40
+ let events = [];
41
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
42
+ provisioning.recordEvent = async (_db, e) => { events.push(e); };
43
+ repoPrivate.enqueueRepoPrivate = async (_db, id, requestedBy) => {
44
+ enqueueCalls.push({ id, requestedBy });
45
+ return enqueueResult || { intent: { id: 9, action: 'repo-private', state: 'pending' }, created: true };
46
+ };
47
+
48
+ const api = require('../src/module-api.js');
49
+ let builder = null;
50
+ api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
51
+ api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
52
+
53
+ const express = require('express');
54
+ const buildRouter = require('../modules/provisioning/routes/repo-private.js');
55
+ const app = express();
56
+ app.use(express.json());
57
+ app.use((_req, res, next) => {
58
+ res.fail = (code, opts, details) => {
59
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
60
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(Array.isArray(details) ? { details } : {}) } });
61
+ };
62
+ next();
63
+ });
64
+ app.use('/api/bongos', buildRouter());
65
+
66
+ const server = app.listen(0);
67
+ await new Promise((r) => server.once('listening', r));
68
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
69
+
70
+ function post(path, body) {
71
+ return new Promise((resolve, reject) => {
72
+ const payload = body === undefined ? null : JSON.stringify(body);
73
+ const req = http.request(`${base}${path}`, {
74
+ method: 'POST', headers: payload ? { 'Content-Type': 'application/json' } : {},
75
+ }, (res) => {
76
+ let d = '';
77
+ res.on('data', (c) => { d += c; });
78
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
79
+ });
80
+ req.on('error', reject);
81
+ if (payload) req.write(payload);
82
+ req.end();
83
+ });
84
+ }
85
+
86
+ const LIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', target_ref: 'example-owner/spike' };
87
+ const OWNER = { id: 7, github_login: 'owner', rank: 'thetes' };
88
+ function reset(patch = {}) {
89
+ instance = { ...LIVE, ...patch };
90
+ enqueueCalls = []; enqueueResult = null; events = [];
91
+ builder = { ...OWNER };
92
+ }
93
+ const URL_18 = '/provisioning/instances/18/repo-private';
94
+
95
+ console.log('POST /provisioning/instances/:id/repo-private — who may ask:');
96
+
97
+ await ta('the owner queues it: 200, one repo-private intent stamped api:self, one audit event', async () => {
98
+ // Arrange
99
+ reset();
100
+ // Act
101
+ const r = await post(URL_18);
102
+ // Assert
103
+ assert.equal(r.status, 200, r.raw);
104
+ assert.deepEqual(enqueueCalls, [{ id: 18, requestedBy: 'api:self' }]);
105
+ assert.equal(events.length, 1);
106
+ assert.equal(events[0].event, 'repo-private');
107
+ assert.equal(events[0].actor, 'api:self');
108
+ assert.equal(events[0].ownerBuilderId, 7);
109
+ });
110
+
111
+ await ta('an ARCHON on someone else\'s project gets 404 — the runner would act on the owner\'s GitHub account', async () => {
112
+ reset();
113
+ builder = { id: 42, github_login: 'arch', rank: 'archon' };
114
+ const r = await post(URL_18);
115
+ assert.equal(r.status, 404, r.raw);
116
+ assert.equal(r.body.error.code, 'instance_not_found');
117
+ assert.equal(enqueueCalls.length, 0, 'nothing queued');
118
+ assert.equal(events.length, 0, 'nothing recorded');
119
+ });
120
+
121
+ await ta('an archon who OWNS the project may ask — the rule is ownership, not rank', async () => {
122
+ reset({ owner_builder_id: 42 });
123
+ builder = { id: 42, github_login: 'arch', rank: 'archon' };
124
+ const r = await post(URL_18);
125
+ assert.equal(r.status, 200, r.raw);
126
+ assert.equal(enqueueCalls[0].requestedBy, 'api:self');
127
+ });
128
+
129
+ await ta('another builder\'s project is 404 — no existence leak', async () => {
130
+ reset();
131
+ builder = { id: 99, github_login: 'stranger', rank: 'thetes' };
132
+ const r = await post(URL_18);
133
+ assert.equal(r.status, 404, r.raw);
134
+ assert.equal(r.body.error.code, 'instance_not_found');
135
+ assert.equal(enqueueCalls.length, 0);
136
+ });
137
+
138
+ await ta('a missing project is 404, not a 500', async () => {
139
+ reset();
140
+ instance = null;
141
+ const r = await post(URL_18);
142
+ assert.equal(r.status, 404, r.raw);
143
+ assert.equal(enqueueCalls.length, 0);
144
+ });
145
+
146
+ await ta('a malformed id is 400 bad_id — and id 0 is ANSWERED, never left hanging', async () => {
147
+ for (const bad of ['abc', '0', '-3', '1.5']) {
148
+ reset();
149
+ const r = await post(`/provisioning/instances/${bad}/repo-private`);
150
+ assert.equal(r.status, 400, `${bad}: ${r.raw}`);
151
+ assert.equal(r.body.error.code, 'bad_id', bad);
152
+ assert.equal(enqueueCalls.length, 0);
153
+ }
154
+ });
155
+
156
+ console.log('\nthe reply:');
157
+
158
+ await ta('it is a RECEIPT — queued + a sentence, never a visibility the repository does not have yet', async () => {
159
+ reset();
160
+ const r = await post(URL_18);
161
+ assert.equal(r.body.ok, true);
162
+ assert.equal(r.body.queued, true);
163
+ assert.equal(r.body.action, 'repo-private');
164
+ assert.equal(r.body.private, undefined, 'no new state is claimed');
165
+ assert.equal(r.body.repo, undefined, 'no repository answer is invented');
166
+ assert.match(r.body.message, /deploy key/, 'the sentence says the key comes first');
167
+ });
168
+
169
+ await ta('a repeat while one is open is idempotent: 200 queued:false, and no second audit event', async () => {
170
+ reset();
171
+ enqueueResult = { intent: { id: 9, action: 'repo-private', state: 'running' }, created: false };
172
+ const r = await post(URL_18);
173
+ assert.equal(r.status, 200, r.raw);
174
+ assert.equal(r.body.queued, false);
175
+ assert.equal(events.length, 0);
176
+ });
177
+
178
+ await ta('any OTHER open run is a 409 intent_conflict naming it', async () => {
179
+ reset();
180
+ enqueueResult = { intent: { id: 3, action: 'settings-apply', state: 'running' }, created: false, conflict: 'open_intent' };
181
+ const r = await post(URL_18);
182
+ assert.equal(r.status, 409, r.raw);
183
+ assert.equal(r.body.error.code, 'intent_conflict');
184
+ assert.match(r.body.error.message, /settings-apply/);
185
+ assert.equal(events.length, 0);
186
+ });
187
+
188
+ console.log('\nrefused before anything is queued:');
189
+
190
+ for (const [label, patch, why] of [
191
+ ['a project on the owner\'s own host', { hosting_shape: 'byo-host' }, /on GitHub directly/],
192
+ ['a project on another shape', { hosting_shape: 'dedicated' }, /Cloud host/],
193
+ ['a project that is not running', { status: 'error' }, /isn’t running/],
194
+ ['a project with no repository linked', { target_ref: null }, /no GitHub repository/],
195
+ ]) {
196
+ await ta(`${label}: 409 repo_private_unavailable in plain words, nothing queued`, async () => {
197
+ reset(patch);
198
+ const r = await post(URL_18);
199
+ assert.equal(r.status, 409, r.raw);
200
+ assert.equal(r.body.error.code, 'repo_private_unavailable');
201
+ assert.match(r.body.error.message, why);
202
+ assert.equal(enqueueCalls.length, 0);
203
+ assert.equal(events.length, 0);
204
+ });
205
+ }
206
+
207
+ await ta('the body is strict — this route takes no fields, so a visibility field is a 400', async () => {
208
+ reset();
209
+ const r = await post(URL_18, { visibility: 'private' });
210
+ assert.equal(r.status, 400, r.raw);
211
+ assert.equal(r.body.error.code, 'validation_failed');
212
+ assert.equal(enqueueCalls.length, 0);
213
+ });
214
+
215
+ console.log('\nwiring:');
216
+
217
+ await ta('module.json mounts the route file', async () => {
218
+ const manifest = JSON.parse(readFileSync(new URL('../modules/provisioning/module.json', import.meta.url), 'utf8'));
219
+ assert.ok(manifest.contributes.routes.includes('repo-private'));
220
+ });
221
+
222
+ server.close();
223
+ console.log(`\nprovisioning_repo_private_route: ${passed} passed, ${failed} failed`);
224
+ process.exit(failed ? 1 : 0);
@@ -155,23 +155,17 @@ test('the served teardown copy no longer claims the address goes quiet (task 100
155
155
  'the truthful replacement is what ships');
156
156
  });
157
157
 
158
- test('the repo-visibility section is served live, with the private direction refused (task 1003045)', async () => {
158
+ test('the repo-visibility section is served live, with the private direction queued (tasks 1003045, 1003078)', async () => {
159
159
  const { body } = await request('/projects', 'game.test.local');
160
160
  assert.match(body, /id="mRepoVis"/, 'the live section body is served');
161
- assert.match(body, /would break the next deploy/,
162
- 'the flip-to-private limitation is stated in plain words');
161
+ assert.match(body, /id="mRepoVisMsg"/, 'with a slot for the make-private receipt');
162
+ assert.match(body, /\/repo-private'/, 'the private direction rides the provisioning route');
163
163
  assert.doesNotMatch(body, /visibility:\s*'private'/,
164
- 'no served code path may send the private direction');
164
+ 'the core flip is never asked for the private direction');
165
165
  assert.match(body, /repos=1/, 'the reconnect door asks for repository access');
166
- // the limitation is one click away (task 1003077). This DB-free serve is the
167
- // STANDALONE shape, where apex-only spans are stripped — so the explanation
168
- // must survive while the cloudbongos task link must NOT (a child instance
169
- // never shows another platform's tracker). The link itself is pinned inside
170
- // its sentinels by tests/projects_hub.mjs.
171
- assert.match(body, /id="mRepoVisWhy"/, 'the why-fold is served');
172
- assert.match(body, /an older key that only works on public repositories/, 'the explanation survives the strip');
173
- assert.doesNotMatch(body, /#\/task\/1002735/,
174
- 'the apex-only task link is stripped from a standalone serve');
166
+ assert.match(body, /id="mRepoVisWhy"/, 'the fold is served');
167
+ assert.match(body, /its own read-only deploy key/, 'and says the key comes first');
168
+ assert.doesNotMatch(body, /isn’t possible from here yet/, 'no served copy still refuses');
175
169
  });
176
170
 
177
171
  test('the empty state points a new owner at the wizard view — never at a hall', async () => {