@bongos/core 1.25.20 → 1.25.21
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 +157 -62
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +23 -8
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +10 -0
- package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
- package/docs/api/openapi.json +409 -36
- package/docs/api-reference.md +7 -2
- package/docs/copy-inventory.md +20 -20
- package/docs/copy-registry.json +23 -23
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +2 -2
- package/modules/government/board.js +8 -7
- package/modules/government/charter-advice.js +10 -3
- package/modules/government/charter.js +88 -4
- package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
- package/modules/government/routes/government.js +12 -12
- package/modules/government/sole-member.js +75 -0
- package/modules/hall-ui/public/genesis-home.js +12 -5
- package/modules/onboarding/genesis-home.js +14 -2
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-standing.js +49 -0
- package/modules/platform-identity/routes/my-projects.js +8 -0
- package/modules/provisioning/grants.js +126 -0
- package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
- package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/project-refusal.js +107 -0
- package/modules/provisioning/provisioning.js +2 -2
- package/modules/provisioning/render-lookup.js +36 -1
- package/modules/provisioning/render-promote.js +80 -0
- package/modules/provisioning/route-helpers.js +70 -16
- package/modules/provisioning/routes/env-manifest.js +21 -5
- package/modules/provisioning/routes/grants.js +141 -0
- package/modules/provisioning/routes/provisioning.js +8 -7
- package/modules/provisioning/routes/render-standup.js +82 -3
- package/modules/public-landing/public/projects.html +99 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/bongos/genesis.js +11 -1
- package/scripts/bongos/government-walk-sole.js +41 -0
- package/scripts/bongos/government-walk.js +15 -16
- package/scripts/bongos/provision-render-common.js +48 -0
- package/scripts/bongos/provision-render-promote.js +93 -0
- package/scripts/bongos/provision-render-sandbox.js +14 -2
- package/scripts/bongos/provision-render.js +4 -36
- package/scripts/bongos/provision.js +1 -0
- package/scripts/bongos/render-api.js +17 -2
- package/scripts/gds/government-walk-sole.js +6 -0
- package/scripts/gds/provision-render-common.js +6 -0
- package/scripts/gds/provision-render-promote.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/fleet_cost_view.mjs +2 -0
- package/tests/genesis_government.mjs +5 -1
- package/tests/genesis_home.mjs +17 -0
- package/tests/government_charter_advice.mjs +49 -0
- package/tests/government_charter_forms.mjs +14 -3
- package/tests/government_seed.mjs +1 -0
- package/tests/government_sole_member.mjs +153 -0
- package/tests/project_catalog_routes.mjs +2 -0
- package/tests/provisioning_env_manifest.mjs +4 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
- package/tests/provisioning_project_grants.mjs +458 -0
- package/tests/provisioning_project_owner_gate.mjs +3 -1
- package/tests/provisioning_project_refusal.mjs +316 -0
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +2 -0
- package/tests/provisioning_settings.mjs +2 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/render_promote.mjs +309 -0
- package/tests/render_sandbox.mjs +9 -4
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// tests/provisioning_project_refusal.mjs
|
|
2
|
+
//
|
|
3
|
+
// What a person the project gate REFUSES is told (task 1004162, ADR 0407 §6). The gate itself
|
|
4
|
+
// is unchanged — who gets in is task 1004161's — and this file proves the refusal can only ever
|
|
5
|
+
// refuse, while someone already connected to the project gets a next step instead of a dead end.
|
|
6
|
+
//
|
|
7
|
+
// Covers:
|
|
8
|
+
// • a stranger, an unregistered standing port, a port that throws and an unknown answer are
|
|
9
|
+
// all the 404 that confirms nothing
|
|
10
|
+
// • 'visitor' → 403 project_membership_required with the real join path (the hub's own
|
|
11
|
+
// POST /my-projects/join, carrying the project's origin); 'pending' → accept the invite;
|
|
12
|
+
// 'applied' → it is with the owner
|
|
13
|
+
// • 'member'/'owner' rows: 403 project_access_required on a PUBLIC project, but the 404 on a
|
|
14
|
+
// private or stealth one — a `member` row comes from any device-flow sign-in, so it is no
|
|
15
|
+
// evidence a person may learn that a closed project's id is real
|
|
16
|
+
// • a grantee on a route not open to grantees → 403 owner_only
|
|
17
|
+
// • THE ATTACK again: with a witnessed `member` row and a public project, every declared
|
|
18
|
+
// project route still refuses and writes nothing; with a private project, every gated
|
|
19
|
+
// route stays the 404
|
|
20
|
+
// • the standing port's own reads (platform-identity/project-standing.js): only witnessed
|
|
21
|
+
// rows, a live invite, a recent application; a non-numeric person is no one
|
|
22
|
+
// • the structure: the refusal file never calls next() and never sets req.project
|
|
23
|
+
//
|
|
24
|
+
// DB-free: the real routers and envelope over stubbed data access.
|
|
25
|
+
//
|
|
26
|
+
// Run: node --test tests/provisioning_project_refusal.mjs
|
|
27
|
+
|
|
28
|
+
import assert from 'node:assert/strict';
|
|
29
|
+
import { test, before, after } from 'node:test';
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
37
|
+
const MODULE = path.join(ROOT, 'modules/provisioning');
|
|
38
|
+
|
|
39
|
+
const api = require(path.join(ROOT, 'src/module-api.js'));
|
|
40
|
+
const seams = require(path.join(ROOT, 'src/module-seams.js'));
|
|
41
|
+
const provisioning = require(path.join(MODULE, 'provisioning.js'));
|
|
42
|
+
const grants = require(path.join(MODULE, 'grants.js'));
|
|
43
|
+
const githubSetup = require(path.join(MODULE, 'github-setup.js'));
|
|
44
|
+
const catalogBridge = require(path.join(MODULE, 'catalog-bridge.js'));
|
|
45
|
+
const envManifest = require(path.join(MODULE, 'env-manifest.js'));
|
|
46
|
+
const STANDING_PORT = 'platform-identity.projectMembershipKind';
|
|
47
|
+
|
|
48
|
+
const VISITOR = { id: 66, github_login: 'visitor', github_id: '6600', rank: 'xenos' };
|
|
49
|
+
const ROW = {
|
|
50
|
+
id: 18, owner_builder_id: 7, slug: 'mercury', status: 'active', hosting_shape: 'cloud-host',
|
|
51
|
+
domain: 'mercury.example.com', target_ref: 'owner/mercury', settings: {}, detail: {}, tier: null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let row = { ...ROW };
|
|
55
|
+
let kind = null; // what the standing port answers
|
|
56
|
+
let kindThrows = false;
|
|
57
|
+
let grantFor = null; // builder id holding a live grant
|
|
58
|
+
let grantThrows = false;
|
|
59
|
+
const asked = [];
|
|
60
|
+
const writes = [];
|
|
61
|
+
|
|
62
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { ...VISITOR }; next(); };
|
|
63
|
+
api.requirePermission = () => (_req, res) => res.fail('permission_forbidden', 403);
|
|
64
|
+
api.branding = () => ({ domains: { publicOrigin: 'https://cb.test', instanceBase: 'cb.test' }, identity: { productName: 'Cloud Bongos' } });
|
|
65
|
+
api.pool.query = async (text) => {
|
|
66
|
+
const q = String(text).replace(/\s+/g, ' ').trim();
|
|
67
|
+
if (/^(INSERT|UPDATE|DELETE)/i.test(q)) writes.push(q);
|
|
68
|
+
return { rows: [], rowCount: 0 };
|
|
69
|
+
};
|
|
70
|
+
api.pool.connect = async () => ({ query: api.pool.query, release() {} });
|
|
71
|
+
|
|
72
|
+
grants.liveGrant = async (_db, instanceId, builderId) => {
|
|
73
|
+
if (grantThrows) throw new Error('grant table gone');
|
|
74
|
+
return String(builderId) === String(grantFor) ? { id: 3, instance_id: instanceId, grantee_builder_id: builderId, level: 'operate' } : null;
|
|
75
|
+
};
|
|
76
|
+
provisioning.getInstanceById = async (_db, id) => (String(id) === String(row.id) ? { ...row } : null);
|
|
77
|
+
provisioning.recordEvent = async (_db, e) => { writes.push(`event ${e.event}`); };
|
|
78
|
+
for (const fn of ['enqueueRestart', 'enqueueTeardown', 'enqueueIntent', 'enqueueRepoPrivate', 'enqueueRenderStandup',
|
|
79
|
+
'updateInstanceSettings', 'updateInstanceDetail', 'updateInstanceDomain', 'createOAuthManifest']) {
|
|
80
|
+
provisioning[fn] = async () => { writes.push(fn); return { created: true, intent: { action: fn, state: 'pending' } }; };
|
|
81
|
+
}
|
|
82
|
+
provisioning.getOpenIntent = async () => null;
|
|
83
|
+
provisioning.getLatestOAuthManifestForInstance = async () => null;
|
|
84
|
+
githubSetup.githubSetupRead = async () => ({ steps: [] });
|
|
85
|
+
githubSetup.installNudge = () => null;
|
|
86
|
+
catalogBridge.healInstanceCatalog = async () => {};
|
|
87
|
+
envManifest.listEnvVars = async () => [];
|
|
88
|
+
for (const fn of ['declareEnvVar', 'updateEnvVar', 'removeEnvVar']) envManifest[fn] = async () => { writes.push(fn); return { row: { id: 5, name: 'X' }, created: true }; };
|
|
89
|
+
|
|
90
|
+
const routeFiles = JSON.parse(fs.readFileSync(path.join(MODULE, 'module.json'), 'utf8')).contributes.routes;
|
|
91
|
+
const read = (f) => fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n');
|
|
92
|
+
const DECLARED = [];
|
|
93
|
+
for (const name of routeFiles) {
|
|
94
|
+
const src = read(path.join(MODULE, 'routes', `${name}.js`));
|
|
95
|
+
const re = /router\.(get|post|patch|put|delete)\(\s*'(\/provisioning\/instances\/:id[^']*)'/g;
|
|
96
|
+
let m;
|
|
97
|
+
while ((m = re.exec(src))) DECLARED.push({ method: m[1], route: m[2], gated: /requireProjectOwner\(/.test(src.slice(m.index, src.indexOf('async', m.index))) });
|
|
98
|
+
}
|
|
99
|
+
const concrete = (r) => r.replace(':id', '18').replace(':varId', '5').replace(':grantId', '3');
|
|
100
|
+
|
|
101
|
+
const express = require('express');
|
|
102
|
+
const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
103
|
+
let server, base;
|
|
104
|
+
before(async () => {
|
|
105
|
+
const app = express();
|
|
106
|
+
app.use(express.json());
|
|
107
|
+
app.use(attachFail);
|
|
108
|
+
for (const name of routeFiles) app.use('/api/bongos', require(path.join(MODULE, 'routes', `${name}.js`))());
|
|
109
|
+
server = app.listen(0);
|
|
110
|
+
await new Promise((r) => server.once('listening', r));
|
|
111
|
+
base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
112
|
+
});
|
|
113
|
+
after(() => { server.closeAllConnections(); server.close(); });
|
|
114
|
+
|
|
115
|
+
async function call(method, route, body = {}) {
|
|
116
|
+
const res = await fetch(`${base}${concrete(route)}`, {
|
|
117
|
+
method: method.toUpperCase(),
|
|
118
|
+
headers: { 'content-type': 'application/json', connection: 'close' },
|
|
119
|
+
body: method === 'get' ? undefined : JSON.stringify(body),
|
|
120
|
+
});
|
|
121
|
+
const raw = await res.text();
|
|
122
|
+
let json = null;
|
|
123
|
+
try { json = JSON.parse(raw); } catch { /* not JSON */ }
|
|
124
|
+
return { status: res.status, body: json, raw };
|
|
125
|
+
}
|
|
126
|
+
function setWorld({ standing = null, throws = false, grant = null, grantErr = false, settings = {} } = {}) {
|
|
127
|
+
kind = standing; kindThrows = throws; grantFor = grant; grantThrows = grantErr;
|
|
128
|
+
row = { ...ROW, settings };
|
|
129
|
+
writes.length = 0; asked.length = 0;
|
|
130
|
+
}
|
|
131
|
+
const READ = ['get', '/provisioning/instances/:id'];
|
|
132
|
+
const TEARDOWN = ['post', '/provisioning/instances/:id/teardown'];
|
|
133
|
+
|
|
134
|
+
// ── before the port exists ──────────────────────────────────────────────────────────
|
|
135
|
+
test('with no standing port registered (a hub without platform-identity), a refused caller gets the 404', async () => {
|
|
136
|
+
assert.equal(seams.hasProvider(STANDING_PORT), false, 'nothing registers the port in this process yet');
|
|
137
|
+
setWorld({ standing: 'visitor' });
|
|
138
|
+
const r = await call(...READ);
|
|
139
|
+
assert.equal(r.status, 404, r.raw);
|
|
140
|
+
assert.equal(r.body.error.code, 'instance_not_found');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ── with the port ──────────────────────────────────────────────────────────────────
|
|
144
|
+
test('register a fake standing port for the rest of the file', () => {
|
|
145
|
+
seams.registerProvider(STANDING_PORT, {
|
|
146
|
+
projectMembershipKind: async (clientId, githubId) => {
|
|
147
|
+
asked.push({ clientId, githubId });
|
|
148
|
+
if (kindThrows) throw new Error('membership table gone');
|
|
149
|
+
return kind;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
assert.equal(seams.hasProvider(STANDING_PORT), true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a stranger keeps the 404, and the port is asked about THIS project by its slug and the caller’s own id', async () => {
|
|
156
|
+
setWorld({ standing: null });
|
|
157
|
+
const r = await call(...READ);
|
|
158
|
+
assert.equal(r.status, 404, r.raw);
|
|
159
|
+
assert.equal(r.body.error.code, 'instance_not_found');
|
|
160
|
+
assert.deepEqual(asked, [{ clientId: 'mercury', githubId: '6600' }]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('a port that throws, or answers something unknown, is the 404 — on doubt, say less', async () => {
|
|
164
|
+
for (const world of [{ throws: true }, { standing: 'admin' }, { standing: 'grantee' }, { standing: '' }]) {
|
|
165
|
+
setWorld(world);
|
|
166
|
+
const r = await call(...READ);
|
|
167
|
+
assert.equal(r.status, 404, `${JSON.stringify(world)}: ${r.raw}`);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('a visitor is told how to apply: the hub’s own join route, with this project’s origin', async () => {
|
|
172
|
+
setWorld({ standing: 'visitor' });
|
|
173
|
+
const r = await call(...TEARDOWN);
|
|
174
|
+
assert.equal(r.status, 403, r.raw);
|
|
175
|
+
assert.equal(r.body.error.code, 'project_membership_required');
|
|
176
|
+
assert.deepEqual(r.body.error.details.apply, {
|
|
177
|
+
state: 'can_apply', method: 'POST', path: '/api/bongos/my-projects/join', body: { origin: 'https://mercury.example.com' },
|
|
178
|
+
});
|
|
179
|
+
assert.match(r.body.error.message, /Ask to join it/);
|
|
180
|
+
assert.match(r.body.error.message, /hosting is something the project’s owner gives separately/);
|
|
181
|
+
assert.deepEqual(writes, []);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('the join path it names is the route that really exists on the hub', () => {
|
|
185
|
+
const src = read(path.join(ROOT, 'modules/platform-identity/routes/my-projects.js'));
|
|
186
|
+
assert.match(src, /router\.post\('\/my-projects\/join', requireBuilder,/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('an invited person is told to sign in to the project to accept', async () => {
|
|
190
|
+
setWorld({ standing: 'pending' });
|
|
191
|
+
const r = await call(...READ);
|
|
192
|
+
assert.equal(r.status, 403, r.raw);
|
|
193
|
+
assert.equal(r.body.error.code, 'project_membership_required');
|
|
194
|
+
assert.deepEqual(r.body.error.details.apply, { state: 'invited', sign_in: 'https://mercury.example.com' });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('someone who already asked to join is told the owner decides', async () => {
|
|
198
|
+
setWorld({ standing: 'applied' });
|
|
199
|
+
const r = await call(...READ);
|
|
200
|
+
assert.equal(r.status, 403, r.raw);
|
|
201
|
+
assert.deepEqual(r.body.error.details.apply, { state: 'applied' });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('a member without a grant, on a PUBLIC project, is told to ask the owner', async () => {
|
|
205
|
+
for (const standing of ['member', 'owner']) {
|
|
206
|
+
setWorld({ standing });
|
|
207
|
+
const r = await call(...TEARDOWN);
|
|
208
|
+
assert.equal(r.status, 403, `${standing}: ${r.raw}`);
|
|
209
|
+
assert.equal(r.body.error.code, 'project_access_required');
|
|
210
|
+
assert.match(r.body.error.message, /Ask the owner to give you access/);
|
|
211
|
+
assert.equal(r.body.error.details, undefined, 'no self-serve request is offered');
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('a member row on a PRIVATE or STEALTH project confirms nothing: the 404', async () => {
|
|
216
|
+
for (const visibility of ['private', 'stealth']) {
|
|
217
|
+
for (const standing of ['member', 'owner']) {
|
|
218
|
+
setWorld({ standing, settings: { visibility } });
|
|
219
|
+
const r = await call(...READ);
|
|
220
|
+
assert.equal(r.status, 404, `${visibility}/${standing}: ${r.raw}`);
|
|
221
|
+
assert.equal(r.body.error.code, 'instance_not_found');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('a grantee on a route not open to grantees is told it is the owner’s', async () => {
|
|
227
|
+
setWorld({ standing: 'member', grant: VISITOR.id });
|
|
228
|
+
const r = await call(...TEARDOWN);
|
|
229
|
+
assert.equal(r.status, 403, r.raw);
|
|
230
|
+
assert.equal(r.body.error.code, 'owner_only');
|
|
231
|
+
assert.deepEqual(writes, []);
|
|
232
|
+
setWorld({ standing: 'member', grant: VISITOR.id, grantErr: true });
|
|
233
|
+
const failed = await call(...TEARDOWN);
|
|
234
|
+
// teardown is not a grantee route, so only the refusal reads the grant: a failed read there
|
|
235
|
+
// says less (the 404), never more
|
|
236
|
+
assert.equal(failed.status, 404, failed.raw);
|
|
237
|
+
assert.deepEqual(writes, []);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('a grant read that fails while choosing the WORDING is the 404, never an admission', async () => {
|
|
241
|
+
// GET /provisioning/instances/:id/look is inline-gated and never reaches this file; use a
|
|
242
|
+
// gated non-grantee route, where only the refusal reads the grant.
|
|
243
|
+
setWorld({ standing: 'visitor', grantErr: true });
|
|
244
|
+
const r = await call('post', '/provisioning/instances/:id/domain', { domain: 'x.example.com' });
|
|
245
|
+
assert.equal(r.status, 404, r.raw);
|
|
246
|
+
assert.equal(r.body.error.code, 'instance_not_found');
|
|
247
|
+
assert.deepEqual(writes, []);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ── the attack, with the new wording in place ───────────────────────────────────────
|
|
251
|
+
test('a device-flow member on a PUBLIC project: every declared project route still refuses, and nothing is written', async () => {
|
|
252
|
+
for (const d of DECLARED) {
|
|
253
|
+
setWorld({ standing: 'member' });
|
|
254
|
+
const r = await call(d.method, d.route, d.method === 'get' ? undefined : {});
|
|
255
|
+
assert.ok(r.status >= 400 && r.status < 500, `${d.method} ${d.route} → ${r.status} ${r.raw.slice(0, 160)}`);
|
|
256
|
+
assert.deepEqual(writes, [], `${d.method} ${d.route} wrote ${JSON.stringify(writes)}`);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('a device-flow member on a PRIVATE project: every gated route is the 404', async () => {
|
|
261
|
+
for (const d of DECLARED.filter((x) => x.gated)) {
|
|
262
|
+
setWorld({ standing: 'member', settings: { visibility: 'private' } });
|
|
263
|
+
const r = await call(d.method, d.route, d.method === 'get' ? undefined : {});
|
|
264
|
+
assert.equal(r.status, 404, `${d.method} ${d.route}: ${r.raw.slice(0, 160)}`);
|
|
265
|
+
assert.deepEqual(writes, []);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ── the standing port's own reads ───────────────────────────────────────────────────
|
|
270
|
+
const { projectMembershipKind } = require(path.join(ROOT, 'modules/platform-identity/project-standing.js'));
|
|
271
|
+
function standingDb({ membership = null, applied = false } = {}) {
|
|
272
|
+
const seen = [];
|
|
273
|
+
return {
|
|
274
|
+
seen,
|
|
275
|
+
query: async (text, params) => {
|
|
276
|
+
const s = String(text).replace(/\s+/g, ' ');
|
|
277
|
+
seen.push({ s, params });
|
|
278
|
+
if (/platform_identity_project_memberships/.test(s)) return { rows: membership ? [{ membership_kind: membership }] : [] };
|
|
279
|
+
if (/platform_identity_application_echoes/.test(s)) return { rows: applied ? [{ 1: 1 }] : [] };
|
|
280
|
+
return { rows: [] };
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
test('the port reads only witnessed rows and a live invite, then a recent application', async () => {
|
|
286
|
+
let db = standingDb({ membership: 'member' });
|
|
287
|
+
assert.equal(await projectMembershipKind('mercury', '6600', { pool: db }), 'member');
|
|
288
|
+
assert.match(db.seen[0].s, /membership_kind <> 'pending' AND m\.signin_verified_at IS NOT NULL/);
|
|
289
|
+
assert.match(db.seen[0].s, /membership_kind = 'pending' AND m\.joined_at > now\(\) - make_interval/);
|
|
290
|
+
assert.deepEqual(db.seen[0].params.slice(0, 2), ['mercury', '6600']);
|
|
291
|
+
assert.equal(db.seen.length, 1, 'a row answers; the application is not asked');
|
|
292
|
+
db = standingDb({ applied: true });
|
|
293
|
+
assert.equal(await projectMembershipKind('mercury', '6600', { pool: db }), 'applied');
|
|
294
|
+
assert.match(db.seen[1].s, /client_id = \$2 AND applied_at > now\(\)/);
|
|
295
|
+
db = standingDb({});
|
|
296
|
+
assert.equal(await projectMembershipKind('mercury', '6600', { pool: db }), null);
|
|
297
|
+
db = standingDb({ membership: 'something-new' });
|
|
298
|
+
assert.equal(await projectMembershipKind('mercury', '6600', { pool: db }), null, 'an unknown kind is no one');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('a non-numeric person or an empty project is no one, and nothing is queried', async () => {
|
|
302
|
+
const db = standingDb({ membership: 'member' });
|
|
303
|
+
assert.equal(await projectMembershipKind('mercury', 'system:bfg', { pool: db }), null);
|
|
304
|
+
assert.equal(await projectMembershipKind('', '6600', { pool: db }), null);
|
|
305
|
+
assert.equal(db.seen.length, 0);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ── the structure ───────────────────────────────────────────────────────────────────
|
|
309
|
+
test('the refusal can only refuse: it never calls next() and never sets the admitted row', () => {
|
|
310
|
+
const src = read(path.join(MODULE, 'project-refusal.js')).replace(/\/\/.*$/gm, '');
|
|
311
|
+
assert.doesNotMatch(src, /next\s*\(/);
|
|
312
|
+
assert.doesNotMatch(src, /req\.project\s*=|req\.projectDoor\s*=/);
|
|
313
|
+
const gate = read(path.join(MODULE, 'route-helpers.js'));
|
|
314
|
+
assert.match(gate, /if \(!door\) return refuseProject\(req, res, inst\);/);
|
|
315
|
+
assert.match(gate, /if \(!inst\) return res\.fail\('instance_not_found', 404\);/, 'a missing project is the plain 404 — no lookup at all');
|
|
316
|
+
});
|
|
@@ -338,7 +338,7 @@ await ta('every path that resolves an intent clears the key', () => {
|
|
|
338
338
|
assert.match(resolve, /borrowed_key_enc = NULL/);
|
|
339
339
|
const teardown = src.slice(src.indexOf('async function enqueueTeardown'), src.indexOf('async function enqueueTeardown') + 600);
|
|
340
340
|
assert.match(teardown, /borrowed_key_enc = NULL/, 'a superseded standup drops its key');
|
|
341
|
-
assert.match(teardown, /'render-standup'\) AND state = 'pending'/);
|
|
341
|
+
assert.match(teardown, /'render-standup', 'render-promote'\) AND state = 'pending'/);
|
|
342
342
|
});
|
|
343
343
|
|
|
344
344
|
server.close();
|
|
@@ -34,6 +34,8 @@ import http from 'node:http';
|
|
|
34
34
|
|
|
35
35
|
const require = createRequire(import.meta.url);
|
|
36
36
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
37
|
+
// The gate also admits an owner-made grant (task 1004161, ADR 0407); nobody in this file holds one.
|
|
38
|
+
require('../modules/provisioning/grants.js').liveGrant = async () => null;
|
|
37
39
|
|
|
38
40
|
let passed = 0, failed = 0;
|
|
39
41
|
function t(name, fn) {
|
|
@@ -23,6 +23,8 @@ import http from 'node:http';
|
|
|
23
23
|
|
|
24
24
|
const require = createRequire(import.meta.url);
|
|
25
25
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
26
|
+
// The gate also admits an owner-made grant (task 1004161, ADR 0407); nobody in this file holds one.
|
|
27
|
+
require('../modules/provisioning/grants.js').liveGrant = async () => null;
|
|
26
28
|
|
|
27
29
|
let passed = 0, failed = 0;
|
|
28
30
|
async function ta(name, fn) {
|
|
@@ -169,7 +169,7 @@ await ta('enqueueTeardown supersedes a PENDING settings push (a push into a proj
|
|
|
169
169
|
const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
|
|
170
170
|
assert.equal(r.created, true);
|
|
171
171
|
assert.equal(r.superseded, true, 'the queued push was cancelled so the teardown could take the slot');
|
|
172
|
-
assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
|
|
172
|
+
assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup', 'render-promote'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
// ── the route, over a real mounted router (stubbed data access) ─────────────────
|
|
@@ -31,7 +31,7 @@ const require = createRequire(import.meta.url);
|
|
|
31
31
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
32
32
|
|
|
33
33
|
// The actions a teardown supersedes, mirroring enqueueTeardown's SQL.
|
|
34
|
-
const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private', 'render-standup']);
|
|
34
|
+
const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private', 'render-standup', 'render-promote']);
|
|
35
35
|
|
|
36
36
|
let passed = 0, failed = 0;
|
|
37
37
|
function t(name, fn) {
|
|
@@ -81,7 +81,7 @@ function fakeIntentDb(seed = [], { shape = 'co-tenant' } = {}) {
|
|
|
81
81
|
// dead work in front of a teardown (task 1003140 widened it to the settings
|
|
82
82
|
// push, task 1003238 to the restart; an open teardown is left alone for the
|
|
83
83
|
// idempotent path).
|
|
84
|
-
assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/);
|
|
84
|
+
assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup', 'render-promote'\) AND state = 'pending'/);
|
|
85
85
|
updates++;
|
|
86
86
|
// The supersede must skip the platform's own row IN ITS OWN SQL (task 1004286): the
|
|
87
87
|
// guard refuses that teardown only afterwards, in enqueueIntent. Modelled, not assumed.
|