@bongos/core 1.20.21 → 1.20.23
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 +78 -48
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -2
- package/clients/bongos-client/index.cjs +4 -2
- package/clients/bongos-client/index.d.ts +8 -4
- package/clients/bongos-client/index.mjs +4 -2
- package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -0
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +70 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +137 -72
- package/docs/api-reference.md +6 -5
- package/docs/architecture.md +14 -0
- package/docs/copy-inventory.md +196 -196
- package/docs/copy-registry.json +250 -250
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +310 -306
- package/migrations/core_265_module_assessments.sql +97 -0
- package/modules/hall-ui/public/watch.js +23 -0
- package/modules/onboarding/routes/access-requests.js +36 -6
- package/modules/platform-identity/invite-notices.js +61 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +12 -18
- package/modules/platform-identity/routes/my-projects.js +6 -15
- package/modules/platform-identity/routes/projects.js +22 -15
- package/modules/platform-identity/routes/sso-invites.js +82 -0
- package/modules/platform-identity/tests/platform-identity.mjs +14 -11
- package/modules/public-landing/public/assets/cosmos.css +5 -0
- package/modules/public-landing/public/projects.html +35 -29
- package/modules/public-landing/public/projects.states.json +1 -0
- package/modules/ui-design/kit/serve.js +9 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/auth-admission.js +40 -0
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +8 -1
- package/tests/access_requests_invite.mjs +39 -1
- package/tests/application_lifecycle.mjs +41 -1
- package/tests/catalog_only_client.mjs +5 -3
- package/tests/hub_invite_notices.mjs +261 -0
- package/tests/module_api.mjs +1 -0
- package/tests/module_assessments_migration.mjs +96 -0
- package/tests/my_projects_surface.mjs +11 -1
- package/tests/project_invite_ownership.mjs +12 -7
- package/tests/project_invite_ui.mjs +42 -45
- package/tests/watch_applications_queue.mjs +33 -2
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// tests/hub_invite_notices.mjs — a project's own invite reaches the hub as a NOTICE, and a
|
|
2
|
+
// rescind takes it back (task 1002818, R6 of goal 1000110; ADR 0353).
|
|
3
|
+
//
|
|
4
|
+
// Three halves, each DB-free:
|
|
5
|
+
// 1. the HUB routes (POST /sso/invites/notify|clear) — the real router, the real
|
|
6
|
+
// platform-identity domain functions and the real error envelope, over a recording
|
|
7
|
+
// fake pool, so the SQL a notice runs is the SQL asserted. What must hold: only an
|
|
8
|
+
// authenticated project writes, only to ITS OWN client_id, only ever a `pending` row,
|
|
9
|
+
// and the answer never says whether the login has a hub account.
|
|
10
|
+
// 2. the hub's hand-off link (hallInviteUrl) — it becomes an <a href> on the hub.
|
|
11
|
+
// 3. the INSTANCE sender (notifyHubOfInvite) — one login and one direction on the wire,
|
|
12
|
+
// the client credentials from config, and nothing it does can throw into a caller
|
|
13
|
+
// that never awaits it.
|
|
14
|
+
//
|
|
15
|
+
// The instance ROUTE wiring (which invite outcomes notify, which rescinds clear) is pinned
|
|
16
|
+
// where those routes are already executed: tests/access_requests_invite.mjs and
|
|
17
|
+
// tests/application_lifecycle.mjs.
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test tests/hub_invite_notices.mjs
|
|
20
|
+
|
|
21
|
+
import test from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import http from 'node:http';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
|
|
32
|
+
// ── the world: two projects, one hub account ─────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
35
|
+
const SECRET = 'project-secret';
|
|
36
|
+
const CLIENTS = {
|
|
37
|
+
mercury: { client_id: 'mercury', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: 'https://mercury.test' },
|
|
38
|
+
// Suspended, and catalog-only (no secret at all): neither may speak for a project.
|
|
39
|
+
frozen: { client_id: 'frozen', secret_hash: pi.sha256hex(SECRET), status: 'suspended', origin: 'https://frozen.test' },
|
|
40
|
+
shell: { client_id: 'shell', secret_hash: '', status: 'active', origin: 'https://shell.test' },
|
|
41
|
+
};
|
|
42
|
+
const ACCOUNTS = { octo: { github_id: '4242', github_login: 'Octo' } };
|
|
43
|
+
|
|
44
|
+
let calls = [];
|
|
45
|
+
const pool = {
|
|
46
|
+
async query(text, params = []) {
|
|
47
|
+
const sql = String(text);
|
|
48
|
+
calls.push({ sql, params });
|
|
49
|
+
if (/FROM platform_identity_sso_clients WHERE client_id = \$1/.test(sql)) {
|
|
50
|
+
return { rows: CLIENTS[params[0]] ? [CLIENTS[params[0]]] : [] };
|
|
51
|
+
}
|
|
52
|
+
if (/FROM platform_identity_accounts WHERE lower\(github_login\) = lower\(\$1\)/.test(sql)) {
|
|
53
|
+
const a = ACCOUNTS[String(params[0]).toLowerCase()];
|
|
54
|
+
return { rows: a ? [a] : [] };
|
|
55
|
+
}
|
|
56
|
+
if (/^\s*INSERT INTO platform_identity_project_memberships/.test(sql)) return { rows: [{ id: '1', membership_kind: 'pending' }] };
|
|
57
|
+
if (/^\s*DELETE FROM platform_identity_project_memberships/.test(sql)) return { rowCount: 1, rows: [] };
|
|
58
|
+
throw new Error(`unexpected statement: ${sql.trim().slice(0, 60)}`);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const writes = () => calls.filter((c) => /^\s*(INSERT|UPDATE|DELETE)/.test(c.sql));
|
|
62
|
+
|
|
63
|
+
// The doorway, with the pool swapped, BEFORE the domain file and the router load: both
|
|
64
|
+
// take `pool` off it at require time.
|
|
65
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
66
|
+
const realApi = require(apiPath);
|
|
67
|
+
require.cache[apiPath].exports = { ...realApi, pool, logger: () => ({ error() {}, warn() {}, info() {} }) };
|
|
68
|
+
delete require.cache[require.resolve('../modules/platform-identity/platform-identity.js')];
|
|
69
|
+
delete require.cache[require.resolve('../modules/platform-identity/invite-notices.js')];
|
|
70
|
+
const buildRoutes = require('../modules/platform-identity/routes/sso-invites.js');
|
|
71
|
+
const inviteNotices = require('../modules/platform-identity/invite-notices.js');
|
|
72
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
73
|
+
|
|
74
|
+
const express = require('express');
|
|
75
|
+
const app = express();
|
|
76
|
+
app.use(express.json());
|
|
77
|
+
app.use(attachFail);
|
|
78
|
+
app.use('/api/bongos', buildRoutes());
|
|
79
|
+
const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
|
|
80
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
81
|
+
test.after(() => server.close());
|
|
82
|
+
|
|
83
|
+
function post(route, body) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const payload = JSON.stringify(body);
|
|
86
|
+
const req = http.request(`${base}${route}`, {
|
|
87
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
|
|
88
|
+
}, (res) => {
|
|
89
|
+
let data = '';
|
|
90
|
+
res.on('data', (c) => { data += c; });
|
|
91
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data ? JSON.parse(data) : null }));
|
|
92
|
+
});
|
|
93
|
+
req.on('error', reject);
|
|
94
|
+
req.end(payload);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const notice = (route, extra = {}) => post(route, { client_id: 'mercury', client_secret: SECRET, github_login: 'octo', ...extra });
|
|
98
|
+
|
|
99
|
+
test.beforeEach(() => { calls = []; });
|
|
100
|
+
|
|
101
|
+
// ── 1. the hub routes ────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
test('notify seeds a PENDING row on the calling project, and cannot touch a real membership', async () => {
|
|
104
|
+
const res = await notice('/sso/invites/notify');
|
|
105
|
+
assert.equal(res.status, 200);
|
|
106
|
+
assert.deepEqual(res.body, { ok: true });
|
|
107
|
+
const w = writes();
|
|
108
|
+
assert.equal(w.length, 1);
|
|
109
|
+
assert.match(w[0].sql, /VALUES \(\$1, \$2, 'pending'\)/, 'a notice is a pending row, never a membership');
|
|
110
|
+
assert.match(w[0].sql, /ON CONFLICT \(client_id, github_id\) DO NOTHING/, 'a current member is left exactly as they are');
|
|
111
|
+
assert.deepEqual(w[0].params, ['mercury', '4242'], 'keyed on the authenticated client and the account the LOGIN resolves to');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('clear drops ONLY a pending row on the calling project', async () => {
|
|
115
|
+
const res = await notice('/sso/invites/clear');
|
|
116
|
+
assert.equal(res.status, 200);
|
|
117
|
+
assert.deepEqual(res.body, { ok: true });
|
|
118
|
+
const w = writes();
|
|
119
|
+
assert.equal(w.length, 1);
|
|
120
|
+
assert.match(w[0].sql, /^\s*DELETE FROM platform_identity_project_memberships[\s\S]*AND membership_kind = 'pending'/,
|
|
121
|
+
'a rescind can never remove someone who has signed in');
|
|
122
|
+
assert.deepEqual(w[0].params, ['mercury', '4242']);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a login with no hub account writes nothing — and the answer is byte-identical', async () => {
|
|
126
|
+
// The project must not learn from this route which GitHub logins have platform accounts.
|
|
127
|
+
for (const route of ['/sso/invites/notify', '/sso/invites/clear']) {
|
|
128
|
+
calls = [];
|
|
129
|
+
const known = await notice(route);
|
|
130
|
+
const unknown = await notice(route, { github_login: 'nobody-here' });
|
|
131
|
+
assert.deepEqual(unknown, known, `${route}: same status, same body`);
|
|
132
|
+
}
|
|
133
|
+
calls = [];
|
|
134
|
+
await notice('/sso/invites/notify', { github_login: 'nobody-here' });
|
|
135
|
+
assert.equal(writes().length, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('only an authenticated, active, credentialed project may speak — and writes nothing otherwise', async () => {
|
|
139
|
+
const refusals = [
|
|
140
|
+
{ client_secret: 'wrong' },
|
|
141
|
+
{ client_id: 'frozen' },
|
|
142
|
+
{ client_id: 'shell', client_secret: '' },
|
|
143
|
+
{ client_id: 'nobody' },
|
|
144
|
+
];
|
|
145
|
+
for (const bad of refusals) {
|
|
146
|
+
for (const route of ['/sso/invites/notify', '/sso/invites/clear']) {
|
|
147
|
+
const res = await notice(route, bad);
|
|
148
|
+
assert.ok(res.status === 401 || res.status === 400, `${route} ${JSON.stringify(bad)} → ${res.status}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
assert.equal(writes().length, 0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a project can only notify about ITSELF — no body field names another project', async () => {
|
|
155
|
+
// The client row authenticated is the one written; there is no project field to steer.
|
|
156
|
+
const res = await notice('/sso/invites/notify', { project: 'someone-else' });
|
|
157
|
+
assert.equal(res.status, 400, 'the body is strict: an unknown field is refused, not ignored');
|
|
158
|
+
assert.equal(writes().length, 0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('each body is exactly three fields — a later field must ride a header (ADR 0352)', () => {
|
|
162
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/platform-identity/routes/sso-invites.js'), 'utf8');
|
|
163
|
+
const schemas = [...src.matchAll(/validateOrRespond\(req, res, \{([\s\S]*?)\}\)\) return/g)].map((m) => m[1]);
|
|
164
|
+
assert.equal(schemas.length, 2, 'one schema per route');
|
|
165
|
+
for (const schema of schemas) {
|
|
166
|
+
assert.deepEqual([...schema.matchAll(/^\s+(\w+): \{/gm)].map((m) => m[1]), ['client_id', 'client_secret', 'github_login']);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('the module mounts the route file, and every route in it carries its rank line', () => {
|
|
171
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/platform-identity/module.json'), 'utf8'));
|
|
172
|
+
assert.ok(manifest.contributes.routes.includes('sso-invites'));
|
|
173
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/platform-identity/routes/sso-invites.js'), 'utf8');
|
|
174
|
+
const routes = src.match(/router\.post\(/g) || [];
|
|
175
|
+
const ranks = src.match(/\/\/ rank: public — authenticated by client_id \+ client_secret/g) || [];
|
|
176
|
+
assert.equal(routes.length, 2);
|
|
177
|
+
assert.equal(ranks.length, routes.length);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── 2. the hand-off link ─────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
test('hallInviteUrl points at the project\'s own hall invite box, name filled in', () => {
|
|
183
|
+
const f = inviteNotices.hallInviteUrl;
|
|
184
|
+
assert.equal(f('https://mercury.test', 'octo'), 'https://mercury.test/builders/watch?invite=octo');
|
|
185
|
+
assert.equal(f('https://mercury.test/some/path/', '@octo'), 'https://mercury.test/builders/watch?invite=octo',
|
|
186
|
+
'the origin only, and a pasted @ is dropped');
|
|
187
|
+
assert.equal(f('https://mercury.test', 'a b&c'), 'https://mercury.test/builders/watch?invite=a%20b%26c', 'the login is encoded');
|
|
188
|
+
assert.equal(f('https://mercury.test', ''), 'https://mercury.test/builders/watch');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('hallInviteUrl refuses anything that is not a web origin — it becomes an <a href>', () => {
|
|
192
|
+
for (const bad of ['javascript:alert(1)', 'data:text/html,x', 'not a url', '', null]) {
|
|
193
|
+
assert.equal(inviteNotices.hallInviteUrl(bad, 'octo'), null, String(bad));
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── 3. the instance sender ───────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
const pin = (rel, exports) => {
|
|
200
|
+
const p = require.resolve(rel);
|
|
201
|
+
require.cache[p] = { id: p, filename: p, loaded: true, exports };
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
function loadSender(idp) {
|
|
205
|
+
pin('../src/bongos/auth-config.js', { loadIdpConfig: typeof idp === 'function' ? idp : () => idp });
|
|
206
|
+
delete require.cache[require.resolve('../src/bongos/auth-admission.js')];
|
|
207
|
+
return require('../src/bongos/auth-admission.js').notifyHubOfInvite;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function withFetch(impl, fn) {
|
|
211
|
+
const real = globalThis.fetch;
|
|
212
|
+
const sent = [];
|
|
213
|
+
globalThis.fetch = async (url, init) => { sent.push({ url, init }); return impl(url, init); };
|
|
214
|
+
try { await fn(sent); } finally { globalThis.fetch = real; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const IDP = { origin: 'https://hub.test', clientId: 'mercury', clientSecret: SECRET };
|
|
218
|
+
|
|
219
|
+
test('an invite posts one login to /notify with this project\'s credentials, and nothing else', async () => {
|
|
220
|
+
const send = loadSender(IDP);
|
|
221
|
+
await withFetch(async () => ({ ok: true }), async (sent) => {
|
|
222
|
+
await send('octo', { invited: true });
|
|
223
|
+
assert.equal(sent.length, 1);
|
|
224
|
+
assert.equal(sent[0].url, 'https://hub.test/api/bongos/sso/invites/notify');
|
|
225
|
+
assert.equal(sent[0].init.method, 'POST');
|
|
226
|
+
assert.deepEqual(JSON.parse(sent[0].init.body), { client_id: 'mercury', client_secret: SECRET, github_login: 'octo' });
|
|
227
|
+
assert.ok(sent[0].init.signal, 'bounded in time — a slow hub must not hold anything open');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('a rescind posts to /clear', async () => {
|
|
232
|
+
const send = loadSender(IDP);
|
|
233
|
+
await withFetch(async () => ({ ok: true }), async (sent) => {
|
|
234
|
+
await send('octo', { invited: false });
|
|
235
|
+
assert.equal(sent[0].url, 'https://hub.test/api/bongos/sso/invites/clear');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('no hub, or no login, sends nothing', async () => {
|
|
240
|
+
await withFetch(async () => ({ ok: true }), async (sent) => {
|
|
241
|
+
await loadSender(null)('octo', { invited: true });
|
|
242
|
+
await loadSender(IDP)('', { invited: true });
|
|
243
|
+
assert.equal(sent.length, 0);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('nothing the sender meets can throw into a caller that never awaits it', async () => {
|
|
248
|
+
// An older hub (404), a hub that is down (rejects), and a config read that throws.
|
|
249
|
+
await withFetch(async () => ({ ok: false, status: 404 }), async () => {
|
|
250
|
+
await assert.doesNotReject(loadSender(IDP)('octo'));
|
|
251
|
+
});
|
|
252
|
+
await withFetch(async () => { throw new Error('ECONNREFUSED'); }, async () => {
|
|
253
|
+
await assert.doesNotReject(loadSender(IDP)('octo'));
|
|
254
|
+
});
|
|
255
|
+
await assert.doesNotReject(loadSender(() => { throw new Error('bad config'); })('octo'));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('the doorway publishes the sender as a narrow port, and never the config that holds the secret', () => {
|
|
259
|
+
assert.equal(typeof realApi.notifyHubOfInvite, 'function');
|
|
260
|
+
assert.equal(realApi.loadIdpConfig, undefined);
|
|
261
|
+
});
|
package/tests/module_api.mjs
CHANGED
|
@@ -48,6 +48,7 @@ const PUBLISHED_SURFACE = [
|
|
|
48
48
|
'projectJoinDoor', // added by task 1002331 (ADR 0182 D6 / ADR 0247): the COMPOSED join/apply door — visibility × join grant × joinability, one answer, so a module reading a single knob cannot re-open a door the other two closed
|
|
49
49
|
'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
|
|
50
50
|
'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
|
|
51
|
+
'notifyHubOfInvite', // added by task 1002818 (ADR 0353): tell the hub about one of THIS project's invites (or its rescind) so the invitee sees it in My Projects. The same NARROW-port reason as readApplicantProfileFromHub: the call carries this instance's hub client secret, so a module passes one login and one direction and can neither widen nor re-address it
|
|
51
52
|
'readApplicationVouch', // added by task 1002285 (ADR 0201 D2): the public access-request write's check of the hub's application vouch. NARROW for the same reason — core supplies the hub key and this instance's client_id, so a module passes a token and a login and can neither swap the key nor re-address the audience
|
|
52
53
|
'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
|
|
53
54
|
'isModuleEnabled', // task 1003881: is ANOTHER module present? onboarding's Discord approve/decline control is only actionable when the `discord` module's reaction handler is loaded, so the posting half has to be able to ask
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// tests/module_assessments_migration.mjs — the module assessment tables (task
|
|
2
|
+
// 1003814, ADR 0343 + ADR 0347 D5). DB-free: the unit gate has no Postgres, so
|
|
3
|
+
// this pins the rules the owner decided directly in the migration text, where a
|
|
4
|
+
// later edit that loosened one would otherwise land silently. The live apply
|
|
5
|
+
// runs in the PG lane (scripts/verify-gds-only-schema.sh) and at deploy.
|
|
6
|
+
//
|
|
7
|
+
// Run: node tests/module_assessments_migration.mjs
|
|
8
|
+
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const FILE = 'core_265_module_assessments.sql';
|
|
19
|
+
const SQL = readFileSync(path.join(ROOT, 'migrations', FILE), 'utf8');
|
|
20
|
+
// Comments stripped so an assertion can only be satisfied by real DDL.
|
|
21
|
+
const DDL = SQL.replace(/--.*$/gm, '');
|
|
22
|
+
|
|
23
|
+
// The body of one CREATE TABLE, up to its closing ");".
|
|
24
|
+
function table(name) {
|
|
25
|
+
const m = new RegExp(`CREATE TABLE IF NOT EXISTS ${name} \\(([\\s\\S]*?)\\n\\);`).exec(DDL);
|
|
26
|
+
assert.ok(m, `${name} is created`);
|
|
27
|
+
return m[1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('both tables exist, idempotently', () => {
|
|
31
|
+
table('module_assessment_signals');
|
|
32
|
+
table('module_assessment_scores');
|
|
33
|
+
assert.doesNotMatch(DDL, /CREATE TABLE (?!IF NOT EXISTS)/, 'every CREATE TABLE is IF NOT EXISTS');
|
|
34
|
+
assert.doesNotMatch(DDL, /CREATE INDEX (?!IF NOT EXISTS)/, 'every CREATE INDEX is IF NOT EXISTS');
|
|
35
|
+
assert.match(DDL, /INSERT INTO schema_migrations \(version\) VALUES \('core_265_module_assessments'\)\s+ON CONFLICT \(version\) DO NOTHING;/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('assessment is per module VERSION, not per module', () => {
|
|
39
|
+
for (const t of ['module_assessment_signals', 'module_assessment_scores']) {
|
|
40
|
+
assert.match(table(t), /version_id\s+bigint NOT NULL REFERENCES store_module_versions\(id\)/, t);
|
|
41
|
+
assert.doesNotMatch(table(t), /module_key/, `${t} is not keyed by module`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the parts are exactly ADR 0343 D1 plus the ADR 0347 Docs part', () => {
|
|
46
|
+
assert.match(table('module_assessment_signals'),
|
|
47
|
+
/CHECK \(part IN \('security', 'tests', 'install', 'reliability',\s+'tester_feedback', 'docs'\)\)/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('"no data yet" is an outcome, never a zero (ADR 0343 D3/D5)', () => {
|
|
51
|
+
const t = table('module_assessment_signals');
|
|
52
|
+
assert.match(t, /'no_data'/);
|
|
53
|
+
assert.match(t, /'pending'/);
|
|
54
|
+
assert.match(t, /'not_scored'/);
|
|
55
|
+
assert.match(t, /score\s+smallint CHECK \(score BETWEEN 0 AND 100\)/);
|
|
56
|
+
assert.match(t, /CHECK \(\(outcome = 'scored'\) = \(score IS NOT NULL\)\)/, 'a number only when scored');
|
|
57
|
+
assert.doesNotMatch(t, /score\s+smallint[^,\n]*NOT NULL/, 'score must be nullable');
|
|
58
|
+
assert.doesNotMatch(t, /score[^,\n]*DEFAULT 0/, 'no default-to-zero');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('security is a pass/fail gate, and nothing else passes or fails (ADR 0343 D2)', () => {
|
|
62
|
+
assert.match(table('module_assessment_signals'),
|
|
63
|
+
/CHECK \(\(part = 'security'\) = \(outcome IN \('passed', 'failed'\)\)\s+OR outcome IN \('no_data', 'pending', 'not_scored'\)\)/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('history is kept: no uniqueness that would force an overwrite, nothing cascades', () => {
|
|
67
|
+
assert.doesNotMatch(DDL, /UNIQUE/, 'a re-run adds a row rather than replacing one');
|
|
68
|
+
assert.doesNotMatch(DDL, /ON DELETE CASCADE/);
|
|
69
|
+
assert.match(DDL, /ON module_assessment_signals \(version_id, part, measured_at DESC, id DESC\)/);
|
|
70
|
+
assert.match(DDL, /ON module_assessment_scores \(version_id, computed_at DESC, id DESC\)/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('an override names who and why; a computed row names neither (ADR 0343 D6)', () => {
|
|
74
|
+
const t = table('module_assessment_scores');
|
|
75
|
+
assert.match(t, /CHECK \(kind IN \('computed', 'override'\)\)/);
|
|
76
|
+
assert.match(t, /CHECK \(\(kind = 'override'\) = \(override_by IS NOT NULL\)\)/);
|
|
77
|
+
assert.match(t, /kind = 'override' AND length\(btrim\(coalesce\(override_reason, ''\)\)\) > 0/);
|
|
78
|
+
assert.match(t, /kind = 'computed' AND override_reason IS NULL/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a score records what it was composed from, and the New label (ADR 0343 D5)', () => {
|
|
82
|
+
const t = table('module_assessment_scores');
|
|
83
|
+
assert.match(t, /signal_ids\s+bigint\[\] NOT NULL/);
|
|
84
|
+
assert.match(t, /formula\s+text NOT NULL/);
|
|
85
|
+
assert.match(t, /is_new\s+boolean NOT NULL/);
|
|
86
|
+
assert.match(t, /overall\s+smallint CHECK \(overall BETWEEN 0 AND 100\)/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('price is never an input (ADR 0343 D6)', () => {
|
|
90
|
+
assert.doesNotMatch(DDL, /price|credits|cents/i);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('the migration is forward-safe under the lint gate', () => {
|
|
94
|
+
const lint = require('../scripts/gds/lint-migrations.js');
|
|
95
|
+
assert.deepEqual(lint.scanMigrationSql(SQL).unsafe || [], []);
|
|
96
|
+
});
|
|
@@ -58,7 +58,6 @@ const MY_PROJECTS_ROUTES = [
|
|
|
58
58
|
"router.get('/my-projects'",
|
|
59
59
|
"router.post('/my-projects/join'",
|
|
60
60
|
"router.get('/my-projects/invites'",
|
|
61
|
-
"router.post('/my-projects/invites/:clientId/accept'",
|
|
62
61
|
"router.post('/my-projects/invites/:clientId/decline'",
|
|
63
62
|
"router.post('/my-projects/:clientId/leave'",
|
|
64
63
|
];
|
|
@@ -122,3 +121,14 @@ test('the hub never calls the accept route — ADR 0335 retired that flip', () =
|
|
|
122
121
|
assert.doesNotMatch(html, /invites\/[^'"]*\/accept/,
|
|
123
122
|
'a hub caller of the accept route reopens the dead end ADR 0335 closed — render the invite as a link into the project instead');
|
|
124
123
|
});
|
|
124
|
+
|
|
125
|
+
// Task 1002818 (ADR 0353) finished the retirement on the server: the route is gone,
|
|
126
|
+
// so no caller — this page, an old cached copy, a script — can flip a pending row
|
|
127
|
+
// to 'member' and admit nobody. Signing in to the project is the only acceptance.
|
|
128
|
+
test('the accept route itself is gone — only decline survives (ADR 0353)', () => {
|
|
129
|
+
const src = read(ROUTES);
|
|
130
|
+
assert.doesNotMatch(src, /router\.\w+\(\s*'\/my-projects\/invites\/:clientId\/accept'/,
|
|
131
|
+
'a hub-side accept admits nobody on the project; do not bring it back');
|
|
132
|
+
assert.doesNotMatch(src, /resolveInvite/, 'the accept-or-decline helper is retired too');
|
|
133
|
+
assert.match(src, /router\.post\('\/my-projects\/invites\/:clientId\/decline'/);
|
|
134
|
+
});
|
|
@@ -149,14 +149,18 @@ beforeEach(() => { caller = OWNER; portMode = 'ok'; invites = []; portCalls = []
|
|
|
149
149
|
|
|
150
150
|
// ── the arm ──────────────────────────────────────────────────────────────────
|
|
151
151
|
|
|
152
|
+
// What an admitted caller gets since task 1002818 (ADR 0353): not a hub row, which let nobody
|
|
153
|
+
// in, but WHERE the invite that admits is sent — the project's own hall, name filled in.
|
|
154
|
+
const HANDOFF = 'https://spike-harbor.cloudbongos.test/builders/watch?invite=someone';
|
|
155
|
+
|
|
152
156
|
test('the OWNER of a wizard-made project invites into it, holding no permission at all', async () => {
|
|
153
157
|
// The whole point of the task: the wizard's audience is any signed-in GitHub user, and a
|
|
154
158
|
// `xenos` owner used to get 403 on their own project before the target was even resolved.
|
|
155
159
|
caller = OWNER;
|
|
156
160
|
const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
|
|
157
|
-
assert.equal(res.status,
|
|
158
|
-
assert.
|
|
159
|
-
assert.deepEqual(invites, [
|
|
161
|
+
assert.equal(res.status, 200);
|
|
162
|
+
assert.deepEqual(res.body, { invited: false, invite_url: HANDOFF });
|
|
163
|
+
assert.deepEqual(invites, [], 'the hub writes no membership row — only the project\'s own invite admits');
|
|
160
164
|
// The ownership question was asked of the provisioning row, keyed on BOTH halves of the
|
|
161
165
|
// catalog projection (ADR 0246: client_id = slug, origin = https://domain).
|
|
162
166
|
assert.deepEqual(portCalls, [{ slug: 'spike-harbor', host: 'spike-harbor.cloudbongos.test', builderId: 7 }]);
|
|
@@ -168,9 +172,9 @@ test('the OWNER of a wizard-made project invites into it, holding no permission
|
|
|
168
172
|
test('a METIC curator still invites into a project they do not own — the old door is untouched', async () => {
|
|
169
173
|
caller = CURATOR;
|
|
170
174
|
const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
|
|
171
|
-
assert.equal(res.status,
|
|
172
|
-
assert.equal(res.body.
|
|
173
|
-
assert.
|
|
175
|
+
assert.equal(res.status, 200);
|
|
176
|
+
assert.equal(res.body.invite_url, HANDOFF);
|
|
177
|
+
assert.deepEqual(invites, []);
|
|
174
178
|
});
|
|
175
179
|
|
|
176
180
|
test('a DIFFERENT builder is refused with the unchanged shape, and writes nothing', async () => {
|
|
@@ -253,7 +257,8 @@ test('a THROWING ownership check denies — it never 500s and never admits', asy
|
|
|
253
257
|
// door is the old door, not an outage.
|
|
254
258
|
caller = CURATOR;
|
|
255
259
|
const asCurator = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
|
|
256
|
-
assert.equal(asCurator.status,
|
|
260
|
+
assert.equal(asCurator.status, 200, 'past the gate — and handed to the hall, as every admitted caller is');
|
|
261
|
+
assert.equal(asCurator.body.invite_url, HANDOFF);
|
|
257
262
|
});
|
|
258
263
|
|
|
259
264
|
test('a non-string client_id is not an ownership claim', async () => {
|
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
// before that an invite could only ever 404
|
|
20
20
|
// • an invite sends exactly { client_id, github_login } — the slug IS the
|
|
21
21
|
// client_id — on the cookie session, and nothing else
|
|
22
|
-
// • the
|
|
23
|
-
//
|
|
22
|
+
// • the answer is a HAND-OFF (task 1002818, ADR 0353): the route writes nothing
|
|
23
|
+
// and answers `{ invited: false, invite_url }` — the project's hall invite link — and
|
|
24
|
+
// each name becomes a line whose link finishes the invite there — never a
|
|
25
|
+
// window opened for the owner, and never a link that is not http(s)
|
|
24
26
|
// • every refusal the route can give is said in the route's own vocabulary,
|
|
25
27
|
// and the 403 is said CAREFULLY: it is byte-identical whether or not the
|
|
26
28
|
// project exists (ADR 0248), so the page may not read it as "it is there"
|
|
@@ -57,8 +59,14 @@ for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function ski
|
|
|
57
59
|
// Narrow on purpose (the project_modules_ui.mjs rule): it supports exactly what
|
|
58
60
|
// the region uses, so it stays honest about what it can prove.
|
|
59
61
|
|
|
60
|
-
const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><\/li>/g;
|
|
61
|
-
const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2] }));
|
|
62
|
+
const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><a class="btn small" href="([^"]*)" target="_blank" rel="noopener">Send the invite →<\/a><\/li>/g;
|
|
63
|
+
const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2], url: m[3] }));
|
|
64
|
+
|
|
65
|
+
// The route's answer to an admitted caller since task 1002818: where to send the invite.
|
|
66
|
+
const handoff = (login, origin = 'https://mercury.cloudbongos.test') => ({
|
|
67
|
+
ok: true, status: 200,
|
|
68
|
+
body: { invited: false, invite_url: origin + '/builders/watch?invite=' + encodeURIComponent(login) },
|
|
69
|
+
});
|
|
62
70
|
|
|
63
71
|
// A suggestion row: whatever the row NAMES, plus the login its button carries.
|
|
64
72
|
// Parsed off the button so the test reads the address the click will actually
|
|
@@ -113,7 +121,8 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
113
121
|
const queued = answers.slice();
|
|
114
122
|
const fetch = (url, init = {}) => {
|
|
115
123
|
requests.push({ url, method: init.method || 'GET', body: init.body, credentials: init.credentials, headers: init.headers });
|
|
116
|
-
const
|
|
124
|
+
const sent = (() => { try { return JSON.parse(init.body).github_login; } catch { return ''; } })();
|
|
125
|
+
const a = queued.shift() || handoff(sent);
|
|
117
126
|
if (a.reject) return Promise.reject(new Error('offline'));
|
|
118
127
|
return Promise.resolve({ ok: a.ok, status: a.status, _json: a.body, json: () => Promise.resolve(a.body) });
|
|
119
128
|
};
|
|
@@ -127,7 +136,12 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
127
136
|
apiError: (data, status) => ({
|
|
128
137
|
code: (data && data.error && data.error.code) || ('http_' + status),
|
|
129
138
|
message: (data && data.error && data.error.message) || '',
|
|
139
|
+
details: (data && data.error && data.error.details) || {},
|
|
130
140
|
}),
|
|
141
|
+
safeHttpUrl: (raw) => {
|
|
142
|
+
try { const u = new URL(String(raw)); return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : ''; } catch (e) { return ''; }
|
|
143
|
+
},
|
|
144
|
+
URL,
|
|
131
145
|
signInAgain: () => 'Your session expired — <a href="/in">sign in again</a>.',
|
|
132
146
|
};
|
|
133
147
|
sandbox.globalThis = sandbox;
|
|
@@ -238,7 +252,7 @@ test('an invite carries exactly { client_id, github_login } — the slug IS the
|
|
|
238
252
|
'exactly the two fields the route validates, and nothing else');
|
|
239
253
|
});
|
|
240
254
|
|
|
241
|
-
test('the
|
|
255
|
+
test('the answer is a hand-off line: the name, "not sent yet", and the hall link that sends it', async () => {
|
|
242
256
|
const ui = boot();
|
|
243
257
|
await ui.open('mercury');
|
|
244
258
|
|
|
@@ -246,22 +260,29 @@ test('the pending line is drawn from the 201 ROW, and the github_id on it is nev
|
|
|
246
260
|
await ui.send();
|
|
247
261
|
|
|
248
262
|
assert.deepEqual(ui.rows().map((r) => r.login), ['octo-builder']);
|
|
249
|
-
assert.match(ui.rows()[0].said, /
|
|
250
|
-
assert.equal(ui.
|
|
251
|
-
|
|
252
|
-
|
|
263
|
+
assert.match(ui.rows()[0].said, /not sent yet/i, 'the line may not claim an invite the hall has not sent');
|
|
264
|
+
assert.equal(ui.rows()[0].url, 'https://mercury.cloudbongos.test/builders/watch?invite=octo-builder',
|
|
265
|
+
'the link is the route\'s own invite_url, verbatim');
|
|
266
|
+
assert.equal(ui.msg().className, 'projMsg ok', 'being handed to the hall is the answer, not a failure');
|
|
267
|
+
assert.match(ui.msg().textContent, /hall/i);
|
|
253
268
|
});
|
|
254
269
|
|
|
255
|
-
test('a
|
|
256
|
-
|
|
257
|
-
// different kind, an owner sees that rather than a comfortable fiction.
|
|
258
|
-
const ui = boot({ answers: [{ ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 1, membership_kind: 'member' } } }] });
|
|
270
|
+
test('a hand-off link that is not http(s) is never drawn — it is said as a failure instead', async () => {
|
|
271
|
+
const ui = boot({ answers: [{ ok: true, status: 200, body: { invited: false, invite_url: 'javascript:alert(1)' } }] });
|
|
259
272
|
await ui.open('mercury');
|
|
260
273
|
|
|
261
274
|
await ui.type('octo-builder');
|
|
262
275
|
await ui.send();
|
|
263
276
|
|
|
264
|
-
assert.equal(ui.rows()
|
|
277
|
+
assert.equal(ui.rows().length, 0, 'no line carries a link that is not a web address');
|
|
278
|
+
assert.doesNotMatch(ui.sandbox.$('wizInviteList').innerHTML, /javascript:/);
|
|
279
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
280
|
+
assert.match(ui.msg().textContent, /couldn’t be sent/);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('the region never opens a window for the owner — the link is theirs to click', () => {
|
|
284
|
+
assert.doesNotMatch(REGION, /window\.open|location\.(?:href|assign|replace)\s*[=(]/,
|
|
285
|
+
'an open fired from a network answer is blocked, and a surprise navigation loses the wizard');
|
|
265
286
|
});
|
|
266
287
|
|
|
267
288
|
test('a name is trimmed, and an empty one is a question rather than a request', async () => {
|
|
@@ -299,12 +320,7 @@ test('Enter in the field sends the invite too', async () => {
|
|
|
299
320
|
});
|
|
300
321
|
|
|
301
322
|
test('two invites in a row both stand, newest last', async () => {
|
|
302
|
-
const ui = boot({
|
|
303
|
-
answers: [
|
|
304
|
-
{ ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 1, membership_kind: 'pending' } } },
|
|
305
|
-
{ ok: true, status: 201, body: { invited: true, row: { id: 8, client_id: 'mercury', github_id: 2, membership_kind: 'pending' } } },
|
|
306
|
-
],
|
|
307
|
-
});
|
|
323
|
+
const ui = boot({ answers: [handoff('octo-builder'), handoff('second-builder')] });
|
|
308
324
|
await ui.open('mercury');
|
|
309
325
|
|
|
310
326
|
await ui.type('octo-builder');
|
|
@@ -317,29 +333,11 @@ test('two invites in a row both stand, newest last', async () => {
|
|
|
317
333
|
|
|
318
334
|
// ── every refusal the route can give ─────────────────────────────────────────
|
|
319
335
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
await ui.send();
|
|
326
|
-
|
|
327
|
-
assert.equal(ui.msg().className, 'projMsg err');
|
|
328
|
-
assert.match(ui.msg().textContent, /hasn’t signed in here yet/i);
|
|
329
|
-
assert.equal(ui.box().hidden, false, 'the step stays — a failed invite is re-tryable');
|
|
330
|
-
assert.equal(ui.rows().length, 0, 'and nothing is drawn as pending that is not');
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
test('someone already on the project is said plainly, not as a failure', async () => {
|
|
334
|
-
const ui = boot({ answers: [{ ok: true, status: 200, body: { invited: false, alreadyMember: true } }] });
|
|
335
|
-
await ui.open('mercury');
|
|
336
|
-
|
|
337
|
-
await ui.type('octo-builder');
|
|
338
|
-
await ui.send();
|
|
339
|
-
|
|
340
|
-
assert.equal(ui.msg().className, 'projMsg ok');
|
|
341
|
-
assert.match(ui.msg().textContent, /already on this project/i);
|
|
342
|
-
assert.equal(ui.rows().length, 0, 'an existing member is not a pending invite');
|
|
336
|
+
// Task 1002818 retired two answers with the hub row they described: `account_not_found`
|
|
337
|
+
// (the hall invites any GitHub login, account or not) and 200 `alreadyMember` (the hall's
|
|
338
|
+
// own invite route answers that, where the membership actually lives).
|
|
339
|
+
test('the refusals the retired write used to give are gone from the page too', () => {
|
|
340
|
+
assert.doesNotMatch(REGION, /account_not_found|alreadyMember/);
|
|
343
341
|
});
|
|
344
342
|
|
|
345
343
|
test('a project the map has not caught up with says exactly that, and stays re-tryable', async () => {
|
|
@@ -407,7 +405,6 @@ test('an unknown refusal falls through to the server’s own words rather than a
|
|
|
407
405
|
|
|
408
406
|
test('inviteRefusal is a pure function over the route’s own vocabulary', () => {
|
|
409
407
|
const f = boot().sandbox.inviteRefusal;
|
|
410
|
-
assert.match(f('account_not_found', 404, ''), /signed in here yet/i);
|
|
411
408
|
assert.match(f('unknown_project', 404, ''), /map yet/i);
|
|
412
409
|
assert.match(f('permission_forbidden', 403, ''), /can’t invite/i);
|
|
413
410
|
assert.equal(f('whatever', 500, 'the server said this'), 'the server said this');
|