@bongos/core 1.19.391 → 1.19.393
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 +53 -18
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -0
- package/docs/api-reference.md +1 -1
- package/docs/design/mocks/hub/hub-sheet.html +192 -0
- package/docs/design/mocks/hub/v1.html +1209 -0
- package/docs/design/mocks/hub/v2.html +1257 -0
- package/docs/design/mocks/hub/v3.html +1496 -0
- package/docs/design/projects-hub-direction-v2.md +108 -0
- package/modules/platform-identity/migrations/platform_identity_011_project_visibility.sql +57 -0
- package/modules/platform-identity/platform-identity.js +73 -10
- package/modules/platform-identity/routes/projects.js +7 -1
- package/modules/platform-identity/tests/platform-identity.mjs +106 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +5 -1
- package/tests/project_visibility_db.mjs +175 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// tests/project_visibility_db.mjs — BV1.R04, task 1002323 (goal 1000046; real DB).
|
|
2
|
+
//
|
|
3
|
+
// The DB-free sibling (modules/platform-identity/tests/platform-identity.mjs) pins the
|
|
4
|
+
// JS gate and the migration's TEXT. This proves what only a real transaction engine can:
|
|
5
|
+
//
|
|
6
|
+
// 1. THE BACKFILL — a row that existed BEFORE migration 011 comes out `visibility =
|
|
7
|
+
// 'public'` (today's behaviour, unchanged) with `description` still NULL, because
|
|
8
|
+
// ADR 0182 D4 refuses to seed the publish bar from `tagline`.
|
|
9
|
+
// 2. THE CHECK actually refuses a fourth stored state — 'unlisted' / 'dark-matter'
|
|
10
|
+
// cannot be written, which is the constraint the whole 3+1 split rests on.
|
|
11
|
+
// 3. THE DERIVED GATE agrees in SQL and in JS — publishableSql() and
|
|
12
|
+
// projectPublishability() return the same verdict for the same row, so the feed
|
|
13
|
+
// (R06) and the owner-facing refusal (R16) cannot disagree about who is on the map.
|
|
14
|
+
// 4. `featured = true AND status = 'active'` is NO LONGER "is this public" — a
|
|
15
|
+
// featured, active, visibility='public' husk with no description is dark matter.
|
|
16
|
+
// 5. A client re-registration (registerClient → upsertProject) never resets an owner's
|
|
17
|
+
// door: the hub row is a CACHE and the door arrives by report (ADR 0182 D5 / R08).
|
|
18
|
+
// 6. The migration is IDEMPOTENT — replaying it over populated rows changes nothing.
|
|
19
|
+
//
|
|
20
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in run-unit-tests.js's
|
|
21
|
+
// INTEGRATION set so the default DB-free lane never runs it.
|
|
22
|
+
//
|
|
23
|
+
// Run: DATABASE_URL=... node tests/project_visibility_db.mjs
|
|
24
|
+
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { Pool } = require('pg');
|
|
32
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
33
|
+
|
|
34
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
36
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
37
|
+
|
|
38
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
39
|
+
try {
|
|
40
|
+
await pool.query('SELECT 1');
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.log(`project_visibility_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
43
|
+
await pool.end().catch(() => {});
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fixture origins carry the task id so a shared DB can never collide with real rows.
|
|
48
|
+
const LEGACY = 'https://legacy-1002323.test';
|
|
49
|
+
const HUSK = 'https://husk-1002323.test';
|
|
50
|
+
const FULL = 'https://full-1002323.test';
|
|
51
|
+
const ALL = [LEGACY, HUSK, FULL];
|
|
52
|
+
|
|
53
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
54
|
+
const cleanup = async () => {
|
|
55
|
+
await q(`DELETE FROM platform_identity_projects WHERE origin = ANY($1::text[])`, [ALL]).catch(() => {});
|
|
56
|
+
await q(`DELETE FROM platform_identity_sso_clients WHERE origin = ANY($1::text[])`, [ALL]).catch(() => {});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let pass = 0;
|
|
60
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
61
|
+
const rowOf = async (origin) => (await q(
|
|
62
|
+
`SELECT id, origin, name, tagline, description, visibility, status, featured
|
|
63
|
+
FROM platform_identity_projects WHERE origin = $1`, [origin],
|
|
64
|
+
)).rows[0];
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// The table as it stood BEFORE this task, so the backfill has something to act on.
|
|
68
|
+
await q(migrationSql('platform_identity_001_tables.sql'));
|
|
69
|
+
await q(migrationSql('platform_identity_004_projects.sql'));
|
|
70
|
+
await q(migrationSql('platform_identity_006_project_type.sql'));
|
|
71
|
+
await cleanup();
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// 1. The backfill: a pre-migration row keeps today's behaviour.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
await q(
|
|
77
|
+
`INSERT INTO platform_identity_projects (origin, name, tagline, featured)
|
|
78
|
+
VALUES ($1, 'Legacy', 'a card one-liner', true)`, [LEGACY],
|
|
79
|
+
);
|
|
80
|
+
await q(migrationSql('platform_identity_011_project_visibility.sql'));
|
|
81
|
+
|
|
82
|
+
const legacy = await rowOf(LEGACY);
|
|
83
|
+
ok(legacy.visibility === 'public', 'the backfill IS the DEFAULT: a pre-migration row lands visibility=public');
|
|
84
|
+
ok(legacy.description === null, 'description is NOT seeded from tagline (ADR 0182 D4) — the card cannot publish the project');
|
|
85
|
+
ok(legacy.tagline === 'a card one-liner', 'the existing tagline is untouched');
|
|
86
|
+
ok(legacy.featured === true && legacy.status === 'active', 'nothing else about the row moved');
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// 2. The CHECK refuses a stored fourth state.
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
for (const forbidden of ['unlisted', 'dark-matter', 'published', '']) {
|
|
92
|
+
let refused = false;
|
|
93
|
+
try {
|
|
94
|
+
await q(`UPDATE platform_identity_projects SET visibility = $2 WHERE origin = $1`, [LEGACY, forbidden]);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
refused = e.code === '23514'; // check_violation
|
|
97
|
+
}
|
|
98
|
+
ok(refused, `the DB CHECK refuses visibility='${forbidden}' — there is no stored fourth state`);
|
|
99
|
+
}
|
|
100
|
+
for (const allowed of ['public', 'private', 'stealth']) {
|
|
101
|
+
await q(`UPDATE platform_identity_projects SET visibility = $2 WHERE origin = $1`, [LEGACY, allowed]);
|
|
102
|
+
ok((await rowOf(LEGACY)).visibility === allowed, `the DB accepts the door value '${allowed}'`);
|
|
103
|
+
}
|
|
104
|
+
await q(`UPDATE platform_identity_projects SET visibility = 'public' WHERE origin = $1`, [LEGACY]);
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// 3 + 4. The derived gate: SQL and JS agree, and `featured` no longer answers it.
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
await pi.upsertProject({ origin: HUSK, name: 'Husk' }, { pool });
|
|
110
|
+
await pi.upsertProject({ origin: FULL, name: 'Full', description: 'A weather simulation.' }, { pool });
|
|
111
|
+
await q(`UPDATE platform_identity_projects SET featured = true WHERE origin = ANY($1::text[])`, [[HUSK, FULL]]);
|
|
112
|
+
|
|
113
|
+
const verdicts = (await q(
|
|
114
|
+
`SELECT origin, name, description, ${pi.publishableSql()} AS publishable
|
|
115
|
+
FROM platform_identity_projects WHERE origin = ANY($1::text[]) ORDER BY origin`,
|
|
116
|
+
[[HUSK, FULL, LEGACY]],
|
|
117
|
+
)).rows;
|
|
118
|
+
for (const row of verdicts) {
|
|
119
|
+
const js = pi.projectPublishability(row);
|
|
120
|
+
ok(row.publishable === js.publishable,
|
|
121
|
+
`SQL and JS agree on ${row.origin} (publishable=${row.publishable}) — the feed and the refusal cannot drift`);
|
|
122
|
+
}
|
|
123
|
+
const husk = verdicts.find((r) => r.origin === HUSK);
|
|
124
|
+
ok(husk.publishable === false, 'a featured, active, visibility=public project with no description is DARK MATTER');
|
|
125
|
+
ok(pi.projectPublishability(husk).missing.join() === 'description', 'and the gate NAMES the missing field, never merely refuses');
|
|
126
|
+
ok(verdicts.find((r) => r.origin === FULL).publishable === true, 'name + description alone clears the bar — the gate is identity only');
|
|
127
|
+
|
|
128
|
+
// A whitespace-only description is not an identity, in SQL too (btrim, not NULL-checks).
|
|
129
|
+
await q(`UPDATE platform_identity_projects SET description = ' ' WHERE origin = $1`, [FULL]);
|
|
130
|
+
const blank = (await q(
|
|
131
|
+
`SELECT ${pi.publishableSql()} AS publishable FROM platform_identity_projects WHERE origin = $1`, [FULL],
|
|
132
|
+
)).rows[0];
|
|
133
|
+
ok(blank.publishable === false, 'a whitespace-only description does not clear the bar in SQL either');
|
|
134
|
+
await q(`UPDATE platform_identity_projects SET description = 'A weather simulation.' WHERE origin = $1`, [FULL]);
|
|
135
|
+
|
|
136
|
+
// The aliased form is the one R06 will use across a join — prove it parses and agrees.
|
|
137
|
+
const joined = (await q(
|
|
138
|
+
`SELECT p.origin, ${pi.publishableSql('p')} AS publishable
|
|
139
|
+
FROM platform_identity_projects p WHERE p.origin = $1`, [FULL],
|
|
140
|
+
)).rows[0];
|
|
141
|
+
ok(joined.publishable === true, 'publishableSql(alias) is valid SQL in a joined query and agrees');
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// 5. The hub row is a CACHE — no write path here resets an owner's door.
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
await q(`UPDATE platform_identity_projects SET visibility = 'stealth' WHERE origin = $1`, [FULL]);
|
|
147
|
+
await pi.registerClient(
|
|
148
|
+
{ clientId: `c-1002323-${Date.now()}`, secret: 'z'.repeat(64), name: 'Full', redirectUris: [], origin: FULL },
|
|
149
|
+
{ pool },
|
|
150
|
+
);
|
|
151
|
+
ok((await rowOf(FULL)).visibility === 'stealth',
|
|
152
|
+
'a client re-registration does not reset the door to public (ADR 0182 D5 — the door arrives by report, R08)');
|
|
153
|
+
await pi.upsertProject({ origin: FULL, name: 'Full renamed' }, { pool });
|
|
154
|
+
const afterUpsert = await rowOf(FULL);
|
|
155
|
+
ok(afterUpsert.visibility === 'stealth', 'nor does a descriptionless catalog upsert');
|
|
156
|
+
ok(afterUpsert.description === 'A weather simulation.', 'nor does it erase the description (COALESCE-preserved)');
|
|
157
|
+
|
|
158
|
+
// A stealth project is a NORMAL row in the curation list — all three doors are visible.
|
|
159
|
+
const listed = (await pi.listAllProjects({ pool })).find((p) => p.origin === FULL);
|
|
160
|
+
ok(listed && listed.visibility === 'stealth' && listed.publishable === true,
|
|
161
|
+
'listAllProjects carries the stored door and the derived verdict together');
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// 6. Idempotent replay over populated rows.
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
await q(migrationSql('platform_identity_011_project_visibility.sql'));
|
|
167
|
+
const replayed = await rowOf(FULL);
|
|
168
|
+
ok(replayed.visibility === 'stealth' && replayed.description === 'A weather simulation.',
|
|
169
|
+
'replaying migration 011 over populated rows changes nothing');
|
|
170
|
+
|
|
171
|
+
console.log(`\nproject_visibility_db: ${pass} assertions passed`);
|
|
172
|
+
} finally {
|
|
173
|
+
await cleanup();
|
|
174
|
+
await pool.end().catch(() => {});
|
|
175
|
+
}
|