@bongos/core 1.20.27 → 1.20.28
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 +59 -34
- package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -1
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
- package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +46 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -2
- package/docs/copy-inventory.md +20 -19
- package/docs/copy-registry.json +34 -25
- package/docs/module-api-changelog.md +3 -1
- package/docs/page-readings.json +9 -9
- package/migrations/core_266_access_requests_invited_login_idx.sql +31 -0
- package/modules/hall-ui/public/watch.js +9 -3
- package/modules/onboarding/routes/access-requests.js +43 -20
- package/modules/platform-identity/platform-identity.js +12 -8
- package/modules/platform-identity/tests/platform-identity.mjs +4 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/fitness.js +4 -0
- package/scripts/gds/login.js +6 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/db-kernel.js +6 -3
- package/src/bongos/invite-expiry.js +47 -0
- package/src/module-api.js +10 -1
- package/tests/access_requests_invite.mjs +2 -1
- package/tests/application_lifecycle.mjs +164 -5
- package/tests/bongos_login.mjs +2 -0
- package/tests/hub_invite_notices.mjs +5 -2
- package/tests/invite_expiry.mjs +37 -0
- package/tests/invite_expiry_db.mjs +196 -0
- package/tests/module_api.mjs +1 -0
- package/tests/project_admin_console_model.mjs +12 -5
package/scripts/gds/login.js
CHANGED
|
@@ -49,6 +49,8 @@ function pollDecision(body) {
|
|
|
49
49
|
if (body.admitted === true) return 'admitted';
|
|
50
50
|
if (body.status === 'member' || body.status === 'invited') return 'admitted';
|
|
51
51
|
if (body.status === 'dismissed') return 'dismissed';
|
|
52
|
+
// An invite past its 30 days (task 1002969): the door is shut until they invite again.
|
|
53
|
+
if (body.status === 'expired') return 'expired';
|
|
52
54
|
return 'wait';
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -302,6 +304,10 @@ async function main() {
|
|
|
302
304
|
console.error('\n✖ your access request was declined.');
|
|
303
305
|
process.exit(1);
|
|
304
306
|
}
|
|
307
|
+
if (decision === 'expired') {
|
|
308
|
+
console.error('\n✖ your invite has expired — ask the project to invite you again.');
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
305
311
|
if (Date.now() > admitDeadline) {
|
|
306
312
|
console.error('\n✖ timed out waiting for approval — re-run `bongos login` once an Archon has approved you.');
|
|
307
313
|
process.exit(1);
|
|
@@ -208,6 +208,10 @@ const INTEGRATION = new Set([
|
|
|
208
208
|
// prove the CHECK actually refuses a stored fourth state, that the backfill lands on a
|
|
209
209
|
// pre-migration row, and that publishableSql and projectPublishability agree.
|
|
210
210
|
'project_visibility_db',
|
|
211
|
+
// task 1002969 (ADR 0356): the invite expiry against real SQL — the router's and the
|
|
212
|
+
// admission reads' statements run on Postgres, and the hub's notice restamp and
|
|
213
|
+
// listing. The DB-free sibling (application_lifecycle) interprets the same WHEREs.
|
|
214
|
+
'invite_expiry_db',
|
|
211
215
|
// task 1004408: migration platform_identity_028 deletes the old hub invite's
|
|
212
216
|
// pending rows before a cutoff it reads from the core_upgrades ledger. Only a real
|
|
213
217
|
// engine runs the DO block, the numeric version compare and the one-shot guard.
|
package/src/bongos/db-kernel.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
const crypto = require('node:crypto');
|
|
27
27
|
const { pool } = require('./pool');
|
|
28
|
+
const { inviteLiveSql } = require('./invite-expiry');
|
|
28
29
|
const seams = require('../module-seams');
|
|
29
30
|
|
|
30
31
|
// withTx(fn, {pool}) — run fn(client) inside BEGIN/COMMIT, ROLLBACK+rethrow on
|
|
@@ -232,11 +233,12 @@ async function getBuilderByGithubId(githubId) {
|
|
|
232
233
|
|
|
233
234
|
// hasInvitedAccessRequest — has an Archon approved this login (an access_requests
|
|
234
235
|
// row at status='invited', case-insensitive)? The approval signal that lets a
|
|
235
|
-
// first-time web sign-in through.
|
|
236
|
+
// first-time web sign-in through. An invite older than 30 days no longer counts
|
|
237
|
+
// (inviteLiveSql, task 1002969 / ADR 0356); an approved application never expires.
|
|
236
238
|
async function hasInvitedAccessRequest(login) {
|
|
237
239
|
const { rows } = await pool.query(
|
|
238
240
|
`SELECT 1 FROM access_requests
|
|
239
|
-
WHERE lower(github_login) = lower($1) AND status = 'invited' LIMIT 1`,
|
|
241
|
+
WHERE lower(github_login) = lower($1) AND status = 'invited' AND ${inviteLiveSql()} LIMIT 1`,
|
|
240
242
|
[login]
|
|
241
243
|
);
|
|
242
244
|
return rows.length > 0;
|
|
@@ -259,7 +261,8 @@ async function accessStateByLogin(login) {
|
|
|
259
261
|
EXISTS (SELECT 1 FROM builders
|
|
260
262
|
WHERE lower(github_login) = lower($1)) AS member,
|
|
261
263
|
EXISTS (SELECT 1 FROM access_requests
|
|
262
|
-
WHERE lower(github_login) = lower($1) AND status = 'invited'
|
|
264
|
+
WHERE lower(github_login) = lower($1) AND status = 'invited'
|
|
265
|
+
AND ${inviteLiveSql()}) AS invited,
|
|
263
266
|
EXISTS (SELECT 1 FROM access_requests
|
|
264
267
|
WHERE lower(github_login) = lower($1) AND status = 'pending') AS pending`,
|
|
265
268
|
[login]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/bongos/invite-expiry.js — a recruit invite's door closes 30 days after it is
|
|
2
|
+
// sent, decided when it is READ (task 1002969; privacy spec D4, ADR 0356).
|
|
3
|
+
//
|
|
4
|
+
// There is no sweeper and no stored "expired" state. Every read that asks "does this
|
|
5
|
+
// invite still open the door" computes the answer from created_at, so the sign-in
|
|
6
|
+
// gate, the queues and the hub notice cannot disagree about the day it closed, and a
|
|
7
|
+
// re-invite (a fresh row) opens a fresh door.
|
|
8
|
+
//
|
|
9
|
+
// ONLY `kind = 'invite'` ROWS EXPIRE. An approved application stays open: its
|
|
10
|
+
// applicant asked, and spec D4 is about invites. Reading `kind` here makes it part
|
|
11
|
+
// of admission for the first time (core_255 called it descriptive only), which is
|
|
12
|
+
// safe for one reason: the predicate can only CLOSE a door. A row that is anything
|
|
13
|
+
// but an invite behaves exactly as it did before this rule, so a mis-kinded row fails
|
|
14
|
+
// toward the old gate, never past it (ADR 0356).
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
// The owner-signed window (spec D4). One constant, not per-instance config.
|
|
18
|
+
const INVITE_EXPIRY_DAYS = 30;
|
|
19
|
+
|
|
20
|
+
const ALIAS_RE = /^[a-z_][a-z0-9_]*$/;
|
|
21
|
+
function columnPrefix(alias) {
|
|
22
|
+
if (alias == null || alias === '') return '';
|
|
23
|
+
if (!ALIAS_RE.test(alias)) throw new Error(`invite-expiry: bad table alias ${JSON.stringify(alias)}`);
|
|
24
|
+
return `${alias}.`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// SQL: this access_requests row is an invite whose door has closed. `alias` names the
|
|
28
|
+
// table in the caller's statement ('ar' → ar.kind); omit it for an unaliased table.
|
|
29
|
+
function inviteExpiredSql(alias) {
|
|
30
|
+
const a = columnPrefix(alias);
|
|
31
|
+
return `(${a}kind = 'invite' AND ${a}created_at <= now() - interval '${INVITE_EXPIRY_DAYS} days')`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// SQL: this row has not expired. ANDed beside `status = 'invited'` wherever the
|
|
35
|
+
// question is "does it still admit".
|
|
36
|
+
function inviteLiveSql(alias) {
|
|
37
|
+
return `NOT ${inviteExpiredSql(alias)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// SQL: when this row's door closes if it is an invite, else NULL (an application
|
|
41
|
+
// never expires). What a queue shows beside `expired`.
|
|
42
|
+
function inviteExpiresAtSql(alias) {
|
|
43
|
+
const a = columnPrefix(alias);
|
|
44
|
+
return `(CASE WHEN ${a}kind = 'invite' THEN ${a}created_at + interval '${INVITE_EXPIRY_DAYS} days' END)`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { INVITE_EXPIRY_DAYS, inviteExpiredSql, inviteLiveSql, inviteExpiresAtSql };
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.28'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -375,6 +375,15 @@ module.exports = {
|
|
|
375
375
|
// instance's hub key and client_id itself, so a module can neither
|
|
376
376
|
// substitute a key nor re-address the check. Null is never a refusal.
|
|
377
377
|
get readApplicationVouch() { return require('./bongos/auth').readApplicationVouch; },
|
|
378
|
+
// inviteExpiry — { INVITE_EXPIRY_DAYS, inviteExpiredSql, inviteLiveSql }: a recruit
|
|
379
|
+
// invite's door closes 30 days after it is sent, computed at read time (task
|
|
380
|
+
// 1002969, ADR 0356). The sign-in gate, the onboarding queues and the hub's
|
|
381
|
+
// notice list all read this one definition, so no two can close on different days.
|
|
382
|
+
// Destructured rather than returned whole so knip can see which exports are used.
|
|
383
|
+
get inviteExpiry() {
|
|
384
|
+
const { INVITE_EXPIRY_DAYS, inviteExpiredSql, inviteLiveSql, inviteExpiresAtSql } = require('./bongos/invite-expiry');
|
|
385
|
+
return { INVITE_EXPIRY_DAYS, inviteExpiredSql, inviteLiveSql, inviteExpiresAtSql };
|
|
386
|
+
},
|
|
378
387
|
// userAgent — the instance's HTTP User-Agent string (branding-derived). The
|
|
379
388
|
// carved lifecycle module's github-push.js stamps every GitHub API request with
|
|
380
389
|
// it instead of reaching into src/branding.js directly (BV1.R86).
|
|
@@ -225,7 +225,8 @@ test('invites list ?mine=1: only the caller’s own invites', async () => {
|
|
|
225
225
|
const r = await get('/api/gds/access-requests/invites?mine=1');
|
|
226
226
|
assert.equal(r.status, 200);
|
|
227
227
|
assert.deepEqual(r.json.invites, []);
|
|
228
|
-
|
|
228
|
+
// Outside the expiry window (task 1002969), so another sender's live re-invite still counts.
|
|
229
|
+
assert.match(calls[0].sql, /\) inv\s+WHERE NOT \(inv\.expired AND inv\.login_has_live\)\s+AND inv\.created_by = \$1/);
|
|
229
230
|
assert.deepEqual(calls[0].params, [42], 'narrowed to the acting builder, never to a caller-supplied id');
|
|
230
231
|
});
|
|
231
232
|
|
|
@@ -54,6 +54,12 @@ const express = require('express');
|
|
|
54
54
|
// ===========================================================================
|
|
55
55
|
|
|
56
56
|
const HOUR = 60 * 60 * 1000;
|
|
57
|
+
const DAY = 24 * HOUR;
|
|
58
|
+
|
|
59
|
+
// An invite's door closes `days` after it was sent (task 1002969, ADR 0356). The
|
|
60
|
+
// interpreter reads the window out of the statement itself, so the constant lives in
|
|
61
|
+
// one place: src/bongos/invite-expiry.js.
|
|
62
|
+
const inviteExpired = (r, days) => r.kind === 'invite' && r.created_at <= Date.now() - days * DAY;
|
|
57
63
|
|
|
58
64
|
let rows = [];
|
|
59
65
|
let builders = [];
|
|
@@ -114,6 +120,16 @@ function predicateFor(rawWhere, params) {
|
|
|
114
120
|
where = where.replace(notMember[0], ' TRUE ');
|
|
115
121
|
}
|
|
116
122
|
|
|
123
|
+
// The expiry itself (inviteLiveSql): the row is not an invite past its window.
|
|
124
|
+
// One unit, like the transition guard, so its inner `kind = 'invite'` is not
|
|
125
|
+
// re-read below as a filter of its own.
|
|
126
|
+
let live;
|
|
127
|
+
while ((live = /NOT \((?:(\w+)\.)?kind = 'invite' AND (?:\1\.)?created_at <= now\(\) - interval '(\d+) days'\)/.exec(where))) {
|
|
128
|
+
const days = Number(live[2]);
|
|
129
|
+
tests.push((r) => !inviteExpired(r, days));
|
|
130
|
+
where = where.replace(live[0], ' TRUE ');
|
|
131
|
+
}
|
|
132
|
+
|
|
117
133
|
let m;
|
|
118
134
|
const eat = () => { where = where.replace(m[0], ' TRUE '); };
|
|
119
135
|
if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); eat(); }
|
|
@@ -225,12 +241,24 @@ const pool = {
|
|
|
225
241
|
return { rows: hit.map((r) => ({ id: r.id, github_login: r.github_login, status: r.status, kind: r.kind, resolved_at: r.resolved_at })) };
|
|
226
242
|
}
|
|
227
243
|
|
|
228
|
-
// The outstanding-invite list. Its sender is joined through created_by.
|
|
229
|
-
|
|
230
|
-
|
|
244
|
+
// The outstanding-invite list. Its sender is joined through created_by. Since task
|
|
245
|
+
// 1002969 each row says when its door closes and whether it has, and an expired
|
|
246
|
+
// row is dropped when its login has a live invite: a window over the inner scan
|
|
247
|
+
// (`login_has_live`), with the sender filter outside it. Modelled as exactly that.
|
|
248
|
+
if ((m = /^SELECT inv\.id, inv\.github_login, inv\.note, inv\.created_at, inv\.expires_at, inv\.expired, inv\.invited_by_login FROM \(SELECT ar\.id, ar\.github_login, ar\.note, ar\.created_at, ar\.created_by, \(CASE WHEN ar\.kind = 'invite' THEN ar\.created_at \+ interval '(\d+) days' END\) AS expires_at, \(ar\.kind = 'invite' AND ar\.created_at <= now\(\) - interval '\1 days'\) AS expired, bool_or\(NOT \(ar\.kind = 'invite' AND ar\.created_at <= now\(\) - interval '\1 days'\)\) OVER \(PARTITION BY lower\(ar\.github_login\)\) AS login_has_live, ib\.github_login AS invited_by_login FROM access_requests ar LEFT JOIN builders ib ON ib\.id = ar\.created_by WHERE (.+?)\) inv WHERE NOT \(inv\.expired AND inv\.login_has_live\)(?: AND inv\.created_by = \$(\d+))? ORDER BY inv\.created_at DESC LIMIT 200$/.exec(sql))) {
|
|
249
|
+
const days = Number(m[1]);
|
|
250
|
+
const inner = rows.filter(predicateFor(m[2], params));
|
|
251
|
+
const key = (r) => String(r.github_login).toLowerCase();
|
|
252
|
+
const liveLogins = new Set(inner.filter((r) => !inviteExpired(r, days)).map(key));
|
|
253
|
+
const sender = m[3] ? params[Number(m[3]) - 1] : undefined;
|
|
254
|
+
const hit = inner
|
|
255
|
+
.filter((r) => !(inviteExpired(r, days) && liveLogins.has(key(r))))
|
|
256
|
+
.filter((r) => sender === undefined || String(r.created_by) === String(sender))
|
|
257
|
+
.sort((a, b) => b.created_at - a.created_at);
|
|
231
258
|
return {
|
|
232
259
|
rows: hit.map((r) => ({
|
|
233
260
|
id: r.id, github_login: r.github_login, note: r.note, created_at: r.created_at,
|
|
261
|
+
expires_at: r.created_at + days * DAY, expired: inviteExpired(r, days),
|
|
234
262
|
invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null,
|
|
235
263
|
})),
|
|
236
264
|
};
|
|
@@ -248,6 +276,9 @@ const pool = {
|
|
|
248
276
|
// proves the route reads the column rather than that this fake invents it.
|
|
249
277
|
...(/\bar\.applicant_handle\b/.test(sql) ? { applicant_handle: r.applicant_handle ?? null } : {}),
|
|
250
278
|
...(/\bar\.kind\b/.test(sql) ? { kind: r.kind } : {}),
|
|
279
|
+
...((m = /\(ar\.status = 'invited' AND \(ar\.kind = 'invite' AND ar\.created_at <= now\(\) - interval '(\d+) days'\)\) AS expired, \(CASE WHEN ar\.kind = 'invite' THEN ar\.created_at \+ interval '\1 days' END\) AS expires_at/.exec(sql))
|
|
280
|
+
? { expired: r.status === 'invited' && inviteExpired(r, Number(m[1])), expires_at: r.kind === 'invite' ? r.created_at + Number(m[1]) * DAY : null }
|
|
281
|
+
: {}),
|
|
251
282
|
...(/\bib\.github_login AS invited_by_login\b/.test(sql) && /\bON ib\.id = ar\.created_by\b/.test(sql)
|
|
252
283
|
? { invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null } : {}),
|
|
253
284
|
vouched: r.applicant_github_id !== null,
|
|
@@ -256,6 +287,27 @@ const pool = {
|
|
|
256
287
|
};
|
|
257
288
|
}
|
|
258
289
|
|
|
290
|
+
// db-kernel's accessStateByLogin — the waiting page's three facts in one read.
|
|
291
|
+
// Each EXISTS is filtered by its own WHERE, so the expiry unit in `invited` is
|
|
292
|
+
// read by the interpreter like every other predicate.
|
|
293
|
+
if ((m = /^SELECT EXISTS \(SELECT 1 FROM builders WHERE (.+?)\) AS member, EXISTS \(SELECT 1 FROM access_requests WHERE (.+?)\) AS invited, EXISTS \(SELECT 1 FROM access_requests WHERE (.+?)\) AS pending$/.exec(sql))) {
|
|
294
|
+
return { rows: [{
|
|
295
|
+
member: builders.some(predicateFor(m[1], params)),
|
|
296
|
+
invited: rows.some(predicateFor(m[2], params)),
|
|
297
|
+
pending: rows.some(predicateFor(m[3], params)),
|
|
298
|
+
}] };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The public status probe (task 1002969): an invite that still admits sorts first,
|
|
302
|
+
// then the newest request; an invite past its window reports itself expired.
|
|
303
|
+
if ((m = /^SELECT status, \(status = 'invited' AND \(kind = 'invite' AND created_at <= now\(\) - interval '(\d+) days'\)\) AS expired FROM access_requests WHERE (.+?) ORDER BY \(status = 'invited' AND NOT \(kind = 'invite' AND created_at <= now\(\) - interval '\1 days'\)\) DESC, created_at DESC LIMIT 1$/.exec(sql))) {
|
|
304
|
+
const days = Number(m[1]);
|
|
305
|
+
const admits = (r) => r.status === 'invited' && !inviteExpired(r, days);
|
|
306
|
+
const hit = rows.filter(predicateFor(m[2], params))
|
|
307
|
+
.sort((a, b) => (admits(b) - admits(a)) || (b.created_at - a.created_at));
|
|
308
|
+
return { rows: hit.slice(0, 1).map((r) => ({ status: r.status, expired: r.status === 'invited' && inviteExpired(r, days) })) };
|
|
309
|
+
}
|
|
310
|
+
|
|
259
311
|
// A vouched re-application taking over an unvouched pending row (ADR 0333 D4).
|
|
260
312
|
// The SET list is matched exactly, like the resolve's below.
|
|
261
313
|
if ((m = /^UPDATE access_requests SET display_name = \$2, note = \$3, applicant_github_id = \$4, applicant_handle = \$5, vouched_at = \$6 WHERE (.+?)$/.exec(sql))) {
|
|
@@ -809,8 +861,12 @@ test('invite: a login that never applied signs straight in, and the row says it
|
|
|
809
861
|
assert.equal(eve.note, 'Come build the parser', 'the inviter’s words, and kind says whose');
|
|
810
862
|
statements = [];
|
|
811
863
|
assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), true, 'the REAL admission read admits her — signing in is the acceptance act');
|
|
812
|
-
|
|
813
|
-
|
|
864
|
+
// D2.5 as amended by ADR 0356 (task 1002969): admission never reads created_by, and
|
|
865
|
+
// reads `kind` ONLY inside the expiry unit, which can close a door but never open one.
|
|
866
|
+
const EXPIRY_UNIT = /NOT \(kind = 'invite' AND created_at <= now\(\) - interval '\d+ days'\)/g;
|
|
867
|
+
assert.ok(statements.length > 0 && statements.every(({ sql }) => !/\bcreated_by\b/.test(sql)
|
|
868
|
+
&& !/\bkind\b/.test(sql.replace(EXPIRY_UNIT, ''))),
|
|
869
|
+
'and it admitted her reading kind only to ask whether her invite has expired (D2.5, ADR 0356)');
|
|
814
870
|
});
|
|
815
871
|
|
|
816
872
|
test('rescind: an invite keeps its sender through a rescind by a different archon, and the door re-locks', async () => {
|
|
@@ -873,6 +929,109 @@ test('the queue history tells an invite from an approved application, and names
|
|
|
873
929
|
assert.equal(byLogin.ada.invite_note, null);
|
|
874
930
|
});
|
|
875
931
|
|
|
932
|
+
// ===========================================================================
|
|
933
|
+
// Expiry — an invite's door closes 30 days after it is sent, read-time (task
|
|
934
|
+
// 1002969, privacy spec D4, ADR 0356)
|
|
935
|
+
// ===========================================================================
|
|
936
|
+
|
|
937
|
+
// Backdate a row as if it had been written `days` ago (an invite's resolved_at is
|
|
938
|
+
// its created_at: the route writes both in one statement).
|
|
939
|
+
const age = (row, days) => {
|
|
940
|
+
row.created_at = Date.now() - days * DAY;
|
|
941
|
+
if (row.kind === 'invite') row.resolved_at = row.created_at;
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
test('expiry: an invite admits for 30 days and not after; an approved application never expires', async () => {
|
|
945
|
+
reset();
|
|
946
|
+
builders.push(...ARCHONS);
|
|
947
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
|
|
948
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
|
|
949
|
+
age(rows.find((x) => x.github_login === 'eve'), 29);
|
|
950
|
+
age(rows.find((x) => x.github_login === 'fay'), 31);
|
|
951
|
+
age(row(3), 60); // cy: an application approved long ago
|
|
952
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), true, 'day 29: the door still opens');
|
|
953
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('fay'), false, 'day 31: the REAL admission read refuses her');
|
|
954
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('cy'), true, 'an approved application is not an invite and does not expire');
|
|
955
|
+
const state = await dbKernel.accessStateByLogin('fay');
|
|
956
|
+
assert.equal(state.invited, false, 'the waiting page does not call an expired invite imminent admission');
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
test('expiry: the queues show an expired invite as expired, and it offers the recruiter a re-invite, not a rescind', async () => {
|
|
960
|
+
reset();
|
|
961
|
+
builders.push(...ARCHONS);
|
|
962
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
|
|
963
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
|
|
964
|
+
age(rows.find((x) => x.github_login === 'fay'), 31);
|
|
965
|
+
const hist = await call('GET', '/api/gds/access-requests?status=invited');
|
|
966
|
+
const h = Object.fromEntries(hist.json.requests.map((x) => [x.github_login, x]));
|
|
967
|
+
assert.equal(h.fay.expired, true);
|
|
968
|
+
assert.equal(h.eve.expired, false);
|
|
969
|
+
assert.equal(h.cy.expired, false, 'an approved application is never marked expired');
|
|
970
|
+
assert.equal(h.cy.expires_at, null, 'and has no day it closes');
|
|
971
|
+
assert.ok(h.fay.expires_at <= Date.now() && h.eve.expires_at > Date.now(), 'each invite says when its door closes');
|
|
972
|
+
const out = await call('GET', '/api/gds/access-requests/invites');
|
|
973
|
+
const o = Object.fromEntries(out.json.invites.map((x) => [x.github_login, x]));
|
|
974
|
+
assert.deepEqual(Object.keys(o).sort(), ['eve', 'fay'], 'an expired invite stays listed, so its sender knows to invite again');
|
|
975
|
+
assert.equal(o.fay.expired, true);
|
|
976
|
+
assert.equal(o.eve.expired, false);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test('expiry: re-inviting an expired login opens a fresh door, notices the hub, and retires the expired line', async () => {
|
|
980
|
+
reset();
|
|
981
|
+
builders.push(...ARCHONS);
|
|
982
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
|
|
983
|
+
age(rows.find((x) => x.github_login === 'fay'), 31);
|
|
984
|
+
notices = [];
|
|
985
|
+
const again = await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
|
|
986
|
+
assert.equal(again.status, 201, 'a fresh row, not already_invited: the expired invite does not stand');
|
|
987
|
+
assert.deepEqual(notices, [{ login: 'fay', invited: true }], 'the hub is told, so its notice shows again');
|
|
988
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('fay'), true, 'the new door opens');
|
|
989
|
+
const out = await call('GET', '/api/gds/access-requests/invites');
|
|
990
|
+
const fays = out.json.invites.filter((x) => x.github_login === 'fay');
|
|
991
|
+
assert.equal(fays.length, 1, 'the expired line is replaced by the live one, not listed beside it');
|
|
992
|
+
assert.equal(fays[0].expired, false);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
test('expiry: another sender re-inviting the login retires your expired line from your own list too', async () => {
|
|
996
|
+
reset();
|
|
997
|
+
builders.push(...ARCHONS);
|
|
998
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' }); // sent by 42
|
|
999
|
+
age(rows.find((x) => x.github_login === 'fay'), 31);
|
|
1000
|
+
actingId = 43;
|
|
1001
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' }); // 43 re-invites
|
|
1002
|
+
actingId = 42;
|
|
1003
|
+
const mine = await call('GET', '/api/gds/access-requests/invites?mine=1');
|
|
1004
|
+
assert.deepEqual(mine.json.invites.map((x) => x.github_login), [],
|
|
1005
|
+
'the sender filter sits outside the "has a live invite" window, so 42 is not told to re-invite fay');
|
|
1006
|
+
actingId = 43;
|
|
1007
|
+
const theirs = await call('GET', '/api/gds/access-requests/invites?mine=1');
|
|
1008
|
+
assert.deepEqual(theirs.json.invites.map((x) => [x.github_login, x.expired]), [['fay', false]]);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
test('expiry: the public status probe says expired, and an expired invitee may apply', async () => {
|
|
1012
|
+
reset();
|
|
1013
|
+
builders.push(...ARCHONS);
|
|
1014
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
|
|
1015
|
+
age(rows.find((x) => x.github_login === 'fay'), 31);
|
|
1016
|
+
const st = await call('GET', '/api/gds/access-requests/status?github_login=fay');
|
|
1017
|
+
assert.deepEqual(st.json, { github_login: 'fay', status: 'expired', admitted: false });
|
|
1018
|
+
const asked = await call('POST', '/api/gds/access-requests', { github_login: 'fay' });
|
|
1019
|
+
assert.equal(asked.status, 201, 'not already_approved: an expired invite is not an approval');
|
|
1020
|
+
const st2 = await call('GET', '/api/gds/access-requests/status?github_login=fay');
|
|
1021
|
+
assert.equal(st2.json.status, 'pending', 'the newer request is what the probe reports');
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
test('expiry: rescinding the last live invite clears the hub notice even though an expired one remains', async () => {
|
|
1025
|
+
reset();
|
|
1026
|
+
builders.push(...ARCHONS);
|
|
1027
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
|
|
1028
|
+
age(rows.find((x) => x.github_login === 'fay'), 31);
|
|
1029
|
+
const live = await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
|
|
1030
|
+
notices = [];
|
|
1031
|
+
await call('PATCH', `/api/gds/access-requests/${live.json.request.id}`, { status: 'dismissed' });
|
|
1032
|
+
assert.deepEqual(notices, [{ login: 'fay', invited: false }], 'an expired row is not a standing invite');
|
|
1033
|
+
});
|
|
1034
|
+
|
|
876
1035
|
test('invite: resolving a pending application in place keeps it an application, with the applicant’s own note', async () => {
|
|
877
1036
|
reset();
|
|
878
1037
|
builders.push(...ARCHONS);
|
package/tests/bongos_login.mjs
CHANGED
|
@@ -39,6 +39,8 @@ test('pollDecision maps the /access-requests/status body to a loop action', () =
|
|
|
39
39
|
assert.equal(pollDecision({ status: 'invited' }), 'admitted');
|
|
40
40
|
assert.equal(pollDecision({ status: 'member', admitted: true }), 'admitted');
|
|
41
41
|
assert.equal(pollDecision({ status: 'dismissed' }), 'dismissed');
|
|
42
|
+
// task 1002969: an invite past its 30 days stops the loop rather than waiting on a shut door
|
|
43
|
+
assert.equal(pollDecision({ status: 'expired', admitted: false }), 'expired');
|
|
42
44
|
assert.equal(pollDecision({ status: 'pending', admitted: false }), 'wait');
|
|
43
45
|
assert.equal(pollDecision({ status: 'none' }), 'wait');
|
|
44
46
|
assert.equal(pollDecision(null), 'wait');
|
|
@@ -107,8 +107,11 @@ test('notify seeds a PENDING row on the calling project, and cannot touch a real
|
|
|
107
107
|
const w = writes();
|
|
108
108
|
assert.equal(w.length, 1);
|
|
109
109
|
assert.match(w[0].sql, /VALUES \(\$1, \$2, 'pending'\)/, 'a notice is a pending row, never a membership');
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// The conflict branch restamps ONLY joined_at, and only on a pending row past its 30
|
|
111
|
+
// days (a re-invite, task 1002969): a current member is left exactly as they are.
|
|
112
|
+
assert.match(w[0].sql, /ON CONFLICT \(client_id, github_id\) DO UPDATE SET joined_at = now\(\)\s+WHERE m\.membership_kind = 'pending' AND m\.joined_at <= now\(\) - make_interval\(days => \$3\)/,
|
|
113
|
+
'a current member is left exactly as they are');
|
|
114
|
+
assert.deepEqual(w[0].params, ['mercury', '4242', 30], 'keyed on the authenticated client and the account the LOGIN resolves to');
|
|
112
115
|
});
|
|
113
116
|
|
|
114
117
|
test('clear drops ONLY a pending row on the calling project', async () => {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// tests/invite_expiry.mjs — task 1002969 (privacy spec D4, ADR 0356). DB-free.
|
|
2
|
+
//
|
|
3
|
+
// The one definition of when a recruit invite's door closes. The behaviour is proved
|
|
4
|
+
// in tests/application_lifecycle.mjs (interpreted) and tests/invite_expiry_db.mjs
|
|
5
|
+
// (real Postgres); this pins the definition itself: the owner-signed window, that
|
|
6
|
+
// only an invite can expire, that "live" is exactly "not expired", and that a table
|
|
7
|
+
// alias can never carry SQL into the statements that interpolate it.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --test tests/invite_expiry.mjs
|
|
10
|
+
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const expiry = require('../src/bongos/invite-expiry.js');
|
|
16
|
+
const api = require('../src/module-api.js');
|
|
17
|
+
|
|
18
|
+
test('the window is the owner-signed 30 days, and the doorway serves this same module', () => {
|
|
19
|
+
assert.equal(expiry.INVITE_EXPIRY_DAYS, 30);
|
|
20
|
+
for (const k of ['INVITE_EXPIRY_DAYS', 'inviteExpiredSql', 'inviteLiveSql', 'inviteExpiresAtSql']) {
|
|
21
|
+
assert.equal(api.inviteExpiry[k], expiry[k], `${k}: modules read the definition core reads, not a copy`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('only an invite expires, and live is exactly not-expired', () => {
|
|
26
|
+
assert.equal(expiry.inviteExpiredSql(), "(kind = 'invite' AND created_at <= now() - interval '30 days')");
|
|
27
|
+
assert.equal(expiry.inviteLiveSql('ar'), `NOT ${expiry.inviteExpiredSql('ar')}`);
|
|
28
|
+
assert.match(expiry.inviteExpiresAtSql('ar'), /^\(CASE WHEN ar\.kind = 'invite' THEN ar\.created_at \+ interval '30 days' END\)$/,
|
|
29
|
+
'an application has no closing day');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('an alias is an identifier or nothing', () => {
|
|
33
|
+
assert.match(expiry.inviteLiveSql('x'), /x\.kind = 'invite' AND x\.created_at/);
|
|
34
|
+
for (const bad of ['ar;', "a'", 'a b', 'A', '1a', 'a.b']) {
|
|
35
|
+
assert.throws(() => expiry.inviteExpiredSql(bad), /bad table alias/, JSON.stringify(bad));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// tests/invite_expiry_db.mjs — task 1002969 (privacy spec D4, ADR 0356). Real DB.
|
|
2
|
+
//
|
|
3
|
+
// A recruit invite's door closes 30 days after it is sent, computed at read time.
|
|
4
|
+
// tests/application_lifecycle.mjs pins the behaviour over an interpreter that reads
|
|
5
|
+
// each statement's WHERE; this file runs the SAME statements on Postgres, because an
|
|
6
|
+
// interpreter cannot tell you that Postgres would reject a statement, or what
|
|
7
|
+
// `now() - interval '30 days'` does at the boundary. Two halves:
|
|
8
|
+
//
|
|
9
|
+
// the instance — the real onboarding router and the real db-kernel admission reads,
|
|
10
|
+
// over access_requests built from its real migrations;
|
|
11
|
+
// the hub — inviteToProject and listPendingInvitesForAccount over the real
|
|
12
|
+
// memberships table: an expired notice is hidden, a re-invite
|
|
13
|
+
// restamps it, and a member's row is never touched.
|
|
14
|
+
//
|
|
15
|
+
// Each half builds its tables in a throwaway schema, so a shared database's rows are
|
|
16
|
+
// never seen or changed.
|
|
17
|
+
//
|
|
18
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
19
|
+
// INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
|
|
20
|
+
// never runs it.
|
|
21
|
+
//
|
|
22
|
+
// Run: DATABASE_URL=... node tests/invite_expiry_db.mjs
|
|
23
|
+
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
import { strict as assert } from 'node:assert';
|
|
29
|
+
import http from 'node:http';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { Pool } = require('pg');
|
|
32
|
+
const express = require('express');
|
|
33
|
+
|
|
34
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const coreSql = (f) => readFileSync(join(here, '..', 'migrations', `${f}.sql`), 'utf8');
|
|
36
|
+
const piSql = (f) => readFileSync(join(here, '..', 'modules', 'platform-identity', 'migrations', `platform_identity_${f}.sql`), 'utf8');
|
|
37
|
+
|
|
38
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
39
|
+
let client;
|
|
40
|
+
try {
|
|
41
|
+
client = await pgPool.connect();
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.log(`invite_expiry_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
44
|
+
await pgPool.end().catch(() => {});
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const q = (sql, params) => client.query(sql, params);
|
|
49
|
+
const SCHEMA = `t1002969_${process.pid}`;
|
|
50
|
+
let pass = 0;
|
|
51
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
52
|
+
|
|
53
|
+
// Core's admission reads go through ./pool, and the router through the doorway's
|
|
54
|
+
// pool: both are pointed at this one connection, whose search_path is the schema.
|
|
55
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
56
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: client } };
|
|
57
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
58
|
+
const realApi = require(apiPath);
|
|
59
|
+
let notices = [];
|
|
60
|
+
require.cache[apiPath].exports = {
|
|
61
|
+
...realApi,
|
|
62
|
+
pool: client,
|
|
63
|
+
projectJoinDoor: () => 'apply',
|
|
64
|
+
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
|
65
|
+
requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'owner' }; next(); },
|
|
66
|
+
requirePermission: () => (_req, _res, next) => next(),
|
|
67
|
+
readApplicationVouch: () => null,
|
|
68
|
+
notifyHubOfInvite: (login, opts) => { notices.push({ login, invited: opts && opts.invited }); },
|
|
69
|
+
};
|
|
70
|
+
const broadcastPath = require.resolve('../modules/onboarding/approval-broadcast.js');
|
|
71
|
+
require.cache[broadcastPath] = { id: broadcastPath, filename: broadcastPath, loaded: true, exports: { postMemberApprovalRequest: async () => ({}) } };
|
|
72
|
+
|
|
73
|
+
const dbKernel = require('../src/bongos/db-kernel.js');
|
|
74
|
+
const buildAccessRequestsRouter = require('../modules/onboarding/routes/access-requests.js');
|
|
75
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
76
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
77
|
+
|
|
78
|
+
const app = express();
|
|
79
|
+
app.use(express.json());
|
|
80
|
+
app.use(attachFail);
|
|
81
|
+
app.use('/api/gds', buildAccessRequestsRouter());
|
|
82
|
+
const server = http.createServer(app);
|
|
83
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
84
|
+
const port = server.address().port;
|
|
85
|
+
function call(method, path, body) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
88
|
+
const req = http.request({ host: '127.0.0.1', port, method, path,
|
|
89
|
+
headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {} }, (res) => {
|
|
90
|
+
const chunks = [];
|
|
91
|
+
res.on('data', (c) => chunks.push(c));
|
|
92
|
+
res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null') }));
|
|
93
|
+
});
|
|
94
|
+
req.on('error', reject);
|
|
95
|
+
req.end(payload);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Backdate one login's rows by `days` (an invite's resolved_at is its created_at).
|
|
100
|
+
const age = (login, days) => q(
|
|
101
|
+
`UPDATE access_requests SET created_at = now() - make_interval(days => $2),
|
|
102
|
+
resolved_at = CASE WHEN kind = 'invite' THEN now() - make_interval(days => $2) ELSE resolved_at END
|
|
103
|
+
WHERE github_login = $1`, [login, days]);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await q(`CREATE SCHEMA ${SCHEMA}`);
|
|
107
|
+
await q(`SET search_path TO ${SCHEMA}`);
|
|
108
|
+
await q('CREATE TABLE schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())');
|
|
109
|
+
await q('CREATE TABLE builders (id bigserial PRIMARY KEY, github_login text NOT NULL)');
|
|
110
|
+
await q(`INSERT INTO builders (id, github_login) VALUES (42, 'owner')`);
|
|
111
|
+
for (const f of ['060_access_requests', 'core_230_access_requests_applicant_vouch',
|
|
112
|
+
'core_254_access_requests_applicant_handle', 'core_255_access_requests_invite_kind',
|
|
113
|
+
'core_266_access_requests_invited_login_idx']) await q(coreSql(f));
|
|
114
|
+
|
|
115
|
+
// ── The instance: the door, the queues, the probe ────────────────────────
|
|
116
|
+
console.log('the instance:');
|
|
117
|
+
for (const login of ['eve', 'fay']) assert.equal((await call('POST', '/api/gds/access-requests/invite', { github_login: login })).status, 201);
|
|
118
|
+
await q(`INSERT INTO access_requests (github_login, status, resolved_at, created_at) VALUES ('cy', 'invited', now(), now() - interval '60 days')`);
|
|
119
|
+
await age('eve', 29);
|
|
120
|
+
await age('fay', 31);
|
|
121
|
+
ok(await dbKernel.hasInvitedAccessRequest('eve') === true, 'day 29: the invite still admits');
|
|
122
|
+
ok(await dbKernel.hasInvitedAccessRequest('fay') === false, 'day 31: the sign-in gate refuses it');
|
|
123
|
+
ok(await dbKernel.hasInvitedAccessRequest('cy') === true, 'an application approved 60 days ago is not an invite and still admits');
|
|
124
|
+
ok((await dbKernel.accessStateByLogin('fay')).invited === false, 'the waiting page agrees with the gate');
|
|
125
|
+
|
|
126
|
+
// core_266: the gate's own statement, captured as it runs, can use the invited-login
|
|
127
|
+
// index. Seq scans are switched off only so a tiny table still shows the plan.
|
|
128
|
+
const seen = [];
|
|
129
|
+
const realQuery = client.query.bind(client);
|
|
130
|
+
client.query = (text, params) => { seen.push({ text, params }); return realQuery(text, params); };
|
|
131
|
+
await dbKernel.hasInvitedAccessRequest('eve');
|
|
132
|
+
client.query = realQuery;
|
|
133
|
+
await q('SET enable_seqscan = off');
|
|
134
|
+
const plan = (await q(`EXPLAIN ${seen[0].text}`, seen[0].params)).rows.map((r) => r['QUERY PLAN']).join('\n');
|
|
135
|
+
await q('RESET enable_seqscan');
|
|
136
|
+
ok(/idx_access_requests_invited_login/.test(plan), 'the sign-in gate can read invited rows through core_266\'s index');
|
|
137
|
+
|
|
138
|
+
const hist = Object.fromEntries((await call('GET', '/api/gds/access-requests?status=invited')).json.requests.map((r) => [r.github_login, r]));
|
|
139
|
+
ok(hist.fay.expired === true && hist.eve.expired === false && hist.cy.expired === false, 'the queue marks only the expired invite');
|
|
140
|
+
ok(hist.cy.expires_at === null && new Date(hist.fay.expires_at) <= new Date() && new Date(hist.eve.expires_at) > new Date(),
|
|
141
|
+
'and says when each invite closes; an application has no such day');
|
|
142
|
+
|
|
143
|
+
const probe = (await call('GET', '/api/gds/access-requests/status?github_login=fay')).json;
|
|
144
|
+
ok(probe.status === 'expired' && probe.admitted === false, 'the status probe reports expired, not invited');
|
|
145
|
+
|
|
146
|
+
notices = [];
|
|
147
|
+
const again = await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
|
|
148
|
+
ok(again.status === 201 && await dbKernel.hasInvitedAccessRequest('fay') === true, 're-inviting an expired login opens a fresh door');
|
|
149
|
+
ok(notices.length === 1 && notices[0].invited === true, 'and tells the hub');
|
|
150
|
+
const out = (await call('GET', '/api/gds/access-requests/invites')).json.invites;
|
|
151
|
+
ok(out.filter((r) => r.github_login === 'fay').length === 1 && out.find((r) => r.github_login === 'fay').expired === false,
|
|
152
|
+
'the outstanding list shows the live invite in place of the expired one');
|
|
153
|
+
const mine = (await call('GET', '/api/gds/access-requests/invites?mine=1')).json.invites;
|
|
154
|
+
ok(mine.filter((r) => r.github_login === 'fay').length === 1, 'and so does the sender\'s own list (?mine=1)');
|
|
155
|
+
|
|
156
|
+
notices = [];
|
|
157
|
+
await call('PATCH', `/api/gds/access-requests/${again.json.request.id}`, { status: 'dismissed' });
|
|
158
|
+
ok(notices.length === 1 && notices[0].invited === false, 'rescinding the live invite clears the hub notice; the expired row does not stand');
|
|
159
|
+
|
|
160
|
+
// ── The hub: the notice follows the door ──────────────────────────────────
|
|
161
|
+
console.log('the hub:');
|
|
162
|
+
for (const f of ['001_tables', '003_project_memberships', '010_membership_kind', '021_drop_legacy_role']) await q(piSql(f));
|
|
163
|
+
const joined = async (id) => (await q(
|
|
164
|
+
`SELECT membership_kind, joined_at FROM platform_identity_project_memberships WHERE client_id = 'demo' AND github_id = $1`, [id])).rows[0];
|
|
165
|
+
const backdate = (id, days) => q(
|
|
166
|
+
`UPDATE platform_identity_project_memberships SET joined_at = now() - make_interval(days => $2) WHERE client_id = 'demo' AND github_id = $1`, [id, days]);
|
|
167
|
+
|
|
168
|
+
ok((await pi.inviteToProject({ clientId: 'demo', githubId: 1 }, { pool: client })).invited === true, 'a first invite writes a notice');
|
|
169
|
+
await backdate(1, 10);
|
|
170
|
+
const before = (await joined(1)).joined_at;
|
|
171
|
+
ok((await pi.inviteToProject({ clientId: 'demo', githubId: 1 }, { pool: client })).invited === false
|
|
172
|
+
&& (await joined(1)).joined_at.getTime() === before.getTime(), 'a re-send while the invite is live leaves the notice as it was');
|
|
173
|
+
|
|
174
|
+
await pi.inviteToProject({ clientId: 'demo', githubId: 2 }, { pool: client });
|
|
175
|
+
await backdate(2, 31);
|
|
176
|
+
const listed = async (id) => (await pi.listPendingInvitesForAccount(id, { pool: client })).length;
|
|
177
|
+
ok(await listed(1) === 1, 'a 10-day-old notice is listed');
|
|
178
|
+
ok(await listed(2) === 0, 'a 31-day-old notice is not: its door no longer opens');
|
|
179
|
+
ok((await pi.inviteToProject({ clientId: 'demo', githubId: 2 }, { pool: client })).invited === true && await listed(2) === 1,
|
|
180
|
+
'a re-invite after the 30 days restamps the notice, so it shows again');
|
|
181
|
+
|
|
182
|
+
await q(`INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind, joined_at)
|
|
183
|
+
VALUES ('demo', 3, 'member', now() - interval '400 days')`);
|
|
184
|
+
const member = await joined(3);
|
|
185
|
+
ok((await pi.inviteToProject({ clientId: 'demo', githubId: 3 }, { pool: client })).invited === false, 'inviting a member writes nothing');
|
|
186
|
+
const after = await joined(3);
|
|
187
|
+
ok(after.membership_kind === 'member' && after.joined_at.getTime() === member.joined_at.getTime(),
|
|
188
|
+
'and their row is untouched, however old it is');
|
|
189
|
+
|
|
190
|
+
console.log(`\ninvite_expiry_db.mjs: ${pass} passed`);
|
|
191
|
+
} finally {
|
|
192
|
+
await new Promise((r) => server.close(r));
|
|
193
|
+
await q(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
194
|
+
client.release();
|
|
195
|
+
await pgPool.end().catch(() => {});
|
|
196
|
+
}
|