@bongos/core 1.19.1016 → 1.19.1017
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 -38
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +56 -5
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +5 -3
- package/docs/copy-registry.json +22 -4
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/people.html +11 -0
- package/modules/hall-ui/public/people.js +77 -11
- package/modules/hall-ui/public/people.states.json +1 -1
- package/modules/platform-identity/account-visibility.js +33 -2
- package/modules/platform-identity/builder-search.js +153 -0
- package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/community-search.js +120 -0
- package/modules/platform-identity/routes/scouting.js +14 -7
- package/modules/platform-identity/scouting-name-only.js +47 -0
- package/modules/public-landing/public/projects-floor.states.json +9 -2
- package/modules/public-landing/public/projects.html +69 -4
- package/modules/ui-design/styles/expedition/mock.html +16 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +4 -1
- package/tests/card_apply_ui.mjs +297 -0
- package/tests/community_search_route.mjs +385 -0
- package/tests/effective_visibility_predicate.mjs +49 -1
- package/tests/people_name_only.mjs +262 -0
- package/tests/profile_route.mjs +23 -5
- package/tests/recruiter_sliver_boundary.mjs +3 -0
- package/tests/scouting_owner_access_and_reach.mjs +112 -2
- package/tests/ui_design_styles.mjs +13 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// tests/people_name_only.mjs — the People directory lists a hidden builder by
|
|
2
|
+
// name alone (task 1002291 / R15, ADR 0334; privacy spec D2's recruiter column:
|
|
3
|
+
// "listed name-only (if not opted out)").
|
|
4
|
+
//
|
|
5
|
+
// GET /scouting now answers two tiers: `builders`, ranked on the cross-project
|
|
6
|
+
// record, and `name_only`, the builders who hid that record but are still open
|
|
7
|
+
// to recruiting. The server decides who is in each (tests/scouting_owner_access_-
|
|
8
|
+
// and_reach.mjs); this file holds the PAGE to the rules that keep the second
|
|
9
|
+
// tier honest on screen:
|
|
10
|
+
// - it is its own panel, revealed only when the server sends a row
|
|
11
|
+
// - a row is the identity cell and nothing else — no figure, no role, no date
|
|
12
|
+
// - it is never ranked: alphabetical whatever the sort lens says
|
|
13
|
+
// - search reads a name; a role filter never matches (the role is part of the
|
|
14
|
+
// record the builder hid)
|
|
15
|
+
// - the page still makes ONE read
|
|
16
|
+
//
|
|
17
|
+
// It runs the REAL people.js and the REAL kit in a vm, over a DOM fake one step
|
|
18
|
+
// deeper than tests/people_directory_clip.mjs's: an element keeps the node it
|
|
19
|
+
// hands out for a selector, so the <tbody> the kit fills is the one read back.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/people_name_only.mjs
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import vm from 'node:vm';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { makeRunner } from './helpers.mjs';
|
|
28
|
+
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
31
|
+
const KIT_JS = read('modules/hall-ui/public/hall-kit.js');
|
|
32
|
+
const PEOPLE_JS = read('modules/hall-ui/public/people.js');
|
|
33
|
+
const PEOPLE_HTML = read('modules/hall-ui/public/people.html');
|
|
34
|
+
|
|
35
|
+
const { test, summary } = makeRunner();
|
|
36
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
37
|
+
|
|
38
|
+
function makeEl(id) {
|
|
39
|
+
const kids = new Map();
|
|
40
|
+
const listeners = {};
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
innerHTML: '', textContent: '', value: '',
|
|
44
|
+
hidden: false, disabled: false, className: '',
|
|
45
|
+
options: [], style: {}, dataset: {},
|
|
46
|
+
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
|
|
47
|
+
appendChild() {}, setAttribute() {}, removeAttribute() {},
|
|
48
|
+
addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
|
|
49
|
+
fire(type) { (listeners[type] || []).forEach((fn) => fn({ target: { matches: () => true } })); },
|
|
50
|
+
querySelector(sel) {
|
|
51
|
+
if (!kids.has(sel)) kids.set(sel, makeEl(sel));
|
|
52
|
+
return kids.get(sel);
|
|
53
|
+
},
|
|
54
|
+
querySelectorAll() { return []; },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function realRoleLabel() {
|
|
59
|
+
const src = read('modules/hall-ui/public/dom-utils.js');
|
|
60
|
+
const win = { location: { search: '', hash: '', pathname: '/' }, addEventListener() {}, localStorage: null };
|
|
61
|
+
const doc = { addEventListener() {}, querySelector: () => null, querySelectorAll: () => [], documentElement: { dataset: {} } };
|
|
62
|
+
new Function('window', 'document', src)(win, doc);
|
|
63
|
+
return win.OTB.roleLabel;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Ranked rows carry a record; the sentinels are what a name-only row must never show.
|
|
67
|
+
const RANKED = [
|
|
68
|
+
{ github_id: 1, github_login: 'zed-ranked', display_name: 'Zed', avatar_url: null, project_count: 2,
|
|
69
|
+
total_credits: 900, total_tasks_shipped: 9, total_karma: 4, last_active: '2026-09-01T00:00:00Z', disciplines: ['engineer'] },
|
|
70
|
+
{ github_id: 2, github_login: 'amy-ranked', display_name: 'Amy', avatar_url: null, project_count: 1,
|
|
71
|
+
total_credits: 100, total_tasks_shipped: 1, total_karma: 1, last_active: '2026-09-02T00:00:00Z', disciplines: ['artist'] },
|
|
72
|
+
];
|
|
73
|
+
// Alphabetical by login, as the server sends them.
|
|
74
|
+
const NAMED = [
|
|
75
|
+
{ github_login: 'bea-hidden', display_name: 'Bea', avatar_url: null },
|
|
76
|
+
{ github_login: 'cal-hidden', display_name: null, avatar_url: 'https://avatars.githubusercontent.com/u/7?v=4' },
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
function harness(payload) {
|
|
80
|
+
const els = new Map();
|
|
81
|
+
const getEl = (id) => {
|
|
82
|
+
if (!els.has(id)) els.set(id, makeEl(id));
|
|
83
|
+
return els.get(id);
|
|
84
|
+
};
|
|
85
|
+
const documentObj = {
|
|
86
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
87
|
+
getElementById: getEl,
|
|
88
|
+
querySelector(sel) { return getEl(String(sel).replace(/^#/, '')); },
|
|
89
|
+
querySelectorAll() { return []; },
|
|
90
|
+
createElement() { return makeEl(); },
|
|
91
|
+
addEventListener() {},
|
|
92
|
+
};
|
|
93
|
+
const requests = [];
|
|
94
|
+
const store = new Map();
|
|
95
|
+
const windowObj = {
|
|
96
|
+
location: { assign() {}, href: '/people', search: '' },
|
|
97
|
+
localStorage: {
|
|
98
|
+
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
|
99
|
+
setItem: (k, v) => store.set(k, String(v)),
|
|
100
|
+
removeItem: (k) => store.delete(k),
|
|
101
|
+
},
|
|
102
|
+
addEventListener() {},
|
|
103
|
+
OTBBranding: { currencyLabel: () => 'Credits' },
|
|
104
|
+
OTB: {
|
|
105
|
+
roleAxisLabel: () => 'Role',
|
|
106
|
+
roleLabel: realRoleLabel(),
|
|
107
|
+
fmtInt: (n) => String(Number(n) || 0),
|
|
108
|
+
rankLabel: (r) => String(r ?? ''),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const sandbox = {
|
|
112
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
113
|
+
console: { error() {}, warn() {}, log() {} },
|
|
114
|
+
CSS: { escape: (s) => String(s) },
|
|
115
|
+
setTimeout, clearTimeout, URLSearchParams, encodeURIComponent, Date, Math, JSON,
|
|
116
|
+
fetch: (url) => {
|
|
117
|
+
requests.push(String(url));
|
|
118
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(payload) });
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
sandbox.globalThis = sandbox;
|
|
122
|
+
vm.createContext(sandbox);
|
|
123
|
+
vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
|
|
124
|
+
vm.runInContext(PEOPLE_JS, sandbox, { filename: 'people.js' });
|
|
125
|
+
|
|
126
|
+
const filters = () => getEl('people-filters');
|
|
127
|
+
return {
|
|
128
|
+
getEl,
|
|
129
|
+
requests,
|
|
130
|
+
rankedRows: () => getEl('people-body').querySelector('tbody').innerHTML,
|
|
131
|
+
namedRows: () => getEl('people-named-body').querySelector('tbody').innerHTML,
|
|
132
|
+
// Drive the kit's filter bar the way a reader does: set the control, fire.
|
|
133
|
+
setFilter(id, value) {
|
|
134
|
+
filters().querySelector(`#${id}`).value = value;
|
|
135
|
+
filters().fire(id === 'people-q' ? 'input' : 'change');
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const loginsIn = (html) => [...html.matchAll(/class="dir-login">@([^<]+)</g)].map((m) => m[1]);
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
await test('the page carries the name-only panel, hidden until the server sends a row', () => {
|
|
145
|
+
const tag = PEOPLE_HTML.match(/<section[^>]*id="people-named-scroll"[^>]*>/);
|
|
146
|
+
assert.ok(tag, 'a panel of its own — a hidden builder is not a row in the ranked ledger');
|
|
147
|
+
assert.match(tag[0], /class="scroll"/, "the shell's panel, not a fourth card level");
|
|
148
|
+
assert.match(tag[0], /\bhidden\b/, 'it ships hidden: an instance with no such builder shows nothing');
|
|
149
|
+
assert.match(PEOPLE_HTML, /id="people-named-h"[^>]*>[^<]+<span class="count-pill" id="people-named-count" hidden>/,
|
|
150
|
+
'a heading with its own count');
|
|
151
|
+
const lede = (PEOPLE_HTML.match(/<p class="scroll__lede" id="people-named-lede">([^<]+)<\/p>/) || [])[1] || '';
|
|
152
|
+
assert.match(lede, /private/i, 'the lede says why these builders show no record');
|
|
153
|
+
assert.match(lede, /ranked/i, 'and that they are never ranked');
|
|
154
|
+
assert.match(lede, /filter/i, 'and that a role filter never finds them');
|
|
155
|
+
assert.match(PEOPLE_HTML, /id="people-named-body"/, 'and a mount for the ledger');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('name-only rows render the identity cell and nothing else', async () => {
|
|
159
|
+
const h = harness({ builders: RANKED, name_only: NAMED });
|
|
160
|
+
await tick(); await tick();
|
|
161
|
+
assert.equal(h.getEl('people-named-scroll').hidden, false, 'revealed once a row arrives');
|
|
162
|
+
assert.equal(h.getEl('people-named-count').textContent, '2');
|
|
163
|
+
assert.equal(h.getEl('people-named-count').hidden, false);
|
|
164
|
+
const rows = h.namedRows();
|
|
165
|
+
assert.deepEqual(loginsIn(rows), ['bea-hidden', 'cal-hidden'], 'each builder, by login');
|
|
166
|
+
assert.match(rows, /<a class="lb-name dir-name" href="https:\/\/github\.com\/bea-hidden"/,
|
|
167
|
+
'the same door the ranked row has — the one page every listed builder has');
|
|
168
|
+
assert.match(rows, /<img class="dir-avatar" src="https:\/\/avatars\.githubusercontent\.com\/u\/7\?v=4"/, 'and the picture');
|
|
169
|
+
assert.match(rows, />cal-hidden</, 'a builder with no display name reads as their login');
|
|
170
|
+
assert.doesNotMatch(rows, /ledger__num|ledger__date|fact-chip/,
|
|
171
|
+
'no figure, no date and no role cell: every one of those is the record the builder hid');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await test('the ranked ledger is untouched by the second tier', async () => {
|
|
175
|
+
const h = harness({ builders: RANKED, name_only: NAMED });
|
|
176
|
+
await tick(); await tick();
|
|
177
|
+
assert.deepEqual(loginsIn(h.rankedRows()), ['zed-ranked', 'amy-ranked'],
|
|
178
|
+
'ranked in the server order, as before — and no hidden builder among them');
|
|
179
|
+
assert.equal(h.getEl('people-count').textContent, '2', 'its count is the ranked count');
|
|
180
|
+
assert.match(h.getEl('people-sub').textContent, /2 more .*by name only/,
|
|
181
|
+
'the subtitle counts both tiers, so it does not claim the ranked list is everyone');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await test('a sort lens never re-orders the name-only list — it is not ranked', async () => {
|
|
185
|
+
const h = harness({ builders: RANKED, name_only: [...NAMED].reverse() });
|
|
186
|
+
await tick(); await tick();
|
|
187
|
+
const before = loginsIn(h.namedRows());
|
|
188
|
+
for (const lens of ['ships', 'credits', 'karma', 'projects', 'recent']) {
|
|
189
|
+
h.setFilter('people-sort', lens);
|
|
190
|
+
assert.deepEqual(loginsIn(h.namedRows()), before, `the ${lens} lens has nothing to sort a hidden record by`);
|
|
191
|
+
}
|
|
192
|
+
assert.deepEqual(loginsIn(h.rankedRows()), ['amy-ranked', 'zed-ranked'],
|
|
193
|
+
'CONTROL: the ranked list DID re-order — the last lens, most recently active, puts Amy first');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await test('search reads a name; a role filter never matches a hidden builder', async () => {
|
|
197
|
+
const h = harness({ builders: RANKED, name_only: NAMED });
|
|
198
|
+
await tick(); await tick();
|
|
199
|
+
h.setFilter('people-q', 'bea');
|
|
200
|
+
assert.deepEqual(loginsIn(h.namedRows()), ['bea-hidden'], 'a hidden builder is findable by name');
|
|
201
|
+
assert.deepEqual(loginsIn(h.rankedRows()), [], 'and the ranked list filters the same search');
|
|
202
|
+
h.setFilter('people-q', '');
|
|
203
|
+
h.setFilter('people-craft', 'engineer');
|
|
204
|
+
assert.deepEqual(loginsIn(h.namedRows()), [], 'the role is part of the hidden record, so a role filter finds no one here');
|
|
205
|
+
assert.match(h.namedRows(), /ledger__empty/, 'and says so in the panel rather than leaving it blank');
|
|
206
|
+
assert.deepEqual(loginsIn(h.rankedRows()), ['zed-ranked'], 'CONTROL: the role filter does apply to the ranked list');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await test('no name-only rows — or an older server that sends no such key — leaves the panel hidden', async () => {
|
|
210
|
+
for (const payload of [{ builders: RANKED, name_only: [] }, { builders: RANKED }]) {
|
|
211
|
+
const h = harness(payload);
|
|
212
|
+
await tick(); await tick();
|
|
213
|
+
assert.equal(h.getEl('people-named-scroll').hidden, true, 'never revealed');
|
|
214
|
+
assert.deepEqual(loginsIn(h.rankedRows()), ['zed-ranked', 'amy-ranked'], 'and the directory renders as before');
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await test('only hidden builders listed: the page renders them rather than the empty-directory notice', async () => {
|
|
219
|
+
const h = harness({ builders: [], name_only: NAMED });
|
|
220
|
+
await tick(); await tick();
|
|
221
|
+
assert.doesNotMatch(h.getEl('people-body').textContent, /Nobody is publicly listed/,
|
|
222
|
+
'the directory is not empty — two builders are listed by name');
|
|
223
|
+
assert.deepEqual(loginsIn(h.namedRows()), ['bea-hidden', 'cal-hidden']);
|
|
224
|
+
assert.match(h.rankedRows(), /ledger__empty/, 'the ranked ledger states its own empty case');
|
|
225
|
+
const sub = h.getEl('people-sub').textContent;
|
|
226
|
+
assert.match(sub, /^2 builders listed across hosted projects by name only\./,
|
|
227
|
+
'the subtitle counts who IS listed');
|
|
228
|
+
assert.doesNotMatch(sub, /\b0 builders|more by name only/, 'not "0 builders … 2 more" — a count of nothing');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await test('a FULL name-only response is hedged, and says a search cannot reach past it', async () => {
|
|
232
|
+
const CEILING = 500;
|
|
233
|
+
const full = Array.from({ length: CEILING }, (_, i) => ({ github_login: `h${String(i).padStart(3, '0')}`, display_name: null, avatar_url: null }));
|
|
234
|
+
let h = harness({ builders: RANKED, name_only: full });
|
|
235
|
+
await tick(); await tick();
|
|
236
|
+
assert.equal(h.getEl('people-named-count').textContent, `${CEILING}+`, 'the pill may not state a total the page cannot know');
|
|
237
|
+
const foot = h.getEl('people-named-foot');
|
|
238
|
+
assert.equal(foot.hidden, false, 'the footnote shows on a clipped list');
|
|
239
|
+
assert.match(foot.textContent, /clipped at the first 500 by login/);
|
|
240
|
+
assert.match(foot.textContent, /Search only reads what this page loaded/, 'naming the one lens that reads this list');
|
|
241
|
+
assert.match(PEOPLE_HTML, /<p class="ledger__note" id="people-named-foot" hidden><\/p>/, 'the slot ships hidden');
|
|
242
|
+
h = harness({ builders: RANKED, name_only: full.slice(0, CEILING - 1) });
|
|
243
|
+
await tick(); await tick();
|
|
244
|
+
assert.equal(h.getEl('people-named-foot').hidden, true, 'one short of the ceiling is a complete list');
|
|
245
|
+
assert.equal(h.getEl('people-named-count').textContent, String(CEILING - 1));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await test('both tiers empty: the consent-boundary notice still explains the empty page', async () => {
|
|
249
|
+
const h = harness({ builders: [], name_only: [] });
|
|
250
|
+
await tick(); await tick();
|
|
251
|
+
assert.match(h.getEl('people-body').textContent, /Nobody is publicly listed/);
|
|
252
|
+
assert.equal(h.getEl('people-named-scroll').hidden, true, 'and the second panel stays hidden');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await test('the page still makes ONE read for both tiers', async () => {
|
|
256
|
+
const h = harness({ builders: RANKED, name_only: NAMED });
|
|
257
|
+
await tick(); await tick();
|
|
258
|
+
assert.equal(h.requests.length, 1);
|
|
259
|
+
assert.match(h.requests[0], /^\/api\/bongos\/scouting\?limit=/);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
summary();
|
package/tests/profile_route.mjs
CHANGED
|
@@ -420,6 +420,17 @@ const engine = {
|
|
|
420
420
|
if (/NOT pa\.hide_stats/.test(sql)) rows = rows.filter((r) => !r.hide_stats);
|
|
421
421
|
return { rows: rows.map((r) => ({ ...r.row })) };
|
|
422
422
|
}
|
|
423
|
+
// listScoutingNameOnly — the directory's name-only tier (task 1002291). The
|
|
424
|
+
// same literal-minded rule: only the predicates it declares. Each row comes
|
|
425
|
+
// back WHOLE, numbers included, so the route's projection has to drop them.
|
|
426
|
+
if (/^\s*SELECT github_login, display_name, avatar_url\s+FROM platform_identity_accounts/.test(sql)) {
|
|
427
|
+
let rows = SCOUTING_TABLE;
|
|
428
|
+
if (/profile_state = 'active'/.test(sql)) rows = rows.filter((r) => r.profile_state === 'active');
|
|
429
|
+
if (/terms_accepted_at IS NOT NULL/.test(sql)) rows = rows.filter((r) => r.terms_accepted_at != null);
|
|
430
|
+
if (/account_visibility = 'public'/.test(sql)) rows = rows.filter((r) => r.account_visibility === 'public');
|
|
431
|
+
if (/AND hide_stats\)/.test(sql)) rows = rows.filter((r) => r.hide_stats);
|
|
432
|
+
return { rows: rows.map((r) => ({ ...r, ...r.row })) };
|
|
433
|
+
}
|
|
423
434
|
// setAccountHideStats — the own-scoped write behind PATCH /me/cross-project.
|
|
424
435
|
if (/INSERT INTO platform_identity_accounts/.test(sql)) {
|
|
425
436
|
hubAccount.hide_stats = !!params[4];
|
|
@@ -635,16 +646,23 @@ scoutApp.use(attachFail);
|
|
|
635
646
|
scoutApp.use('/api/bongos', buildScoutingRoutes());
|
|
636
647
|
const scout = await listen(scoutApp);
|
|
637
648
|
|
|
638
|
-
await t('R06.3 GET /scouting
|
|
649
|
+
await t('R06.3 GET /scouting ranks the visible builder, lists the HIDDEN one by name alone, and drops the provisional AND the private', async () => {
|
|
639
650
|
engine.calls.length = 0;
|
|
640
651
|
const r = await get(scout.port, '/api/bongos/scouting');
|
|
641
652
|
assert.equal(r.status, 200);
|
|
642
653
|
const logins = r.json.builders.map((b) => b.github_login);
|
|
643
654
|
assert.deepEqual(logins, ['r06-visible'],
|
|
644
|
-
'only the consented, publicly-visible, non-hidden builder is
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
655
|
+
'only the consented, publicly-visible, non-hidden builder is RANKED (task 1002967 added the visibility axis)');
|
|
656
|
+
// Task 1002291 (privacy spec D2): a hidden builder is "listed name-only", not
|
|
657
|
+
// dropped. Hide takes the record away; the name stays findable.
|
|
658
|
+
assert.deepEqual(r.json.name_only, [{ github_login: HIDDEN_LOGIN, display_name: 'Hidden', avatar_url: null }],
|
|
659
|
+
'the hidden builder is listed by login, name and avatar — and nothing else');
|
|
660
|
+
assert.equal(engine.calls.length, 2, 'exactly TWO reads — the ranked tier and the name-only tier');
|
|
661
|
+
for (const c of engine.calls) {
|
|
662
|
+
assert.match(c.sql, /profile_state = 'active'/, 'and each carries the consent predicate — nothing re-enters unfiltered');
|
|
663
|
+
}
|
|
664
|
+
for (const s of [PROV_LOGIN, PRIVATE_LOGIN, '88817', '6613', '5519', '71713', '63127', '4409', '2287']) {
|
|
665
|
+
assert.ok(!r.text.includes(s), `the excluded builder, and the hidden builder's record, leak nothing — found "${s}"`);
|
|
648
666
|
}
|
|
649
667
|
});
|
|
650
668
|
|
|
@@ -271,6 +271,9 @@ function accountFilter(where, params) {
|
|
|
271
271
|
take(/\b(?:[a-z_]+\.)?terms_accepted_at IS NOT NULL/, () => (r) => r.terms_accepted_at != null);
|
|
272
272
|
take(/\b(?:[a-z_]+\.)?account_visibility = 'public'/, () => (r) => r.account_visibility === 'public');
|
|
273
273
|
take(/\bNOT (?:[a-z_]+\.)?hide_stats\b/, () => (r) => !r.hide_stats);
|
|
274
|
+
// The directory's name-only tier asks the hide flag POSITIVELY (task 1002291).
|
|
275
|
+
// After the NOT form, which consumes its own text first.
|
|
276
|
+
take(/\b(?:[a-z_]+\.)?hide_stats\b/, () => (r) => !!r.hide_stats);
|
|
274
277
|
|
|
275
278
|
take(/\b(?:[a-z_]+\.)?github_id = \$(\d+)/, (m) => {
|
|
276
279
|
consumed.add(m[1]);
|
|
@@ -70,7 +70,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
70
70
|
// importing them here safe.
|
|
71
71
|
import {
|
|
72
72
|
scoutingListableSql,
|
|
73
|
+
scoutingNameOnlySql,
|
|
73
74
|
rollupVisibleSql,
|
|
75
|
+
accountVisibleSql,
|
|
76
|
+
accountActiveSql,
|
|
77
|
+
recruiterReachSql,
|
|
74
78
|
isRecruiterDiscoverable,
|
|
75
79
|
} from '../modules/platform-identity/account-visibility.js';
|
|
76
80
|
import {
|
|
@@ -130,8 +134,21 @@ const ACCOUNTS = [
|
|
|
130
134
|
// presence in the SQL provable here rather than merely asserted.
|
|
131
135
|
{ github_id: '3030', github_login: 'unaccepted', display_name: 'Unaccepted',
|
|
132
136
|
profile_state: 'active', terms_accepted_at: null, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
137
|
+
// Task 1002291's two controls for the NAME-ONLY tier, each differing from `shy`
|
|
138
|
+
// in the one axis that must keep them out of it: `shyout` opted out of
|
|
139
|
+
// recruiting, `shypriv` is private (and opted in, which never buys existence).
|
|
140
|
+
{ github_id: '6161', github_login: 'shyout', display_name: 'Shy Out',
|
|
141
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true, recruiter_discoverable: false },
|
|
142
|
+
{ github_id: '6262', github_login: 'shypriv', display_name: 'Shy Private',
|
|
143
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: true, recruiter_discoverable: true },
|
|
133
144
|
];
|
|
134
145
|
|
|
146
|
+
// The record a hidden builder keeps private. Planted on every row the name-only
|
|
147
|
+
// read hands back — a real row grows columns — so a projection that spread the
|
|
148
|
+
// row instead of naming its keys would carry these into the body and fail below.
|
|
149
|
+
const HIDDEN_RECORD = { github_id_echo: '6060-ID-ECHO', total_credits: 773311, total_tasks_shipped: 773322,
|
|
150
|
+
total_karma: 773333, project_count: 773344, last_active: 'shy-last-active-773355', disciplines: ['shy-craft-773366'] };
|
|
151
|
+
|
|
135
152
|
// Project memberships, as the hub holds them. `membership_kind` is what the
|
|
136
153
|
// PROJECT reported at check-in; `signin_verified_at` is the hub's OWN witness
|
|
137
154
|
// that the person really signed in (ADR 0205 D1/D2). Owner needs both.
|
|
@@ -233,6 +250,9 @@ function accountFilter(where, params) {
|
|
|
233
250
|
take(/\b(?:[a-z_]+\.)?terms_accepted_at IS NOT NULL/, () => (r) => r.terms_accepted_at != null);
|
|
234
251
|
take(/\b(?:[a-z_]+\.)?account_visibility = 'public'/, () => (r) => r.account_visibility === 'public');
|
|
235
252
|
take(/\bNOT (?:[a-z_]+\.)?hide_stats\b/, () => (r) => !r.hide_stats);
|
|
253
|
+
// The name-only tier's term (task 1002291): the hide flag asked POSITIVELY.
|
|
254
|
+
// Tried after the NOT form, which consumes its own text first.
|
|
255
|
+
take(/\b(?:[a-z_]+\.)?hide_stats\b/, () => (r) => !!r.hide_stats);
|
|
236
256
|
|
|
237
257
|
// The TERM guards run before the STRUCTURE guard, so an unreadable term is
|
|
238
258
|
// reported as the unreadable term it is rather than as leftover text.
|
|
@@ -318,6 +338,22 @@ const pool = {
|
|
|
318
338
|
return { rows, rowCount: rows.length };
|
|
319
339
|
}
|
|
320
340
|
|
|
341
|
+
// The directory's NAME-ONLY tier (task 1002291): a plain read of the accounts
|
|
342
|
+
// table, judged by its own WHERE. Each row comes back WHOLE, plus the record
|
|
343
|
+
// the builder hid, so the route's projection is what has to drop them.
|
|
344
|
+
if (/^SELECT .+ FROM platform_identity_accounts WHERE /.test(sql)) {
|
|
345
|
+
assert.doesNotMatch(sql, /platform_identity_builder_project_activity/,
|
|
346
|
+
'the name-only read must not consult the rollup — presence there would say who has built');
|
|
347
|
+
const where = sql.slice(sql.indexOf(' WHERE ') + 7).replace(/ ORDER BY .*| LIMIT .*/g, '');
|
|
348
|
+
const limit = Number(params[Number((/ LIMIT \$(\d+)/.exec(sql) || [, 0])[1]) - 1]);
|
|
349
|
+
assert.ok(limit > 0, 'the name-only read is bounded like the ranked one');
|
|
350
|
+
const rows = ACCOUNTS.filter(accountFilter(where, params))
|
|
351
|
+
.sort((a, b) => a.github_login.localeCompare(b.github_login))
|
|
352
|
+
.slice(0, limit)
|
|
353
|
+
.map((a) => ({ ...a, ...HIDDEN_RECORD }));
|
|
354
|
+
return { rows, rowCount: rows.length };
|
|
355
|
+
}
|
|
356
|
+
|
|
321
357
|
throw new Error(`fake pool: unhandled statement — ${sql.slice(0, 120)}`);
|
|
322
358
|
},
|
|
323
359
|
};
|
|
@@ -391,6 +427,7 @@ function getScouting(caller, query = '') {
|
|
|
391
427
|
});
|
|
392
428
|
}
|
|
393
429
|
const logins = (res) => JSON.parse(res.text).builders.map((b) => b.github_login).sort();
|
|
430
|
+
const namedLogins = (res) => (JSON.parse(res.text).name_only || []).map((b) => b.github_login).sort();
|
|
394
431
|
|
|
395
432
|
// The cast. `rank` is the caller's rank on the HUB; `github_id` is the hub
|
|
396
433
|
// account whose project memberships decide the ownership door.
|
|
@@ -521,8 +558,8 @@ await t('and the whole matrix lands exactly where the specs put it', async () =>
|
|
|
521
558
|
assert.deepEqual(shown, ['octo', 'optin'],
|
|
522
559
|
'in: reach-unset-and-public (octo) + explicitly-opted-in (optin). '
|
|
523
560
|
+ 'out: optout (opted out), scout (private, and reach never buys addressability), '
|
|
524
|
-
+ 'shy (hidden), newbie (provisional),
|
|
525
|
-
+ `terms — ADR 0213) — got ${JSON.stringify(shown)}`);
|
|
561
|
+
+ 'shy (hidden — ranked out, listed by name in the second tier below), newbie (provisional), '
|
|
562
|
+
+ `unaccepted (never accepted the platform terms — ADR 0213) — got ${JSON.stringify(shown)}`);
|
|
526
563
|
});
|
|
527
564
|
|
|
528
565
|
await t('opting back IN restores them — the toggle is a toggle, not a tombstone', async () => {
|
|
@@ -613,6 +650,77 @@ await t('/people is the SAME endpoint, so "absent from /people" is true by const
|
|
|
613
650
|
'and listScoutingRollup — the one reader behind both surfaces — joins the composed predicate');
|
|
614
651
|
});
|
|
615
652
|
|
|
653
|
+
// ===========================================================================
|
|
654
|
+
// 2b. HIDE CHANGES THE SHAPE, NOT THE PRESENCE (task 1002291, R15)
|
|
655
|
+
// ===========================================================================
|
|
656
|
+
//
|
|
657
|
+
// Privacy spec D2, the "active · public · hide_stats" row, recruiter column:
|
|
658
|
+
// "listed name-only (if not opted out)". Until R15 the directory read hide as a
|
|
659
|
+
// second opt-out and dropped the builder entirely, while the Settings row told
|
|
660
|
+
// that same builder recruiters would see their name and picture. The directory
|
|
661
|
+
// now has two tiers: the ranked record, and a name-only list.
|
|
662
|
+
|
|
663
|
+
console.log('\nhide — a hidden builder stays findable, by name alone:');
|
|
664
|
+
|
|
665
|
+
await t('a hidden, reachable builder is listed NAME-ONLY — and never in the ranked tier', async () => {
|
|
666
|
+
const res = await getScouting(OPERATOR);
|
|
667
|
+
assert.equal(res.status, 200);
|
|
668
|
+
assert.deepEqual(namedLogins(res), ['shy'],
|
|
669
|
+
'in: shy (public, hidden, reach unset). out: shyout (hidden but opted out), shypriv (hidden and private — '
|
|
670
|
+
+ `reach never buys existence), and every unhidden builder, who is ranked instead — got ${JSON.stringify(namedLogins(res))}`);
|
|
671
|
+
assert.ok(!logins(res).includes('shy'), 'a hidden builder is never RANKED — rank position is derived from the record');
|
|
672
|
+
const read = calls.find((c) => /^SELECT .+ FROM platform_identity_accounts WHERE /.test(c.sql));
|
|
673
|
+
assert.ok(read, 'the name-only tier is a read of its own');
|
|
674
|
+
assert.ok(read.sql.includes(scoutingNameOnlySql()),
|
|
675
|
+
'and it joins the SHARED predicate, not a copy of it');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
await t('the name-only row is identity and nothing else — the record the builder hid never reaches the body', async () => {
|
|
679
|
+
const res = await getScouting(OWNER);
|
|
680
|
+
const row = JSON.parse(res.text).name_only[0];
|
|
681
|
+
assert.deepEqual(Object.keys(row).sort(), ['avatar_url', 'display_name', 'github_login'],
|
|
682
|
+
'a literal whitelist: what a hidden builder\'s own public page already shows');
|
|
683
|
+
for (const [key, value] of Object.entries(HIDDEN_RECORD)) {
|
|
684
|
+
for (const v of [].concat(value)) {
|
|
685
|
+
assert.ok(!res.text.includes(String(v)), `${key} (${v}) leaked into the directory — the read spread a row it should have named`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
assert.ok(!res.text.includes('Shy Out') && !res.text.includes('Shy Private'),
|
|
689
|
+
'and the two hidden builders the tier refuses leave no trace either');
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
await t('un-hiding moves a builder between the tiers; opting out removes them from both', async () => {
|
|
693
|
+
const shy = ACCOUNTS.find((a) => a.github_login === 'shy');
|
|
694
|
+
try {
|
|
695
|
+
shy.hide_stats = false;
|
|
696
|
+
let res = await getScouting(OPERATOR);
|
|
697
|
+
assert.ok(logins(res).includes('shy') && !namedLogins(res).includes('shy'),
|
|
698
|
+
'shown again, the builder is ranked on their record and gone from the name-only list — never in both');
|
|
699
|
+
shy.hide_stats = true;
|
|
700
|
+
shy.recruiter_discoverable = false;
|
|
701
|
+
res = await getScouting(OPERATOR);
|
|
702
|
+
assert.ok(!logins(res).includes('shy') && !namedLogins(res).includes('shy'),
|
|
703
|
+
'hidden AND opted out: absent from the directory entirely — the opt-out is still the only thing that removes a builder');
|
|
704
|
+
} finally {
|
|
705
|
+
shy.hide_stats = true;
|
|
706
|
+
shy.recruiter_discoverable = null;
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
await t('MUTATION CHECK: drop the reach or the existence half and the wrong hidden builder walks in', async () => {
|
|
711
|
+
const admits = (clause) => ACCOUNTS.filter(accountFilter(clause, [])).map((a) => a.github_login).sort();
|
|
712
|
+
const tier = scoutingNameOnlySql();
|
|
713
|
+
assert.deepEqual(admits(tier), ['shy'], 'the real predicate');
|
|
714
|
+
const withoutReach = tier.replace(` AND ${recruiterReachSql()}`, '');
|
|
715
|
+
assert.notEqual(withoutReach, tier, 'fixture check: the reach term was really removed');
|
|
716
|
+
assert.deepEqual(admits(withoutReach), ['shy', 'shyout'],
|
|
717
|
+
'WITHOUT reach, the builder who opted out is listed — so the opt-out assertion above is load-bearing');
|
|
718
|
+
const withoutExistence = tier.replace(accountVisibleSql(), accountActiveSql());
|
|
719
|
+
assert.notEqual(withoutExistence, tier, 'fixture check: the existence axis was really removed');
|
|
720
|
+
assert.deepEqual(admits(withoutExistence), ['shy', 'shypriv'],
|
|
721
|
+
'WITHOUT the existence axis, a PRIVATE account is listed by name — the D3 sliver\'s subject, which the directory must never carry');
|
|
722
|
+
});
|
|
723
|
+
|
|
616
724
|
// ===========================================================================
|
|
617
725
|
// 3. THE TWO AXES NEVER TOUCH (ADR 0016)
|
|
618
726
|
// ===========================================================================
|
|
@@ -631,6 +739,8 @@ await t('opting out of recruiting grants and denies NO permission', async () =>
|
|
|
631
739
|
+ 'so a reader can never infer a privacy setting from whose list they got');
|
|
632
740
|
assert.deepEqual(shown, logins(await getScouting(OPERATOR)),
|
|
633
741
|
'byte-for-byte the same population an operator sees');
|
|
742
|
+
assert.deepEqual(namedLogins(res), namedLogins(await getScouting(OPERATOR)),
|
|
743
|
+
'and the same name-only list — neither tier varies by viewer');
|
|
634
744
|
});
|
|
635
745
|
|
|
636
746
|
await t('being scoutable buys nothing either — the response is the same descriptive whitelist', async () => {
|
|
@@ -120,6 +120,19 @@ test('every mock passes the platform rules under its own colours and its own DES
|
|
|
120
120
|
}
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
test("the expedition's halftone screen renders: it sits on a fixed body::before layer, never body's own background-image (task 1003633)", () => {
|
|
124
|
+
const html = read(path.join(STYLES_DIR, 'expedition', 'mock.html'));
|
|
125
|
+
const bodyRule = html.match(/\bbody\s*\{[^}]*\}/);
|
|
126
|
+
assert.ok(bodyRule, 'the mock declares a body rule');
|
|
127
|
+
assert.doesNotMatch(bodyRule[0], /background-image/, "body's own rule carries no background-image — the branding pack's backdrop emits body{background:...} after the page's sheet (themeMarkup, serve-internal.js) and that shorthand resets it to none");
|
|
128
|
+
const before = html.match(/body::before\s*\{[^}]*\}/);
|
|
129
|
+
assert.ok(before, 'the halftone moved onto a body::before layer');
|
|
130
|
+
assert.match(before[0], /position:\s*fixed/, 'the layer is fixed, not in flow');
|
|
131
|
+
assert.match(before[0], /pointer-events:\s*none/, 'the layer never intercepts input');
|
|
132
|
+
assert.match(before[0], /background-image:\s*radial-gradient\(var\(--dots\)/, 'the halftone dots ride the layer, keyed to the pack-derived --dots var');
|
|
133
|
+
assert.match(before[0], /background-size:\s*var\(--dot-pitch\)/, 'the dot pitch moved with it');
|
|
134
|
+
});
|
|
135
|
+
|
|
123
136
|
test('the world, hub and hall sheets clear AA under every alternate in both modes: the three token suites run once per pack', () => {
|
|
124
137
|
// relative, forward-slash paths: `node --test <path>` reads its positionals as globs, and a
|
|
125
138
|
// Windows absolute path matches nothing (zero tests, exit 0 — a silent pass)
|