@bongos/core 1.19.421 → 1.19.423
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 +21 -16
- package/docs/module-api-changelog.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +24 -10
- package/public-landing/projects.html +1 -1
- package/scripts/gds/run-unit-tests.js +8 -1
- package/src/module-api.js +1 -1
- package/tests/profile_privacy_owner_view.mjs +477 -0
- package/tests/projects_hub.mjs +21 -0
- package/tests/projects_hub_pre_uat.mjs +1 -1
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.423",
|
|
6
|
+
"core_contract": "1.19.423",
|
|
7
|
+
"source_commit": "4766551ebf3debe1bd66da9feb7974ea29a9fc7d",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-29T18:48:03.289Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 359,
|
|
13
13
|
"agent_docs_stubbed": 23,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1720,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2102,
|
|
20
|
+
"tree_sha256": "e08d232a8919912b3eaa5b098b0ad5e505a24ec5c55e56f236762ef12684ba97",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1937,7 +1937,7 @@
|
|
|
1937
1937
|
{
|
|
1938
1938
|
"path": "docs/module-api-changelog.md",
|
|
1939
1939
|
"mode": "0000644",
|
|
1940
|
-
"sha256": "
|
|
1940
|
+
"sha256": "13dba1ab067b238b37395e6e23728b198a7f944bb15e0a4b4850ceefdfafb2b1"
|
|
1941
1941
|
},
|
|
1942
1942
|
{
|
|
1943
1943
|
"path": "docs/modules-contract.md",
|
|
@@ -5497,12 +5497,12 @@
|
|
|
5497
5497
|
{
|
|
5498
5498
|
"path": "package-lock.json",
|
|
5499
5499
|
"mode": "0000644",
|
|
5500
|
-
"sha256": "
|
|
5500
|
+
"sha256": "c6703f3be312d7ab5830f5f4aceda03cb1e5689ac3d82568f835d8a9ee3eeb4a"
|
|
5501
5501
|
},
|
|
5502
5502
|
{
|
|
5503
5503
|
"path": "package.json",
|
|
5504
5504
|
"mode": "0000644",
|
|
5505
|
-
"sha256": "
|
|
5505
|
+
"sha256": "5fca13b9621fcb29a90b4c4ea608a0083a9fc3574dfeecf8b35fed342d147491"
|
|
5506
5506
|
},
|
|
5507
5507
|
{
|
|
5508
5508
|
"path": "public-docs/index.html",
|
|
@@ -5742,7 +5742,7 @@
|
|
|
5742
5742
|
{
|
|
5743
5743
|
"path": "public-landing/assets/cosmos.css",
|
|
5744
5744
|
"mode": "0000644",
|
|
5745
|
-
"sha256": "
|
|
5745
|
+
"sha256": "282ec017ecdd9ecde6ef3c80df5b7874b392e2b4d97ae29b4222a87c2ed51f3a"
|
|
5746
5746
|
},
|
|
5747
5747
|
{
|
|
5748
5748
|
"path": "public-landing/assets/verro-medallion.png",
|
|
@@ -5962,7 +5962,7 @@
|
|
|
5962
5962
|
{
|
|
5963
5963
|
"path": "public-landing/projects.html",
|
|
5964
5964
|
"mode": "0000644",
|
|
5965
|
-
"sha256": "
|
|
5965
|
+
"sha256": "4174cf9209dd56e38944462d62f178bf684a7fb593d3c20b81620f004e64a57b"
|
|
5966
5966
|
},
|
|
5967
5967
|
{
|
|
5968
5968
|
"path": "public-landing/projects.probes.json",
|
|
@@ -6842,7 +6842,7 @@
|
|
|
6842
6842
|
{
|
|
6843
6843
|
"path": "scripts/gds/run-unit-tests.js",
|
|
6844
6844
|
"mode": "0000644",
|
|
6845
|
-
"sha256": "
|
|
6845
|
+
"sha256": "9a173b5e307f5c1396f1d72b88d0b05534cccbff53af0a39a620001df69bf1a8"
|
|
6846
6846
|
},
|
|
6847
6847
|
{
|
|
6848
6848
|
"path": "scripts/gds/sandbox-stage.js",
|
|
@@ -7632,7 +7632,7 @@
|
|
|
7632
7632
|
{
|
|
7633
7633
|
"path": "src/module-api.js",
|
|
7634
7634
|
"mode": "0000644",
|
|
7635
|
-
"sha256": "
|
|
7635
|
+
"sha256": "187cad1863a33d5e9c7db35bad173525124dbc8534a87ecf2b760397fab37d12"
|
|
7636
7636
|
},
|
|
7637
7637
|
{
|
|
7638
7638
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9654,6 +9654,11 @@
|
|
|
9654
9654
|
"mode": "0000644",
|
|
9655
9655
|
"sha256": "a7f70fa612494593438565f4bd1e3d454a3dbca862c8396a7dcb252d38e3e489"
|
|
9656
9656
|
},
|
|
9657
|
+
{
|
|
9658
|
+
"path": "tests/profile_privacy_owner_view.mjs",
|
|
9659
|
+
"mode": "0000644",
|
|
9660
|
+
"sha256": "7ccf39b7557fdb7f906210a8d2e58079b92c860d25bc906d8243b8477b02a02a"
|
|
9661
|
+
},
|
|
9657
9662
|
{
|
|
9658
9663
|
"path": "tests/profile_rollup_consent.mjs",
|
|
9659
9664
|
"mode": "0000644",
|
|
@@ -9682,12 +9687,12 @@
|
|
|
9682
9687
|
{
|
|
9683
9688
|
"path": "tests/projects_hub.mjs",
|
|
9684
9689
|
"mode": "0000644",
|
|
9685
|
-
"sha256": "
|
|
9690
|
+
"sha256": "b40e405b673524ad0d33c041730771d1ebf9c95198062ebf307aed5fa1e7d501"
|
|
9686
9691
|
},
|
|
9687
9692
|
{
|
|
9688
9693
|
"path": "tests/projects_hub_pre_uat.mjs",
|
|
9689
9694
|
"mode": "0000644",
|
|
9690
|
-
"sha256": "
|
|
9695
|
+
"sha256": "48b314b096c67793dba8f5faeec5b03d043c42a17a38466e072e6bf9c7ae9ec5"
|
|
9691
9696
|
},
|
|
9692
9697
|
{
|
|
9693
9698
|
"path": "tests/promote_goal_id.mjs",
|
|
@@ -1279,5 +1279,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1279
1279
|
landed since 1.19.419 with no explicit bump. run 33266034679. (task 1002620)
|
|
1280
1280
|
1.19.421 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1281
1281
|
landed since 1.19.420 with no explicit bump. run 33266605270. (task 1002620)
|
|
1282
|
+
1.19.422 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1283
|
+
landed since 1.19.421 with no explicit bump. run 33267393283. (task 1002620)
|
|
1284
|
+
1.19.423 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1285
|
+
landed since 1.19.422 with no explicit bump. run 33269205170. (task 1002620)
|
|
1282
1286
|
---------------------------------------------------------------------------
|
|
1283
1287
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.423",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.423",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.423",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -903,17 +903,30 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
903
903
|
.tbl td.right{text-align:right;}
|
|
904
904
|
/* Nothing in an action or status cell may wrap: "Manage →" breaking after the
|
|
905
905
|
space and the chip's recency dropping to a second line both read as a broken
|
|
906
|
-
control rather than a tight column.
|
|
907
|
-
name cell absorbs the slack. */
|
|
906
|
+
control rather than a tight column. */
|
|
908
907
|
.tbl .btn,.tbl .chip{white-space:nowrap;}
|
|
909
|
-
.tbl .colStatus{
|
|
910
|
-
.tbl .colGo{
|
|
911
|
-
|
|
912
|
-
|
|
908
|
+
.tbl .colStatus,.tbl .colGo,.tbl .colType{white-space:nowrap;}
|
|
909
|
+
.tbl .colGo{padding-left:8px;}
|
|
910
|
+
/* ── FIXED tracks (task 1003348; the owner: "the columns don't look aligned").
|
|
911
|
+
An auto table sized every column by its content, so the status column hugged
|
|
912
|
+
its widest chip and the headers drifted off their cells. table-layout:fixed
|
|
913
|
+
takes the widths from the header row — one set of tracks for every row, each
|
|
914
|
+
column edge one vertical line — and the name column absorbs the slack. The
|
|
915
|
+
Project header sits over the NAME (past the 10px dot and its 12px gap), and a
|
|
916
|
+
right-aligned column's header is right-aligned too. Below the table's minimum
|
|
917
|
+
the wrap scrolls (#sub-table-body), never the page. ── */
|
|
918
|
+
.tbl{table-layout:fixed;min-width:860px;}
|
|
919
|
+
.tbl th.colName{padding-left:22px;}
|
|
920
|
+
.tbl th.right{text-align:right;}
|
|
921
|
+
.tbl th.colType{width:88px;}
|
|
922
|
+
.tbl th.colBuilders{width:96px;}
|
|
923
|
+
.tbl th.colTags{width:168px;}
|
|
924
|
+
.tbl th.colStatus{width:224px;}
|
|
925
|
+
.tbl th.colGo{width:112px;}
|
|
913
926
|
/* the declared-type tag (task 1002772) — its own vocabulary: not the relationship
|
|
914
927
|
.pill, not the liveness .chip. Two different facts never wear the same chrome. */
|
|
915
928
|
.typeTag{font-family:var(--font-mono);font-size:10.5px;font-weight:500;color:var(--ink-soft);border:1px solid var(--rule);border-radius:999px;padding:2px 9px;white-space:nowrap;}
|
|
916
|
-
@media (max-width:780px){.tbl .colTags,.tbl .colType{display:none;}}
|
|
929
|
+
@media (max-width:780px){.tbl .colTags,.tbl .colType{display:none;}.tbl{min-width:600px;}}
|
|
917
930
|
@media (max-width:900px){
|
|
918
931
|
.tblMast{gap:16px;padding:22px 0 20px;}
|
|
919
932
|
.tblTitle{font-size:clamp(28px,7.4vw,34px);}
|
|
@@ -1065,7 +1078,6 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1065
1078
|
.rowHall{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin-left:10px;
|
|
1066
1079
|
vertical-align:middle;color:var(--accent-deep);border-radius:var(--radius-pill);}
|
|
1067
1080
|
.rowHall:hover{background:var(--glass);}
|
|
1068
|
-
.tbl .colGo{white-space:nowrap;}
|
|
1069
1081
|
@media (max-width:640px){
|
|
1070
1082
|
.projCard h3{font-size:26px;padding:0 20px;}
|
|
1071
1083
|
.projCard .cardTag,.projCard .cardRel,.projCard .cardFaces,.projCard .cardFigs,.projCard .cardMeta,
|
|
@@ -1154,8 +1166,10 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1154
1166
|
structure, the project's own planet as the row's object, the strip drawn
|
|
1155
1167
|
as the lifecycle rail, the doors as hairline pills with the accent only on
|
|
1156
1168
|
the one that acts. One set of FIXED tracks across every row, so the list
|
|
1157
|
-
reads as one ledger
|
|
1158
|
-
|
|
1169
|
+
reads as one ledger — the same column grammar the every-project table takes
|
|
1170
|
+
in its `.tbl` rules (table-layout:fixed, the header row's widths; task
|
|
1171
|
+
1003348, both halves pinned together in tests/projects_hub.mjs). Dark only
|
|
1172
|
+
(ADR 0204); every colour a token; halos are outlines, never box-shadow.
|
|
1159
1173
|
═══════════════════════════════════════════════════════════════════════ */
|
|
1160
1174
|
#view-mine .wrap,#view-manage .wrap{max-width:1180px;}
|
|
1161
1175
|
#view-mine .card,#view-manage .card{background:none;border-radius:0;box-shadow:none;padding:0;margin:0;}
|
|
@@ -489,7 +489,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
489
489
|
<tr>
|
|
490
490
|
<th class="colName" data-sort="name" aria-sort="ascending"><button type="button" class="thSort">Project</button></th>
|
|
491
491
|
<th class="colType">Type</th>
|
|
492
|
-
<th class="right" data-sort="builders"><button type="button" class="thSort">Builders</button></th>
|
|
492
|
+
<th class="right colBuilders" data-sort="builders"><button type="button" class="thSort">Builders</button></th>
|
|
493
493
|
<th class="colTags">Runs</th>
|
|
494
494
|
<th class="right colStatus">Status</th>
|
|
495
495
|
<th class="colGo"></th>
|
|
@@ -106,7 +106,14 @@ const INTEGRATION = new Set([
|
|
|
106
106
|
// the DISTINCT, and that a deactivated builder or a machine principal still
|
|
107
107
|
// never governs under the widened predicate.
|
|
108
108
|
'government_board_membership_db',
|
|
109
|
-
'government_fold_bench', 'government_live_proof',
|
|
109
|
+
'government_fold_bench', 'government_live_proof',
|
|
110
|
+
// task 1002806: these three declare "in the INTEGRATION set" in their own headers
|
|
111
|
+
// and were never in it. Every one boots a real Postgres and self-skips without one,
|
|
112
|
+
// so in the DB-free lane they ran, skipped, exited 0 and asserted NOTHING — while
|
|
113
|
+
// the --integration lane never saw them either, because it runs only this set. A
|
|
114
|
+
// test that cannot fail in either lane is a file that looks like a gate.
|
|
115
|
+
'handle_machinery', 'hub_profile_leak', 'profile_fields',
|
|
116
|
+
'linkify_refs', 'memory_isolation',
|
|
110
117
|
'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent',
|
|
111
118
|
// task 1003145: whether the PLANNER uses the migration 225 index for the recall
|
|
112
119
|
// freshness probe. The DB-free sibling (recall_corpus_health) pins the query
|
package/src/module-api.js
CHANGED
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.423'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
37
37
|
|
|
38
38
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
39
39
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
// tests/profile_privacy_owner_view.mjs — P10 (task 1002806, goal 1000045):
|
|
2
|
+
// "a private account's profile 404s to a stranger and RENDERS TO ITS OWNER",
|
|
3
|
+
// and a stats-hidden account renders NO PARTIAL NUMBERS.
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS FILE IS, AND WHAT IT DELIBERATELY IS NOT. The 404 half of P10 was
|
|
6
|
+
// already landed by task 1002967 in tests/effective_visibility_predicate.mjs:
|
|
7
|
+
// private / provisional / private-discoverable / private-previous-handle all
|
|
8
|
+
// 404 on GET /profiles/:handle, never 403, byte-identically for an anonymous
|
|
9
|
+
// viewer, a signed-in stranger and the owner. Re-asserting that here would buy
|
|
10
|
+
// nothing. This file is the OTHER half — the two things that proof left open:
|
|
11
|
+
//
|
|
12
|
+
// 1. THE OWNER'S PAGE, OVER REAL HTTP. 1002967 proves the own-scope side door
|
|
13
|
+
// at the FUNCTION level (pi.getOwnProfileExtras). But the page shell reads
|
|
14
|
+
// GET /me/cross-project, and that route is what must hand it the banner
|
|
15
|
+
// input (account_visibility='private') alongside the owner's own numbers.
|
|
16
|
+
// A function that behaves is not a route that behaves.
|
|
17
|
+
//
|
|
18
|
+
// 2. THE BYPASS THAT WOULD NULLIFY EVERY 404. The public read is viewer-
|
|
19
|
+
// independent precisely because the owner gets their page from the
|
|
20
|
+
// own-scope door instead. That door is therefore load-bearing for the
|
|
21
|
+
// privacy model: if it ever answered a CALLER-NAMED github_id instead of
|
|
22
|
+
// the session's, every 404 in 1002967 becomes a speed bump — ask the side
|
|
23
|
+
// door for the private account instead. Nothing tested that. This does.
|
|
24
|
+
//
|
|
25
|
+
// 3. NO PARTIAL NUMBERS, PROVED BY SUBTRACTION. 1002967 asserts the hidden
|
|
26
|
+
// account's body matches /"cross_project":null/. That is necessary and not
|
|
27
|
+
// sufficient: a future response that added `projects_count: 7` beside a
|
|
28
|
+
// null cross_project still matches it. So here every rollup-derived value
|
|
29
|
+
// is a PLANTED SENTINEL and the assertion is that none of them appears
|
|
30
|
+
// ANYWHERE in the hidden account's response text — with two controls that
|
|
31
|
+
// stop it passing vacuously (a public account's body DOES carry its
|
|
32
|
+
// sentinels; the hidden account's own numbers DO exist behind the door).
|
|
33
|
+
//
|
|
34
|
+
// THE RULE THIS FOLLOWS IS THE CODE'S, NOT THE PROFILE-PAGE SPEC'S. The spec
|
|
35
|
+
// docs/specs/bongos-v1-builder-profile-page.md says 404 "to everyone but the
|
|
36
|
+
// account itself", which reads as a viewer-dependent public response. The
|
|
37
|
+
// AUTHORITATIVE model — the privacy spec (task 1002785) D2 — reconciles it the
|
|
38
|
+
// other way and the code implements the reconciliation: the PUBLIC read 404s to
|
|
39
|
+
// everyone INCLUDING the owner, and the owner's page is composed from the
|
|
40
|
+
// own-scope endpoint. Both halves are asserted below.
|
|
41
|
+
//
|
|
42
|
+
// MECHANISM (tests/connections_ownership.mjs + tests/own_session_revoke.mjs):
|
|
43
|
+
// an in-memory accounts + activity table whose row filter is built FROM EACH
|
|
44
|
+
// STATEMENT'S OWN WHERE CLAUSE, refusing any statement whose placeholders or
|
|
45
|
+
// literal comparisons it did not consume — so a predicate the fake cannot read
|
|
46
|
+
// is a loud error rather than a silently absent filter. Everything above the
|
|
47
|
+
// pool is real: the real routers, the real domain module, real HTTP.
|
|
48
|
+
//
|
|
49
|
+
// DB-free by construction. Runs in the lane that actually executes (the last
|
|
50
|
+
// test in this file proves that).
|
|
51
|
+
//
|
|
52
|
+
// Run: node tests/profile_privacy_owner_view.mjs
|
|
53
|
+
|
|
54
|
+
import { strict as assert } from 'node:assert';
|
|
55
|
+
import { test, after } from 'node:test';
|
|
56
|
+
import { createRequire } from 'node:module';
|
|
57
|
+
import http from 'node:http';
|
|
58
|
+
import fs from 'node:fs';
|
|
59
|
+
import { fileURLToPath } from 'node:url';
|
|
60
|
+
import { dirname, join } from 'node:path';
|
|
61
|
+
|
|
62
|
+
const require = createRequire(import.meta.url);
|
|
63
|
+
const express = require('express');
|
|
64
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
65
|
+
|
|
66
|
+
// ===========================================================================
|
|
67
|
+
// The fixture: four accounts, and a sentinel for every rollup-derived value
|
|
68
|
+
// ===========================================================================
|
|
69
|
+
//
|
|
70
|
+
// Sentinels are per-account so a cross-wired response is caught too, and they
|
|
71
|
+
// are improbable literals so "absent from the body" means absent, not merely
|
|
72
|
+
// "did not collide with a 0 or a 1".
|
|
73
|
+
|
|
74
|
+
const OCTO = '4242'; // active · public · shown — the CONTROL
|
|
75
|
+
const GHOST = '5150'; // active · PRIVATE — the owner-renders subject
|
|
76
|
+
const SHY = '6060'; // active · public · HIDDEN — the no-partial-numbers subject
|
|
77
|
+
const NOSY = '7777'; // active · public — the stranger at the side door
|
|
78
|
+
|
|
79
|
+
const ACCOUNTS = [
|
|
80
|
+
{ id: 1, github_id: OCTO, github_login: 'octo', display_name: 'Octo', avatar_url: null,
|
|
81
|
+
handle: 'octo', previous_handle: null, handle_changed_at: null, bio: null, links: null,
|
|
82
|
+
display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
|
|
83
|
+
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
84
|
+
{ id: 2, github_id: GHOST, github_login: 'ghost', display_name: 'Ghost', avatar_url: null,
|
|
85
|
+
handle: 'ghost', previous_handle: null, handle_changed_at: null, bio: 'ghost-bio-515007', links: null,
|
|
86
|
+
display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
|
|
87
|
+
profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
|
|
88
|
+
{ id: 3, github_id: SHY, github_login: 'shy', display_name: 'Shy', avatar_url: null,
|
|
89
|
+
handle: 'shy', previous_handle: null, handle_changed_at: null, bio: null, links: null,
|
|
90
|
+
display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
|
|
91
|
+
profile_state: 'active', account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
|
|
92
|
+
{ id: 4, github_id: NOSY, github_login: 'nosy', display_name: 'Nosy', avatar_url: null,
|
|
93
|
+
handle: 'nosy', previous_handle: null, handle_changed_at: null, bio: null, links: null,
|
|
94
|
+
display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
|
|
95
|
+
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// Every number and name the cross-project rollup can produce, per account.
|
|
99
|
+
const ROLLUP = {
|
|
100
|
+
[OCTO]: {
|
|
101
|
+
agg: { projects: 811008, credits: 811001, works_shipped: 811002, karma: 811003, last_active: 'octo-active-811007', disciplines: ['octo-craft-811004'] },
|
|
102
|
+
projects: [{ name: 'Octo-Project-811005', origin: 'https://octo.example', rank_held: 'metic', recent_ships: ['Octo-Ship-811006'], is_public: true }],
|
|
103
|
+
},
|
|
104
|
+
[GHOST]: {
|
|
105
|
+
agg: { projects: 515008, credits: 515001, works_shipped: 515002, karma: 515003, last_active: 'ghost-active-515007', disciplines: ['ghost-craft-515004'] },
|
|
106
|
+
projects: [{ name: 'Ghost-Project-515005', origin: 'https://ghost.example', rank_held: 'metic', recent_ships: ['Ghost-Ship-515006'], is_public: true }],
|
|
107
|
+
},
|
|
108
|
+
[SHY]: {
|
|
109
|
+
agg: { projects: 606008, credits: 606001, works_shipped: 606002, karma: 606003, last_active: 'shy-active-606007', disciplines: ['shy-craft-606004'] },
|
|
110
|
+
projects: [{ name: 'Shy-Project-606005', origin: 'https://shy.example', rank_held: 'metic', recent_ships: ['Shy-Ship-606006'], is_public: true }],
|
|
111
|
+
},
|
|
112
|
+
[NOSY]: {
|
|
113
|
+
agg: { projects: 777008, credits: 777001, works_shipped: 777002, karma: 777003, last_active: 'nosy-active-777007', disciplines: ['nosy-craft-777004'] },
|
|
114
|
+
projects: [{ name: 'Nosy-Project-777005', origin: 'https://nosy.example', rank_held: 'metic', recent_ships: ['Nosy-Ship-777006'], is_public: true }],
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Every sentinel belonging to one account, as strings — the "did any of this
|
|
119
|
+
// leak?" alphabet. Numbers are included as their serialized form.
|
|
120
|
+
function sentinelsOf(githubId) {
|
|
121
|
+
const r = ROLLUP[githubId];
|
|
122
|
+
return [
|
|
123
|
+
String(r.agg.projects),
|
|
124
|
+
String(r.agg.credits), String(r.agg.works_shipped), String(r.agg.karma),
|
|
125
|
+
r.agg.last_active, ...r.agg.disciplines,
|
|
126
|
+
...r.projects.flatMap((p) => [p.name, p.origin, ...p.recent_ships]),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ===========================================================================
|
|
131
|
+
// The in-memory tables + their statement interpreter
|
|
132
|
+
// ===========================================================================
|
|
133
|
+
|
|
134
|
+
// The columns this interpreter can filter on. A literal comparison against
|
|
135
|
+
// anything else throws — a WHERE it cannot read must never degrade to no WHERE.
|
|
136
|
+
const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'github_id', 'handle', 'previous_handle']);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build a row test from a statement's own WHERE text. Literal comparisons carry
|
|
140
|
+
* as much weight as placeholders here: the effective-visibility predicate is
|
|
141
|
+
* made ENTIRELY of literal comparisons, so a fake that modelled only $N filters
|
|
142
|
+
* would score the privacy terms as absent and pass however the code changed.
|
|
143
|
+
*/
|
|
144
|
+
function accountFilter(where, params) {
|
|
145
|
+
const tests = [];
|
|
146
|
+
const consumed = new Set();
|
|
147
|
+
|
|
148
|
+
if (/\bprofile_state = 'active'/.test(where)) tests.push((r) => r.profile_state === 'active');
|
|
149
|
+
if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
|
|
150
|
+
if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
|
|
151
|
+
|
|
152
|
+
const byId = /\b(?:[a-z_]+\.)?github_id = \$(\d+)/.exec(where);
|
|
153
|
+
if (byId) {
|
|
154
|
+
consumed.add(byId[1]);
|
|
155
|
+
const v = params[Number(byId[1]) - 1];
|
|
156
|
+
tests.push((r) => String(r.github_id) === String(v));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const byHandle = /lower\(handle\) = \$(\d+) OR lower\(previous_handle\) = \$(\d+)/.exec(where);
|
|
160
|
+
if (byHandle) {
|
|
161
|
+
consumed.add(byHandle[1]); consumed.add(byHandle[2]);
|
|
162
|
+
const v = String(params[Number(byHandle[1]) - 1]);
|
|
163
|
+
tests.push((r) => String(r.handle || '').toLowerCase() === v || String(r.previous_handle || '').toLowerCase() === v);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const m of where.matchAll(/\$(\d+)/g)) {
|
|
167
|
+
if (!consumed.has(m[1])) {
|
|
168
|
+
throw new Error(`fake pool: unread predicate on $${m[1]} in "${where}" — a filter this interpreter cannot read must never degrade to no filter`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const m of where.matchAll(/\b([a-z_]+)\s*(?:=|<>|!=)\s*'/g)) {
|
|
172
|
+
if (!KNOWN_COLUMNS.has(m[1])) {
|
|
173
|
+
throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return (r) => tests.every((fn) => fn(r));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The github_id an activity statement is scoped to, read from its own WHERE.
|
|
180
|
+
function activityScope(sql, params) {
|
|
181
|
+
const m = /\ba\.github_id = \$(\d+)/.exec(sql);
|
|
182
|
+
if (!m) throw new Error(`fake pool: an activity read with no github_id scope — ${sql.slice(0, 120)}`);
|
|
183
|
+
return String(params[Number(m[1]) - 1]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let calls = [];
|
|
187
|
+
const pool = {
|
|
188
|
+
query: async (text, params = []) => {
|
|
189
|
+
const sql = String(text).replace(/\s+/g, ' ').trim();
|
|
190
|
+
calls.push({ sql, params });
|
|
191
|
+
|
|
192
|
+
if (/FROM platform_identity_accounts/.test(sql)) {
|
|
193
|
+
const where = sql.includes(' WHERE ')
|
|
194
|
+
? sql.slice(sql.indexOf(' WHERE ') + 7).replace(/ ORDER BY .*| LIMIT .*/g, '')
|
|
195
|
+
: '';
|
|
196
|
+
const cols = (/^SELECT (.+?) FROM /.exec(sql) || [, ''])[1];
|
|
197
|
+
let rows = ACCOUNTS.filter(accountFilter(where, params));
|
|
198
|
+
if (/is_current/.test(cols)) {
|
|
199
|
+
// resolveHandle asks the row whether the handle it matched is the CURRENT
|
|
200
|
+
// one — the flag that forks 200 from 301. Derived, never assumed.
|
|
201
|
+
const asked = String(params[0]).toLowerCase();
|
|
202
|
+
rows = rows
|
|
203
|
+
.map((r) => ({ ...r, is_current: String(r.handle || '').toLowerCase() === asked }))
|
|
204
|
+
.sort((a, b) => Number(b.is_current) - Number(a.is_current))
|
|
205
|
+
.slice(0, /LIMIT 1/.test(sql) ? 1 : rows.length);
|
|
206
|
+
}
|
|
207
|
+
return { rows: rows.map((r) => ({ ...r })), rowCount: rows.length };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The rollup aggregate — one row, always (an aggregate with no GROUP BY).
|
|
211
|
+
if (/WITH agg AS/.test(sql) && /platform_identity_builder_project_activity/.test(sql)) {
|
|
212
|
+
const who = ROLLUP[activityScope(sql, params)];
|
|
213
|
+
const agg = who ? who.agg : { projects: 0, credits: 0, works_shipped: 0, karma: 0, last_active: null, disciplines: null };
|
|
214
|
+
return { rows: [{ ...agg }], rowCount: 1 };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// The per-project rows the rollup names (or collapses into more_projects).
|
|
218
|
+
if (/platform_identity_builder_project_activity/.test(sql)) {
|
|
219
|
+
const who = ROLLUP[activityScope(sql, params)];
|
|
220
|
+
const rows = who ? who.projects.map((p) => ({ ...p })) : [];
|
|
221
|
+
return { rows, rowCount: rows.length };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
throw new Error(`fake pool: unsupported statement — ${sql.slice(0, 140)}`);
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// ===========================================================================
|
|
229
|
+
// The real routers over those tables
|
|
230
|
+
// ===========================================================================
|
|
231
|
+
|
|
232
|
+
// The viewer the stubbed session middleware presents. Real requireBuilder reads
|
|
233
|
+
// it from the session; the route body under test is untouched, and the route
|
|
234
|
+
// body is the whole point — a route that reached for req.query.github_id would
|
|
235
|
+
// be just as wrong with real auth in front of it.
|
|
236
|
+
let viewer = null;
|
|
237
|
+
|
|
238
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
239
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
240
|
+
require.cache[moduleApiPath] = {
|
|
241
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
242
|
+
exports: {
|
|
243
|
+
pool,
|
|
244
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
245
|
+
// The public route's CORS wrapper is not under test; a pass-through keeps
|
|
246
|
+
// the doorway honest without dragging core config in.
|
|
247
|
+
corsPublicGet: () => (_req, _res, next) => next(),
|
|
248
|
+
requireBuilder: (req, res, next) => {
|
|
249
|
+
if (!viewer) return res.fail('unauthenticated', 401);
|
|
250
|
+
req.builder = { ...viewer };
|
|
251
|
+
next();
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
257
|
+
const buildProfileRoutes = require('../modules/platform-identity/routes/profile.js');
|
|
258
|
+
const buildPublicProfileRoutes = require('../modules/platform-identity/routes/public-profile.js');
|
|
259
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
260
|
+
|
|
261
|
+
const app = express();
|
|
262
|
+
app.use(express.json());
|
|
263
|
+
app.use(attachFail);
|
|
264
|
+
app.use('/api/bongos', buildProfileRoutes());
|
|
265
|
+
app.use('/api/bongos', buildPublicProfileRoutes());
|
|
266
|
+
const server = app.listen(0, '127.0.0.1');
|
|
267
|
+
await new Promise((r) => server.once('listening', r));
|
|
268
|
+
const port = server.address().port;
|
|
269
|
+
after(() => server.close());
|
|
270
|
+
|
|
271
|
+
function get(path_, headers = {}) {
|
|
272
|
+
calls = [];
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const req = http.request({ host: '127.0.0.1', port, method: 'GET', path: path_, headers }, (res) => {
|
|
275
|
+
const chunks = [];
|
|
276
|
+
res.on('data', (c) => chunks.push(c));
|
|
277
|
+
res.on('end', () => {
|
|
278
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
279
|
+
let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
|
|
280
|
+
resolve({ status: res.statusCode, text, json });
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
req.on('error', reject);
|
|
284
|
+
req.end();
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const asAccount = (githubId) => {
|
|
289
|
+
const a = ACCOUNTS.find((x) => x.github_id === githubId);
|
|
290
|
+
viewer = { github_id: a.github_id, github_login: a.github_login, display_name: a.display_name, avatar_url: a.avatar_url };
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// ===========================================================================
|
|
294
|
+
// 0. The instrument is calibrated before it is trusted
|
|
295
|
+
// ===========================================================================
|
|
296
|
+
|
|
297
|
+
test('the interpreter refuses a predicate it cannot read, rather than ignoring it', () => {
|
|
298
|
+
assert.throws(
|
|
299
|
+
() => accountFilter('github_id = $1 AND some_new_flag = $2', [OCTO, true]),
|
|
300
|
+
/unread predicate on \$2/,
|
|
301
|
+
'a filter the fake cannot model must be a loud error — otherwise a rewritten WHERE silently widens',
|
|
302
|
+
);
|
|
303
|
+
assert.throws(
|
|
304
|
+
() => accountFilter("github_id = $1 AND shadow_banned = 'yes'", [OCTO]),
|
|
305
|
+
/unrecognised literal comparison on shadow_banned/,
|
|
306
|
+
'the privacy predicate is made of LITERAL comparisons — an unmodelled one must not read as absent',
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('the interpreter really does narrow on the visibility literals', () => {
|
|
311
|
+
const gated = ACCOUNTS.filter(accountFilter("(profile_state = 'active' AND account_visibility = 'public') AND (lower(handle) = $1 OR lower(previous_handle) = $1)", ['ghost']));
|
|
312
|
+
const ungated = ACCOUNTS.filter(accountFilter('(lower(handle) = $1 OR lower(previous_handle) = $1)', ['ghost']));
|
|
313
|
+
assert.equal(gated.length, 0, 'with the predicate, the private account is not reachable by handle');
|
|
314
|
+
assert.equal(ungated.length, 1, 'without it the SAME shape finds them — so dropping it genuinely changes this fixture');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ===========================================================================
|
|
318
|
+
// 1. No partial numbers: the hidden account leaks nothing by subtraction
|
|
319
|
+
// ===========================================================================
|
|
320
|
+
|
|
321
|
+
test('CONTROL: a public account really does serve every rollup sentinel', async () => {
|
|
322
|
+
// Without this, "no sentinels in the hidden body" could just mean the rollup
|
|
323
|
+
// never ran at all, and every assertion below would be vacuous.
|
|
324
|
+
viewer = null;
|
|
325
|
+
const res = await get('/api/bongos/profiles/octo');
|
|
326
|
+
assert.equal(res.status, 200);
|
|
327
|
+
for (const s of sentinelsOf(OCTO)) {
|
|
328
|
+
assert.ok(res.text.includes(s), `a public account's body must carry its rollup value ${s} — otherwise the leak test proves nothing`);
|
|
329
|
+
}
|
|
330
|
+
assert.ok(res.json.cross_project && res.json.cross_project.totals, 'and the payload really is the rollup shape');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('CONTROL: the hidden account HAS numbers — they exist, they are withheld', async () => {
|
|
334
|
+
// The other vacuity guard: prove the sentinels are real data behind the door,
|
|
335
|
+
// not an empty fixture that trivially cannot appear in any response.
|
|
336
|
+
const own = await pi.getOwnProfileExtras(SHY, { pool });
|
|
337
|
+
assert.equal(own.totals.credits, 606001, 'the hidden account has real numbers');
|
|
338
|
+
assert.equal(own.totals.works_shipped, 606002);
|
|
339
|
+
assert.deepEqual(own.projects.map((p) => p.name), ['Shy-Project-606005']);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('a stats-hidden account renders NO PARTIAL NUMBERS — not one sentinel survives', async () => {
|
|
343
|
+
viewer = null;
|
|
344
|
+
const res = await get('/api/bongos/profiles/shy');
|
|
345
|
+
assert.equal(res.status, 200, 'hide removes the ROLLUP, not the page (spec D2) — the identity band still resolves');
|
|
346
|
+
|
|
347
|
+
// The whole point of P10: a count beside a hidden total is a leak by
|
|
348
|
+
// subtraction, so the test is not "cross_project is null" (which a stray
|
|
349
|
+
// sibling key would satisfy) but "none of these values is anywhere".
|
|
350
|
+
for (const s of sentinelsOf(SHY)) {
|
|
351
|
+
assert.ok(!res.text.includes(s), `the hidden account's ${s} appears in the response — a count beside a hidden total is a leak by subtraction`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ...and the withholding is TOTAL, not a hollowed-out object. An empty
|
|
355
|
+
// {totals:{}} would tell a reader the rollup exists and is being suppressed.
|
|
356
|
+
assert.equal(res.json.cross_project, null, 'cross_project must be exactly null, never a partial or emptied object');
|
|
357
|
+
assert.ok(!('totals' in res.json), 'no rollup key may be hoisted to the top level to survive the null');
|
|
358
|
+
assert.deepEqual(Object.keys(res.json).sort(), ['account', 'cross_project'],
|
|
359
|
+
'the hidden response must carry no extra key — a new sibling key is exactly how a partial number arrives');
|
|
360
|
+
|
|
361
|
+
// The identity band is the other place a stray number can dock. The route
|
|
362
|
+
// whitelists it, and this pins that whitelist: a field added here reaches the
|
|
363
|
+
// world, so it must be a deliberate act that fails a test first.
|
|
364
|
+
assert.deepEqual(Object.keys(res.json.account).sort(),
|
|
365
|
+
['avatar_url', 'bio', 'display_name', 'github_login', 'handle', 'links', 'member_since'],
|
|
366
|
+
'a new key on the identity band of a HIDDEN account is how a count arrives beside a hidden total');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('the withholding is indistinguishable from having no numbers at all', async () => {
|
|
370
|
+
// A distinct "hidden" marker would be the same disclosure as a 403: it tells
|
|
371
|
+
// a stranger the account has a track record it is choosing not to show.
|
|
372
|
+
viewer = null;
|
|
373
|
+
const shy = await get('/api/bongos/profiles/shy');
|
|
374
|
+
assert.doesNotMatch(shy.text, /hidden|hide_stats|private|withheld|suppressed/i,
|
|
375
|
+
'the response must not name WHY the numbers are absent — that is itself the disclosure');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ===========================================================================
|
|
379
|
+
// 2. Renders to its owner: the own-scope door, over real HTTP
|
|
380
|
+
// ===========================================================================
|
|
381
|
+
|
|
382
|
+
test('the private account gets its OWN page from the own-scope door', async () => {
|
|
383
|
+
// The half the public 404 exists to make safe. GET /me/cross-project is what
|
|
384
|
+
// the page shell reads when the viewer is the account.
|
|
385
|
+
asAccount(GHOST);
|
|
386
|
+
const res = await get('/api/bongos/me/cross-project');
|
|
387
|
+
assert.equal(res.status, 200, 'a private account is still a full participant in its own scope');
|
|
388
|
+
|
|
389
|
+
// The banner input: the shell renders "private" from this field, and it must
|
|
390
|
+
// be the STORED value, not a default.
|
|
391
|
+
assert.equal(res.json.account_visibility, 'private',
|
|
392
|
+
'without this the owner cannot be told their page is private — the banner has no input');
|
|
393
|
+
assert.equal(res.json.handle, 'ghost', 'and it is their own record, identified');
|
|
394
|
+
assert.equal(res.json.bio, 'ghost-bio-515007');
|
|
395
|
+
|
|
396
|
+
// Their numbers are theirs: the side door is deliberately flag-blind.
|
|
397
|
+
assert.ok(res.json.cross_project, 'a private account must be able to read its own track record');
|
|
398
|
+
assert.equal(res.json.cross_project.totals.credits, 515001);
|
|
399
|
+
assert.equal(res.json.cross_project.totals.works_shipped, 515002);
|
|
400
|
+
assert.deepEqual(res.json.cross_project.projects.map((p) => p.name), ['Ghost-Project-515005']);
|
|
401
|
+
|
|
402
|
+
// ...and the reason it is a SIDE door: no visibility predicate is applied.
|
|
403
|
+
assert.ok(!calls.some((c) => /account_visibility = '|profile_state = '/.test(c.sql)),
|
|
404
|
+
'the self view must apply no visibility predicate — that is what makes it a side door, not a second public read');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('BOTH HALVES AT ONCE: the same account, same instant — 404 public, 200 own', async () => {
|
|
408
|
+
// P10 stated as one assertion. The public response is viewer-independent, so
|
|
409
|
+
// the owner sees 404 there too (privacy spec D2, reconciling the profile-page
|
|
410
|
+
// spec's looser "404 to everyone but the account itself"); the page they
|
|
411
|
+
// actually see is composed from the own-scope door.
|
|
412
|
+
asAccount(GHOST);
|
|
413
|
+
const publicRead = await get('/api/bongos/profiles/ghost');
|
|
414
|
+
assert.equal(publicRead.status, 404, 'the PUBLIC read 404s even for the owner — it takes no viewer, so it cannot vary by one');
|
|
415
|
+
assert.notEqual(publicRead.status, 403, 'a 403 would confirm the handle exists, which is the disclosure the 404 rule prevents');
|
|
416
|
+
assert.ok(!publicRead.text.includes('Ghost'), 'and nothing about the account rides in the 404 body');
|
|
417
|
+
assert.ok(!publicRead.text.includes('ghost-bio-515007'));
|
|
418
|
+
|
|
419
|
+
const ownRead = await get('/api/bongos/me/cross-project');
|
|
420
|
+
assert.equal(ownRead.status, 200, 'and the very same account, in the very same state, renders to itself');
|
|
421
|
+
assert.equal(ownRead.json.handle, 'ghost');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test('a HIDDEN owner still sees their own numbers — hide is a public rule, not a self rule', async () => {
|
|
425
|
+
asAccount(SHY);
|
|
426
|
+
const res = await get('/api/bongos/me/cross-project');
|
|
427
|
+
assert.equal(res.status, 200);
|
|
428
|
+
assert.equal(res.json.hide_stats, true, 'the toggle reads back, so Settings can render its state');
|
|
429
|
+
assert.equal(res.json.cross_project.totals.credits, 606001,
|
|
430
|
+
'ADR 0171 D2: a hidden builder inspects their numbers HERE, which is why the public route may return null');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// ===========================================================================
|
|
434
|
+
// 3. The bypass that would nullify every 404
|
|
435
|
+
// ===========================================================================
|
|
436
|
+
|
|
437
|
+
test('the own-scope door answers the SESSION, never a caller-named account', async () => {
|
|
438
|
+
// If this door ever honoured a caller-named id, every 404 the public read
|
|
439
|
+
// returns becomes a speed bump: ask the side door for the private account
|
|
440
|
+
// instead. It is the single most load-bearing assertion in this file.
|
|
441
|
+
asAccount(NOSY);
|
|
442
|
+
const attempts = [
|
|
443
|
+
['/api/bongos/me/cross-project?github_id=' + GHOST, {}],
|
|
444
|
+
['/api/bongos/me/cross-project?id=' + GHOST, {}],
|
|
445
|
+
['/api/bongos/me/cross-project?handle=ghost', {}],
|
|
446
|
+
['/api/bongos/me/cross-project?github_login=ghost', {}],
|
|
447
|
+
['/api/bongos/me/cross-project', { 'x-github-id': GHOST }],
|
|
448
|
+
['/api/bongos/me/cross-project', { 'x-builder-id': GHOST, 'x-github-login': 'ghost' }],
|
|
449
|
+
];
|
|
450
|
+
for (const [path_, headers] of attempts) {
|
|
451
|
+
const res = await get(path_, headers);
|
|
452
|
+
assert.equal(res.status, 200, `${path_} must still serve the caller`);
|
|
453
|
+
assert.equal(res.json.handle, 'nosy', `${path_} returned someone else's account — the own-scope door is not own-scoped`);
|
|
454
|
+
assert.equal(res.json.account_visibility, 'public', `${path_} leaked another account's privacy state`);
|
|
455
|
+
for (const s of sentinelsOf(GHOST)) {
|
|
456
|
+
assert.ok(!res.text.includes(s), `${path_} leaked the private account's ${s}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('the own-scope door is closed to an unauthenticated caller', async () => {
|
|
462
|
+
viewer = null;
|
|
463
|
+
const res = await get('/api/bongos/me/cross-project');
|
|
464
|
+
assert.equal(res.status, 401, 'no session, no self — otherwise "own scope" means "anyone\'s scope"');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// ===========================================================================
|
|
468
|
+
// 4. Lane proof — a test that never runs is not a gate (task 1002282)
|
|
469
|
+
// ===========================================================================
|
|
470
|
+
|
|
471
|
+
test('this proof runs in the DB-free lane that executes on every PR', () => {
|
|
472
|
+
const runner = fs.readFileSync(join(ROOT, 'scripts', 'gds', 'run-unit-tests.js'), 'utf8');
|
|
473
|
+
assert.ok(
|
|
474
|
+
!/profile_privacy_owner_view/.test(runner),
|
|
475
|
+
'named in run-unit-tests.js means parked into INTEGRATION or KNOWN_FAILING — where CI has no Postgres, this file would self-skip forever and gate nothing',
|
|
476
|
+
);
|
|
477
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -1655,3 +1655,24 @@ test('the kit proves the field path: the fixture carries a curated template and
|
|
|
1655
1655
|
assert.ok(attrs.some((a) => a.includes('amazonprimea.com') && a.endsWith('data-template bead-wash')), 'the curated template reaches the orb');
|
|
1656
1656
|
assert.ok(attrs.some((a) => a.includes('demo.cloudbongos.com') && a.endsWith('data-material gold')), 'the material reaches the orb as its own attribute');
|
|
1657
1657
|
});
|
|
1658
|
+
|
|
1659
|
+
/* ── the table's columns on FIXED tracks (task 1003348; the owner, 2026-08-29: "the
|
|
1660
|
+
columns in that projects table view dont look aligned") ── */
|
|
1661
|
+
test('one column grammar: the table lays every row on one set of fixed tracks (each header over its cells), and the mine list shares the rule', () => {
|
|
1662
|
+
assert.match(COSMOS, /\.tbl\{table-layout:fixed;min-width:860px;\}/, 'the header row\'s widths are the table\'s — no per-row content sizing');
|
|
1663
|
+
// the task's other half — the Your projects list — landed with task 1003345 and is pinned above
|
|
1664
|
+
// ('the mine list lays every row on one set of FIXED tracks'); it is asserted here too so the two
|
|
1665
|
+
// halves of the grammar can never drift apart: fixed planet + doors, ratio tracks with no content minimum
|
|
1666
|
+
assert.match(COSMOS, /#view-mine \.proj\{display:grid;grid-template-columns:72px minmax\(0,1fr\);/, 'the mine row: the planet track is a width');
|
|
1667
|
+
assert.match(COSMOS, /#view-mine \.projBody\{display:grid;grid-template-columns:minmax\(0,1\.1fr\) minmax\(0,1\.3fr\) minmax\(0,1fr\) 232px;/, 'the mine row: name, facts and rail by ratio with no content minimum, the doors a width');
|
|
1668
|
+
assert.match(COSMOS, /the same column grammar the every-project table takes/, 'the mine block names the table as its twin');
|
|
1669
|
+
for (const [col, w] of [['colType', 88], ['colBuilders', 96], ['colTags', 168], ['colStatus', 224], ['colGo', 112]]) {
|
|
1670
|
+
assert.match(COSMOS, new RegExp(`\\.tbl th\\.${col}\\{width:${w}px;\\}`), `${col} is a width, not a 1% hug`);
|
|
1671
|
+
}
|
|
1672
|
+
assert.doesNotMatch(COSMOS, /\.tbl \.col\w+\{width:1%/, 'no column hugs its widest cell any more');
|
|
1673
|
+
assert.match(COSMOS, /\.tbl th\.colName\{padding-left:22px;\}/, 'the Project header sits over the name, past the dot and its gap');
|
|
1674
|
+
assert.match(COSMOS, /\.tbl th\.right\{text-align:right;\}/, 'a right-aligned column\'s header is right-aligned too');
|
|
1675
|
+
assert.match(HUB, /<th class="right colBuilders" data-sort="builders">/, 'the builders header names its column so its width can be set');
|
|
1676
|
+
assert.match(COSMOS, /@media \(max-width:780px\)\{\.tbl \.colTags,\.tbl \.colType\{display:none;\}\.tbl\{min-width:600px;\}\}/, 'a phone scrolls the table inside its wrap');
|
|
1677
|
+
assert.match(COSMOS, /#sub-table-body\{overflow-x:auto;\}/, 'the wrap scrolls, never the page');
|
|
1678
|
+
});
|
|
@@ -560,7 +560,7 @@ test('the done panel’s progress region is stable, change-only, and says each s
|
|
|
560
560
|
|
|
561
561
|
test('the map table sorts from the keyboard and an owned row’s name is a real link', () => {
|
|
562
562
|
assert.match(HUB, /<th class="colName" data-sort="name" aria-sort="ascending"><button type="button" class="thSort">Project<\/button><\/th>/);
|
|
563
|
-
assert.match(HUB, /<th class="right" data-sort="builders"><button type="button" class="thSort">Builders<\/button><\/th>/);
|
|
563
|
+
assert.match(HUB, /<th class="right colBuilders" data-sort="builders"><button type="button" class="thSort">Builders<\/button><\/th>/);
|
|
564
564
|
assert.match(SCRIPT, /var btn = th\.querySelector\('button'\);\n\s*if \(!btn\) return;\n\s*btn\.addEventListener\('click'/, 'the listener sits on the button, not the th');
|
|
565
565
|
assert.doesNotMatch(SCRIPT, /th\.style\.cursor/, 'no JS-set cursor on the th');
|
|
566
566
|
assert.match(COSMOS, /\.tbl th button\{all:unset;cursor:pointer;\}/);
|