@bongos/core 1.19.826 → 1.19.828
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 +58 -23
- package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +17 -0
- package/docs/copy-inventory.md +20 -14
- package/docs/copy-registry.json +83 -19
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/blockers-lib.js +110 -0
- package/modules/hall-ui/public/blockers.html +65 -0
- package/modules/hall-ui/public/blockers.js +88 -0
- package/modules/hall-ui/public/goal-inbox.js +74 -0
- package/modules/hall-ui/public/index.html +4 -0
- package/modules/hall-ui/public/shell.js +9 -0
- package/modules/platform-identity/routes/my-projects.js +27 -1
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/routes/provisioning.js +2 -22
- package/modules/provisioning/seams.js +54 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/blockers_hall.mjs +107 -0
- package/tests/blockers_lib.mjs +102 -0
- package/tests/hall_nav.mjs +4 -1
- package/tests/hall_page_gate_map.mjs +8 -0
- package/tests/join_dark_matter.mjs +208 -0
- package/tests/projects_hub.mjs +2 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// tests/blockers_hall.mjs — task 1003455 (goal 1000089): "Blockers become
|
|
2
|
+
// visible: the queue page and the attention slice, from one code path."
|
|
3
|
+
//
|
|
4
|
+
// GET /blockers had zero hall consumers before this task. Done-when: the queue
|
|
5
|
+
// renders on a real page from GET /blockers, the attention inbox shows open
|
|
6
|
+
// blockers as line items, BOTH read the same renderer (blockers-lib.js, pinned
|
|
7
|
+
// separately in tests/blockers_lib.mjs), and the page's list is paged through
|
|
8
|
+
// OTBKit rather than hand-rolled. This file pins the WIRING — that both
|
|
9
|
+
// consumers actually call the shared lib and the real API, and that the page
|
|
10
|
+
// uses the kit's pager rather than reimplementing slicing.
|
|
11
|
+
//
|
|
12
|
+
// Source-regex style, matching tests/rot_inbox.mjs's checks on the same file —
|
|
13
|
+
// pure text assertions over the real client source, no DOM/vm harness needed for
|
|
14
|
+
// what this pins (presence + call shape, not runtime behaviour).
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/blockers_hall.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const HALL = join(ROOT, 'modules/hall-ui/public');
|
|
26
|
+
const GOAL_INBOX_SRC = readFileSync(join(HALL, 'goal-inbox.js'), 'utf8');
|
|
27
|
+
const BLOCKERS_JS_SRC = readFileSync(join(HALL, 'blockers.js'), 'utf8');
|
|
28
|
+
const BLOCKERS_HTML_SRC = readFileSync(join(HALL, 'blockers.html'), 'utf8');
|
|
29
|
+
const INDEX_HTML_SRC = readFileSync(join(HALL, 'index.html'), 'utf8');
|
|
30
|
+
|
|
31
|
+
// ---- the attention-inbox slice (goal-inbox.js) -----------------------------
|
|
32
|
+
|
|
33
|
+
test('goal-inbox.js carries a blockers slice div in the SAME needs-attention card', () => {
|
|
34
|
+
assert.match(GOAL_INBOX_SRC, /<div id="attention-blockers"><\/div>/,
|
|
35
|
+
'a second Home card would violate the placement rule (hall-ui/CLAUDE.md): an action awaiting a human is an inbox line, never a new card');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('goal-inbox.js reads the real GET /blockers route, not a stand-in', () => {
|
|
39
|
+
assert.match(GOAL_INBOX_SRC, /getJSON\(`\$\{API\}\/blockers/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('goal-inbox.js asks the server for a BOUNDED read and reports the total, not fetch-all-then-slice', () => {
|
|
43
|
+
// The "Home bounded reads" rule this module's own CLAUDE.md names: a capped
|
|
44
|
+
// Home card must ask for exactly what it shows and get a total back, never
|
|
45
|
+
// download the whole feed and .slice() it client-side.
|
|
46
|
+
assert.match(GOAL_INBOX_SRC, /\/blockers\?limit=\$\{MAX_BLOCKER_ROWS\}/);
|
|
47
|
+
assert.match(GOAL_INBOX_SRC, /data\.page\.total/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('goal-inbox.js renders blocker rows through the SHARED lib, not a re-implementation', () => {
|
|
51
|
+
assert.match(GOAL_INBOX_SRC, /window\.OTBBlockersLib/);
|
|
52
|
+
assert.match(GOAL_INBOX_SRC, /lib\.blockerFacts\(/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('the blocker row carries .ginbox__item, so the card\'s count-pill and visibility include it', () => {
|
|
56
|
+
assert.match(GOAL_INBOX_SRC, /function blockerRowHtml[\s\S]*?class="ginbox__item"/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('the mount() boot sequence actually calls loadBlockers()', () => {
|
|
60
|
+
assert.match(GOAL_INBOX_SRC, /loadNodQueue\(\);\s*\n\s*loadBlockers\(\);\s*\n\s*loadRot\(\);/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---- the full queue page (blockers.html + blockers.js) ---------------------
|
|
64
|
+
|
|
65
|
+
test('blockers.js reads the real GET /blockers route, not a stand-in', () => {
|
|
66
|
+
assert.match(BLOCKERS_JS_SRC, /API \+ '\/blockers/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('blockers.js renders rows through the SAME shared lib the attention slice uses', () => {
|
|
70
|
+
assert.match(BLOCKERS_JS_SRC, /window\.OTBBlockersLib/);
|
|
71
|
+
assert.match(BLOCKERS_JS_SRC, /lib\.blockerFacts\(/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('blockers.js pages through OTBKit — makePager/paginate/wirePager/pagerBarHtml — not by hand', () => {
|
|
75
|
+
assert.match(BLOCKERS_JS_SRC, /kit\.makePager\(/);
|
|
76
|
+
assert.match(BLOCKERS_JS_SRC, /kit\.paginate\(/);
|
|
77
|
+
assert.match(BLOCKERS_JS_SRC, /kit\.wirePager\(/);
|
|
78
|
+
assert.match(BLOCKERS_JS_SRC, /kit\.pagerBarHtml\(/);
|
|
79
|
+
// The negative half: no hand-rolled slice math (start/end arithmetic) sitting
|
|
80
|
+
// beside the kit call, which is what "paged through OTBKit rather than
|
|
81
|
+
// hand-rolled" would otherwise silently tolerate.
|
|
82
|
+
assert.doesNotMatch(BLOCKERS_JS_SRC, /\.slice\(\s*\(?\s*pager\.page/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('blockers.html loads blockers-lib.js BEFORE blockers.js', () => {
|
|
86
|
+
const libAt = BLOCKERS_HTML_SRC.indexOf('src="/builders/blockers-lib.js"');
|
|
87
|
+
const pageAt = BLOCKERS_HTML_SRC.indexOf('src="/builders/blockers.js"');
|
|
88
|
+
assert.ok(libAt > -1 && pageAt > -1, 'both scripts must be present');
|
|
89
|
+
assert.ok(libAt < pageAt, 'blockers.js calls window.OTBBlockersLib at load time');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('index.html loads blockers-lib.js BEFORE goal-inbox.js', () => {
|
|
93
|
+
// The literal <script src> attribute, not a bare filename mention — a
|
|
94
|
+
// surrounding comment is free to name either file in either order (and does:
|
|
95
|
+
// profile-nudge.js's own comment mentions "goal-inbox.js" ahead of the real
|
|
96
|
+
// goal-inbox.js <script> tag), so only the src strings say what actually loads
|
|
97
|
+
// first.
|
|
98
|
+
const libAt = INDEX_HTML_SRC.indexOf('src="/builders/blockers-lib.js"');
|
|
99
|
+
const inboxAt = INDEX_HTML_SRC.indexOf('src="/builders/goal-inbox.js');
|
|
100
|
+
assert.ok(libAt > -1 && inboxAt > -1, 'both scripts must be present');
|
|
101
|
+
assert.ok(libAt < inboxAt);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('every title link opens the SAME #/blocker/N overlay task-detail.js already renders', () => {
|
|
105
|
+
assert.match(GOAL_INBOX_SRC, /`#\/blocker\/\$\{encodeURIComponent\(id\)\}`/);
|
|
106
|
+
assert.match(BLOCKERS_JS_SRC, /'#\/blocker\/' \+ encodeURIComponent\(id\)/);
|
|
107
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// tests/blockers_lib.mjs — the shared blocker-row renderer (blockers-lib.js,
|
|
2
|
+
// task 1003455, goal 1000089).
|
|
3
|
+
//
|
|
4
|
+
// Pins that the lib is genuinely pure (no window.OTB, no DOM) so it loads
|
|
5
|
+
// standalone via require() the way goals-lib.js loads standalone in a vm — and
|
|
6
|
+
// that its output is what both callers (goal-inbox.js's attention-inbox slice,
|
|
7
|
+
// blockers.js's full-queue page) actually depend on: a title link into the
|
|
8
|
+
// EXISTING #/blocker/N overlay (task-detail.js), a quoted note, and a `·`-joined
|
|
9
|
+
// facts line naming which tasks a blocker holds up.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/blockers_lib.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const lib = require('../modules/hall-ui/public/blockers-lib.js');
|
|
19
|
+
|
|
20
|
+
test('loads standalone via require() — no window, no DOM (Node-require-safe like hall-kit.js)', () => {
|
|
21
|
+
assert.ok(lib, 'module.exports must be set');
|
|
22
|
+
for (const k of ['esc', 'fmtDate', 'blockingLine', 'blockerFacts']) {
|
|
23
|
+
assert.ok(k in lib, `missing export: ${k}`);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('blockerFacts: title links into the SAME #/blocker/N overlay task-detail.js renders', () => {
|
|
28
|
+
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}` });
|
|
30
|
+
assert.match(titleHtml, /href="#\/blocker\/42"/);
|
|
31
|
+
assert.match(titleHtml, /Need the owner/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('blockerFacts: with no blockerHref, the title renders as plain text, not a dead link', () => {
|
|
35
|
+
const b = { id: 42, title: 'Something stuck', created_at: null, blocking_task_ids: [] };
|
|
36
|
+
const { titleHtml } = lib.blockerFacts(b);
|
|
37
|
+
assert.doesNotMatch(titleHtml, /<a /);
|
|
38
|
+
assert.match(titleHtml, /<span/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('blockerFacts: the caller-supplied classes land on the right fragments', () => {
|
|
42
|
+
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, {
|
|
44
|
+
titleClass: 'ginbox__goal', noteClass: 'ginbox__note', whenClass: 'ginbox__sub',
|
|
45
|
+
});
|
|
46
|
+
assert.match(titleHtml, /class="ginbox__goal"/);
|
|
47
|
+
assert.match(noteHtml, /class="ginbox__note"/);
|
|
48
|
+
assert.match(factsHtml, /class="ginbox__sub"/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
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');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
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}`);
|
|
58
|
+
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}`);
|
|
60
|
+
assert.equal(many, 'Blocking 2 tasks: <a href="/task/5">#5</a>, <a href="/task/6">#6</a>');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
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);
|
|
65
|
+
assert.equal(line, 'Blocking 1 task: #5');
|
|
66
|
+
assert.doesNotMatch(line, /<a /);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('blockerFacts: the facts line is ONE `·`-joined span, not several ungapped siblings', () => {
|
|
70
|
+
// The bug this shape avoids: two adjacent .ginbox__sub spans have no flex gap
|
|
71
|
+
// between them (unlike .ov-row__facts, which is a flex container) and would
|
|
72
|
+
// render Filed-date and Blocking-line running together with no separator.
|
|
73
|
+
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}` });
|
|
75
|
+
assert.equal((factsHtml.match(/<span/g) || []).length, 1, 'exactly one wrapping span');
|
|
76
|
+
assert.match(factsHtml, /Filed .* · Blocking 1 task/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
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: [] });
|
|
81
|
+
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: [] });
|
|
83
|
+
assert.equal(noNote.noteHtml, '');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('blockerFacts: a very long body_md is clipped, so one enormous blocker cannot blow up the row', () => {
|
|
87
|
+
const long = 'x'.repeat(500);
|
|
88
|
+
const { noteHtml } = lib.blockerFacts({ id: 1, title: 't', body_md: long, blocking_task_ids: [] });
|
|
89
|
+
assert.ok(noteHtml.length < 500, 'clipped well below the source length');
|
|
90
|
+
assert.match(noteHtml, /…”<\/p>$/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('blockerFacts: HTML in a title/body/task id is escaped, not injected', () => {
|
|
94
|
+
const b = {
|
|
95
|
+
id: 1, title: '<script>alert(1)</script>', body_md: '<img onerror=alert(1)>',
|
|
96
|
+
created_at: '2026-08-30T00:00:00.000Z', blocking_task_ids: [1],
|
|
97
|
+
};
|
|
98
|
+
const { titleHtml, noteHtml, factsHtml } = lib.blockerFacts(b, { taskHref: (id) => `/task/"><script>${id}` });
|
|
99
|
+
assert.doesNotMatch(titleHtml, /<script>/);
|
|
100
|
+
assert.doesNotMatch(noteHtml, /<img /);
|
|
101
|
+
assert.doesNotMatch(factsHtml, /<script>/);
|
|
102
|
+
});
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -255,7 +255,10 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
255
255
|
// (2026-09-09) was to move it: a sitting is work the project does, beside Goals
|
|
256
256
|
// and Tasks, not one of Government's monitoring surfaces. The item is invisible
|
|
257
257
|
// to anyone without the atom, so most builders never see it here.
|
|
258
|
-
|
|
258
|
+
// task 1003455 (goal 1000089): 'blockers' joins the same group, after Tasks —
|
|
259
|
+
// its subject is the project (what's stuck on the owner), not a person or a
|
|
260
|
+
// craft, and its one data source (GET /blockers) is public like Roadmap's.
|
|
261
|
+
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'blockers', 'board-room', 'ideas', 'copy-desk', 'studio'],
|
|
259
262
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
260
263
|
assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
|
|
261
264
|
// People (task 1002577): the /scouting directory graduated into the hall.
|
|
@@ -129,6 +129,14 @@ const ORACLE = {
|
|
|
129
129
|
'/builders/drachmae': null,
|
|
130
130
|
'/builders/profile': null,
|
|
131
131
|
|
|
132
|
+
// task 1003455: the blocker queue. GET /blockers is deliberately public (the
|
|
133
|
+
// status dashboard is meant to read it too — "anyone landing on /builders or
|
|
134
|
+
// the status dashboard can see what's stuck on the owner", the route's own
|
|
135
|
+
// handler comment), so the page must not be gated tighter than the API it
|
|
136
|
+
// fronts — the same rule that puts /roster and /drachmae here. The nav link
|
|
137
|
+
// is still cosmetically 'signed', same as those two.
|
|
138
|
+
'/builders/blockers': null,
|
|
139
|
+
|
|
132
140
|
// The bare detail slugs, gated by task 1003629 — the decision idea 1000907
|
|
133
141
|
// (task 1003448) asked for. They resolve to the same task.html / idea.html
|
|
134
142
|
// shells their /:id forms do, so they now sit on the same tier: the id-less
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// tests/join_dark_matter.mjs — the hub's join relay refuses a project the platform's own
|
|
2
|
+
// record says is unpublished, before it relays anything (task 1003578, ADR 0247 D4).
|
|
3
|
+
//
|
|
4
|
+
// TWO HALVES.
|
|
5
|
+
//
|
|
6
|
+
// Part A — the provider: `provisioning.projectPublishIdentity` reads the provisioning row
|
|
7
|
+
// (the one LIVE copy of a control-plane project's description) keyed on BOTH halves of the
|
|
8
|
+
// ADR 0246 projection, and maps slug → name so the hub's gate never learns this schema.
|
|
9
|
+
//
|
|
10
|
+
// Part B — the real POST /my-projects/join with the port scripted. Only a positive
|
|
11
|
+
// "unpublished" verdict refuses. No port, no row (a self-hosted or hand-seeded project),
|
|
12
|
+
// or a failed read is NO verdict, and the request is relayed exactly as before — the
|
|
13
|
+
// project's own door still decides, and the hub grants nothing by relaying.
|
|
14
|
+
//
|
|
15
|
+
// DB-free. Run: node --test tests/join_dark_matter.mjs
|
|
16
|
+
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { test, before, after } from 'node:test';
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
|
|
23
|
+
// ESM import so the knip ratchet credits the exports this file consumes
|
|
24
|
+
// (a createRequire() import does not credit a CJS export).
|
|
25
|
+
import { projectPublishIdentity, registerProvisioningSeams } from '../modules/provisioning/seams.js';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const express = require('express');
|
|
29
|
+
|
|
30
|
+
const PORT_NAME = 'provisioning.projectPublishIdentity';
|
|
31
|
+
|
|
32
|
+
// ── the doorway, with the port scripted ──────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
let portMode = 'present'; // 'present' | 'absent' | 'throws'
|
|
35
|
+
let identity = null; // what the port answers
|
|
36
|
+
let portCalls = [];
|
|
37
|
+
let fetches = [];
|
|
38
|
+
let echoWrites = 0;
|
|
39
|
+
|
|
40
|
+
const pool = {
|
|
41
|
+
query: async (text, params = []) => {
|
|
42
|
+
const sql = String(text).replace(/\s+/g, ' ').trim();
|
|
43
|
+
if (/FROM platform_identity_sso_clients WHERE origin = \$1 AND status = 'active'/.test(sql)) {
|
|
44
|
+
return { rows: params[0] === 'https://Target.example'
|
|
45
|
+
? [{ client_id: 'proj-open', name: 'Target', origin: 'https://Target.example', status: 'active' }]
|
|
46
|
+
: [] };
|
|
47
|
+
}
|
|
48
|
+
if (/^INSERT INTO platform_identity_application_echoes/.test(sql)) { echoWrites++; return { rows: [] }; }
|
|
49
|
+
throw new Error(`fake pool: unsupported statement — ${sql}`);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
54
|
+
const realApi = require(apiPath);
|
|
55
|
+
require.cache[apiPath].exports = {
|
|
56
|
+
...realApi,
|
|
57
|
+
pool,
|
|
58
|
+
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
|
59
|
+
requireBuilder: (req, _res, next) => {
|
|
60
|
+
req.builder = { id: 1, github_id: 4242, github_login: 'octo', display_name: 'Octo' };
|
|
61
|
+
next();
|
|
62
|
+
},
|
|
63
|
+
resolveOptional: (name, fallback = null) => {
|
|
64
|
+
if (name !== PORT_NAME) return realApi.resolveOptional(name, fallback);
|
|
65
|
+
if (portMode === 'absent') return fallback;
|
|
66
|
+
return async (target) => {
|
|
67
|
+
portCalls.push(target);
|
|
68
|
+
if (portMode === 'throws') throw new Error('provisioning is down');
|
|
69
|
+
return identity;
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const statsPath = require.resolve('../modules/platform-identity/project-stats.js');
|
|
75
|
+
require.cache[statsPath] = {
|
|
76
|
+
id: statsPath, filename: statsPath, loaded: true,
|
|
77
|
+
exports: { getProjectStats: async () => ({}) },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const myProjectsRoutes = require('../modules/platform-identity/routes/my-projects.js');
|
|
81
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
82
|
+
|
|
83
|
+
const realFetch = globalThis.fetch;
|
|
84
|
+
globalThis.fetch = async (url) => {
|
|
85
|
+
fetches.push(String(url));
|
|
86
|
+
return { status: 201, json: async () => ({ ok: true }) };
|
|
87
|
+
};
|
|
88
|
+
after(() => { globalThis.fetch = realFetch; });
|
|
89
|
+
|
|
90
|
+
const app = express();
|
|
91
|
+
app.use(express.json());
|
|
92
|
+
app.use(attachFail);
|
|
93
|
+
app.use('/api/bongos', myProjectsRoutes());
|
|
94
|
+
|
|
95
|
+
let server, port;
|
|
96
|
+
before(async () => {
|
|
97
|
+
server = http.createServer(app);
|
|
98
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
99
|
+
port = server.address().port;
|
|
100
|
+
});
|
|
101
|
+
after(async () => { await new Promise((r) => server.close(r)); });
|
|
102
|
+
|
|
103
|
+
function join(origin = 'https://Target.example') {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const payload = Buffer.from(JSON.stringify({ origin }));
|
|
106
|
+
const req = http.request({ host: '127.0.0.1', port, method: 'POST', path: '/api/bongos/my-projects/join',
|
|
107
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length } }, (res) => {
|
|
108
|
+
let data = '';
|
|
109
|
+
res.on('data', (c) => { data += c; });
|
|
110
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data ? JSON.parse(data) : null }));
|
|
111
|
+
});
|
|
112
|
+
req.on('error', reject);
|
|
113
|
+
req.end(payload);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function reset(mode, answer) {
|
|
118
|
+
portMode = mode; identity = answer; portCalls = []; fetches = []; echoWrites = 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Part A — the provider ────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function recorder(rows = []) {
|
|
124
|
+
const calls = [];
|
|
125
|
+
return { calls, query: async (sql, params) => { calls.push({ sql: String(sql).replace(/\s+/g, ' ').trim(), params }); return { rows }; } };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test('projectPublishIdentity asks the provisioning row for BOTH halves of the projection', async () => {
|
|
129
|
+
const db = recorder([{ slug: 'spike', description: 'A harbour of builders' }]);
|
|
130
|
+
const got = await projectPublishIdentity(db, { slug: 'spike', host: 'Spike.Example.com' });
|
|
131
|
+
assert.deepEqual(got, { name: 'spike', description: 'A harbour of builders' },
|
|
132
|
+
'the slug IS the name on this plane — the mapping stays here, the rule stays in the hub');
|
|
133
|
+
const [{ sql, params }] = db.calls;
|
|
134
|
+
assert.match(sql, /FROM provisioning_instances WHERE slug = \$1 AND lower\(domain\) = \$2/);
|
|
135
|
+
assert.deepEqual(params, ['spike', 'spike.example.com']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('projectPublishIdentity answers null — no verdict — when no row carries both halves', async () => {
|
|
139
|
+
assert.equal(await projectPublishIdentity(recorder([]), { slug: 'spike', host: 'h.test' }), null);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('projectPublishIdentity never queries for a target that names no project', async () => {
|
|
143
|
+
for (const target of [{}, { slug: '', host: 'h.test' }, { slug: ['spike'], host: 'h.test' }, undefined]) {
|
|
144
|
+
const db = recorder([{ slug: 'spike', description: 'x' }]);
|
|
145
|
+
assert.equal(await projectPublishIdentity(db, target), null, JSON.stringify(target));
|
|
146
|
+
assert.equal(db.calls.length, 0);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the port is registered once, declared by provisioning, and not required by the hub', () => {
|
|
151
|
+
registerProvisioningSeams();
|
|
152
|
+
registerProvisioningSeams();
|
|
153
|
+
assert.equal(typeof realApi.resolveOptional(PORT_NAME), 'function');
|
|
154
|
+
const read = (rel) => JSON.parse(readFileSync(new URL(rel, import.meta.url), 'utf8'));
|
|
155
|
+
assert.ok(read('../modules/provisioning/module.json').provides.includes(PORT_NAME), 'declared');
|
|
156
|
+
assert.ok(!(read('../modules/platform-identity/module.json').consumes || []).includes(PORT_NAME),
|
|
157
|
+
'optional — a hub with no provisioning module must still boot and relay');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── Part B — the relay ───────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
test('an unpublished project is refused at the hub: not_published, no relay, no echo', async () => {
|
|
163
|
+
reset('present', { name: 'proj-open', description: null });
|
|
164
|
+
const r = await join();
|
|
165
|
+
assert.equal(r.status, 200);
|
|
166
|
+
assert.equal(r.body.status, 'not_published');
|
|
167
|
+
assert.match(r.body.message, /isn’t published yet/);
|
|
168
|
+
assert.deepEqual(r.body.project, { name: 'Target', origin: 'https://Target.example' });
|
|
169
|
+
assert.deepEqual(fetches, [], 'the request never reaches a project with no door');
|
|
170
|
+
assert.equal(echoWrites, 0, 'a refusal is not an application');
|
|
171
|
+
assert.deepEqual(portCalls, [{ slug: 'proj-open', host: 'target.example' }],
|
|
172
|
+
'asked about the client row the relay would have used: client_id + its origin host');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('a blank description is dark matter too — the gate trims, so the relay does', async () => {
|
|
176
|
+
reset('present', { name: 'proj-open', description: ' ' });
|
|
177
|
+
const r = await join();
|
|
178
|
+
assert.equal(r.body.status, 'not_published');
|
|
179
|
+
assert.deepEqual(fetches, []);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('a published project is relayed exactly as before', async () => {
|
|
183
|
+
reset('present', { name: 'proj-open', description: 'A harbour of builders' });
|
|
184
|
+
const r = await join();
|
|
185
|
+
assert.equal(r.body.status, 'requested');
|
|
186
|
+
assert.equal(fetches.length, 1);
|
|
187
|
+
assert.equal(echoWrites, 1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
for (const [label, mode, answer] of [
|
|
191
|
+
['no provisioning module (no port)', 'absent', null],
|
|
192
|
+
['no provisioning row (self-hosted or hand-seeded)', 'present', null],
|
|
193
|
+
['a verdict read that fails', 'throws', null],
|
|
194
|
+
]) {
|
|
195
|
+
test(`${label} is NO verdict: the request is relayed and the project decides`, async () => {
|
|
196
|
+
reset(mode, answer);
|
|
197
|
+
const r = await join();
|
|
198
|
+
assert.equal(r.body.status, 'requested');
|
|
199
|
+
assert.equal(fetches.length, 1, 'never refused on a guess');
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
test('an unknown project is still a 404 and the port is never asked', async () => {
|
|
204
|
+
reset('present', { name: 'x', description: null });
|
|
205
|
+
const r = await join('https://nowhere.example');
|
|
206
|
+
assert.equal(r.status, 404);
|
|
207
|
+
assert.deepEqual(portCalls, []);
|
|
208
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -1950,7 +1950,8 @@ test('the feed carries the owner\'s choice through a seam: platform-identity res
|
|
|
1950
1950
|
assert.ok(manifest.provides.includes('provisioning.projectPlanets'), 'the port is declared');
|
|
1951
1951
|
const pi = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'module.json'), 'utf8'));
|
|
1952
1952
|
assert.ok(!(pi.consumes || []).includes('provisioning.projectPlanets'), 'not a required capability — the hub must boot without provisioning');
|
|
1953
|
-
|
|
1953
|
+
// the registrations moved to seams.js at the routes file's size ratchet (task 1003578)
|
|
1954
|
+
const prov = fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'seams.js'), 'utf8');
|
|
1954
1955
|
assert.match(prov, /api\.registerProvider\('provisioning\.projectPlanets', \(hosts\) => provisioning\.planetChoicesForHosts\(pool, hosts\)\);/, 'registered beside instanceState');
|
|
1955
1956
|
});
|
|
1956
1957
|
|