@bongos/core 1.19.422 → 1.19.424
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 +60 -25
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +164 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +10 -5
- package/docs/api-reference.md +2 -2
- package/docs/copy-inventory.md +25 -25
- package/docs/copy-registry.json +27 -27
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_230_access_requests_applicant_vouch.sql +79 -0
- package/modules/discord/discord-approvals.js +7 -2
- package/modules/hall-ui/public/watch.js +10 -1
- package/modules/onboarding/routes/access-requests.js +115 -11
- package/modules/platform-identity/application-echo.js +115 -0
- package/modules/platform-identity/migrations/platform_identity_016_application_echo.sql +68 -0
- package/modules/platform-identity/routes/my-projects.js +57 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +8 -1
- package/src/module-api.js +1 -1
- package/tests/application_echo.mjs +446 -0
- package/tests/application_lifecycle.mjs +634 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/profile_privacy_owner_view.mjs +477 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// tests/application_echo.mjs — the hub's statusless echo of an application, and
|
|
2
|
+
// the one rule that makes it safe: it records only what the PROJECT actually
|
|
3
|
+
// accepted, and it can never say what came of it (task 1002284, R08 of goal
|
|
4
|
+
// 1000045; ADR 0201 D1.3/D1.4, ADR 0016).
|
|
5
|
+
//
|
|
6
|
+
// TWO HALVES.
|
|
7
|
+
//
|
|
8
|
+
// Part A — the domain functions directly, over a recording pool. The window
|
|
9
|
+
// constant is asserted to be the one the query BINDS, so the doc, the reader
|
|
10
|
+
// and the table comment cannot drift apart into three different 30s.
|
|
11
|
+
//
|
|
12
|
+
// Part B — the real POST /my-projects/join and GET /my-projects over an
|
|
13
|
+
// in-memory echo table whose filter is built from each statement's own WHERE
|
|
14
|
+
// clause. The relay's answer is scripted, so every outcome joinRelayOutcome
|
|
15
|
+
// can produce is asked the same question: did this leave a trace? Only an
|
|
16
|
+
// accepted application may. A refusal that wrote an echo would let the hub
|
|
17
|
+
// tell an applicant they applied to a project that turned them away at the
|
|
18
|
+
// door — the hub asserting a fact about someone else's project, which is the
|
|
19
|
+
// inversion ADR 0141 exists to prevent.
|
|
20
|
+
//
|
|
21
|
+
// The echo is a CONVENIENCE on the applicant's own view and never authority, so
|
|
22
|
+
// it is best-effort in both directions: a failed echo write must not turn a
|
|
23
|
+
// filed application into a reported failure, and a failed echo read must not
|
|
24
|
+
// cost the memberships listing. Both are asserted, because "best-effort" written
|
|
25
|
+
// in a comment is not best-effort.
|
|
26
|
+
//
|
|
27
|
+
// DB-free. Runs in the unit gate that executes on a PR.
|
|
28
|
+
//
|
|
29
|
+
// Run: node --test tests/application_echo.mjs
|
|
30
|
+
|
|
31
|
+
import assert from 'node:assert/strict';
|
|
32
|
+
import { test, before, after } from 'node:test';
|
|
33
|
+
import http from 'node:http';
|
|
34
|
+
import { createRequire } from 'node:module';
|
|
35
|
+
|
|
36
|
+
// ESM import so the knip ratchet credits the constant this file consumes
|
|
37
|
+
// (a createRequire() import does not credit a CJS export).
|
|
38
|
+
import {
|
|
39
|
+
APPLICATION_ECHO_WINDOW_DAYS,
|
|
40
|
+
recordApplication,
|
|
41
|
+
listRecentApplications,
|
|
42
|
+
} from '../modules/platform-identity/application-echo.js';
|
|
43
|
+
|
|
44
|
+
const require = createRequire(import.meta.url);
|
|
45
|
+
const express = require('express');
|
|
46
|
+
|
|
47
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
48
|
+
const ME = 4242;
|
|
49
|
+
|
|
50
|
+
// ===========================================================================
|
|
51
|
+
// Part A — the domain functions, over a recording pool
|
|
52
|
+
// ===========================================================================
|
|
53
|
+
|
|
54
|
+
function recorder(result = { rows: [] }) {
|
|
55
|
+
const calls = [];
|
|
56
|
+
return { calls, query: async (sql, params) => { calls.push({ sql: String(sql).replace(/\s+/g, ' ').trim(), params }); return result; } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('the read binds the window constant itself — one 30, not three', async () => {
|
|
60
|
+
assert.equal(APPLICATION_ECHO_WINDOW_DAYS, 30, 'the privacy-spec D4 constant');
|
|
61
|
+
const pool = recorder();
|
|
62
|
+
await listRecentApplications(ME, { pool });
|
|
63
|
+
const [{ sql, params }] = pool.calls;
|
|
64
|
+
assert.match(sql, /applied_at > now\(\) - make_interval\(days => \$2::int\)/,
|
|
65
|
+
'the window is READ-TIME — no sweep, no expiry column');
|
|
66
|
+
assert.equal(params[1], APPLICATION_ECHO_WINDOW_DAYS,
|
|
67
|
+
'the query must bind the exported constant, so a change to it moves the behaviour');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('the write is an upsert on the pair, refreshing the date (D1.4)', async () => {
|
|
71
|
+
const pool = recorder();
|
|
72
|
+
const wrote = await recordApplication({ githubId: ME, clientId: 'proj-a' }, { pool });
|
|
73
|
+
assert.equal(wrote, true);
|
|
74
|
+
const [{ sql, params }] = pool.calls;
|
|
75
|
+
assert.match(sql, /ON CONFLICT \(github_id, client_id\) DO UPDATE SET applied_at = now\(\)/,
|
|
76
|
+
're-applying is a success, so a second ask refreshes rather than erroring or piling up a row');
|
|
77
|
+
assert.deepEqual(params, [String(ME), 'proj-a']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('neither function carries a status, because the table holds none', async () => {
|
|
81
|
+
const pool = recorder();
|
|
82
|
+
await recordApplication({ githubId: ME, clientId: 'proj-a' }, { pool });
|
|
83
|
+
await listRecentApplications(ME, { pool });
|
|
84
|
+
for (const { sql } of pool.calls) {
|
|
85
|
+
assert.ok(!/\bstatus\b/.test(sql),
|
|
86
|
+
`the hub is not allowed to hold an admission fact about someone else's project: ${sql}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a non-numeric principal is refused before any query (ADR 0171 D2)', async () => {
|
|
91
|
+
const pool = recorder();
|
|
92
|
+
assert.equal(await recordApplication({ githubId: 'system:bfg', clientId: 'proj-a' }, { pool }), false);
|
|
93
|
+
assert.deepEqual(await listRecentApplications('system:bfg', { pool }), []);
|
|
94
|
+
assert.equal(await recordApplication({ githubId: ME, clientId: '' }, { pool }), false);
|
|
95
|
+
assert.equal(pool.calls.length, 0, 'binding a text principal to a bigint column would throw mid-request');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ===========================================================================
|
|
99
|
+
// Part B — the real routes over an in-memory echo table
|
|
100
|
+
// ===========================================================================
|
|
101
|
+
|
|
102
|
+
let echoes = [];
|
|
103
|
+
let memberships = [];
|
|
104
|
+
let echoFails = false;
|
|
105
|
+
let statements = [];
|
|
106
|
+
|
|
107
|
+
function seed() {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
echoes = [
|
|
110
|
+
{ github_id: ME, client_id: 'proj-open', applied_at: now - 2 * DAY },
|
|
111
|
+
{ github_id: ME, client_id: 'proj-joined', applied_at: now - 5 * DAY },
|
|
112
|
+
{ github_id: ME, client_id: 'proj-stale', applied_at: now - 40 * DAY },
|
|
113
|
+
{ github_id: 9999, client_id: 'proj-someone-elses', applied_at: now - DAY },
|
|
114
|
+
];
|
|
115
|
+
memberships = [
|
|
116
|
+
{ client_id: 'proj-joined', membership_kind: 'builder', joined_at: now - 4 * DAY, last_active: now, project_name: 'Joined', project_origin: 'https://joined.example', owner_github_id: null },
|
|
117
|
+
];
|
|
118
|
+
echoFails = false;
|
|
119
|
+
statements = [];
|
|
120
|
+
}
|
|
121
|
+
const echo = (clientId) => echoes.find((e) => e.github_id === ME && e.client_id === clientId);
|
|
122
|
+
|
|
123
|
+
/** A row filter built from the statement's own WHERE text; an unread placeholder throws. */
|
|
124
|
+
function predicateFor(rawWhere, params) {
|
|
125
|
+
const tests = [];
|
|
126
|
+
const consumed = new Set();
|
|
127
|
+
const p = (n) => { consumed.add(String(n)); return params[Number(n) - 1]; };
|
|
128
|
+
let where = rawWhere;
|
|
129
|
+
let m;
|
|
130
|
+
const eat = () => { where = where.replace(m[0], ' TRUE '); };
|
|
131
|
+
if ((m = /\b(?:e\.)?github_id = \$(\d+)::bigint/.exec(where))) {
|
|
132
|
+
const v = p(m[1]); tests.push((r) => String(r.github_id) === String(v)); eat();
|
|
133
|
+
}
|
|
134
|
+
if ((m = /\bm\.github_id = \$(\d+)/.exec(where))) {
|
|
135
|
+
const v = p(m[1]); tests.push((r) => String(r.github_id ?? ME) === String(v)); eat();
|
|
136
|
+
}
|
|
137
|
+
if ((m = /\b(?:e\.)?applied_at > now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
|
|
138
|
+
const days = Number(p(m[1])); tests.push((r) => r.applied_at > Date.now() - days * DAY); eat();
|
|
139
|
+
}
|
|
140
|
+
if ((m = /\bclient_id = \$(\d+)/.exec(where))) {
|
|
141
|
+
const v = p(m[1]); tests.push((r) => r.client_id === v); eat();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
|
|
145
|
+
if (!consumed.has(ph[1])) {
|
|
146
|
+
throw new Error(`fake pool: unread predicate on $${ph[1]} in "${rawWhere}" — the interpreter must never silently ignore a filter`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Every fragment matched above blanks itself to TRUE, so whatever is LEFT is a
|
|
151
|
+
// predicate this interpreter did not model. The $-sweep above cannot catch a
|
|
152
|
+
// LITERAL-ONLY one (`AND resolved_at IS NULL`, `AND status <> 'dismissed'`) —
|
|
153
|
+
// it binds nothing — and such a predicate silently narrows or widens the real
|
|
154
|
+
// query while every test stays green. That hole was found by review after it
|
|
155
|
+
// had already hidden one real filter, so the residual is swept too: this is
|
|
156
|
+
// what makes "refuses a predicate it cannot read" true of ALL predicates
|
|
157
|
+
// rather than only parameterised ones.
|
|
158
|
+
const residual = where
|
|
159
|
+
.replace(/\bTRUE\b/gi, ' ')
|
|
160
|
+
.replace(/\b(?:AND|OR)\b/gi, ' ')
|
|
161
|
+
.replace(/[()\s]/g, '');
|
|
162
|
+
if (residual) {
|
|
163
|
+
throw new Error(`fake pool: unmodelled predicate "${residual}" in "${rawWhere}" — a filter the interpreter cannot read must be a loud error, not a silent no-op`);
|
|
164
|
+
}
|
|
165
|
+
return (r) => tests.every((fn) => fn(r));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const CLIENTS = {
|
|
169
|
+
'https://target.example': { client_id: 'proj-open', name: 'Target', origin: 'https://target.example', status: 'active' },
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const pool = {
|
|
173
|
+
query: async (text, params = []) => {
|
|
174
|
+
const sql = String(text).replace(/\s+/g, ' ').trim();
|
|
175
|
+
statements.push({ sql, params });
|
|
176
|
+
let m;
|
|
177
|
+
|
|
178
|
+
if (/FROM platform_identity_sso_clients WHERE origin = \$1 AND status = 'active'/.test(sql)) {
|
|
179
|
+
const c = CLIENTS[params[0]];
|
|
180
|
+
return { rows: c ? [c] : [] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (/FROM platform_identity_project_memberships m/.test(sql)) {
|
|
184
|
+
m = /WHERE (.+?) ORDER BY/.exec(sql);
|
|
185
|
+
return { rows: memberships.filter(predicateFor(m[1].replace(/ AND m\.membership_kind <> 'pending'/, ''), params)) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (/^DELETE FROM platform_identity_project_memberships WHERE client_id = \$1 AND github_id = \$2$/.test(sql)) {
|
|
189
|
+
const before = memberships.length;
|
|
190
|
+
memberships = memberships.filter((x) => !(x.client_id === params[0] && String(params[1]) === String(ME)));
|
|
191
|
+
return { rows: [], rowCount: before - memberships.length };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if ((m = /^DELETE FROM platform_identity_application_echoes WHERE (.+?)$/.exec(sql))) {
|
|
195
|
+
if (echoFails) throw new Error('echo table is on fire');
|
|
196
|
+
const doomed = echoes.filter(predicateFor(m[1], params));
|
|
197
|
+
echoes = echoes.filter((e) => !doomed.includes(e));
|
|
198
|
+
return { rows: [], rowCount: doomed.length };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (/^INSERT INTO platform_identity_application_echoes/.test(sql)) {
|
|
202
|
+
if (echoFails) throw new Error('echo table is on fire');
|
|
203
|
+
const [githubId, clientId] = params;
|
|
204
|
+
const existing = echoes.find((e) => String(e.github_id) === String(githubId) && e.client_id === clientId);
|
|
205
|
+
if (existing) existing.applied_at = Date.now();
|
|
206
|
+
else echoes.push({ github_id: Number(githubId), client_id: clientId, applied_at: Date.now() });
|
|
207
|
+
return { rows: [] };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (/FROM platform_identity_application_echoes e/.test(sql)) {
|
|
211
|
+
if (echoFails) throw new Error('echo table is on fire');
|
|
212
|
+
m = /WHERE (.+?) ORDER BY/.exec(sql);
|
|
213
|
+
const hit = echoes.filter(predicateFor(m[1], params)).sort((a, b) => b.applied_at - a.applied_at);
|
|
214
|
+
return {
|
|
215
|
+
rows: hit.map((e) => ({
|
|
216
|
+
client_id: e.client_id, applied_at: e.applied_at,
|
|
217
|
+
project_name: e.client_id === 'proj-open' ? 'Target' : null,
|
|
218
|
+
project_origin: e.client_id === 'proj-open' ? 'https://target.example' : null,
|
|
219
|
+
})),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
throw new Error(`fake pool: unsupported statement — ${sql}`);
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// project-stats reaches every project's own public API over the network; the
|
|
228
|
+
// hall enrichment is not what this file is about, so it is stubbed out.
|
|
229
|
+
const statsPath = require.resolve('../modules/platform-identity/project-stats.js');
|
|
230
|
+
require.cache[statsPath] = {
|
|
231
|
+
id: statsPath, filename: statsPath, loaded: true,
|
|
232
|
+
exports: { getProjectStats: async () => ({}) },
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
236
|
+
const realApi = require(apiPath);
|
|
237
|
+
require.cache[apiPath].exports = {
|
|
238
|
+
...realApi,
|
|
239
|
+
pool,
|
|
240
|
+
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
|
241
|
+
requireBuilder: (req, _res, next) => {
|
|
242
|
+
req.builder = { id: 1, github_id: ME, github_login: 'octo', display_name: 'Octo' };
|
|
243
|
+
next();
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const myProjectsRoutes = require('../modules/platform-identity/routes/my-projects.js');
|
|
248
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
249
|
+
|
|
250
|
+
// The project's own admission surface, scripted. This is the only thing that
|
|
251
|
+
// decides whether an application happened.
|
|
252
|
+
let relay = { status: 201, body: { ok: true } };
|
|
253
|
+
const realFetch = globalThis.fetch;
|
|
254
|
+
globalThis.fetch = async (url) => {
|
|
255
|
+
if (String(url).endsWith('/api/gds/access-requests')) {
|
|
256
|
+
if (relay === null) throw new Error('connection refused');
|
|
257
|
+
return { status: relay.status, json: async () => relay.body };
|
|
258
|
+
}
|
|
259
|
+
throw new Error(`unexpected fetch: ${url}`);
|
|
260
|
+
};
|
|
261
|
+
after(() => { globalThis.fetch = realFetch; });
|
|
262
|
+
|
|
263
|
+
const app = express();
|
|
264
|
+
app.use(express.json());
|
|
265
|
+
app.use(attachFail);
|
|
266
|
+
app.use('/api/gds', myProjectsRoutes());
|
|
267
|
+
|
|
268
|
+
let server, port;
|
|
269
|
+
before(async () => {
|
|
270
|
+
server = http.createServer(app);
|
|
271
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
272
|
+
port = server.address().port;
|
|
273
|
+
});
|
|
274
|
+
after(async () => { await new Promise((r) => server.close(r)); });
|
|
275
|
+
|
|
276
|
+
function call(method, pathname, body) {
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
279
|
+
const req = http.request({
|
|
280
|
+
host: '127.0.0.1', port, method, path: pathname,
|
|
281
|
+
headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
|
|
282
|
+
}, (res) => {
|
|
283
|
+
const chunks = [];
|
|
284
|
+
res.on('data', (c) => chunks.push(c));
|
|
285
|
+
res.on('end', () => {
|
|
286
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
287
|
+
let json = null; try { json = JSON.parse(text); } catch { /* empty */ }
|
|
288
|
+
resolve({ status: res.statusCode, json });
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
req.on('error', reject);
|
|
292
|
+
req.end(payload);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const join = (note) => call('POST', '/api/gds/my-projects/join', { origin: 'https://target.example', note });
|
|
296
|
+
|
|
297
|
+
test('the interpreter refuses a predicate it cannot read — parameterised or not', () => {
|
|
298
|
+
assert.throws(() => predicateFor('github_id = $1::bigint AND something = $2', [1, 2]), /unread predicate on \$2/);
|
|
299
|
+
assert.throws(() => predicateFor("github_id = $1::bigint AND client_id <> 'x'", [1]), /unmodelled predicate/,
|
|
300
|
+
'a literal-only filter binds nothing, so the placeholder sweep alone would miss it');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ── the echo is written only for an application the project actually took ────
|
|
304
|
+
|
|
305
|
+
test('an accepted application leaves an echo', async () => {
|
|
306
|
+
seed();
|
|
307
|
+
echoes = echoes.filter((e) => e.client_id !== 'proj-open');
|
|
308
|
+
relay = { status: 201, body: { ok: true } };
|
|
309
|
+
const r = await join('please');
|
|
310
|
+
assert.equal(r.status, 200);
|
|
311
|
+
assert.equal(r.json.status, 'requested');
|
|
312
|
+
assert.ok(echo('proj-open'), 'the applicant can now be told when they applied');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('a re-application refreshes the date rather than failing or duplicating (D1.4)', async () => {
|
|
316
|
+
seed();
|
|
317
|
+
const before = echo('proj-open').applied_at;
|
|
318
|
+
// The project answers its own already_pending 200; the relay reads any 2xx as
|
|
319
|
+
// a filed request, which is what makes a double-clicked Apply harmless.
|
|
320
|
+
relay = { status: 200, body: { ok: true, already_pending: true } };
|
|
321
|
+
const r = await join();
|
|
322
|
+
assert.equal(r.json.status, 'requested');
|
|
323
|
+
assert.equal(echoes.filter((e) => e.github_id === ME && e.client_id === 'proj-open').length, 1, 'the pair is the key');
|
|
324
|
+
assert.ok(echo('proj-open').applied_at > before,
|
|
325
|
+
'the window is measured from the most recent ask, so a re-asker does not watch their own card go quiet');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
for (const [name, answer, outcome] of [
|
|
329
|
+
['a standing membership', { status: 409, body: { error: { code: 'already_member' } } }, 'already_member'],
|
|
330
|
+
['an invite-only door', { status: 403, body: { error: { code: 'invite_only' } } }, 'invite_only'],
|
|
331
|
+
['an open door', { status: 409, body: { error: { code: 'open_enrollment' } } }, 'open_enrollment'],
|
|
332
|
+
['a members-only project', { status: 401, body: { error: { code: 'unauthorized' } } }, 'members_only'],
|
|
333
|
+
]) {
|
|
334
|
+
test(`${name} is not an application, and leaves NO echo`, async () => {
|
|
335
|
+
seed();
|
|
336
|
+
echoes = echoes.filter((e) => e.client_id !== 'proj-open');
|
|
337
|
+
relay = answer;
|
|
338
|
+
const r = await join();
|
|
339
|
+
assert.equal(r.status, 200);
|
|
340
|
+
assert.equal(r.json.status, outcome);
|
|
341
|
+
assert.equal(echo('proj-open'), undefined,
|
|
342
|
+
'the hub must never tell an applicant they applied to a project that refused them at the door');
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
test('a project that cannot be reached leaves no echo', async () => {
|
|
347
|
+
seed();
|
|
348
|
+
echoes = echoes.filter((e) => e.client_id !== 'proj-open');
|
|
349
|
+
relay = null;
|
|
350
|
+
const r = await join();
|
|
351
|
+
assert.equal(r.status, 502);
|
|
352
|
+
assert.equal(echo('proj-open'), undefined);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('a failed echo write never turns a filed application into a reported failure', async () => {
|
|
356
|
+
seed();
|
|
357
|
+
relay = { status: 201, body: { ok: true } };
|
|
358
|
+
echoFails = true;
|
|
359
|
+
const r = await join();
|
|
360
|
+
assert.equal(r.status, 200, 'the application IS filed on the project — the hub losing its note cannot unfile it');
|
|
361
|
+
assert.equal(r.json.status, 'requested');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ── the read-back ────────────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
test('GET /my-projects reports what was applied to and when — and no status', async () => {
|
|
367
|
+
seed();
|
|
368
|
+
relay = { status: 201, body: { ok: true } };
|
|
369
|
+
const r = await call('GET', '/api/gds/my-projects');
|
|
370
|
+
assert.equal(r.status, 200);
|
|
371
|
+
const applied = r.json.applied;
|
|
372
|
+
assert.deepEqual(applied.map((a) => a.client_id), ['proj-open']);
|
|
373
|
+
assert.equal(applied[0].name, 'Target', 'a client_id alone is not renderable');
|
|
374
|
+
assert.equal(applied[0].origin, 'https://target.example');
|
|
375
|
+
assert.ok(applied[0].applied_at);
|
|
376
|
+
assert.deepEqual(Object.keys(applied[0]).sort(), ['applied_at', 'client_id', 'name', 'origin'],
|
|
377
|
+
'no status field may appear here — the hub is not allowed to know what came of it');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('a project the applicant now BUILDS on drops out of applied — membership is the approval signal', async () => {
|
|
381
|
+
seed();
|
|
382
|
+
const r = await call('GET', '/api/gds/my-projects');
|
|
383
|
+
assert.deepEqual(r.json.building.map((b) => b.client_id), ['proj-joined']);
|
|
384
|
+
assert.ok(!r.json.applied.some((a) => a.client_id === 'proj-joined'),
|
|
385
|
+
'an approval becomes visible by the card flipping to member, never by the echo claiming it');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('an echo older than the window is invisible, not deleted', async () => {
|
|
389
|
+
seed();
|
|
390
|
+
const r = await call('GET', '/api/gds/my-projects');
|
|
391
|
+
assert.ok(!r.json.applied.some((a) => a.client_id === 'proj-stale'),
|
|
392
|
+
'a declined application must not read as "applied" forever');
|
|
393
|
+
assert.ok(echoes.some((e) => e.client_id === 'proj-stale'), 'and the row is still there — the window is read-time');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('the echo read is own-scope: another account’s row is never reachable', async () => {
|
|
397
|
+
seed();
|
|
398
|
+
const r = await call('GET', '/api/gds/my-projects');
|
|
399
|
+
assert.ok(!r.json.applied.some((a) => a.client_id === 'proj-someone-elses'));
|
|
400
|
+
const echoRead = statements.find((s) => /FROM platform_identity_application_echoes/.test(s.sql));
|
|
401
|
+
assert.match(echoRead.sql, /WHERE e\.github_id = \$1::bigint/, 'keyed on the session, never on anything from the request');
|
|
402
|
+
assert.equal(echoRead.params[0], String(ME));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// MED-5 from the adversarial review: apply → approved → sign in (the echo is
|
|
406
|
+
// hidden because membership wins) → LEAVE, and the echo resurfaced with the
|
|
407
|
+
// original date for the rest of the window. The card then said "you applied on
|
|
408
|
+
// <date>" about a project the caller had already joined — the echo implying an
|
|
409
|
+
// outcome, which is the one thing ADR 0201 D1.3 forbids it to do.
|
|
410
|
+
test('leaving a project forgets the application that got you in', async () => {
|
|
411
|
+
seed();
|
|
412
|
+
assert.ok(echo('proj-joined'), 'precondition: the echo from the original application is still on file');
|
|
413
|
+
const left = await call('POST', '/api/gds/my-projects/proj-joined/leave');
|
|
414
|
+
assert.equal(left.status, 200);
|
|
415
|
+
assert.equal(left.json.left, true);
|
|
416
|
+
assert.equal(echo('proj-joined'), undefined, 'the echo goes with the membership');
|
|
417
|
+
|
|
418
|
+
const after = await call('GET', '/api/gds/my-projects');
|
|
419
|
+
assert.ok(!after.json.building.some((b) => b.client_id === 'proj-joined'));
|
|
420
|
+
assert.ok(!after.json.applied.some((a) => a.client_id === 'proj-joined'),
|
|
421
|
+
'and it does NOT come back as "applied" now that the membership no longer hides it');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test('leaving forgets only the leaver’s own echo, for only that project', async () => {
|
|
425
|
+
seed();
|
|
426
|
+
await call('POST', '/api/gds/my-projects/proj-joined/leave');
|
|
427
|
+
assert.ok(echo('proj-open'), 'a different project of mine is untouched');
|
|
428
|
+
assert.ok(echoes.some((e) => e.client_id === 'proj-someone-elses'), 'and so is another account’s row');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('a failed echo cleanup does not fail a leave that already happened', async () => {
|
|
432
|
+
seed();
|
|
433
|
+
echoFails = true;
|
|
434
|
+
const left = await call('POST', '/api/gds/my-projects/proj-joined/leave');
|
|
435
|
+
assert.equal(left.status, 200, 'the membership is already gone — the echo is a convenience, not the act');
|
|
436
|
+
assert.equal(left.json.left, true);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('a failed echo read costs the applied list and nothing else', async () => {
|
|
440
|
+
seed();
|
|
441
|
+
echoFails = true;
|
|
442
|
+
const r = await call('GET', '/api/gds/my-projects');
|
|
443
|
+
assert.equal(r.status, 200);
|
|
444
|
+
assert.deepEqual(r.json.applied, []);
|
|
445
|
+
assert.deepEqual(r.json.building.map((b) => b.client_id), ['proj-joined'], 'the memberships listing still lands');
|
|
446
|
+
});
|