@bongos/core 1.21.81 → 1.21.83
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 +55 -40
- 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/api/openapi.json +76 -4
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +53 -50
- package/docs/copy-registry.json +79 -52
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +7 -0
- package/docs/page-readings.json +660 -659
- package/modules/government/db.js +45 -0
- package/modules/government/routes/government.js +71 -0
- package/modules/hall-ui/public/government.js +38 -0
- package/modules/platform-identity/invite-notices.js +78 -10
- package/modules/platform-identity/migrations/platform_identity_031_parked_invite_notices.sql +45 -0
- package/modules/platform-identity/routes/my-projects.js +7 -3
- package/modules/public-landing/public/account.html +1 -0
- package/modules/public-landing/public/assets/cosmos.css +8 -0
- package/modules/public-landing/public/assets/invite-badge.js +91 -0
- package/modules/public-landing/public/contact.html +1 -0
- package/modules/public-landing/public/guild.html +1 -0
- package/modules/public-landing/public/invitations.html +7 -2
- package/modules/public-landing/public/privacy.html +1 -0
- package/modules/public-landing/public/projects.html +3 -0
- package/modules/public-landing/public/terms.html +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/route-rank-check.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/government_routes.mjs +72 -0
- package/tests/hub_invite_badge.mjs +204 -0
- package/tests/hub_invite_notices.mjs +61 -6
- package/tests/landing_legal_pages.mjs +1 -1
package/release-notes.json
CHANGED
|
@@ -9312,5 +9312,17 @@
|
|
|
9312
9312
|
"id": "1004442",
|
|
9313
9313
|
"text": "When the board turns someone's idea into a goal, every task later finished under that goal now pays the idea's author, as promised. Before this, only work promoted straight from the idea paid them, so a goal full of finished w"
|
|
9314
9314
|
}
|
|
9315
|
+
],
|
|
9316
|
+
"1.21.82": [
|
|
9317
|
+
{
|
|
9318
|
+
"id": "1004481",
|
|
9319
|
+
"text": "Custom ranks can now be deleted from the Government page, so trying a recommended rank set is no longer a permanent choice. A rank someone still holds can't be deleted, and the page says who holds it. The four built-in ranks c"
|
|
9320
|
+
}
|
|
9321
|
+
],
|
|
9322
|
+
"1.21.83": [
|
|
9323
|
+
{
|
|
9324
|
+
"id": "1004646",
|
|
9325
|
+
"text": "Project invites now reach people on cloudbongos.com. Before, an invite sent by GitHub username was silently lost if the person had never signed in to a project through cloudbongos.com, which is almost every new invitee, so the"
|
|
9326
|
+
}
|
|
9315
9327
|
]
|
|
9316
9328
|
}
|
|
@@ -261,6 +261,7 @@ const GOVERNMENT_PINS = {
|
|
|
261
261
|
'PUT /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
|
|
262
262
|
'DELETE /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
|
|
263
263
|
'POST /government/ranks/:rankKey/reset': 'perm:government.manage',
|
|
264
|
+
'DELETE /government/ranks/:rankKey': 'perm:government.manage',
|
|
264
265
|
'POST /government/rank-sets/apply': 'perm:government.manage',
|
|
265
266
|
'POST /government/assignments': 'perm:government.manage',
|
|
266
267
|
'DELETE /government/assignments/:builderId/:rankKey': 'perm:government.manage',
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.83'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -35,6 +35,7 @@ const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
|
35
35
|
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
36
36
|
const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
|
|
37
37
|
const govDb = require(path.join(ROOT, 'modules', 'government', 'db.js'));
|
|
38
|
+
const governmentConfig = require(path.join(ROOT, 'modules', 'government', 'config.js'));
|
|
38
39
|
const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
39
40
|
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
40
41
|
const express = require('express');
|
|
@@ -58,6 +59,7 @@ test('the routes file declares the full R110 surface', () => {
|
|
|
58
59
|
'PUT /government/ranks/:rankKey/permissions/:permissionKey',
|
|
59
60
|
'DELETE /government/ranks/:rankKey/permissions/:permissionKey',
|
|
60
61
|
'POST /government/ranks/:rankKey/reset',
|
|
62
|
+
'DELETE /government/ranks/:rankKey',
|
|
61
63
|
'POST /government/assignments',
|
|
62
64
|
'DELETE /government/assignments/:builderId/:rankKey',
|
|
63
65
|
'POST /government/rank-sets/apply',
|
|
@@ -125,6 +127,7 @@ test('checkRouteRanks passes over the real tree (no governance findings)', () =>
|
|
|
125
127
|
|
|
126
128
|
// The db stub: every function the routes call, recording args. Reset per test.
|
|
127
129
|
const calls = [];
|
|
130
|
+
let deleteAnswer = { deleted: true, holders: [] };
|
|
128
131
|
const stub = {
|
|
129
132
|
listRanksWithGrants: async () => [{ rank_key: 'metic', label: 'Metic', description: null, is_system: true, seeded_rank: 'metic', created_by: null, created_at: 't', permissions: ['task.create'] }],
|
|
130
133
|
listAssignments: async () => [{ builder_id: 7, github_login: 'x', display_name: 'X', rank: 'metic', rank_key: 'metic', role_label: 'Metic', is_system: true, seeded_rank: 'metic', assigned_by: null, assigned_at: 't' }],
|
|
@@ -141,6 +144,8 @@ const stub = {
|
|
|
141
144
|
grantRankPermission: async () => true,
|
|
142
145
|
revokeRankPermission: async () => true,
|
|
143
146
|
resetRankGrants: async ({ permissionKeys }) => permissionKeys.length,
|
|
147
|
+
// task 1004481: `deleteAnswer` scripts what the locked delete found.
|
|
148
|
+
deleteCustomRank: async () => deleteAnswer,
|
|
144
149
|
assignRank: async () => true,
|
|
145
150
|
unassignRank: async () => true,
|
|
146
151
|
// task 1004529: who decides at People. [] = the as-today default row.
|
|
@@ -412,6 +417,73 @@ test('POST reset: refused on a custom role (no catalog default to return to)', a
|
|
|
412
417
|
assert.equal(calls.filter(([k]) => k === 'resetRankGrants').length, 0);
|
|
413
418
|
});
|
|
414
419
|
|
|
420
|
+
// --- delete a custom rank (task 1004481) --------------------------------------
|
|
421
|
+
|
|
422
|
+
test('DELETE a rank: a custom rank nobody holds is deleted', async () => {
|
|
423
|
+
deleteAnswer = { deleted: true, holders: [] };
|
|
424
|
+
const r = await call('DELETE', '/government/ranks/triage-helper');
|
|
425
|
+
assert.equal(r.status, 200);
|
|
426
|
+
assert.equal(r.json.deleted, true);
|
|
427
|
+
const c = calls.find(([k]) => k === 'deleteCustomRank');
|
|
428
|
+
assert.equal(c[1], 'triage-helper');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('DELETE a rank: refused while held, and the refusal names who holds it', async () => {
|
|
432
|
+
deleteAnswer = { deleted: false, reason: 'held', holders: [{ builder_id: 9, github_login: 'ana', display_name: 'Ana', extra: 'x' }] };
|
|
433
|
+
const r = await call('DELETE', '/government/ranks/triage-helper');
|
|
434
|
+
assert.equal(r.status, 409);
|
|
435
|
+
assert.equal(r.json.error.code, 'rank_still_held');
|
|
436
|
+
assert.deepEqual(r.json.error.details.holders, [{ builder_id: 9, github_login: 'ana', display_name: 'Ana' }], 'names them, and only those fields');
|
|
437
|
+
assert.equal(calls.filter(([k]) => k === 'unassignRank').length, 0, 'never unassigns on the way');
|
|
438
|
+
deleteAnswer = { deleted: true, holders: [] };
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('DELETE a rank: every seeded rank is refused before the db is asked', async () => {
|
|
442
|
+
for (const k of catalog.RANK_ORDER) {
|
|
443
|
+
const r = await call('DELETE', `/government/ranks/${k}`);
|
|
444
|
+
assert.equal(r.status, 403, k);
|
|
445
|
+
assert.equal(r.json.error.code, 'seeded_rank_immutable');
|
|
446
|
+
assert.equal(calls.filter(([n]) => n === 'deleteCustomRank').length, 0);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('DELETE a rank: a missing rank 404s, from the read or from the locked delete', async () => {
|
|
451
|
+
assert.equal((await call('DELETE', '/government/ranks/missing')).status, 404);
|
|
452
|
+
deleteAnswer = { deleted: false, reason: 'not_found', holders: [] };
|
|
453
|
+
assert.equal((await call('DELETE', '/government/ranks/triage-helper')).status, 404);
|
|
454
|
+
deleteAnswer = { deleted: true, holders: [] };
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('DELETE a rank: refused while the board or a who-decides row seats it', async () => {
|
|
458
|
+
const realLoad = governmentConfig.loadGovernmentConfig;
|
|
459
|
+
try {
|
|
460
|
+
governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:archon,triage-helper' } });
|
|
461
|
+
let r = await call('DELETE', '/government/ranks/triage-helper');
|
|
462
|
+
assert.equal(r.status, 409);
|
|
463
|
+
assert.equal(r.json.error.code, 'rank_in_use_by_government');
|
|
464
|
+
assert.deepEqual(r.json.error.details.seated_in, ['the board']);
|
|
465
|
+
assert.equal(calls.filter(([k]) => k === 'deleteCustomRank').length, 0);
|
|
466
|
+
|
|
467
|
+
governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:archon' } });
|
|
468
|
+
stub.listDecisionRules = async () => [{ area: 'people', decided_by: 'rank:triage-helper' }, { area: 'treasury', decided_by: 'gate' }];
|
|
469
|
+
r = await call('DELETE', '/government/ranks/triage-helper');
|
|
470
|
+
assert.equal(r.status, 409);
|
|
471
|
+
assert.deepEqual(r.json.error.details.seated_in, ['who decides at people']);
|
|
472
|
+
r = await call('DELETE', '/government/ranks/other-rank');
|
|
473
|
+
assert.equal(r.status, 200, 'a rank nothing seats is still deletable');
|
|
474
|
+
|
|
475
|
+
// fail-closed: an unparseable predicate that spells the key still refuses
|
|
476
|
+
stub.listDecisionRules = async () => [];
|
|
477
|
+
governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:triage-helper+' } });
|
|
478
|
+
r = await call('DELETE', '/government/ranks/triage-helper');
|
|
479
|
+
assert.equal(r.status, 409, 'rank:<custom>+ is unparseable, and still names the rank');
|
|
480
|
+
assert.deepEqual(r.json.error.details.seated_in, ['the board']);
|
|
481
|
+
} finally {
|
|
482
|
+
governmentConfig.loadGovernmentConfig = realLoad;
|
|
483
|
+
stub.listDecisionRules = async () => [];
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
415
487
|
// --- assignments -------------------------------------------------------------
|
|
416
488
|
|
|
417
489
|
test('POST /government/assignments assigns a custom role, assigned_by = the session builder', async () => {
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// tests/hub_invite_badge.mjs — what is waiting on you, said where you already look
|
|
2
|
+
// (task 1004646; the owner's report of 2026-10-08 that invitations "aren't obvious").
|
|
3
|
+
//
|
|
4
|
+
// The REAL assets/invite-badge.js, executed in a node:vm under a small DOM stub: the
|
|
5
|
+
// count on the identity chip and on the profile band's Invitations item, the line on
|
|
6
|
+
// the mine view, nothing at all when nothing waits or nobody is signed in, and no
|
|
7
|
+
// read at all on a child instance (its Invitations item is stripped as apex-only).
|
|
8
|
+
// Then the pages: every page that wears the hub's bar loads it.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test tests/hub_invite_badge.mjs
|
|
11
|
+
|
|
12
|
+
import test from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import vm from 'node:vm';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
21
|
+
const BADGE = fs.readFileSync(path.join(LANDING, 'assets', 'invite-badge.js'), 'utf8');
|
|
22
|
+
|
|
23
|
+
// ── a DOM just big enough for this file ──────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
class El {
|
|
26
|
+
constructor(tag, attrs = {}) {
|
|
27
|
+
this.tagName = tag.toUpperCase();
|
|
28
|
+
this.children = [];
|
|
29
|
+
this.parentNode = null;
|
|
30
|
+
this.attrs = { ...attrs };
|
|
31
|
+
this.className = attrs.class || '';
|
|
32
|
+
this.id = attrs.id || '';
|
|
33
|
+
this._text = '';
|
|
34
|
+
}
|
|
35
|
+
get firstChild() { return this.children[0] || null; }
|
|
36
|
+
get lastChild() { return this.children[this.children.length - 1] || null; }
|
|
37
|
+
get nextSibling() { const k = this.parentNode.children; return k[k.indexOf(this) + 1] || null; }
|
|
38
|
+
set textContent(v) { this.children = []; this._text = String(v); }
|
|
39
|
+
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
|
|
40
|
+
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
41
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
|
42
|
+
set href(v) { this.attrs.href = v; }
|
|
43
|
+
get href() { return this.attrs.href; }
|
|
44
|
+
appendChild(c) { return this.insertBefore(c, null); }
|
|
45
|
+
insertBefore(c, ref) {
|
|
46
|
+
if (c.parentNode) c.remove();
|
|
47
|
+
c.parentNode = this;
|
|
48
|
+
const i = ref ? this.children.indexOf(ref) : -1;
|
|
49
|
+
if (i < 0) this.children.push(c); else this.children.splice(i, 0, c);
|
|
50
|
+
return c;
|
|
51
|
+
}
|
|
52
|
+
remove() { const p = this.parentNode; if (p) p.children.splice(p.children.indexOf(this), 1); this.parentNode = null; }
|
|
53
|
+
*walk() { for (const c of this.children) { yield c; yield* c.walk(); } }
|
|
54
|
+
matches(sel) {
|
|
55
|
+
if (sel === '.invBadge') return this.className.split(' ').includes('invBadge');
|
|
56
|
+
if (sel === '.hero') return this.className === 'hero';
|
|
57
|
+
if (sel === 'svg.cv') return this.tagName === 'SVG' && this.className === 'cv';
|
|
58
|
+
if (sel === 'a[href="/invitations"]') return this.tagName === 'A' && this.attrs.href === '/invitations';
|
|
59
|
+
throw new Error(`stub selector: ${sel}`);
|
|
60
|
+
}
|
|
61
|
+
querySelector(sel) { for (const n of this.walk()) if (n.matches(sel)) return n; return null; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function world({ child = false, mineView = false } = {}) {
|
|
65
|
+
const body = new El('body');
|
|
66
|
+
const who = body.appendChild(new El('button', { id: 'who', class: 'trig who' }));
|
|
67
|
+
who.appendChild(new El('span', { class: 'whoName' }));
|
|
68
|
+
who.appendChild(new El('svg', { class: 'cv' }));
|
|
69
|
+
const band = body.appendChild(new El('div', { id: 'menuProfile' }));
|
|
70
|
+
const mine = band.appendChild(new El('a', { href: '/projects?view=mine' }));
|
|
71
|
+
mine.textContent = 'My projects';
|
|
72
|
+
let inv = null;
|
|
73
|
+
if (!child) { inv = band.appendChild(new El('a', { href: '/invitations', class: 'bandT' })); inv.textContent = 'Invitations'; }
|
|
74
|
+
let section = null;
|
|
75
|
+
if (mineView) {
|
|
76
|
+
const view = body.appendChild(new El('section', { id: 'view-mine' }));
|
|
77
|
+
section = view.appendChild(new El('div', { class: 'hero' }));
|
|
78
|
+
section.appendChild(new El('h1')).textContent = 'Your projects';
|
|
79
|
+
section.appendChild(new El('p', { class: 'lede' }));
|
|
80
|
+
view.appendChild(new El('ul', { id: 'projectsList' }));
|
|
81
|
+
}
|
|
82
|
+
const byId = (id) => { for (const n of body.walk()) if (n.id === id) return n; return null; };
|
|
83
|
+
const document = { getElementById: byId, createElement: (t) => new El(t) };
|
|
84
|
+
return { body, who, inv, section, document, byId };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function run(w, answers) {
|
|
88
|
+
const asked = [];
|
|
89
|
+
const fetch = async (url) => {
|
|
90
|
+
asked.push(url);
|
|
91
|
+
const a = answers[url];
|
|
92
|
+
if (a === undefined) return { ok: false, status: 404, json: async () => ({}) };
|
|
93
|
+
if (a instanceof Error) throw a;
|
|
94
|
+
return { ok: a.status === undefined, status: a.status || 200, json: async () => a };
|
|
95
|
+
};
|
|
96
|
+
const window = {};
|
|
97
|
+
vm.runInNewContext(BADGE, { window, document: w.document, fetch, Promise });
|
|
98
|
+
await window.InviteBadge.refresh();
|
|
99
|
+
return { asked, window };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const INVITES = '/api/bongos/my-projects/invites';
|
|
103
|
+
const GUILDS = '/api/bongos/me/guild-requests';
|
|
104
|
+
const badgeOf = (el) => el && el.querySelector('.invBadge');
|
|
105
|
+
|
|
106
|
+
// ── the count ────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
test('project invites and guild asks add up, on the chip and on the Invitations item', async () => {
|
|
109
|
+
const w = world();
|
|
110
|
+
await run(w, {
|
|
111
|
+
[INVITES]: { invites: [{ client_id: 'a' }, { client_id: 'b' }] },
|
|
112
|
+
[GUILDS]: { incoming: [{ id: '1' }], owner_incoming: [{ id: '2' }], outgoing: [{ id: '3' }] },
|
|
113
|
+
});
|
|
114
|
+
for (const host of [w.who, w.inv]) {
|
|
115
|
+
const b = badgeOf(host);
|
|
116
|
+
assert.ok(b, 'a badge is drawn');
|
|
117
|
+
assert.equal(b.firstChild.textContent, '4', 'two project invites, a guild invite and a request to a guild you own');
|
|
118
|
+
assert.equal(b.firstChild.getAttribute('aria-hidden'), 'true');
|
|
119
|
+
assert.equal(b.lastChild.className, 'vh');
|
|
120
|
+
assert.equal(b.lastChild.textContent, ', 4 invitations waiting', 'a screen reader hears what the number means');
|
|
121
|
+
}
|
|
122
|
+
assert.equal(w.who.children.indexOf(badgeOf(w.who)), 1, 'on the chip, before its chevron');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('one invitation reads in the singular, and a large count is capped', async () => {
|
|
126
|
+
let w = world();
|
|
127
|
+
await run(w, { [INVITES]: { invites: [{}] }, [GUILDS]: {} });
|
|
128
|
+
assert.equal(badgeOf(w.inv).lastChild.textContent, ', 1 invitation waiting');
|
|
129
|
+
w = world();
|
|
130
|
+
await run(w, { [INVITES]: { invites: Array.from({ length: 120 }, () => ({})) }, [GUILDS]: {} });
|
|
131
|
+
assert.equal(badgeOf(w.who).firstChild.textContent, '99+');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('the mine view says it in a line that links to the invitations page', async () => {
|
|
135
|
+
const w = world({ mineView: true });
|
|
136
|
+
await run(w, { [INVITES]: { invites: [{}, {}] }, [GUILDS]: {} });
|
|
137
|
+
const p = w.byId('mineInvitesWaiting');
|
|
138
|
+
assert.ok(p, 'the line is drawn');
|
|
139
|
+
assert.equal(w.section.children[2], p, 'at the top of the view, under its heading and lede — not below a long list');
|
|
140
|
+
assert.equal(p.firstChild.tagName, 'A');
|
|
141
|
+
assert.equal(p.firstChild.href, '/invitations');
|
|
142
|
+
assert.equal(p.textContent, 'You have 2 invitations waiting →');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('nothing waiting, signed out, or a failed read: nothing is drawn', async () => {
|
|
146
|
+
for (const answers of [
|
|
147
|
+
{ [INVITES]: { invites: [] }, [GUILDS]: { incoming: [], owner_incoming: [] } },
|
|
148
|
+
{ [INVITES]: { status: 401 }, [GUILDS]: { status: 401 } },
|
|
149
|
+
{ [INVITES]: new Error('offline'), [GUILDS]: new Error('offline') },
|
|
150
|
+
]) {
|
|
151
|
+
const w = world({ mineView: true });
|
|
152
|
+
await run(w, answers);
|
|
153
|
+
assert.equal(badgeOf(w.who), null);
|
|
154
|
+
assert.equal(badgeOf(w.inv), null);
|
|
155
|
+
assert.equal(w.byId('mineInvitesWaiting'), null);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a refresh after an answer takes the badge and the line away again', async () => {
|
|
160
|
+
const w = world({ mineView: true });
|
|
161
|
+
const answers = { [INVITES]: { invites: [{}] }, [GUILDS]: {} };
|
|
162
|
+
const { window } = await run(w, answers);
|
|
163
|
+
assert.ok(badgeOf(w.who));
|
|
164
|
+
window.InviteBadge.paint(0);
|
|
165
|
+
assert.equal(badgeOf(w.who), null);
|
|
166
|
+
assert.equal(badgeOf(w.inv), null);
|
|
167
|
+
assert.equal(w.byId('mineInvitesWaiting'), null);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a child instance, with no Invitations item, reads nothing', async () => {
|
|
171
|
+
const w = world({ child: true });
|
|
172
|
+
const { asked } = await run(w, { [INVITES]: { invites: [{}] }, [GUILDS]: {} });
|
|
173
|
+
assert.deepEqual(asked, [], 'the hub-only routes are never asked');
|
|
174
|
+
assert.equal(badgeOf(w.who), null);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('the file builds nodes only and makes only its two reads', () => {
|
|
178
|
+
assert.doesNotMatch(BADGE, /innerHTML|insertAdjacentHTML|outerHTML/);
|
|
179
|
+
const calls = [...BADGE.matchAll(/readCount\('([^']+)'/g)].map((m) => m[1]);
|
|
180
|
+
assert.deepEqual(calls, ['/my-projects/invites', '/me/guild-requests']);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── the pages ────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
test('every page that wears the hub bar loads it once, before the Artist review tag', () => {
|
|
186
|
+
for (const page of ['projects', 'account', 'invitations', 'guild', 'privacy', 'terms', 'contact']) {
|
|
187
|
+
const html = fs.readFileSync(path.join(LANDING, `${page}.html`), 'utf8');
|
|
188
|
+
const tags = html.match(/<script src="\/assets\/invite-badge\.js"><\/script>/g) || [];
|
|
189
|
+
assert.equal(tags.length, 1, `${page}: one tag`);
|
|
190
|
+
assert.ok(html.indexOf(tags[0]) < html.lastIndexOf('<script src="/assets/artist-review.js"'), `${page}: before the review button`);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('the hub page repaints the count after it rebuilds the chip, and the invitations page after an answer', () => {
|
|
195
|
+
const hub = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
|
|
196
|
+
assert.match(hub, /renderWho\(\);\r?\n[^\n]*\r?\n\s*if \(window\.InviteBadge\) window\.InviteBadge\.refresh\(\);/);
|
|
197
|
+
const inv = fs.readFileSync(path.join(LANDING, 'invitations.html'), 'utf8');
|
|
198
|
+
assert.equal((inv.match(/recount\(\);/g) || []).length, 4, 'a decline, a guild decline, a guild accept and a request accepted');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('the badge is the accent with its own ink, the .btn pairing', () => {
|
|
202
|
+
const css = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
203
|
+
assert.match(css, /\.invBadge\{[^}]*background:var\(--accent\);color:var\(--accent-on\);/);
|
|
204
|
+
});
|
|
@@ -55,6 +55,8 @@ const pool = {
|
|
|
55
55
|
}
|
|
56
56
|
if (/^\s*INSERT INTO platform_identity_project_memberships/.test(sql)) return { rows: [{ id: '1', membership_kind: 'pending' }] };
|
|
57
57
|
if (/^\s*DELETE FROM platform_identity_project_memberships/.test(sql)) return { rowCount: 1, rows: [] };
|
|
58
|
+
if (/^\s*INSERT INTO platform_identity_parked_invite_notices/.test(sql)) return { rowCount: 1, rows: [] };
|
|
59
|
+
if (/^\s*DELETE FROM platform_identity_parked_invite_notices/.test(sql)) return { rowCount: 0, rows: [] };
|
|
58
60
|
throw new Error(`unexpected statement: ${sql.trim().slice(0, 60)}`);
|
|
59
61
|
},
|
|
60
62
|
};
|
|
@@ -119,13 +121,15 @@ test('clear drops ONLY a pending row on the calling project', async () => {
|
|
|
119
121
|
assert.equal(res.status, 200);
|
|
120
122
|
assert.deepEqual(res.body, { ok: true });
|
|
121
123
|
const w = writes();
|
|
122
|
-
assert.equal(w.length,
|
|
123
|
-
assert.match(w[0].sql, /^\s*DELETE FROM
|
|
124
|
+
assert.equal(w.length, 2, 'the parked notice under the login, then the pending row on the account');
|
|
125
|
+
assert.match(w[0].sql, /^\s*DELETE FROM platform_identity_parked_invite_notices WHERE client_id = \$1 AND login_lower = \$2/);
|
|
126
|
+
assert.deepEqual(w[0].params, ['mercury', 'octo']);
|
|
127
|
+
assert.match(w[1].sql, /^\s*DELETE FROM platform_identity_project_memberships[\s\S]*AND membership_kind = 'pending'/,
|
|
124
128
|
'a rescind can never remove someone who has signed in');
|
|
125
|
-
assert.deepEqual(w[
|
|
129
|
+
assert.deepEqual(w[1].params, ['mercury', '4242']);
|
|
126
130
|
});
|
|
127
131
|
|
|
128
|
-
test('a login with no hub account
|
|
132
|
+
test('a login with no hub account is PARKED by login — and the answer is byte-identical (task 1004646)', async () => {
|
|
129
133
|
// The project must not learn from this route which GitHub logins have platform accounts.
|
|
130
134
|
for (const route of ['/sso/invites/notify', '/sso/invites/clear']) {
|
|
131
135
|
calls = [];
|
|
@@ -133,9 +137,60 @@ test('a login with no hub account writes nothing — and the answer is byte-iden
|
|
|
133
137
|
const unknown = await notice(route, { github_login: 'nobody-here' });
|
|
134
138
|
assert.deepEqual(unknown, known, `${route}: same status, same body`);
|
|
135
139
|
}
|
|
140
|
+
// Before task 1004646 this wrote nothing, so a new invitee — no account until they sign
|
|
141
|
+
// in to a project through the hub — never saw the invite. Now it waits under the login.
|
|
136
142
|
calls = [];
|
|
137
|
-
await notice('/sso/invites/notify', { github_login: '
|
|
138
|
-
|
|
143
|
+
await notice('/sso/invites/notify', { github_login: '@Nobody-Here' });
|
|
144
|
+
const w = writes();
|
|
145
|
+
assert.equal(w.length, 1);
|
|
146
|
+
assert.match(w[0].sql, /^\s*INSERT INTO platform_identity_parked_invite_notices \(client_id, login_lower\)/);
|
|
147
|
+
assert.match(w[0].sql, /ON CONFLICT \(client_id, login_lower\) DO UPDATE SET noted_at = now\(\)/, 'a re-invite restarts its 30 days in place');
|
|
148
|
+
assert.deepEqual(w[0].params, ['mercury', 'nobody-here'], 'the calling project, and the login folded: no @, lower case');
|
|
149
|
+
assert.equal(w.filter((x) => /project_memberships/.test(x.sql)).length, 0, 'no membership row for a login with no account');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('the invitations read lists a parked notice for whoever signs in with that login', async () => {
|
|
153
|
+
const seen = [];
|
|
154
|
+
const readPool = {
|
|
155
|
+
async query(text, params = []) {
|
|
156
|
+
const sql = String(text);
|
|
157
|
+
seen.push({ sql, params });
|
|
158
|
+
if (/FROM platform_identity_project_memberships m\s+LEFT JOIN/.test(sql)) {
|
|
159
|
+
return { rows: [{ client_id: 'mercury', invited_at: 't2', project_name: 'Mercury', project_origin: 'https://mercury.test' }] };
|
|
160
|
+
}
|
|
161
|
+
if (/FROM platform_identity_parked_invite_notices n/.test(sql)) {
|
|
162
|
+
return { rows: [
|
|
163
|
+
{ client_id: 'mercury', invited_at: 't1', project_name: 'Mercury', project_origin: 'https://mercury.test' },
|
|
164
|
+
{ client_id: 'venus', invited_at: 't0', project_name: 'Venus', project_origin: 'https://venus.test' },
|
|
165
|
+
] };
|
|
166
|
+
}
|
|
167
|
+
throw new Error(`unexpected statement: ${sql.trim().slice(0, 60)}`);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
const out = await inviteNotices.listInvitesForBuilder({ githubId: '4242', githubLogin: 'Octo' }, { pool: readPool });
|
|
171
|
+
assert.deepEqual(out.map((r) => r.client_id), ['mercury', 'venus'], 'one row per project: the pending row wins over its parked twin');
|
|
172
|
+
const parked = seen.find((c) => /parked_invite_notices/.test(c.sql));
|
|
173
|
+
assert.deepEqual(parked.params, ['octo', 30, '4242'], 'keyed on the SESSION login, folded; the 30 days; the session account');
|
|
174
|
+
assert.match(parked.sql, /n\.noted_at > now\(\) - make_interval\(days => \$2\)/, 'a lapsed invite is not shown (ADR 0356)');
|
|
175
|
+
assert.match(parked.sql, /NOT EXISTS \([\s\S]*m\.github_id = \$3 AND m\.membership_kind <> 'pending'\)/,
|
|
176
|
+
'a project the reader already builds on is not listed: signing in there accepted it');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a decline drops the parked notice too, keyed on the session login', async () => {
|
|
180
|
+
const seen = [];
|
|
181
|
+
const p = { async query(text, params) { seen.push({ sql: String(text), params }); return { rowCount: 1, rows: [] }; } };
|
|
182
|
+
const out = await inviteNotices.removeParkedInvite({ clientId: 'venus', githubLogin: 'Octo' }, { pool: p });
|
|
183
|
+
assert.deepEqual(out, { removed: true });
|
|
184
|
+
assert.deepEqual(seen[0].params, ['venus', 'octo']);
|
|
185
|
+
assert.deepEqual(await inviteNotices.removeParkedInvite({ clientId: 'venus', githubLogin: '' }, { pool: p }), { removed: false });
|
|
186
|
+
assert.equal(seen.length, 1, 'no login, no statement');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('the invitations routes read and decline through the parked notices', () => {
|
|
190
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/platform-identity/routes/my-projects.js'), 'utf8');
|
|
191
|
+
assert.match(src, /inviteNotices\.listInvitesForBuilder\(\s*\{ githubId: req\.builder\.github_id, githubLogin: req\.builder\.github_login \}/);
|
|
192
|
+
assert.match(src, /inviteNotices\.removeParkedInvite\(\{ clientId, githubLogin: req\.builder\.github_login \}/);
|
|
193
|
+
assert.match(src, /if \(!out\.removed && !parked\.removed\) return res\.fail\('no_pending_invite', 404\)/);
|
|
139
194
|
});
|
|
140
195
|
|
|
141
196
|
test('only an authenticated, active, credentialed project may speak — and writes nothing otherwise', async () => {
|
|
@@ -207,7 +207,7 @@ for (const p of PAGES) {
|
|
|
207
207
|
const styles = [...html.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
|
|
208
208
|
assert.doesNotMatch(styles, /\.(shell|bar|barMark|barLeft|barRight|trig|who|signinDoor|menuBand|band[A-Z]\w*)\b[^{}]*\{/, 'no bar or band rule in the page');
|
|
209
209
|
// then the Artist review button's loader (task 1004499), the page's last tag
|
|
210
|
-
assert.match(html, /<\/footer>\n<script src="\/assets\/hub-bar\.js"><\/script>\n<!-- [^\n]*-->\n<script src="\/assets\/artist-review\.js" data-hall="\{\{buildersOrigin\}\}"><\/script>\n<\/body>/, '
|
|
210
|
+
assert.match(html, /<\/footer>\n<script src="\/assets\/hub-bar\.js"><\/script>\n<script src="\/assets\/invite-badge\.js"><\/script>\n<!-- [^\n]*-->\n<script src="\/assets\/artist-review\.js" data-hall="\{\{buildersOrigin\}\}"><\/script>\n<\/body>/, 'the shared bar and its waiting count (task 1004646), after the markup they drive');
|
|
211
211
|
assert.doesNotMatch(html, /<script>/, 'and no inline copy of the bands');
|
|
212
212
|
assert.match(html, /<a data-link-hall href="\{\{buildersOrigin\}\}">Builders' hall<\/a><\/nav><\/footer>/, 'the hall keeps a door in the footer row now the bar has no Hall pill, as on /account');
|
|
213
213
|
});
|