@bongos/core 1.20.27 → 1.20.29
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 +75 -45
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- 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 +51 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +20 -19
- package/docs/copy-registry.json +34 -25
- package/docs/module-api-changelog.md +5 -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/modules/provisioning/core-upgrade.js +70 -1
- package/modules/provisioning/migrations/provisioning_029_update_channel.sql +24 -0
- package/modules/provisioning/routes/core-upgrade.js +21 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/fitness.js +4 -0
- package/scripts/gds/login.js +6 -0
- package/scripts/gds/provision-core-upgrade.js +17 -11
- 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/core_upgrade_door.mjs +80 -1
- package/tests/core_upgrade_runner.mjs +54 -1
- 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
|
@@ -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');
|
|
@@ -18,7 +18,7 @@ import { createRequire } from 'node:module';
|
|
|
18
18
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
import { dirname, join } from 'node:path';
|
|
21
|
-
import { parseRepoNote } from '../modules/provisioning/core-upgrade.js';
|
|
21
|
+
import { parseRepoNote, UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, makeUpdateChannelHandler as updateChannelHandlerFor } from '../modules/provisioning/core-upgrade.js';
|
|
22
22
|
|
|
23
23
|
const require = createRequire(import.meta.url);
|
|
24
24
|
const P = require('../modules/provisioning/provisioning.js');
|
|
@@ -885,5 +885,84 @@ await t('lastMoveSuperseded computes no version ORDERING — only identity', ()
|
|
|
885
885
|
assert.equal(CU.lastMoveSuperseded('1.19.921', '1.19.900', '1.19.949'), true);
|
|
886
886
|
});
|
|
887
887
|
|
|
888
|
+
console.log('\nthe project\'s update rule (task 1004445):');
|
|
889
|
+
|
|
890
|
+
const channelHandler = (inst, calls) => updateChannelHandlerFor({
|
|
891
|
+
provisioning: {
|
|
892
|
+
getInstanceById: async () => inst,
|
|
893
|
+
recordEvent: async (_db, e) => { calls.push({ event: e.event, detail: e.detail, actor: e.actor }); },
|
|
894
|
+
},
|
|
895
|
+
pool: { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [] }; } },
|
|
896
|
+
parseId: () => 7, validateOrRespond: () => false, log: { error() {} },
|
|
897
|
+
});
|
|
898
|
+
const runChannel = async (inst, body, calls = []) => {
|
|
899
|
+
let out = null;
|
|
900
|
+
const res = { json: (b) => { out = b; return res; }, fail: (code, o) => { out = { code, ...o }; return res; } };
|
|
901
|
+
await channelHandler(inst, calls)({ builder: { id: 42 }, params: { id: '7' }, body }, res);
|
|
902
|
+
return out;
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
await t('the web tier\'s channel list is the runner\'s, and the migration\'s CHECK carries the same values', () => {
|
|
906
|
+
const policy = require('../scripts/gds/update-channel.js');
|
|
907
|
+
assert.deepEqual([...UPDATE_CHANNELS], policy.VALID_CHANNELS, 're-stated in the module (ADR 0083) — so it must be equal');
|
|
908
|
+
assert.equal(DEFAULT_UPDATE_CHANNEL, policy.DEFAULT_CHANNEL);
|
|
909
|
+
const sql = read('modules', 'provisioning', 'migrations', 'provisioning_029_update_channel.sql');
|
|
910
|
+
assert.match(sql, /DEFAULT 'patch'/);
|
|
911
|
+
for (const c of policy.VALID_CHANNELS) assert.match(sql, new RegExp(`'${c}'`), `the CHECK admits ${c}`);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
await t('updateChannelOf reads the row and falls back to patch for a pre-column row', () => {
|
|
915
|
+
assert.equal(updateChannelOf({ update_channel: 'minor' }), 'minor');
|
|
916
|
+
assert.equal(updateChannelOf({}), 'patch');
|
|
917
|
+
assert.equal(updateChannelOf({ update_channel: 'everything' }), 'patch');
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
await t('setting the rule writes the row and audits who changed it, from what to what', async () => {
|
|
921
|
+
const calls = [];
|
|
922
|
+
const out = await runChannel({ ...ACTIVE, update_channel: 'patch' }, { channel: 'minor' }, calls);
|
|
923
|
+
assert.equal(out.ok, true);
|
|
924
|
+
assert.equal(out.update_channel, 'minor');
|
|
925
|
+
assert.equal(out.changed, true);
|
|
926
|
+
const w = calls.find((c) => c.sql && /UPDATE provisioning_instances SET update_channel/.test(c.sql));
|
|
927
|
+
assert.ok(w, 'the row was written');
|
|
928
|
+
assert.deepEqual(w.params, [7, 'minor']);
|
|
929
|
+
const ev = calls.find((c) => c.event === 'update-channel-changed');
|
|
930
|
+
assert.ok(ev, 'and an event recorded');
|
|
931
|
+
assert.match(ev.detail, /from patch to minor/);
|
|
932
|
+
assert.equal(ev.actor, 'api:archon:42');
|
|
933
|
+
assert.ok(!calls.some((c) => c.sql && /provisioning_intents/.test(c.sql)), 'nothing is queued — a rule change moves no core');
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
await t('an unknown rule is a 400 naming the valid ones, and writes nothing', async () => {
|
|
937
|
+
const calls = [];
|
|
938
|
+
const out = await runChannel(ACTIVE, { channel: 'major' }, calls);
|
|
939
|
+
assert.equal(out.code, 'bad_update_channel');
|
|
940
|
+
assert.equal(out.status, 400);
|
|
941
|
+
assert.deepEqual(out.details.valid, ['pinned', 'patch', 'minor']);
|
|
942
|
+
assert.equal(calls.length, 0);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
await t('re-sending the current rule changes nothing and says so', async () => {
|
|
946
|
+
const calls = [];
|
|
947
|
+
const out = await runChannel({ ...ACTIVE, update_channel: 'minor' }, { channel: 'MINOR' }, calls);
|
|
948
|
+
assert.equal(out.ok, true);
|
|
949
|
+
assert.equal(out.changed, false);
|
|
950
|
+
assert.equal(calls.length, 0, 'no write, no event');
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
await t('the preview carries the rule, so the page can say which rule shaped the offer', () => {
|
|
954
|
+
assert.equal(CU.previewFromSnapshot({ ...ACTIVE, update_channel: 'minor' }, null, null, null).update_channel, 'minor');
|
|
955
|
+
assert.equal(CU.previewFromSnapshot(ACTIVE, null, null, null).update_channel, 'patch');
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
await t('the route is gated like the move: the owner door OR core.pin.move', () => {
|
|
959
|
+
const src = read('modules', 'provisioning', 'routes', 'core-upgrade.js');
|
|
960
|
+
const i = src.indexOf("'/provisioning/instances/:id/update-channel'");
|
|
961
|
+
assert.ok(i > 0, 'the route exists');
|
|
962
|
+
const block = src.slice(i, src.indexOf(');', i));
|
|
963
|
+
assert.match(block, /api\.requireBuilder/);
|
|
964
|
+
assert.match(block, /requireOwnProjectOrPin\(\{ pinGate: api\.requirePermission\('core\.pin\.move'\)/);
|
|
965
|
+
});
|
|
966
|
+
|
|
888
967
|
console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
|
|
889
968
|
if (failed) process.exit(1);
|
|
@@ -31,7 +31,7 @@ import { createRequire } from 'node:module';
|
|
|
31
31
|
import fs from 'node:fs';
|
|
32
32
|
import path from 'node:path';
|
|
33
33
|
// An ESM named import, not R.parseSchemaPending: knip credits a CJS export only when a test imports it this way.
|
|
34
|
-
import { parseSchemaPending, repoNote, REPO_NOTES } from '../scripts/gds/provision-core-upgrade.js';
|
|
34
|
+
import { parseSchemaPending, repoNote, REPO_NOTES, instanceChannel } from '../scripts/gds/provision-core-upgrade.js';
|
|
35
35
|
import { parseRepoNote } from '../modules/provisioning/core-upgrade.js';
|
|
36
36
|
|
|
37
37
|
const require = createRequire(import.meta.url);
|
|
@@ -1396,5 +1396,58 @@ await t('the DRAIN LOOP resolves a done-with-a-note move WITH its note, still as
|
|
|
1396
1396
|
assert.equal(statusWrites.length, 0, 'a healthy instance is not marked anything');
|
|
1397
1397
|
});
|
|
1398
1398
|
|
|
1399
|
+
console.log('\nthe project\'s own update rule (task 1004445):');
|
|
1400
|
+
|
|
1401
|
+
// The REAL policy module, not the stub makeDeps hands every other case: these cases are
|
|
1402
|
+
// about which rule the runner applies, so a stub that admits everything would prove nothing.
|
|
1403
|
+
const realChannel = require('../scripts/gds/update-channel.js');
|
|
1404
|
+
const withRealChannel = (over) => {
|
|
1405
|
+
const d = makeDeps(over);
|
|
1406
|
+
d.updateChannel = { ...realChannel, listAvailableVersions: () => ({ ok: true, versions: over.available }) };
|
|
1407
|
+
return d;
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
await t('instanceChannel reads the row, and a row from before the column falls back to patch', () => {
|
|
1411
|
+
assert.equal(instanceChannel({ update_channel: 'minor' }, realChannel), 'minor');
|
|
1412
|
+
assert.equal(instanceChannel({ update_channel: 'pinned' }, realChannel), 'pinned');
|
|
1413
|
+
assert.equal(instanceChannel({}, realChannel), 'patch', 'no column yet → the old default');
|
|
1414
|
+
assert.equal(instanceChannel({ update_channel: 'bogus' }, realChannel), 'patch', 'unknown → the default, never a wider rule');
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
await t('a project on PATCH still refuses a step-up release (1.19 → 1.20)', async () => {
|
|
1418
|
+
const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1081', available: ['1.19.1081', '1.20.1'] });
|
|
1419
|
+
const r = await R.coreUpgradeInstance(inst({ update_channel: 'patch' }), d, intent({ target_version: '1.20.1' }));
|
|
1420
|
+
assert.equal(r.ok, false);
|
|
1421
|
+
assert.match(r.error, /outside this project's 'patch' update channel/);
|
|
1422
|
+
assert.ok(!d.ran.some((c) => /upgrade\.js/.test(c)), 'and nothing was run');
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
await t('a project on MINOR takes that same step-up release', async () => {
|
|
1426
|
+
const d = withRealChannel({ installed: '1.19.1081', served: '1.20.1', available: ['1.19.1081', '1.20.1'] });
|
|
1427
|
+
const r = await R.coreUpgradeInstance(inst({ update_channel: 'minor' }), d, intent({ target_version: '1.20.1' }));
|
|
1428
|
+
assert.ok(!(r.error && /update channel/.test(r.error)), `the channel must not refuse it: ${r.error}`);
|
|
1429
|
+
assert.ok(d.ran.some((c) => /upgrade\.js/.test(c) && /--to 1\.20\.1/.test(c)), 'the upgrade was invoked for 1.20.1');
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
await t('MINOR still refuses a MAJOR jump — no rule makes 2.0 automatic', async () => {
|
|
1433
|
+
const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1081', available: ['1.19.1081', '2.0.0'] });
|
|
1434
|
+
const r = await R.coreUpgradeInstance(inst({ update_channel: 'minor' }), d, intent({ target_version: '2.0.0' }));
|
|
1435
|
+
assert.equal(r.ok, false);
|
|
1436
|
+
assert.match(r.error, /outside this project's 'minor' update channel/);
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
await t('the snapshot OFFERS what the project\'s rule admits — the button and the runner agree', async () => {
|
|
1440
|
+
const available = ['1.19.1081', '1.19.1082', '1.20.1', '1.20.11'];
|
|
1441
|
+
const offered = async (update_channel) => {
|
|
1442
|
+
const queries = [];
|
|
1443
|
+
await R.snapshotCoreVersions(inst({ update_channel }), withRealChannel({ served: '1.19.1081', available, queries }));
|
|
1444
|
+
return queries.find((q) => /provisioning_core_version_snapshot/.test(q.sql)).params[3];
|
|
1445
|
+
};
|
|
1446
|
+
assert.equal(await offered('patch'), '1.19.1082', 'patch offers the newest fix on the same line');
|
|
1447
|
+
assert.equal(await offered('minor'), '1.20.11', 'minor offers the step up');
|
|
1448
|
+
assert.equal(await offered('pinned'), null, 'pinned offers nothing');
|
|
1449
|
+
assert.equal(await offered(undefined), '1.19.1082', 'a pre-column row behaves exactly as before');
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1399
1452
|
console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
|
|
1400
1453
|
if (failed) process.exit(1);
|
|
@@ -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
|
+
});
|