@bongos/core 1.19.533 → 1.19.535
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 +121 -56
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
- package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
- package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
- package/docs/adr/README.md +4 -0
- package/docs/api/openapi.json +8 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +162 -157
- package/docs/copy-registry.json +252 -196
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +16 -0
- package/modules/hall-ui/public/hall-kit.js +93 -7
- package/modules/hall-ui/public/roadmap-timeline.js +105 -0
- package/modules/hall-ui/public/roadmap.html +7 -1
- package/modules/hall-ui/public/roadmap.js +16 -0
- package/modules/hall-ui/public/style.css +4 -2
- package/modules/hall-ui/public/watch.html +4 -0
- package/modules/hall-ui/public/watch.js +116 -0
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +21 -6
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-catalog.js +202 -0
- package/modules/platform-identity/routes/my-projects.js +7 -2
- package/modules/platform-identity/routes/projects.js +6 -0
- package/modules/platform-identity/routes/sso.js +10 -0
- package/modules/provisioning/catalog-bridge.js +105 -0
- package/modules/provisioning/provisioning.js +28 -2
- package/modules/provisioning/routes/provisioning.js +45 -11
- package/modules/provisioning/tests/provisioning.mjs +11 -8
- package/modules/public-landing/public/assets/cosmos.css +15 -0
- package/modules/public-landing/public/projects.html +162 -3
- package/modules/ui-design/kit/serve.js +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/provision-net.js +7 -0
- package/scripts/hall-preview/refresh-fixtures.js +6 -0
- package/src/bongos/project-door.js +138 -0
- package/src/branding.js +8 -0
- package/src/module-api.js +12 -1
- package/tests/access_requests_invite.mjs +2 -2
- package/tests/application_lifecycle.mjs +1 -0
- package/tests/catalog_only_client.mjs +242 -0
- package/tests/currency_label.mjs +8 -5
- package/tests/hall_kit_charts.mjs +173 -0
- package/tests/hall_oversight_world.mjs +159 -0
- package/tests/hall_reading_world.mjs +153 -0
- package/tests/module_api.mjs +1 -0
- package/tests/project_catalog_bridge.mjs +355 -0
- package/tests/project_catalog_routes.mjs +213 -0
- package/tests/project_join_door.mjs +166 -0
- package/tests/project_join_door_route.mjs +206 -0
- package/tests/project_modules_ui.mjs +318 -0
- package/tests/projects_hub.mjs +6 -1
- package/tests/projects_hub_pre_uat.mjs +2 -2
- package/tests/provision_settings_apply.mjs +6 -5
- package/tests/provisioning_module_selection.mjs +164 -3
- package/tests/provisioning_settings.mjs +10 -7
- package/tests/provisioning_settings_apply.mjs +3 -3
- package/tests/provisioning_settings_env.mjs +7 -4
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// tests/project_catalog_routes.mjs — WHEN the catalog projection is re-taken (task 1003576,
|
|
2
|
+
// ADR 0246). The wiring half; tests/project_catalog_bridge.mjs owns WHAT it writes.
|
|
3
|
+
//
|
|
4
|
+
// The R18 proof's finding was not "the write is wrong" but "the write happens ONCE, at
|
|
5
|
+
// provision time, for one hosting shape". So the assertions here are about CALL SITES:
|
|
6
|
+
// every provisioning write that can change what the map shows must re-take the picture.
|
|
7
|
+
//
|
|
8
|
+
// POST /provisioning/instances — the project joins the catalog at birth
|
|
9
|
+
// PATCH /provisioning/instances/:id/detail — the description that publishes it, AND
|
|
10
|
+
// the clearing that un-publishes it
|
|
11
|
+
// PATCH /provisioning/instances/:id/settings — the join door the orb is drawn from
|
|
12
|
+
// POST /provisioning/instances/:id/domain — the origin the row is KEYED on
|
|
13
|
+
//
|
|
14
|
+
// …and one negative: a settings save that changes nothing must not write to the catalog
|
|
15
|
+
// either, for the same reason it does not restart the project.
|
|
16
|
+
//
|
|
17
|
+
// DB-free: the real router over loopback, data access and the bridge stubbed on the cached
|
|
18
|
+
// module objects (the provisioning_settings.mjs pattern).
|
|
19
|
+
//
|
|
20
|
+
// Run: node --test --test-reporter=tap tests/project_catalog_routes.mjs
|
|
21
|
+
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { test, after, beforeEach } from 'node:test';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import http from 'node:http';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
29
|
+
const catalogBridge = require('../modules/provisioning/catalog-bridge.js');
|
|
30
|
+
const api = require('../src/module-api.js');
|
|
31
|
+
|
|
32
|
+
const OWNER = { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
33
|
+
const BASE_ROW = Object.freeze({
|
|
34
|
+
id: 18, slug: 'spike-harbor', domain: 'spike-harbor.cloudbongos.test',
|
|
35
|
+
hosting_shape: 'standalone', status: 'active', owner_builder_id: 7,
|
|
36
|
+
description: null, type: 'research', settings: { visibility: 'public' },
|
|
37
|
+
released_domains: [], cost_estimate_usd: 0,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
let instance = { ...BASE_ROW };
|
|
41
|
+
// Every catalog projection the routes ask for, in order.
|
|
42
|
+
let published = [];
|
|
43
|
+
|
|
44
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
45
|
+
provisioning.getInstanceBySlug = async (_db, slug) => (instance && instance.slug === slug ? { ...instance } : null);
|
|
46
|
+
provisioning.getInstanceByDomain = async () => null;
|
|
47
|
+
provisioning.createInstance = async (_db, req) => {
|
|
48
|
+
instance = { ...BASE_ROW, slug: req.slug, domain: req.domain || null, description: req.description || null };
|
|
49
|
+
return { instance: { ...instance }, created: true };
|
|
50
|
+
};
|
|
51
|
+
provisioning.updateInstanceDetail = async (_db, id, patch) => {
|
|
52
|
+
instance = { ...instance, ...patch };
|
|
53
|
+
return { ...instance, id };
|
|
54
|
+
};
|
|
55
|
+
provisioning.updateInstanceSettings = async (_db, id, patch) => {
|
|
56
|
+
instance = { ...instance, settings: { ...(instance.settings || {}), ...patch } };
|
|
57
|
+
return { ...instance, id };
|
|
58
|
+
};
|
|
59
|
+
provisioning.updateInstanceDomain = async (_db, id, domain) => {
|
|
60
|
+
instance = { ...instance, domain };
|
|
61
|
+
return { ...instance, id };
|
|
62
|
+
};
|
|
63
|
+
provisioning.recordEvent = async () => {};
|
|
64
|
+
provisioning.getOpenIntent = async () => null;
|
|
65
|
+
provisioning.enqueueIntent = async () => ({ intent: { id: 1, action: 'provision', state: 'pending' }, created: true });
|
|
66
|
+
provisioning.enqueueSettingsApply = async () => ({ intent: { id: 2, action: 'settings-apply', state: 'pending' }, created: true });
|
|
67
|
+
provisioning.getLatestOAuthManifestForInstance = async () => null;
|
|
68
|
+
|
|
69
|
+
// THE BRIDGE ITSELF is stubbed: this file asserts that the routes call it, with which row,
|
|
70
|
+
// and how often. What it then writes is pinned in tests/project_catalog_bridge.mjs.
|
|
71
|
+
catalogBridge.publishInstanceToCatalog = async (_db, row, opts = {}) => {
|
|
72
|
+
published.push({ row: row ? { ...row } : null, ...opts });
|
|
73
|
+
return { published: true };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Destructured off the doorway at route-module load — stub before requiring the router.
|
|
77
|
+
api.requireBuilder = (req, _res, next) => { req.builder = OWNER; next(); };
|
|
78
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
79
|
+
api.branding = () => ({ domains: { publicOrigin: 'https://cb.test', instanceBase: 'cloudbongos.test' },
|
|
80
|
+
identity: { productName: 'Cloud Bongos' } });
|
|
81
|
+
|
|
82
|
+
const express = require('express');
|
|
83
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
84
|
+
|
|
85
|
+
const app = express();
|
|
86
|
+
app.use(express.json());
|
|
87
|
+
app.use((_req, res, next) => {
|
|
88
|
+
res.fail = (code, opts, details) => {
|
|
89
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
90
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
|
|
91
|
+
};
|
|
92
|
+
next();
|
|
93
|
+
});
|
|
94
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
95
|
+
|
|
96
|
+
const server = app.listen(0);
|
|
97
|
+
await new Promise((r) => server.once('listening', r));
|
|
98
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
99
|
+
after(() => server.close());
|
|
100
|
+
|
|
101
|
+
function call(method, path, body) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
104
|
+
const req = http.request(`${base}${path}`, {
|
|
105
|
+
method,
|
|
106
|
+
headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {},
|
|
107
|
+
}, (res) => {
|
|
108
|
+
let d = '';
|
|
109
|
+
res.on('data', (c) => { d += c; });
|
|
110
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j }); });
|
|
111
|
+
});
|
|
112
|
+
req.on('error', reject);
|
|
113
|
+
if (payload) req.write(payload);
|
|
114
|
+
req.end();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
beforeEach(() => { instance = { ...BASE_ROW }; published = []; });
|
|
119
|
+
|
|
120
|
+
test('CREATE catalogues the project at birth, whatever its hosting shape', async () => {
|
|
121
|
+
// The wizard hard-codes hosting_shape 'standalone', which is exactly the shape
|
|
122
|
+
// shouldFederate() refused — so this is the call that ends "never catalogued at all".
|
|
123
|
+
const res = await call('POST', '/provisioning/instances', {
|
|
124
|
+
slug: 'spike-harbor', hosting_shape: 'standalone', description: 'A harbour for spikes.',
|
|
125
|
+
});
|
|
126
|
+
assert.equal(res.status, 201);
|
|
127
|
+
assert.equal(published.length, 1);
|
|
128
|
+
assert.equal(published[0].row.slug, 'spike-harbor');
|
|
129
|
+
assert.equal(published[0].row.description, 'A harbour for spikes.');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('CREATE catalogues even when the owner SKIPS the description', async () => {
|
|
133
|
+
// Skipping is the supported default: the row exists, the publish gate keeps it dark
|
|
134
|
+
// matter, and PATCH …/detail is the way back onto the map. The row has to be there for
|
|
135
|
+
// that way back to reach anything.
|
|
136
|
+
const res = await call('POST', '/provisioning/instances', { slug: 'spike-harbor', hosting_shape: 'standalone' });
|
|
137
|
+
assert.equal(res.status, 201);
|
|
138
|
+
assert.equal(published.length, 1);
|
|
139
|
+
assert.equal(published[0].row.description, null);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('the DETAIL write re-takes the projection with the row it just saved', async () => {
|
|
143
|
+
// THE bug: a description added after provisioning never reached the catalog.
|
|
144
|
+
const res = await call('PATCH', '/provisioning/instances/18/detail', { description: 'A harbour for spikes.' });
|
|
145
|
+
assert.equal(res.status, 200);
|
|
146
|
+
assert.equal(published.length, 1);
|
|
147
|
+
assert.equal(published[0].row.description, 'A harbour for spikes.',
|
|
148
|
+
'the UPDATED row, not the one read at the top of the handler');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('CLEARING the description also re-takes it, so the project leaves the map', async () => {
|
|
152
|
+
instance = { ...BASE_ROW, description: 'A harbour for spikes.' };
|
|
153
|
+
const res = await call('PATCH', '/provisioning/instances/18/detail', { description: ' ' });
|
|
154
|
+
assert.equal(res.status, 200);
|
|
155
|
+
assert.equal(published.length, 1);
|
|
156
|
+
assert.equal(published[0].row.description, null, 'normalizeDescription made it a real null');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a detail write that only answers the interview still re-takes it', async () => {
|
|
160
|
+
// team_shape does not move the publish gate, but the projection is cheap and one call site
|
|
161
|
+
// per write is the invariant — a conditional here is how the next field gets forgotten.
|
|
162
|
+
const res = await call('PATCH', '/provisioning/instances/18/detail', { detail: { team_shape: 'solo' } });
|
|
163
|
+
assert.equal(res.status, 200);
|
|
164
|
+
assert.equal(published.length, 1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('the SETTINGS write re-takes the projection when the join door changes', async () => {
|
|
168
|
+
const res = await call('PATCH', '/provisioning/instances/18/settings', { visibility: 'stealth' });
|
|
169
|
+
assert.equal(res.status, 200);
|
|
170
|
+
assert.equal(published.length, 1);
|
|
171
|
+
assert.equal(published[0].row.settings.visibility, 'stealth');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a settings save that changes NOTHING writes nothing to the catalog', async () => {
|
|
175
|
+
// The same rule the restart follows: a whole-form save or a retried request must not
|
|
176
|
+
// bounce a live project, and it must not churn the catalog row either.
|
|
177
|
+
const res = await call('PATCH', '/provisioning/instances/18/settings', { visibility: 'public' });
|
|
178
|
+
assert.equal(res.status, 200);
|
|
179
|
+
assert.equal(published.length, 0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('the DOMAIN write re-keys the row and RETIRES the address it moved off', async () => {
|
|
183
|
+
// The catalog is keyed on origin, so a re-point both moves the project and abandons an
|
|
184
|
+
// origin — one left `active` would keep drawing an orb at a hostname it no longer answers on.
|
|
185
|
+
const res = await call('POST', '/provisioning/instances/18/domain', { domain: 'harbour.example.com' });
|
|
186
|
+
assert.equal(res.status, 200);
|
|
187
|
+
assert.equal(published.length, 1);
|
|
188
|
+
assert.equal(published[0].row.domain, 'harbour.example.com');
|
|
189
|
+
assert.equal(published[0].retireOrigin, 'spike-harbor.cloudbongos.test');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('re-sending the SAME address is a no-op and re-takes nothing', async () => {
|
|
193
|
+
const res = await call('POST', '/provisioning/instances/18/domain', { domain: 'spike-harbor.cloudbongos.test' });
|
|
194
|
+
assert.equal(res.status, 200);
|
|
195
|
+
assert.equal(res.body.changed, false);
|
|
196
|
+
assert.equal(published.length, 0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('a catalog hiccup can never turn a saved change into a failure', async () => {
|
|
200
|
+
// Every call site runs AFTER the durable write, so a hub that is unreachable must not
|
|
201
|
+
// make the owner believe their description was lost. The bridge swallows its own errors;
|
|
202
|
+
// the `.catch(() => {})` at each call site is the second belt, and this is what proves it —
|
|
203
|
+
// a bridge that breaks its contract entirely and throws still leaves a 200 and a saved row.
|
|
204
|
+
const saved = catalogBridge.publishInstanceToCatalog;
|
|
205
|
+
catalogBridge.publishInstanceToCatalog = async () => { throw new Error('hub unreachable'); };
|
|
206
|
+
try {
|
|
207
|
+
const res = await call('PATCH', '/provisioning/instances/18/detail', { description: 'A harbour.' });
|
|
208
|
+
assert.equal(res.status, 200);
|
|
209
|
+
assert.equal(res.body.description, 'A harbour.');
|
|
210
|
+
} finally {
|
|
211
|
+
catalogBridge.publishInstanceToCatalog = saved;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// tests/project_join_door.mjs — the composed join/apply door (BV1.R12, task
|
|
2
|
+
// 1002331): every visibility state × every join grant × every joinability, plus
|
|
3
|
+
// the derived dark-matter lock and the fail-closed edges.
|
|
4
|
+
//
|
|
5
|
+
// The matrix is written out rather than sampled because the whole point of ADR
|
|
6
|
+
// 0182 D6 is that two knobs must never negotiate — "the stricter of the two always
|
|
7
|
+
// wins, and neither can widen the other" is a claim about all 27 cells, and the
|
|
8
|
+
// bug it prevents (an owner setting `private` on the map while the public
|
|
9
|
+
// application endpoint keeps taking applications) is invisible in any one of them.
|
|
10
|
+
//
|
|
11
|
+
// ESM `import` on purpose, not createRequire: knip does not credit a CJS module's
|
|
12
|
+
// exports to a test that reaches them through require().
|
|
13
|
+
//
|
|
14
|
+
// Run: node --test tests/project_join_door.mjs
|
|
15
|
+
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import {
|
|
19
|
+
JOIN_DOORS, DOOR_RANK, PROJECT_JOIN_GRANTS,
|
|
20
|
+
strictestDoor, visibilityCeiling, projectJoinDoor, effectiveJoinDoor,
|
|
21
|
+
} from '../src/bongos/project-door.js';
|
|
22
|
+
|
|
23
|
+
const VISIBILITIES = ['public', 'private', 'stealth'];
|
|
24
|
+
const JOINABILITIES = ['open', 'apply', 'invite_only'];
|
|
25
|
+
|
|
26
|
+
const door = (visibility, joinGrant, joinability, publishable) =>
|
|
27
|
+
projectJoinDoor({ visibility, joinGrant, joinability, publishable });
|
|
28
|
+
|
|
29
|
+
// ── the vocabulary itself ────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
test('the door vocabulary is ordered widest → strictest, and the rank IS that order', () => {
|
|
32
|
+
assert.deepEqual(JOIN_DOORS, ['open', 'apply', 'invite_only', 'locked']);
|
|
33
|
+
assert.deepEqual(DOOR_RANK, { open: 0, apply: 1, invite_only: 2, locked: 3 });
|
|
34
|
+
assert.deepEqual(PROJECT_JOIN_GRANTS, ['view', 'apply', 'full']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('strictestDoor never widens, and an unknown door name is the strictest there is', () => {
|
|
38
|
+
assert.equal(strictestDoor('open', 'apply'), 'apply');
|
|
39
|
+
assert.equal(strictestDoor('invite_only', 'open'), 'invite_only');
|
|
40
|
+
assert.equal(strictestDoor('locked', 'open'), 'locked');
|
|
41
|
+
assert.equal(strictestDoor('open', 'open'), 'open');
|
|
42
|
+
assert.equal(strictestDoor('open', 'whatever'), 'locked', 'a value the table does not carry cannot widen the door');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── the visibility ceiling (ADR 0182 D6's table) ─────────────────────────────
|
|
46
|
+
|
|
47
|
+
test('visibilityCeiling: public defers to the grant, every other state sets its own', () => {
|
|
48
|
+
assert.equal(visibilityCeiling('public', 'full'), 'open');
|
|
49
|
+
assert.equal(visibilityCeiling('public', 'apply'), 'apply');
|
|
50
|
+
assert.equal(visibilityCeiling('public', 'view'), 'invite_only');
|
|
51
|
+
assert.equal(visibilityCeiling('private', 'full'), 'apply', 'private accepts applications, never a self-join');
|
|
52
|
+
assert.equal(visibilityCeiling('stealth', 'full'), 'invite_only', 'stealth refuses the application at the door');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('visibilityCeiling fails closed on anything it cannot read', () => {
|
|
56
|
+
assert.equal(visibilityCeiling('unlisted', 'full'), 'invite_only', 'a fourth enum value is never a planet');
|
|
57
|
+
assert.equal(visibilityCeiling(undefined, 'full'), 'invite_only');
|
|
58
|
+
assert.equal(visibilityCeiling('public', 'FULL'), 'invite_only', 'a grant this core cannot read is not the wide door');
|
|
59
|
+
assert.equal(visibilityCeiling('public', undefined), 'invite_only');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── the full matrix ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
test('public: the join grants exactly what the owner set — until joinability narrows it', () => {
|
|
65
|
+
// grant `full` is the "does not narrow" value, so a public project runs the
|
|
66
|
+
// door its joinability already ran: this is what makes the default a no-op.
|
|
67
|
+
assert.equal(door('public', 'full', 'open'), 'open');
|
|
68
|
+
assert.equal(door('public', 'full', 'apply'), 'apply');
|
|
69
|
+
assert.equal(door('public', 'full', 'invite_only'), 'invite_only');
|
|
70
|
+
// grant `apply`: a self-join is off the table even where enrolment is open.
|
|
71
|
+
assert.equal(door('public', 'apply', 'open'), 'apply');
|
|
72
|
+
assert.equal(door('public', 'apply', 'apply'), 'apply');
|
|
73
|
+
assert.equal(door('public', 'apply', 'invite_only'), 'invite_only');
|
|
74
|
+
// grant `view`: the project is on the map to be looked at; joining is by invite.
|
|
75
|
+
assert.equal(door('public', 'view', 'open'), 'invite_only');
|
|
76
|
+
assert.equal(door('public', 'view', 'apply'), 'invite_only');
|
|
77
|
+
assert.equal(door('public', 'view', 'invite_only'), 'invite_only');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('private: invite-only with applications queued — the grant is a PUBLIC join\'s, and does not reach here', () => {
|
|
81
|
+
for (const grant of PROJECT_JOIN_GRANTS) {
|
|
82
|
+
assert.equal(door('private', grant, 'open'), 'apply', `grant ${grant} cannot widen private into a self-join`);
|
|
83
|
+
assert.equal(door('private', grant, 'apply'), 'apply', 'a private project queues the application for its owner');
|
|
84
|
+
assert.equal(door('private', grant, 'invite_only'), 'invite_only');
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('stealth: no application is accepted, whatever the other two knobs say', () => {
|
|
89
|
+
for (const grant of PROJECT_JOIN_GRANTS) {
|
|
90
|
+
for (const joinability of JOINABILITIES) {
|
|
91
|
+
assert.equal(door('stealth', grant, joinability), 'invite_only',
|
|
92
|
+
`stealth + ${grant} + ${joinability} still refuses at the door`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('ADR 0182 D6, cell by cell: neither knob can widen the other', () => {
|
|
98
|
+
let cells = 0;
|
|
99
|
+
for (const visibility of VISIBILITIES) {
|
|
100
|
+
for (const grant of PROJECT_JOIN_GRANTS) {
|
|
101
|
+
for (const joinability of JOINABILITIES) {
|
|
102
|
+
const answer = door(visibility, grant, joinability);
|
|
103
|
+
const ceiling = visibilityCeiling(visibility, grant);
|
|
104
|
+
assert.ok(DOOR_RANK[answer] >= DOOR_RANK[ceiling], 'the visibility ceiling is never widened');
|
|
105
|
+
assert.ok(DOOR_RANK[answer] >= DOOR_RANK[joinability], 'the joinability policy is never widened');
|
|
106
|
+
assert.equal(answer, strictestDoor(ceiling, joinability), 'the answer IS the stricter of the two');
|
|
107
|
+
cells++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
assert.equal(cells, 27, 'every state × grant × policy cell is asserted');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── dark matter ──────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
test('dark matter locks the door outright — there is nothing to knock on', () => {
|
|
117
|
+
for (const visibility of VISIBILITIES) {
|
|
118
|
+
for (const grant of PROJECT_JOIN_GRANTS) {
|
|
119
|
+
for (const joinability of JOINABILITIES) {
|
|
120
|
+
assert.equal(door(visibility, grant, joinability, false), 'locked');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('publishable is a TRI-state: only an explicit false locks, and no verdict decides nothing', () => {
|
|
127
|
+
assert.equal(door('public', 'full', 'open', true), 'open', 'a met publish bar opens nothing on its own');
|
|
128
|
+
assert.equal(door('public', 'full', 'open', null), 'open', 'no verdict → the three stored knobs answer alone');
|
|
129
|
+
assert.equal(door('public', 'full', 'open', undefined), 'open');
|
|
130
|
+
assert.equal(door('public', 'full', 'open'), 'open', 'an absent argument is no verdict, never a lock');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a door surface holding no inputs at all is refused, not opened — and not mis-explained', () => {
|
|
134
|
+
assert.equal(projectJoinDoor(), 'invite_only');
|
|
135
|
+
assert.equal(projectJoinDoor({}), 'invite_only');
|
|
136
|
+
assert.equal(door('public', 'full', 'whenever'), 'invite_only',
|
|
137
|
+
'an unreadable policy closes to invite-only, never to `locked` — that word claims the project is unpublished');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── the live reader ──────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
test('effectiveJoinDoor composes the resolved pack with the admission reader', () => {
|
|
143
|
+
const pack = (project) => () => ({ project });
|
|
144
|
+
assert.equal(effectiveJoinDoor({
|
|
145
|
+
branding: pack({ visibility: 'public', joinGrant: 'full' }), joinabilityMode: () => 'apply',
|
|
146
|
+
}), 'apply');
|
|
147
|
+
assert.equal(effectiveJoinDoor({
|
|
148
|
+
branding: pack({ visibility: 'stealth', joinGrant: 'full' }), joinabilityMode: () => 'open',
|
|
149
|
+
}), 'invite_only', 'an open stealth project still refuses everything (ADR 0182 D6)');
|
|
150
|
+
assert.equal(effectiveJoinDoor({
|
|
151
|
+
branding: pack({ visibility: 'public', joinGrant: 'apply' }), joinabilityMode: () => 'open',
|
|
152
|
+
}), 'apply', 'a grant narrows a project whose enrolment is open');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('effectiveJoinDoor fails closed when the pack will not load', () => {
|
|
156
|
+
const boom = () => { throw new Error('pack unreadable'); };
|
|
157
|
+
assert.equal(effectiveJoinDoor({ branding: boom, joinabilityMode: () => 'open' }), 'invite_only');
|
|
158
|
+
assert.equal(effectiveJoinDoor({ branding: () => ({}), joinabilityMode: () => 'open' }), 'invite_only',
|
|
159
|
+
'a pack with no project block is unknown policy, not an open door');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('effectiveJoinDoor passes a publish verdict through when its caller holds one', () => {
|
|
163
|
+
const pack = () => ({ project: { visibility: 'public', joinGrant: 'full' } });
|
|
164
|
+
assert.equal(effectiveJoinDoor({ branding: pack, joinabilityMode: () => 'open', publishable: false }), 'locked');
|
|
165
|
+
assert.equal(effectiveJoinDoor({ branding: pack, joinabilityMode: () => 'open', publishable: true }), 'open');
|
|
166
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// tests/project_join_door_route.mjs — the composed door AT THE ROUTE (BV1.R12,
|
|
2
|
+
// task 1002331): every visibility state × every join grant, driven through the
|
|
3
|
+
// real public POST /access-requests against a scripted pool.
|
|
4
|
+
//
|
|
5
|
+
// tests/project_join_door.mjs pins the decision; this pins that the DOOR ACTUALLY
|
|
6
|
+
// RUNS IT — the gap R12 exists to close was a correct policy nothing consulted.
|
|
7
|
+
// So the REAL composer is wired in (only `branding` and the joinability reader are
|
|
8
|
+
// stubbed), and every case asserts the query count too: a refused application must
|
|
9
|
+
// cost no DB work and, per ADR 0182 D6, must never be stored.
|
|
10
|
+
//
|
|
11
|
+
// The doorway is stubbed in require.cache BEFORE the route loads — the route
|
|
12
|
+
// destructures pool/validators at require time (the access_requests_invite pattern).
|
|
13
|
+
//
|
|
14
|
+
// Run: node --test tests/project_join_door_route.mjs
|
|
15
|
+
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { test, before, after } from 'node:test';
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { effectiveJoinDoor } from '../src/bongos/project-door.js';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const express = require('express');
|
|
24
|
+
|
|
25
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
26
|
+
const realApi = require(apiPath);
|
|
27
|
+
|
|
28
|
+
let calls = [];
|
|
29
|
+
let script = [];
|
|
30
|
+
// The policy this project runs — the three knobs the platform pushes into the
|
|
31
|
+
// instance's branding pack, exactly as src/branding.js resolves them.
|
|
32
|
+
let policy = { visibility: 'public', joinGrant: 'full', joinability: 'apply' };
|
|
33
|
+
let publishable;
|
|
34
|
+
|
|
35
|
+
const pool = {
|
|
36
|
+
async query(sql, params) {
|
|
37
|
+
calls.push({ sql, params });
|
|
38
|
+
if (!script.length) throw new Error(`unscripted query: ${sql.trim().slice(0, 40)}`);
|
|
39
|
+
const next = script.shift();
|
|
40
|
+
if (next instanceof Error) throw next;
|
|
41
|
+
return next;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
require.cache[apiPath].exports = {
|
|
45
|
+
...realApi,
|
|
46
|
+
pool,
|
|
47
|
+
// The REAL composition, over a stubbed pack + admission reader. Nothing about
|
|
48
|
+
// the decision is faked here — only where its three inputs come from.
|
|
49
|
+
projectJoinDoor: () => effectiveJoinDoor({
|
|
50
|
+
branding: () => ({ project: { visibility: policy.visibility, joinGrant: policy.joinGrant } }),
|
|
51
|
+
joinabilityMode: () => policy.joinability,
|
|
52
|
+
publishable,
|
|
53
|
+
}),
|
|
54
|
+
requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'archon' }; next(); },
|
|
55
|
+
requirePermission: () => (_req, _res, next) => next(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const buildAccessRequestsRouter = require('../modules/onboarding/routes/access-requests.js');
|
|
59
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
60
|
+
|
|
61
|
+
const app = express();
|
|
62
|
+
app.use(express.json());
|
|
63
|
+
app.use(attachFail);
|
|
64
|
+
app.use('/api/gds', buildAccessRequestsRouter());
|
|
65
|
+
|
|
66
|
+
let server, port;
|
|
67
|
+
before(async () => {
|
|
68
|
+
server = http.createServer(app);
|
|
69
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
70
|
+
port = server.address().port;
|
|
71
|
+
});
|
|
72
|
+
after(async () => { await new Promise((r) => server.close(r)); });
|
|
73
|
+
|
|
74
|
+
function post(pathname, body) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const payload = JSON.stringify(body);
|
|
77
|
+
const req = http.request({
|
|
78
|
+
host: '127.0.0.1', port, path: pathname, method: 'POST',
|
|
79
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
|
|
80
|
+
}, (res) => {
|
|
81
|
+
let data = '';
|
|
82
|
+
res.on('data', (c) => { data += c; });
|
|
83
|
+
res.on('end', () => resolve({ status: res.statusCode, json: data ? JSON.parse(data) : null }));
|
|
84
|
+
});
|
|
85
|
+
req.on('error', reject);
|
|
86
|
+
req.write(payload);
|
|
87
|
+
req.end();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const none = { rows: [] };
|
|
92
|
+
const filed = [none, none, { rows: [{ id: 9, github_login: 'octocat', status: 'pending' }] }];
|
|
93
|
+
|
|
94
|
+
// Knock on the door as a stranger under one policy, and say what happened.
|
|
95
|
+
async function knock(next, { publishable: verdict } = {}) {
|
|
96
|
+
policy = next;
|
|
97
|
+
publishable = verdict;
|
|
98
|
+
script = [...filed];
|
|
99
|
+
calls = [];
|
|
100
|
+
const r = await post('/api/gds/access-requests', { github_login: 'octocat' });
|
|
101
|
+
return { status: r.status, code: r.json?.error?.code || null, filed: calls.length > 0, queries: calls.length };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const GRANTS = ['view', 'apply', 'full'];
|
|
105
|
+
const REFUSED = { invite_only: 403, open_enrollment: 409, not_published: 403 };
|
|
106
|
+
|
|
107
|
+
// ── public: the join grants exactly what the owner set ───────────────────────
|
|
108
|
+
|
|
109
|
+
test('public + grant full: the door runs whatever joinability runs — the default changes nothing', async () => {
|
|
110
|
+
assert.deepEqual(await knock({ visibility: 'public', joinGrant: 'full', joinability: 'apply' }),
|
|
111
|
+
{ status: 201, code: null, filed: true, queries: 3 });
|
|
112
|
+
assert.deepEqual(await knock({ visibility: 'public', joinGrant: 'full', joinability: 'open' }),
|
|
113
|
+
{ status: 409, code: 'open_enrollment', filed: false, queries: 0 });
|
|
114
|
+
assert.deepEqual(await knock({ visibility: 'public', joinGrant: 'full', joinability: 'invite_only' }),
|
|
115
|
+
{ status: 403, code: 'invite_only', filed: false, queries: 0 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('public + grant apply: the application is filed even where enrolment is open', async () => {
|
|
119
|
+
const r = await knock({ visibility: 'public', joinGrant: 'apply', joinability: 'open' });
|
|
120
|
+
assert.deepEqual(r, { status: 201, code: null, filed: true, queries: 3 },
|
|
121
|
+
'the grant narrowed a self-join into a request — the knob nothing used to read');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('public + grant view: the project is there to be looked at; joining is by invite', async () => {
|
|
125
|
+
for (const joinability of ['open', 'apply', 'invite_only']) {
|
|
126
|
+
const r = await knock({ visibility: 'public', joinGrant: 'view', joinability });
|
|
127
|
+
assert.deepEqual(r, { status: 403, code: 'invite_only', filed: false, queries: 0 }, `joinability ${joinability}`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── private: invite-only, with applications queued for the owner ─────────────
|
|
132
|
+
|
|
133
|
+
test('private: the application queues for the owner, and no grant re-opens a self-join', async () => {
|
|
134
|
+
for (const joinGrant of GRANTS) {
|
|
135
|
+
assert.deepEqual(await knock({ visibility: 'private', joinGrant, joinability: 'open' }),
|
|
136
|
+
{ status: 201, code: null, filed: true, queries: 3 }, `grant ${joinGrant} on an open-enrolment private project`);
|
|
137
|
+
assert.deepEqual(await knock({ visibility: 'private', joinGrant, joinability: 'apply' }),
|
|
138
|
+
{ status: 201, code: null, filed: true, queries: 3 }, `grant ${joinGrant}`);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('private + invite_only: the stricter knob still wins', async () => {
|
|
143
|
+
assert.deepEqual(await knock({ visibility: 'private', joinGrant: 'full', joinability: 'invite_only' }),
|
|
144
|
+
{ status: 403, code: 'invite_only', filed: false, queries: 0 });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── stealth: no application is accepted, and none is stored ──────────────────
|
|
148
|
+
|
|
149
|
+
test('stealth: every combination is refused at the door, before any query', async () => {
|
|
150
|
+
for (const joinGrant of GRANTS) {
|
|
151
|
+
for (const joinability of ['open', 'apply', 'invite_only']) {
|
|
152
|
+
const r = await knock({ visibility: 'stealth', joinGrant, joinability });
|
|
153
|
+
assert.deepEqual(r, { status: 403, code: 'invite_only', filed: false, queries: 0 },
|
|
154
|
+
`stealth + ${joinGrant} + ${joinability}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('the refusal never says WHICH knob closed the door — stealth is indistinguishable from the other two', async () => {
|
|
160
|
+
const stealth = await knock({ visibility: 'stealth', joinGrant: 'full', joinability: 'apply' });
|
|
161
|
+
const viewOnly = await knock({ visibility: 'public', joinGrant: 'view', joinability: 'apply' });
|
|
162
|
+
const inviteOnly = await knock({ visibility: 'public', joinGrant: 'full', joinability: 'invite_only' });
|
|
163
|
+
assert.deepEqual(stealth, viewOnly, 'a stealth project answers exactly as a look-only public one');
|
|
164
|
+
assert.deepEqual(stealth, inviteOnly, 'and exactly as an ordinary invite-only one (ADR 0182 D2a)');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── dark matter ──────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
test('dark matter: locked outright, whatever the three stored knobs say', async () => {
|
|
170
|
+
for (const visibility of ['public', 'private', 'stealth']) {
|
|
171
|
+
const r = await knock({ visibility, joinGrant: 'full', joinability: 'open' }, { publishable: false });
|
|
172
|
+
assert.deepEqual(r, { status: 403, code: 'not_published', filed: false, queries: 0 }, visibility);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('the locked refusal names the PROJECT\'s state, not the applicant\'s — nothing they can retry', async () => {
|
|
177
|
+
policy = { visibility: 'public', joinGrant: 'full', joinability: 'apply' };
|
|
178
|
+
publishable = false;
|
|
179
|
+
script = [];
|
|
180
|
+
calls = [];
|
|
181
|
+
const r = await post('/api/gds/access-requests', { github_login: 'octocat' });
|
|
182
|
+
assert.equal(r.status, 403);
|
|
183
|
+
assert.match(r.json.error.message, /isn’t published yet/);
|
|
184
|
+
assert.doesNotMatch(r.json.error.message, /sign in|invite/i, 'it offers no way in, because there is none yet');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── the refusal costs nothing, and reveals nothing ───────────────────────────
|
|
188
|
+
|
|
189
|
+
test('every refusal answers before the body is even validated — no applicant lookup, no oracle', async () => {
|
|
190
|
+
for (const [code, status] of Object.entries(REFUSED)) {
|
|
191
|
+
const closed = code === 'not_published'
|
|
192
|
+
? { policy: { visibility: 'public', joinGrant: 'full', joinability: 'apply' }, verdict: false }
|
|
193
|
+
: code === 'open_enrollment'
|
|
194
|
+
? { policy: { visibility: 'public', joinGrant: 'full', joinability: 'open' }, verdict: undefined }
|
|
195
|
+
: { policy: { visibility: 'stealth', joinGrant: 'full', joinability: 'apply' }, verdict: undefined };
|
|
196
|
+
policy = closed.policy;
|
|
197
|
+
publishable = closed.verdict;
|
|
198
|
+
script = [];
|
|
199
|
+
calls = [];
|
|
200
|
+
// A body that would 400 on a working door: the refusal must come first.
|
|
201
|
+
const r = await post('/api/gds/access-requests', { github_login: 'not a login!' });
|
|
202
|
+
assert.equal(r.status, status, code);
|
|
203
|
+
assert.equal(r.json.error.code, code);
|
|
204
|
+
assert.equal(calls.length, 0, `${code} consulted the database`);
|
|
205
|
+
}
|
|
206
|
+
});
|