@bongos/core 1.19.980 → 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.
- package/.bongos-core.json +47 -32
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0176-private-repo-deploy-keys.md +5 -4
- package/docs/api/openapi.json +87 -4
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +204 -208
- package/docs/copy-registry.json +268 -304
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/blockers-lib.js +14 -19
- package/modules/hall-ui/public/blockers.js +7 -5
- package/modules/hall-ui/public/goal-inbox.js +2 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/routes/repo-private.js +82 -0
- package/modules/public-landing/public/projects.html +129 -21
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/github.js +10 -9
- package/src/module-api.js +1 -1
- package/tests/blockers_lib.mjs +22 -14
- package/tests/github_repo_visibility.mjs +9 -6
- package/tests/projects_hub.mjs +18 -24
- package/tests/projects_hub_pre_uat.mjs +5 -4
- package/tests/projects_hub_repo_private.mjs +238 -0
- package/tests/provisioning_repo_private_route.mjs +224 -0
- package/tests/public_landing_projects.mjs +7 -13
package/tests/blockers_lib.mjs
CHANGED
|
@@ -16,31 +16,35 @@ import { createRequire } from 'node:module';
|
|
|
16
16
|
|
|
17
17
|
const require = createRequire(import.meta.url);
|
|
18
18
|
const lib = require('../modules/hall-ui/public/blockers-lib.js');
|
|
19
|
+
// The lib owns no esc/fmtDate — callers pass the kit's (task 1003984).
|
|
20
|
+
const kit = require('../modules/hall-ui/public/hall-kit.js');
|
|
21
|
+
const F = { esc: kit.esc, fmtDate: kit.fmtAbsDate };
|
|
19
22
|
|
|
20
23
|
test('loads standalone via require() — no window, no DOM (Node-require-safe like hall-kit.js)', () => {
|
|
21
24
|
assert.ok(lib, 'module.exports must be set');
|
|
22
|
-
for (const k of ['
|
|
25
|
+
for (const k of ['blockingLine', 'blockerFacts']) {
|
|
23
26
|
assert.ok(k in lib, `missing export: ${k}`);
|
|
24
27
|
}
|
|
28
|
+
for (const k of ['esc', 'fmtDate']) assert.ok(!(k in lib), `lib must not re-own ${k}`);
|
|
25
29
|
});
|
|
26
30
|
|
|
27
31
|
test('blockerFacts: title links into the SAME #/blocker/N overlay task-detail.js renders', () => {
|
|
28
32
|
const b = { id: 42, title: 'Need the owner’s AWS card on file', created_at: '2026-08-30T00:00:00.000Z', blocking_task_ids: [] };
|
|
29
|
-
const { titleHtml } = lib.blockerFacts(b, { blockerHref: (id) => `#/blocker/${id}` });
|
|
33
|
+
const { titleHtml } = lib.blockerFacts(b, { ...F, blockerHref: (id) => `#/blocker/${id}` });
|
|
30
34
|
assert.match(titleHtml, /href="#\/blocker\/42"/);
|
|
31
35
|
assert.match(titleHtml, /Need the owner/);
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
test('blockerFacts: with no blockerHref, the title renders as plain text, not a dead link', () => {
|
|
35
39
|
const b = { id: 42, title: 'Something stuck', created_at: null, blocking_task_ids: [] };
|
|
36
|
-
const { titleHtml } = lib.blockerFacts(b);
|
|
40
|
+
const { titleHtml } = lib.blockerFacts(b, F);
|
|
37
41
|
assert.doesNotMatch(titleHtml, /<a /);
|
|
38
42
|
assert.match(titleHtml, /<span/);
|
|
39
43
|
});
|
|
40
44
|
|
|
41
45
|
test('blockerFacts: the caller-supplied classes land on the right fragments', () => {
|
|
42
46
|
const b = { id: 1, title: 'x', body_md: 'a reason', created_at: '2026-08-30T00:00:00.000Z', blocking_task_ids: [] };
|
|
43
|
-
const { titleHtml, noteHtml, factsHtml } = lib.blockerFacts(b, {
|
|
47
|
+
const { titleHtml, noteHtml, factsHtml } = lib.blockerFacts(b, { ...F,
|
|
44
48
|
titleClass: 'ginbox__goal', noteClass: 'ginbox__note', whenClass: 'ginbox__sub',
|
|
45
49
|
});
|
|
46
50
|
assert.match(titleHtml, /class="ginbox__goal"/);
|
|
@@ -49,19 +53,19 @@ test('blockerFacts: the caller-supplied classes land on the right fragments', ()
|
|
|
49
53
|
});
|
|
50
54
|
|
|
51
55
|
test('blockingLine: no linked tasks reads honestly, not as a false "0 tasks"', () => {
|
|
52
|
-
assert.equal(lib.blockingLine({ blocking_task_ids: [] }, null), 'Blocking nothing yet');
|
|
53
|
-
assert.equal(lib.blockingLine({}, null), 'Blocking nothing yet');
|
|
56
|
+
assert.equal(lib.blockingLine({ blocking_task_ids: [] }, null, kit.esc), 'Blocking nothing yet');
|
|
57
|
+
assert.equal(lib.blockingLine({}, null, kit.esc), 'Blocking nothing yet');
|
|
54
58
|
});
|
|
55
59
|
|
|
56
60
|
test('blockingLine: singular/plural noun agreement, and every id becomes a link when taskHref is given', () => {
|
|
57
|
-
const one = lib.blockingLine({ blocking_task_ids: [5] }, (id) => `/task/${id}
|
|
61
|
+
const one = lib.blockingLine({ blocking_task_ids: [5] }, (id) => `/task/${id}`, kit.esc);
|
|
58
62
|
assert.equal(one, 'Blocking 1 task: <a href="/task/5">#5</a>');
|
|
59
|
-
const many = lib.blockingLine({ blocking_task_ids: [5, 6] }, (id) => `/task/${id}
|
|
63
|
+
const many = lib.blockingLine({ blocking_task_ids: [5, 6] }, (id) => `/task/${id}`, kit.esc);
|
|
60
64
|
assert.equal(many, 'Blocking 2 tasks: <a href="/task/5">#5</a>, <a href="/task/6">#6</a>');
|
|
61
65
|
});
|
|
62
66
|
|
|
63
67
|
test('blockingLine: with no taskHref the ids still render, as plain text — never a dead link', () => {
|
|
64
|
-
const line = lib.blockingLine({ blocking_task_ids: [5] }, null);
|
|
68
|
+
const line = lib.blockingLine({ blocking_task_ids: [5] }, null, kit.esc);
|
|
65
69
|
assert.equal(line, 'Blocking 1 task: #5');
|
|
66
70
|
assert.doesNotMatch(line, /<a /);
|
|
67
71
|
});
|
|
@@ -71,21 +75,21 @@ test('blockerFacts: the facts line is ONE `·`-joined span, not several ungapped
|
|
|
71
75
|
// between them (unlike .ov-row__facts, which is a flex container) and would
|
|
72
76
|
// render Filed-date and Blocking-line running together with no separator.
|
|
73
77
|
const b = { id: 1, title: 'x', created_at: '2026-08-30T00:00:00.000Z', blocking_task_ids: [7] };
|
|
74
|
-
const { factsHtml } = lib.blockerFacts(b, { taskHref: (id) => `/task/${id}` });
|
|
78
|
+
const { factsHtml } = lib.blockerFacts(b, { ...F, taskHref: (id) => `/task/${id}` });
|
|
75
79
|
assert.equal((factsHtml.match(/<span/g) || []).length, 1, 'exactly one wrapping span');
|
|
76
80
|
assert.match(factsHtml, /Filed .* · Blocking 1 task/);
|
|
77
81
|
});
|
|
78
82
|
|
|
79
83
|
test('blockerFacts: body_md becomes a quoted note; an empty one renders nothing', () => {
|
|
80
|
-
const withNote = lib.blockerFacts({ id: 1, title: 'x', body_md: 'reason', blocking_task_ids: [] });
|
|
84
|
+
const withNote = lib.blockerFacts({ id: 1, title: 'x', body_md: 'reason', blocking_task_ids: [] }, F);
|
|
81
85
|
assert.match(withNote.noteHtml, /<p class="ov-row__sub">“reason”<\/p>/);
|
|
82
|
-
const noNote = lib.blockerFacts({ id: 1, title: 'x', body_md: ' ', blocking_task_ids: [] });
|
|
86
|
+
const noNote = lib.blockerFacts({ id: 1, title: 'x', body_md: ' ', blocking_task_ids: [] }, F);
|
|
83
87
|
assert.equal(noNote.noteHtml, '');
|
|
84
88
|
});
|
|
85
89
|
|
|
86
90
|
test('blockerFacts: a very long body_md is clipped, so one enormous blocker cannot blow up the row', () => {
|
|
87
91
|
const long = 'x'.repeat(500);
|
|
88
|
-
const { noteHtml } = lib.blockerFacts({ id: 1, title: 't', body_md: long, blocking_task_ids: [] });
|
|
92
|
+
const { noteHtml } = lib.blockerFacts({ id: 1, title: 't', body_md: long, blocking_task_ids: [] }, F);
|
|
89
93
|
assert.ok(noteHtml.length < 500, 'clipped well below the source length');
|
|
90
94
|
assert.match(noteHtml, /…”<\/p>$/);
|
|
91
95
|
});
|
|
@@ -95,8 +99,12 @@ test('blockerFacts: HTML in a title/body/task id is escaped, not injected', () =
|
|
|
95
99
|
id: 1, title: '<script>alert(1)</script>', body_md: '<img onerror=alert(1)>',
|
|
96
100
|
created_at: '2026-08-30T00:00:00.000Z', blocking_task_ids: [1],
|
|
97
101
|
};
|
|
98
|
-
const { titleHtml, noteHtml, factsHtml } = lib.blockerFacts(b, { taskHref: (id) => `/task/"><script>${id}` });
|
|
102
|
+
const { titleHtml, noteHtml, factsHtml } = lib.blockerFacts(b, { ...F, taskHref: (id) => `/task/"><script>${id}` });
|
|
99
103
|
assert.doesNotMatch(titleHtml, /<script>/);
|
|
100
104
|
assert.doesNotMatch(noteHtml, /<img /);
|
|
101
105
|
assert.doesNotMatch(factsHtml, /<script>/);
|
|
102
106
|
});
|
|
107
|
+
|
|
108
|
+
test('blockerFacts: refuses to render without the caller-supplied esc/fmtDate', () => {
|
|
109
|
+
assert.throws(() => lib.blockerFacts({ id: 1, title: 'x' }), /opts.esc and opts.fmtDate are required/);
|
|
110
|
+
});
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
//
|
|
4
4
|
// The load-bearing assertions:
|
|
5
5
|
// • the PRIVATE direction is refused BEFORE GitHub is ever called (409
|
|
6
|
-
// private_not_deployable):
|
|
7
|
-
// which cannot read a private repo (walk finding N4), so
|
|
8
|
-
// would break
|
|
9
|
-
//
|
|
6
|
+
// private_not_deployable): a project stood up public reads with the shared
|
|
7
|
+
// key, which cannot read a private repo (walk finding N4), so a bare flip
|
|
8
|
+
// would break its next deploy. The refusal points at the manage page, whose
|
|
9
|
+
// queued route wires the project's own deploy key first (task 1003078). It
|
|
10
|
+
// must not even consult the stored token.
|
|
10
11
|
// • flip-to-public calls makeRepoPublic with the parsed owner/name and
|
|
11
12
|
// forwards GitHub's own answer — the new state is never assumed
|
|
12
13
|
// • a GitHub 401 drops the stored token and re-prompts (the /github/repos
|
|
@@ -80,7 +81,7 @@ test('GET /github/repo: forwards GitHub\'s answer for the caller\'s own credenti
|
|
|
80
81
|
assert.equal(res._status, 200);
|
|
81
82
|
assert.equal(res._body.repo.private, true);
|
|
82
83
|
assert.equal(res._body.repo.admin, true);
|
|
83
|
-
assert.equal(res._body.private_deployable,
|
|
84
|
+
assert.equal(res._body.private_deployable, true, 'a private repo deploys through its own key (ADR 0176)');
|
|
84
85
|
assert.deepEqual(s.calls.read, [{ owner: 'jaxri', name: 'mercury' }]);
|
|
85
86
|
} finally { s.restore(); }
|
|
86
87
|
});
|
|
@@ -129,7 +130,9 @@ test('POST visibility=private: refused 409 private_not_deployable — GitHub and
|
|
|
129
130
|
await postHandler()({ builder: { id: 1 }, body: { repo: 'jaxri/mercury', visibility: 'private' } }, res);
|
|
130
131
|
assert.equal(res._status, 409);
|
|
131
132
|
assert.equal(res._body.error.code, 'private_not_deployable');
|
|
132
|
-
assert.match(res._body.error.message, /deploy/i, 'the refusal says WHY in plain words');
|
|
133
|
+
assert.match(res._body.error.message, /deploy key/i, 'the refusal says WHY in plain words');
|
|
134
|
+
assert.match(res._body.error.message, /manage page/, 'and names the door that CAN do it');
|
|
135
|
+
assert.doesNotMatch(res._body.error.message, /can’t be deployed yet/, 'the old platform-wide refusal is gone');
|
|
133
136
|
assert.equal(s.calls.flip.length, 0, 'no GitHub call for a refused direction');
|
|
134
137
|
assert.equal(tokenReads, 0, 'the refusal happens before the credential is even read');
|
|
135
138
|
} finally { db.getGithubToken = origGet; s.restore(); }
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -1210,10 +1210,10 @@ test('every owned card carries its Manage door, and a modified click keeps its m
|
|
|
1210
1210
|
|
|
1211
1211
|
/* ── Repo visibility on the manage page (task 1003045) ──────────────────────
|
|
1212
1212
|
The section reads the repo's REAL visibility from GitHub with the owner's
|
|
1213
|
-
own repository credential
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1213
|
+
own repository credential. Going public is the direct core call; going
|
|
1214
|
+
private (task 1003078) is QUEUED on the provisioning route, because the
|
|
1215
|
+
project's own deploy key must be wired before the flip — a project stood up
|
|
1216
|
+
public reads with the shared key, which cannot read a private repo (N4). ── */
|
|
1217
1217
|
|
|
1218
1218
|
test('the repo-visibility section is live markup with its body slot (C7)', () => {
|
|
1219
1219
|
assert.match(HUB, /id="mRepoVis"/, 'the section body must be real markup');
|
|
@@ -1221,16 +1221,14 @@ test('the repo-visibility section is live markup with its body slot (C7)', () =>
|
|
|
1221
1221
|
'the pre-answer state is honest waiting copy, not a fake answer');
|
|
1222
1222
|
});
|
|
1223
1223
|
|
|
1224
|
-
test('flip-to-private
|
|
1225
|
-
assert.match(SCRIPT, /visibility: 'public'/, 'the
|
|
1224
|
+
test('flip-to-private travels ONLY through the queued provisioning route, never the core flip', () => {
|
|
1225
|
+
assert.match(SCRIPT, /visibility: 'public'/, 'the core POST body still pins the public direction');
|
|
1226
1226
|
assert.doesNotMatch(SCRIPT, /visibility:\s*'private'/,
|
|
1227
|
-
'
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
assert.
|
|
1231
|
-
|
|
1232
|
-
assert.match(HUB, /would break the next deploy/,
|
|
1233
|
-
'the flip-to-private limitation is stated in plain words, in the fold');
|
|
1227
|
+
'the core flip is never asked for private — it cannot wire the deploy key');
|
|
1228
|
+
assert.match(SCRIPT, /'\/provisioning\/instances\/' \+ encodeURIComponent\(manageId\) \+ '\/repo-private'/,
|
|
1229
|
+
'the private direction is the provisioning route, keyed on the project');
|
|
1230
|
+
assert.doesNotMatch(HUB, /isn’t possible from here yet/, 'the refusal copy is gone');
|
|
1231
|
+
assert.doesNotMatch(HUB, /Why can’t it be private yet\?/, 'and so is its fold');
|
|
1234
1232
|
});
|
|
1235
1233
|
|
|
1236
1234
|
test('the visibility shown is GitHub\'s answer, never an assumption', () => {
|
|
@@ -1261,19 +1259,15 @@ test('the reconnect door asks for repository access and returns to this project'
|
|
|
1261
1259
|
'and returns to the exact project being managed');
|
|
1262
1260
|
});
|
|
1263
1261
|
|
|
1264
|
-
test('the
|
|
1265
|
-
assert.match(HUB, /id="mRepoVisWhy"/, 'the
|
|
1262
|
+
test('the fold says what making it private does, in the order it happens (task 1003078)', () => {
|
|
1263
|
+
assert.match(HUB, /id="mRepoVisWhy"/, 'the fold is real markup beside the section body');
|
|
1266
1264
|
const fold = (HUB.match(/<details[^>]*id="mRepoVisWhy"[\s\S]*?<\/details>/) || [''])[0];
|
|
1267
1265
|
assert.ok(fold, 'the fold must be extractable');
|
|
1268
|
-
assert.match(fold, /
|
|
1269
|
-
assert.match(fold,
|
|
1270
|
-
'the
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
const before = HUB.slice(0, at);
|
|
1274
|
-
const opens = (before.match(/<!--apex-only:start-->/g) || []).length;
|
|
1275
|
-
const closes = (before.match(/<!--apex-only:end-->/g) || []).length;
|
|
1276
|
-
assert.equal(opens - closes, 1, 'the task link sits inside an apex-only sentinel pair');
|
|
1266
|
+
assert.match(fold, /What making it private does/);
|
|
1267
|
+
assert.match(fold, /its own read-only deploy key[\s\S]*only then makes the repository private/,
|
|
1268
|
+
'the key comes first, the flip second — the safety property, in plain words');
|
|
1269
|
+
assert.match(fold, /needs admin rights on the repository/, 'the one thing the owner must bring');
|
|
1270
|
+
assert.doesNotMatch(fold, /an older key that only works on public repositories/, 'the old limitation is gone');
|
|
1277
1271
|
});
|
|
1278
1272
|
|
|
1279
1273
|
test('making a repo public asks first — exposure is never one accidental click', () => {
|
|
@@ -585,15 +585,16 @@ test('founder voice on the Device Flow step and the manual OAuth fallback', () =
|
|
|
585
585
|
assert.doesNotMatch(manual, /auth_configured|healthz/, 'no JSON health check for a founder to perform');
|
|
586
586
|
});
|
|
587
587
|
|
|
588
|
-
test('repo visibility: the
|
|
588
|
+
test('repo visibility: the public line stays one line, then the door or the reason — the mechanism lives in the fold', () => {
|
|
589
589
|
const rv = fn('renderRepoVis');
|
|
590
590
|
assert.match(rv, /This repository is <b>public<\/b> — anyone can read its code\./, 'line one stays');
|
|
591
|
-
assert.match(rv, /
|
|
591
|
+
assert.match(rv, /repoPrivDoor\(manageInst, repo\)/, 'the public branch offers the door, or says why there is none');
|
|
592
|
+
assert.doesNotMatch(rv, /isn’t possible from here yet/, 'the old refusal is gone');
|
|
592
593
|
assert.doesNotMatch(rv, /shared credential/);
|
|
593
594
|
const fold = (HUB.match(/<details[^>]*id="mRepoVisWhy"[\s\S]*?<\/details>/) || [''])[0];
|
|
594
|
-
assert.match(fold, /
|
|
595
|
+
assert.match(fold, /What making it private does/);
|
|
595
596
|
assert.doesNotMatch(fold, /shared read-only credential/);
|
|
596
|
-
assert.
|
|
597
|
+
assert.doesNotMatch(fold, /#\/task\//, 'the fold explains; it no longer points at a tracked fix');
|
|
597
598
|
});
|
|
598
599
|
|
|
599
600
|
test('a failed manage read offers a retry; the no-address sentence offers a door', () => {
|
|
@@ -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 <b>ok</b>.');
|
|
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 <i>running<\/i>/);
|
|
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);
|