@bongos/core 1.19.1023 → 1.19.1025
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 +55 -40
- 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 +4 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +2 -0
- package/docs/api/openapi.json +58 -4
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +18 -16
- package/docs/copy-registry.json +43 -25
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_255_access_requests_invite_kind.sql +52 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/onboarding/routes/access-requests.js +62 -11
- package/modules/platform-identity/connection-suggestions.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_024_terms_grandfathered.sql +68 -0
- package/modules/platform-identity/terms-acceptance.js +34 -14
- package/modules/public-landing/public/terms.html +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/bongos/auth-github.js +4 -2
- package/src/bongos/db-kernel.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/access_requests_invite.mjs +81 -4
- package/tests/application_lifecycle.mjs +174 -6
- package/tests/auth_inactive_builder.mjs +84 -0
- package/tests/connection_suggestions.mjs +1 -1
- package/tests/connections_api.mjs +1 -1
- package/tests/handle_machinery.mjs +2 -0
- package/tests/people_name_only.mjs +2 -1
- package/tests/profile_connections_ui.mjs +2 -2
- package/tests/profile_rollup_consent.mjs +3 -0
- package/tests/terms_acceptance.mjs +264 -14
- package/tests/terms_grandfather_db.mjs +144 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
-- platform_identity_024_terms_grandfathered.sql — grandfather existing accounts
|
|
2
|
+
-- onto the platform T&C, and record that they were grandfathered rather than
|
|
3
|
+
-- signed in (task 1003360; owner decision on blocker 1000123; ADR 0217 D3,
|
|
4
|
+
-- amending ADR 0213 D4).
|
|
5
|
+
--
|
|
6
|
+
-- WHY. Migration 019 made consent two columns and deliberately backfilled
|
|
7
|
+
-- nothing, so every account the hub had already witnessed ('active') stopped
|
|
8
|
+
-- being published until its owner next signed in. The owner chose instead to
|
|
9
|
+
-- grandfather them — nothing goes dark — having been shown that this records a
|
|
10
|
+
-- consent which did not happen. So the record must not hide it: a grandfathered
|
|
11
|
+
-- acceptance is marked as such, and /terms says so.
|
|
12
|
+
--
|
|
13
|
+
-- WHAT IT DOES, once:
|
|
14
|
+
-- 1. adds terms_accepted_source ('signin' | 'grandfathered'). A named marker,
|
|
15
|
+
-- not a magic timestamp, so any later audit can count the two apart.
|
|
16
|
+
-- 2. marks every acceptance already on record as 'signin' — until now the one
|
|
17
|
+
-- statement that could write an acceptance was the hub sign-in
|
|
18
|
+
-- (upsertAccount's activate branch, ADR 0213 D1). This runs FIRST, or step 3's
|
|
19
|
+
-- rows would be relabelled too.
|
|
20
|
+
-- 3. grandfathers every 'active' account with no acceptance: stamped now(),
|
|
21
|
+
-- with the version in force today, marked 'grandfathered'. profile_state is
|
|
22
|
+
-- not touched — a 'provisional' account was never public and stays
|
|
23
|
+
-- unpublished; this keeps published profiles published, it publishes nobody.
|
|
24
|
+
--
|
|
25
|
+
-- ONE-SHOT, ON PURPOSE. The writes run only in the transaction that creates the
|
|
26
|
+
-- column. Grandfathering is an event at this deploy ("accounts that existed
|
|
27
|
+
-- before these terms"), not a rule: replaying this file (the integration tests
|
|
28
|
+
-- do) must never grandfather an account created since.
|
|
29
|
+
--
|
|
30
|
+
-- The version literal is pinned, not a placeholder: it names the text these
|
|
31
|
+
-- accounts were accepted under, and a later revision must not rewrite that.
|
|
32
|
+
--
|
|
33
|
+
-- AFTER THIS, the grandfather mark gives way to a real acceptance: the owner's
|
|
34
|
+
-- next hub sign-in restamps all three columns as a 'signin'
|
|
35
|
+
-- (terms-acceptance.js acceptanceConflictSet). No runtime code writes
|
|
36
|
+
-- 'grandfathered'; tests/terms_acceptance.mjs fails any that does.
|
|
37
|
+
--
|
|
38
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). Safe to re-run.
|
|
39
|
+
|
|
40
|
+
BEGIN;
|
|
41
|
+
|
|
42
|
+
DO $$
|
|
43
|
+
BEGIN
|
|
44
|
+
IF NOT EXISTS (
|
|
45
|
+
SELECT 1 FROM information_schema.columns
|
|
46
|
+
WHERE table_schema = current_schema()
|
|
47
|
+
AND table_name = 'platform_identity_accounts'
|
|
48
|
+
AND column_name = 'terms_accepted_source'
|
|
49
|
+
) THEN
|
|
50
|
+
ALTER TABLE platform_identity_accounts
|
|
51
|
+
ADD COLUMN terms_accepted_source text
|
|
52
|
+
CHECK (terms_accepted_source IN ('signin', 'grandfathered'));
|
|
53
|
+
|
|
54
|
+
UPDATE platform_identity_accounts
|
|
55
|
+
SET terms_accepted_source = 'signin'
|
|
56
|
+
WHERE terms_accepted_at IS NOT NULL;
|
|
57
|
+
|
|
58
|
+
UPDATE platform_identity_accounts
|
|
59
|
+
SET terms_accepted_at = now(),
|
|
60
|
+
terms_version = '2026-08-29',
|
|
61
|
+
terms_accepted_source = 'grandfathered'
|
|
62
|
+
WHERE profile_state = 'active'
|
|
63
|
+
AND terms_accepted_at IS NULL;
|
|
64
|
+
END IF;
|
|
65
|
+
END
|
|
66
|
+
$$;
|
|
67
|
+
|
|
68
|
+
COMMIT;
|
|
@@ -30,12 +30,22 @@
|
|
|
30
30
|
// reaches these statements, which is the whole point: an acceptance record a
|
|
31
31
|
// caller could assert would be evidence of nothing.
|
|
32
32
|
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
33
|
+
// THE FIRST ACCEPTANCE A HUMAN GAVE STANDS. The conflict branch keeps an existing
|
|
34
|
+
// acceptance (COALESCE), so a second sign-in never rewrites or duplicates one.
|
|
35
|
+
// The three columns are only ever written together, so they cannot diverge.
|
|
36
|
+
//
|
|
37
|
+
// HOW IT WAS GIVEN IS RECORDED TOO (task 1003360, ADR 0217 D3). Every acceptance
|
|
38
|
+
// written here is marked terms_accepted_source = 'signin'. The only other value,
|
|
39
|
+
// the grandfather marker, is written by migration platform_identity_024 alone —
|
|
40
|
+
// accounts that already existed were accepted on their owner's behalf, and the
|
|
41
|
+
// record says so rather than passing that off as a sign-in. A grandfather is not
|
|
42
|
+
// an acceptance its owner gave, so it is the one acceptance that does not
|
|
43
|
+
// "stand": the owner's next sign-in replaces it with their own. This file only
|
|
44
|
+
// ever READS the marker. tests/terms_acceptance.mjs holds both rules, and fails
|
|
45
|
+
// any other server file that writes these columns or names the marker.
|
|
36
46
|
//
|
|
37
47
|
// A LATER REVISION IS A KNOWN GAP, NOT A SILENT ONE. Because the version is
|
|
38
|
-
// recorded and never
|
|
48
|
+
// recorded and a sign-in never overwrites it, publishing a new TERMS_VERSION leaves every
|
|
39
49
|
// existing row naming the text it actually agreed to — so the platform can see
|
|
40
50
|
// who has not accepted the new one. Re-acceptance on revision is deliberately
|
|
41
51
|
// NOT built here (ADR 0213 D4): it is a product flow, not a storage shape.
|
|
@@ -60,7 +70,7 @@ function assertPlaceholder(fn, value) {
|
|
|
60
70
|
// The acceptance columns, for an INSERT column list. Leading comma: it appends
|
|
61
71
|
// to an existing list rather than standing alone.
|
|
62
72
|
function acceptanceInsertColumns() {
|
|
63
|
-
return ', terms_accepted_at, terms_version';
|
|
73
|
+
return ', terms_accepted_at, terms_version, terms_accepted_source';
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
// The matching VALUES fragment. A row created by a NON-activating caller
|
|
@@ -70,23 +80,33 @@ function acceptanceInsertValues(activateParam, versionParam) {
|
|
|
70
80
|
assertPlaceholder('acceptanceInsertValues', activateParam);
|
|
71
81
|
assertPlaceholder('acceptanceInsertValues', versionParam);
|
|
72
82
|
return `, CASE WHEN ${activateParam}::boolean THEN now() ELSE NULL END`
|
|
73
|
-
+ `, CASE WHEN ${activateParam}::boolean THEN ${versionParam}::text ELSE NULL END
|
|
83
|
+
+ `, CASE WHEN ${activateParam}::boolean THEN ${versionParam}::text ELSE NULL END`
|
|
84
|
+
+ `, CASE WHEN ${activateParam}::boolean THEN 'signin' ELSE NULL END`;
|
|
74
85
|
}
|
|
75
86
|
|
|
76
|
-
// The ON CONFLICT DO UPDATE SET fragment — the
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
87
|
+
// The ON CONFLICT DO UPDATE SET fragment — the write-once half. On the
|
|
88
|
+
// activating sign-in: a grandfathered acceptance is replaced outright; any other
|
|
89
|
+
// acceptance on record is kept (COALESCE), so a second sign-in re-reads the terms
|
|
90
|
+
// without restamping them; and the source becomes 'signin' either way, since a
|
|
91
|
+
// human just accepted. Keying the replacement on the grandfather marker, not on
|
|
92
|
+
// "no signin recorded", matters: an acceptance with no source (written by the
|
|
93
|
+
// pre-024 code in the deploy window) is a real sign-in and keeps its date. A
|
|
94
|
+
// non-activating upsert leaves every column exactly as it is, mirroring the
|
|
95
|
+
// one-way rule profile_state already follows. Postgres evaluates each right-hand
|
|
96
|
+
// side against the row BEFORE the update, so all three read the same old source.
|
|
80
97
|
//
|
|
81
98
|
// Trailing comma: it sits between two other assignments in the SET list.
|
|
82
99
|
function acceptanceConflictSet(activateParam, versionParam) {
|
|
83
100
|
assertPlaceholder('acceptanceConflictSet', activateParam);
|
|
84
101
|
assertPlaceholder('acceptanceConflictSet', versionParam);
|
|
85
102
|
const self = 'platform_identity_accounts';
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
+ ` THEN COALESCE(${self}.
|
|
103
|
+
const signin = `${activateParam}::boolean`;
|
|
104
|
+
const replaces = `WHEN ${signin} AND ${self}.terms_accepted_source = 'grandfathered'`;
|
|
105
|
+
return `terms_accepted_at = CASE ${replaces} THEN now()`
|
|
106
|
+
+ ` WHEN ${signin} THEN COALESCE(${self}.terms_accepted_at, now()) ELSE ${self}.terms_accepted_at END,`
|
|
107
|
+
+ ` terms_version = CASE ${replaces} THEN ${versionParam}::text`
|
|
108
|
+
+ ` WHEN ${signin} THEN COALESCE(${self}.terms_version, ${versionParam}::text) ELSE ${self}.terms_version END,`
|
|
109
|
+
+ ` terms_accepted_source = CASE WHEN ${signin} THEN 'signin' ELSE ${self}.terms_accepted_source END,`;
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
module.exports = {
|
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
<p><b>These numbers describe; they do not govern.</b> Your cross-project record is descriptive analytics and never authority. What you may do inside any project is decided by that project alone, from its own records, at the moment you ask. A large record grants nothing and a hidden one takes nothing away; no project is required to read it, and none may treat it as permission. A rank shown here is that project’s own standing for you, as that project reported it — the platform holds no rank of its own.</p>
|
|
68
68
|
<p><b>What is public.</b> Once you have signed in here and accepted these terms, your profile and that record are public by default: at your handle’s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in here stays provisional and is published nowhere.</p>
|
|
69
69
|
<p><b>What you control.</b> From your settings you can hide your statistics, drop out of the recruiting directory, or make your account private. Each takes effect immediately, everywhere, including on a page someone already has open. Hiding removes the platform’s combined view; it does not retract the per-project numbers each project publishes itself, so a determined reader could still add them up the hard way.</p>
|
|
70
|
-
<p><b>Your acceptance is recorded.</b> The platform stores the date you accepted these terms, and which version, on your account. It is written
|
|
70
|
+
<p><b>Your acceptance is recorded.</b> The platform stores the date you accepted these terms, and which version, on your account. It is written by signing in here, and only once — signing in again does not change the original date. This is version <b>2026-08-29</b>.</p>
|
|
71
|
+
<p><b>Accounts from before these terms.</b> If your account already existed when this version took effect, the platform accepted it on your behalf, rather than waiting for you to sign in, and dated it the day that was done. The record marks that acceptance as grandfathered, so it is never mistaken for one you gave, and your own acceptance replaces it the next time you sign in.</p>
|
|
71
72
|
<!--apex-only:end-->
|
|
72
73
|
<h2>Fair use</h2>
|
|
73
74
|
<p>Don't use {{productName}} to break the law, to attack this site or other people's projects, to mine, spam or otherwise abuse hosted resources, or to harass other builders. The owner may deactivate a builder or take a project offline for breaking these, and may set further house rules inside the hall.</p>
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1025",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1025",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1025",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -198,6 +198,11 @@ const INTEGRATION = new Set([
|
|
|
198
198
|
// DB-free siblings (session_records, session_cost) cover the transcript
|
|
199
199
|
// de-duplication half.
|
|
200
200
|
'session_cost_row_raise',
|
|
201
|
+
// task 1003360: the T&C grandfather migration against real SQL. The DB-free
|
|
202
|
+
// sibling (the T&C acceptance suite) pins the statement text and runs the conflict
|
|
203
|
+
// branch over a fake table; only a real engine runs the one-shot DO block and
|
|
204
|
+
// shows a legacy account published again and marked apart from a sign-in.
|
|
205
|
+
'terms_grandfather_db',
|
|
201
206
|
'setup_smoke',
|
|
202
207
|
'status-probe',
|
|
203
208
|
]);
|
|
@@ -312,8 +312,10 @@ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken
|
|
|
312
312
|
// callback can persist it (encrypted, short-TTL) for the repo picker/creator.
|
|
313
313
|
// auth.js stays kernel-clean — it never writes the token to the DB itself; the
|
|
314
314
|
// token is surfaced ONLY on this opt-in path, so an ordinary hall sign-in never
|
|
315
|
-
// even returns it.
|
|
316
|
-
|
|
315
|
+
// even returns it. Never for an offboarded builder: the gate admits every existing
|
|
316
|
+
// row, and this would re-seed the token deactivateBuilder deleted (ADR 0332, task 1004245).
|
|
317
|
+
const captured = captureRepoToken && builder.status !== 'inactive'
|
|
318
|
+
? { githubToken: accessToken, githubScope: grantedScope } : {};
|
|
317
319
|
return { token, builder, expiresAt, ...captured };
|
|
318
320
|
}
|
|
319
321
|
|
package/src/bongos/db-kernel.js
CHANGED
|
@@ -192,7 +192,7 @@ async function upsertBuilder({ githubId, githubLogin, displayName, avatarUrl })
|
|
|
192
192
|
THEN 'user'
|
|
193
193
|
ELSE 'github' END,
|
|
194
194
|
avatar_url = EXCLUDED.avatar_url
|
|
195
|
-
RETURNING id, github_id, github_login, display_name, avatar_url, total_credits, created_at, rank, preferred_disciplines`,
|
|
195
|
+
RETURNING id, github_id, github_login, display_name, avatar_url, total_credits, created_at, rank, preferred_disciplines, status`,
|
|
196
196
|
[String(githubId), githubLogin, displayName, avatarUrl ?? null]
|
|
197
197
|
);
|
|
198
198
|
return rows[0];
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1025'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// branch is pinned: already a member, an invite already standing, a pending
|
|
5
5
|
// request resolved in place, a fresh row, a bad username, a DB failure. Plus
|
|
6
6
|
// the public POST's three doors against the same router: apply reaches the
|
|
7
|
-
// queries, invite_only and open refuse before any.
|
|
7
|
+
// queries, invite_only and open refuse before any. Task 1002814 (ADR 0335 D2)
|
|
8
|
+
// adds the invite row's own marks — kind, created_by, the inviter's note — and
|
|
9
|
+
// the outstanding-invite list; tests/application_lifecycle.mjs executes them.
|
|
8
10
|
//
|
|
9
11
|
// The doorway is stubbed in require.cache BEFORE the route loads (the route
|
|
10
12
|
// destructures pool/validators at require time — the web_admission_status
|
|
@@ -76,6 +78,15 @@ function post(pathname, body) {
|
|
|
76
78
|
req.end();
|
|
77
79
|
});
|
|
78
80
|
}
|
|
81
|
+
function get(pathname) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
http.get({ host: '127.0.0.1', port, path: pathname }, (res) => {
|
|
84
|
+
let data = '';
|
|
85
|
+
res.on('data', (c) => { data += c; });
|
|
86
|
+
res.on('end', () => resolve({ status: res.statusCode, json: data ? JSON.parse(data) : null }));
|
|
87
|
+
}).on('error', reject);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
79
90
|
const reset = (m, s) => { mode = m; script = s; calls = []; };
|
|
80
91
|
const none = { rows: [] };
|
|
81
92
|
const one = (row = {}) => ({ rows: [row] });
|
|
@@ -112,14 +123,80 @@ test('invite: a pending request is resolved in place — the same row the sign-i
|
|
|
112
123
|
assert.deepEqual(calls[2].params, [42, 'Octocat'], 'resolved by the Archon; the login is trimmed and un-@ed, case kept for lower()');
|
|
113
124
|
});
|
|
114
125
|
|
|
115
|
-
test('invite: no request at all → a fresh invited row, 201', async () => {
|
|
116
|
-
reset('invite_only', [none, none, none, one({ id: 8, github_login: 'octocat', status: 'invited' })]);
|
|
126
|
+
test('invite: no request at all → a fresh invited row, 201, marked as an invite from its sender', async () => {
|
|
127
|
+
reset('invite_only', [none, none, none, one({ id: 8, github_login: 'octocat', status: 'invited', kind: 'invite' })]);
|
|
117
128
|
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
|
|
118
129
|
assert.equal(r.status, 201);
|
|
119
130
|
assert.equal(r.json.request.status, 'invited');
|
|
131
|
+
assert.equal(r.json.request.kind, 'invite');
|
|
120
132
|
assert.match(calls[3].sql, /INSERT INTO access_requests/);
|
|
121
133
|
assert.match(calls[3].sql, /'invited'/);
|
|
122
|
-
assert.
|
|
134
|
+
assert.match(calls[3].sql, /'invite'/, 'kind = invite, so the row can be told apart from an approved application (ADR 0335 D2.1)');
|
|
135
|
+
assert.match(calls[3].sql, /\bcreated_by\b/, 'created_by names the sender, and no rescind overwrites it (D2.2)');
|
|
136
|
+
assert.deepEqual(calls[3].params, ['octocat', null, 42]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('invite: an optional note rides on the invite row — the inviter’s words', async () => {
|
|
140
|
+
reset('invite_only', [none, none, none, one({ id: 8, github_login: 'octocat', status: 'invited', kind: 'invite' })]);
|
|
141
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat', note: ' Come build the parser with us ' });
|
|
142
|
+
assert.equal(r.status, 201);
|
|
143
|
+
assert.match(calls[3].sql, /\bnote\b/);
|
|
144
|
+
assert.deepEqual(calls[3].params, ['octocat', 'Come build the parser with us', 42]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('invite: a note over 500 characters → 400 before any query', async () => {
|
|
148
|
+
reset('invite_only', []);
|
|
149
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat', note: 'x'.repeat(501) });
|
|
150
|
+
assert.equal(r.status, 400);
|
|
151
|
+
assert.equal(calls.length, 0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('invite: resolving a pending application in place leaves it an application — its kind, sender column and note untouched', async () => {
|
|
155
|
+
reset('invite_only', [none, none, one({ id: 7, github_login: 'octocat', status: 'invited', kind: 'application' })]);
|
|
156
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat', note: 'not their words' });
|
|
157
|
+
assert.equal(r.status, 200);
|
|
158
|
+
assert.equal(r.json.request.kind, 'application', 'it was an application, and its history says so (D2.1)');
|
|
159
|
+
const update = calls[2].sql;
|
|
160
|
+
assert.match(update, /UPDATE access_requests/);
|
|
161
|
+
assert.doesNotMatch(update, /\bkind\s*=/, 'the resolve never re-labels an application as an invite');
|
|
162
|
+
assert.doesNotMatch(update, /\bcreated_by\b/, 'created_by is set on invite rows only (D2.2)');
|
|
163
|
+
assert.doesNotMatch(update, /\bnote\b/, 'the applicant’s note is theirs — an inviter’s note never overwrites it');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── GET /access-requests/invites — the sender's outstanding list ─────────────
|
|
167
|
+
|
|
168
|
+
test('invites list: outstanding invites only, each naming who sent it', async () => {
|
|
169
|
+
const sent = { id: 8, github_login: 'octocat', note: 'hi', created_at: '2026-09-25T00:00:00Z', invited_by_login: 'archon' };
|
|
170
|
+
reset('invite_only', [one(sent)]);
|
|
171
|
+
const r = await get('/api/gds/access-requests/invites');
|
|
172
|
+
assert.equal(r.status, 200);
|
|
173
|
+
assert.deepEqual(r.json.invites, [sent]);
|
|
174
|
+
assert.equal(calls.length, 1);
|
|
175
|
+
const { sql } = calls[0];
|
|
176
|
+
assert.match(sql, /ar\.kind = 'invite'/, 'an approved application is not an invite this project sent');
|
|
177
|
+
assert.match(sql, /ar\.status = 'invited'/, 'a rescinded invite is no longer outstanding');
|
|
178
|
+
assert.match(sql, /NOT EXISTS \(SELECT 1 FROM builders b\s+WHERE lower\(b\.github_login\) = lower\(ar\.github_login\)\)/,
|
|
179
|
+
'an invitee who already signed in is a member, not an outstanding invite');
|
|
180
|
+
assert.match(sql, /LEFT JOIN builders ib ON ib\.id = ar\.created_by/, 'the sender is read from created_by, which survives a rescind');
|
|
181
|
+
assert.doesNotMatch(sql, /ar\.created_by = \$/, 'without ?mine=1 the list is the whole project’s');
|
|
182
|
+
assert.deepEqual(calls[0].params, []);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('invites list ?mine=1: only the caller’s own invites', async () => {
|
|
186
|
+
reset('invite_only', [none]);
|
|
187
|
+
const r = await get('/api/gds/access-requests/invites?mine=1');
|
|
188
|
+
assert.equal(r.status, 200);
|
|
189
|
+
assert.deepEqual(r.json.invites, []);
|
|
190
|
+
assert.match(calls[0].sql, /AND ar\.created_by = \$1/);
|
|
191
|
+
assert.deepEqual(calls[0].params, [42], 'narrowed to the acting builder, never to a caller-supplied id');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('invites list: a DB failure → 500 list_invites_failed, no internals in the message', async () => {
|
|
195
|
+
reset('invite_only', [new Error('connection refused')]);
|
|
196
|
+
const r = await get('/api/gds/access-requests/invites');
|
|
197
|
+
assert.equal(r.status, 500);
|
|
198
|
+
assert.equal(r.json.error.code, 'list_invites_failed');
|
|
199
|
+
assert.doesNotMatch(r.json.error.message, /connection refused/);
|
|
123
200
|
});
|
|
124
201
|
|
|
125
202
|
test('invite: a bad username → 400 before any query', async () => {
|
|
@@ -65,11 +65,14 @@ let nextId = 100;
|
|
|
65
65
|
// comparing them rather than by reading the code and believing it.
|
|
66
66
|
function seed() {
|
|
67
67
|
const now = Date.now();
|
|
68
|
+
// `kind`/`created_by` carry core_255's column default on every seeded row:
|
|
69
|
+
// each of these is an application, and cy's is an APPROVED one.
|
|
70
|
+
const app = { kind: 'application', created_by: null };
|
|
68
71
|
rows = [
|
|
69
|
-
{ id: 1, github_login: 'ada', display_name: 'Ada', note: 'I build tools', status: 'pending', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR },
|
|
70
|
-
{ id: 2, github_login: 'bo', display_name: 'Bo', note: 'I build tools', status: 'pending', applicant_github_id: '9002', vouched_at: now - 3 * HOUR, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR },
|
|
71
|
-
{ id: 3, github_login: 'cy', display_name: 'Cy', note: null, status: 'invited', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - 2 * HOUR, created_at: now - 4 * HOUR },
|
|
72
|
-
{ id: 4, github_login: 'dee', display_name: 'Dee', note: null, status: 'dismissed', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - HOUR, created_at: now - 5 * HOUR },
|
|
72
|
+
{ id: 1, github_login: 'ada', display_name: 'Ada', note: 'I build tools', status: 'pending', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR, ...app },
|
|
73
|
+
{ id: 2, github_login: 'bo', display_name: 'Bo', note: 'I build tools', status: 'pending', applicant_github_id: '9002', vouched_at: now - 3 * HOUR, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR, ...app },
|
|
74
|
+
{ id: 3, github_login: 'cy', display_name: 'Cy', note: null, status: 'invited', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - 2 * HOUR, created_at: now - 4 * HOUR, ...app },
|
|
75
|
+
{ id: 4, github_login: 'dee', display_name: 'Dee', note: null, status: 'dismissed', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - HOUR, created_at: now - 5 * HOUR, ...app },
|
|
73
76
|
];
|
|
74
77
|
builders = [{ github_login: 'existing' }];
|
|
75
78
|
statements = [];
|
|
@@ -102,6 +105,15 @@ function predicateFor(rawWhere, params) {
|
|
|
102
105
|
where = where.replace(transition[0], ' TRUE ');
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
// The outstanding-invite list's member exclusion (task 1002814): an invitee who
|
|
109
|
+
// has signed in is a member, not an outstanding invite. Stripped as one unit,
|
|
110
|
+
// like the transition guard, so its inner `lower(...)` is not re-read.
|
|
111
|
+
const notMember = /NOT EXISTS \(SELECT 1 FROM builders b WHERE lower\(b\.github_login\) = lower\(ar\.github_login\)\)/.exec(where);
|
|
112
|
+
if (notMember) {
|
|
113
|
+
tests.push((r) => !isBuilder(r.github_login));
|
|
114
|
+
where = where.replace(notMember[0], ' TRUE ');
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
let m;
|
|
106
118
|
const eat = () => { where = where.replace(m[0], ' TRUE '); };
|
|
107
119
|
if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); eat(); }
|
|
@@ -112,6 +124,8 @@ function predicateFor(rawWhere, params) {
|
|
|
112
124
|
}
|
|
113
125
|
if ((m = /\b(?:ar\.)?status = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => r.status === v); eat(); }
|
|
114
126
|
if ((m = /\b(?:ar\.)?status = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); eat(); }
|
|
127
|
+
if ((m = /\b(?:ar\.)?kind = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.kind === v); eat(); }
|
|
128
|
+
if ((m = /\b(?:ar\.)?created_by = \$(\d+)/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.created_by) === String(v)); eat(); }
|
|
115
129
|
if ((m = /\bapplicant_github_id IS NULL\b/.exec(where))) { tests.push((r) => r.applicant_github_id == null); eat(); }
|
|
116
130
|
|
|
117
131
|
for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
|
|
@@ -181,11 +195,47 @@ const pool = {
|
|
|
181
195
|
applicant_github_id: applicantGithubId, applicant_handle: applicantHandle, vouched_at: vouchedAt,
|
|
182
196
|
resolved_by_builder_id: null, resolved_at: null,
|
|
183
197
|
created_at: Date.now(),
|
|
198
|
+
kind: 'application', created_by: null, // the column defaults — the public write names neither
|
|
184
199
|
};
|
|
185
200
|
rows.push(fresh);
|
|
186
201
|
return { rows: [{ id: fresh.id, github_login: fresh.github_login, status: fresh.status, created_at: fresh.created_at }] };
|
|
187
202
|
}
|
|
188
203
|
|
|
204
|
+
// The invite route's fresh row (task 1002814, ADR 0335 D2). Matched EXACTLY,
|
|
205
|
+
// like the public write above, so `kind` or `created_by` cannot be dropped
|
|
206
|
+
// from the real statement with every test still green.
|
|
207
|
+
if (/^INSERT INTO access_requests \(github_login, note, kind, created_by, status, resolved_by_builder_id, resolved_at\) VALUES \(\$1, \$2, 'invite', \$3, 'invited', \$3, now\(\)\) RETURNING id, github_login, status, kind, resolved_at$/.test(sql)) {
|
|
208
|
+
const [login, note, sender] = params;
|
|
209
|
+
const at = Date.now();
|
|
210
|
+
const fresh = {
|
|
211
|
+
id: nextId++, github_login: login, display_name: null, note, status: 'invited',
|
|
212
|
+
applicant_github_id: null, applicant_handle: null, vouched_at: null,
|
|
213
|
+
resolved_by_builder_id: sender, resolved_at: at, created_at: at,
|
|
214
|
+
kind: 'invite', created_by: sender,
|
|
215
|
+
};
|
|
216
|
+
rows.push(fresh);
|
|
217
|
+
return { rows: [{ id: fresh.id, github_login: login, status: 'invited', kind: 'invite', resolved_at: at }] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The invite route resolving a PENDING application in place. The SET list is
|
|
221
|
+
// matched exactly: it must never touch kind, created_by or the applicant's note.
|
|
222
|
+
if ((m = /^UPDATE access_requests SET status = 'invited', resolved_by_builder_id = \$1, resolved_at = now\(\) WHERE (.+?) RETURNING id, github_login, status, kind, resolved_at$/.exec(sql))) {
|
|
223
|
+
const hit = rows.filter(predicateFor(m[1], params));
|
|
224
|
+
for (const r of hit) { r.status = 'invited'; r.resolved_by_builder_id = params[0]; r.resolved_at = Date.now(); }
|
|
225
|
+
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
|
+
}
|
|
227
|
+
|
|
228
|
+
// The outstanding-invite list. Its sender is joined through created_by.
|
|
229
|
+
if ((m = /^SELECT ar\.id, ar\.github_login, ar\.note, ar\.created_at, ib\.github_login AS invited_by_login FROM access_requests ar LEFT JOIN builders ib ON ib\.id = ar\.created_by WHERE (.+?) ORDER BY ar\.created_at DESC LIMIT 200$/.exec(sql))) {
|
|
230
|
+
const hit = rows.filter(predicateFor(m[1], params)).sort((a, b) => b.created_at - a.created_at);
|
|
231
|
+
return {
|
|
232
|
+
rows: hit.map((r) => ({
|
|
233
|
+
id: r.id, github_login: r.github_login, note: r.note, created_at: r.created_at,
|
|
234
|
+
invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null,
|
|
235
|
+
})),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
189
239
|
// The owner's queue.
|
|
190
240
|
if ((m = /^SELECT ar\.id, .* FROM access_requests ar .* WHERE (.+?) ORDER BY/.exec(sql))) {
|
|
191
241
|
const hit = rows.filter(predicateFor(m[1], params)).sort((a, b) => b.created_at - a.created_at);
|
|
@@ -197,6 +247,9 @@ const pool = {
|
|
|
197
247
|
// Returned only when the real SELECT names it, so the queue test below
|
|
198
248
|
// proves the route reads the column rather than that this fake invents it.
|
|
199
249
|
...(/\bar\.applicant_handle\b/.test(sql) ? { applicant_handle: r.applicant_handle ?? null } : {}),
|
|
250
|
+
...(/\bar\.kind\b/.test(sql) ? { kind: r.kind } : {}),
|
|
251
|
+
...(/\bib\.github_login AS invited_by_login\b/.test(sql) && /\bON ib\.id = ar\.created_by\b/.test(sql)
|
|
252
|
+
? { invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null } : {}),
|
|
200
253
|
vouched: r.applicant_github_id !== null,
|
|
201
254
|
resolved_by_login: null,
|
|
202
255
|
})),
|
|
@@ -280,6 +333,9 @@ const dbKernel = require('../src/bongos/db-kernel.js');
|
|
|
280
333
|
// Who is calling, and at what LIVE rank. The rank is resolved through the real
|
|
281
334
|
// government port on every request — never cached — exactly as production does.
|
|
282
335
|
let actingRank = 'archon';
|
|
336
|
+
// Which builder is acting — switched only to prove an invite keeps its SENDER
|
|
337
|
+
// when a different archon rescinds it.
|
|
338
|
+
let actingId = 42;
|
|
283
339
|
|
|
284
340
|
require.cache[apiPath].exports = {
|
|
285
341
|
...realApi,
|
|
@@ -287,7 +343,7 @@ require.cache[apiPath].exports = {
|
|
|
287
343
|
joinabilityMode: () => 'apply',
|
|
288
344
|
projectJoinDoor: () => 'apply',
|
|
289
345
|
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
|
290
|
-
requireBuilder: (req, _res, next) => { req.builder = { id:
|
|
346
|
+
requireBuilder: (req, _res, next) => { req.builder = { id: actingId, github_login: 'owner' }; next(); },
|
|
291
347
|
// requirePermission is NOT stubbed: realApi's own gate is used, so the denial
|
|
292
348
|
// below is the production denial.
|
|
293
349
|
readApplicationVouch: (token, { githubLogin } = {}) =>
|
|
@@ -336,7 +392,7 @@ function call(method, pathname, body, extraHeaders = {}) {
|
|
|
336
392
|
req.end(payload);
|
|
337
393
|
});
|
|
338
394
|
}
|
|
339
|
-
const reset = (rank = 'archon') => { seed(); actingRank = rank; };
|
|
395
|
+
const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; };
|
|
340
396
|
|
|
341
397
|
// ===========================================================================
|
|
342
398
|
// The interpreter is the instrument, so it is calibrated first
|
|
@@ -692,6 +748,118 @@ test('only invited|dismissed are accepted, and a rejected status writes nothing'
|
|
|
692
748
|
assert.equal(JSON.stringify(rows), snapshot);
|
|
693
749
|
});
|
|
694
750
|
|
|
751
|
+
// ===========================================================================
|
|
752
|
+
// Invite — a pre-approved row the gate already reads (task 1002814, ADR 0335 D2)
|
|
753
|
+
// ===========================================================================
|
|
754
|
+
|
|
755
|
+
// The two archons the invite tests act as. Only their ids matter: the sender is
|
|
756
|
+
// joined through created_by → builders.id.
|
|
757
|
+
const ARCHONS = [{ id: 42, github_login: 'owner' }, { id: 43, github_login: 'second' }];
|
|
758
|
+
|
|
759
|
+
test('invite: a login that never applied signs straight in, and the row says it is an invite and who sent it', async () => {
|
|
760
|
+
reset();
|
|
761
|
+
builders.push(...ARCHONS);
|
|
762
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false, 'precondition: eve never applied');
|
|
763
|
+
const r = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'Come build the parser' });
|
|
764
|
+
assert.equal(r.status, 201);
|
|
765
|
+
assert.equal(r.json.request.kind, 'invite');
|
|
766
|
+
const eve = rows.find((x) => x.github_login === 'eve');
|
|
767
|
+
assert.equal(eve.kind, 'invite');
|
|
768
|
+
assert.equal(eve.created_by, 42, 'the sender');
|
|
769
|
+
assert.equal(eve.note, 'Come build the parser', 'the inviter’s words, and kind says whose');
|
|
770
|
+
statements = [];
|
|
771
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), true, 'the REAL admission read admits her — signing in is the acceptance act');
|
|
772
|
+
assert.ok(statements.length > 0 && statements.every(({ sql }) => !/\bkind\b|\bcreated_by\b/.test(sql)),
|
|
773
|
+
'and it admitted her without reading either new column (D2.5)');
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test('rescind: an invite keeps its sender through a rescind by a different archon, and the door re-locks', async () => {
|
|
777
|
+
reset();
|
|
778
|
+
builders.push(...ARCHONS);
|
|
779
|
+
const sent = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
|
|
780
|
+
actingId = 43;
|
|
781
|
+
const r = await call('PATCH', `/api/gds/access-requests/${sent.json.request.id}`, { status: 'dismissed' });
|
|
782
|
+
assert.equal(r.status, 200);
|
|
783
|
+
const eve = rows.find((x) => x.github_login === 'eve');
|
|
784
|
+
assert.equal(eve.status, 'dismissed');
|
|
785
|
+
assert.equal(eve.resolved_by_builder_id, 43, 'resolved_by records whoever acted last (ADR 0201 D4.2)…');
|
|
786
|
+
assert.equal(eve.created_by, 42, '…so the sender needs its own column, and the rescind does not touch it (D2.2)');
|
|
787
|
+
assert.equal(eve.kind, 'invite', 'a rescinded invite is still an invite in the history');
|
|
788
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false, 'the door is shut again');
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test('invites list: outstanding only — not an approved application, a rescinded invite, or an invitee who signed in', async () => {
|
|
792
|
+
reset();
|
|
793
|
+
builders.push(...ARCHONS);
|
|
794
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'hi' });
|
|
795
|
+
actingId = 43;
|
|
796
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
|
|
797
|
+
const gus = await call('POST', '/api/gds/access-requests/invite', { github_login: 'gus' });
|
|
798
|
+
await call('PATCH', `/api/gds/access-requests/${gus.json.request.id}`, { status: 'dismissed' });
|
|
799
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'hal' });
|
|
800
|
+
builders.push({ id: 90, github_login: 'HAL' }); // hal accepted: he signed in
|
|
801
|
+
const r = await call('GET', '/api/gds/access-requests/invites');
|
|
802
|
+
assert.equal(r.status, 200);
|
|
803
|
+
const byLogin = Object.fromEntries(r.json.invites.map((x) => [x.github_login, x]));
|
|
804
|
+
assert.deepEqual(Object.keys(byLogin).sort(), ['eve', 'fay'],
|
|
805
|
+
'cy (an approved application), gus (rescinded) and hal (signed in, case-insensitively) are not outstanding');
|
|
806
|
+
assert.equal(byLogin.eve.invited_by_login, 'owner');
|
|
807
|
+
assert.equal(byLogin.fay.invited_by_login, 'second', 'each names its own sender');
|
|
808
|
+
assert.equal(byLogin.eve.note, 'hi');
|
|
809
|
+
|
|
810
|
+
const mine43 = await call('GET', '/api/gds/access-requests/invites?mine=1');
|
|
811
|
+
assert.deepEqual(mine43.json.invites.map((x) => x.github_login), ['fay'], '?mine=1: the sender lists their own outstanding invites');
|
|
812
|
+
actingId = 42;
|
|
813
|
+
const mine42 = await call('GET', '/api/gds/access-requests/invites?mine=1');
|
|
814
|
+
assert.deepEqual(mine42.json.invites.map((x) => x.github_login), ['eve']);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test('the queue history tells an invite from an approved application, and names the sender', async () => {
|
|
818
|
+
reset();
|
|
819
|
+
builders.push(...ARCHONS);
|
|
820
|
+
await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve', note: 'Come build the parser' });
|
|
821
|
+
await call('PATCH', '/api/gds/access-requests/1', { status: 'invited' }); // approve ada's application
|
|
822
|
+
const r = await call('GET', '/api/gds/access-requests?status=invited');
|
|
823
|
+
const byLogin = Object.fromEntries(r.json.requests.map((x) => [x.github_login, x]));
|
|
824
|
+
assert.equal(byLogin.cy.kind, 'application', 'cy applied and was approved');
|
|
825
|
+
assert.equal(byLogin.cy.invited_by_login, null);
|
|
826
|
+
assert.equal(byLogin.eve.kind, 'invite', 'eve never applied — an archon invited her');
|
|
827
|
+
assert.equal(byLogin.eve.invited_by_login, 'owner');
|
|
828
|
+
// `note` on this read has always meant the APPLICANT's words (the hall quotes it
|
|
829
|
+
// as theirs), so an inviter's note must never arrive in it.
|
|
830
|
+
assert.equal(byLogin.eve.note, null, 'an inviter’s note is not the invitee’s words');
|
|
831
|
+
assert.equal(byLogin.eve.invite_note, 'Come build the parser');
|
|
832
|
+
assert.equal(byLogin.ada.note, 'I build tools', 'an applicant’s note stays where it always was');
|
|
833
|
+
assert.equal(byLogin.ada.invite_note, null);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test('invite: resolving a pending application in place keeps it an application, with the applicant’s own note', async () => {
|
|
837
|
+
reset();
|
|
838
|
+
builders.push(...ARCHONS);
|
|
839
|
+
const r = await call('POST', '/api/gds/access-requests/invite', { github_login: 'ada', note: 'not their words' });
|
|
840
|
+
assert.equal(r.status, 200);
|
|
841
|
+
assert.equal(r.json.request.kind, 'application');
|
|
842
|
+
assert.equal(row(1).status, 'invited');
|
|
843
|
+
assert.equal(row(1).kind, 'application', 'it was an application, and its history says so (D2.1)');
|
|
844
|
+
assert.equal(row(1).created_by, null, 'created_by is set on invite rows only (D2.2)');
|
|
845
|
+
assert.equal(row(1).note, 'I build tools', 'an inviter never overwrites the applicant’s note');
|
|
846
|
+
assert.equal(rows.filter((x) => x.github_login === 'ada').length, 1, 'resolved, not duplicated');
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
for (const rank of ['thetes', 'metic']) {
|
|
850
|
+
test(`a ${rank} can neither invite nor list outstanding invites — access_request.review until R3 mints project.recruit`, async () => {
|
|
851
|
+
reset(rank);
|
|
852
|
+
const snapshot = JSON.stringify(rows);
|
|
853
|
+
const invited = await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
|
|
854
|
+
assert.equal(invited.status, 403);
|
|
855
|
+
assert.deepEqual(invited.json.error.details.required, ['access_request.review']);
|
|
856
|
+
const listed = await call('GET', '/api/gds/access-requests/invites');
|
|
857
|
+
assert.equal(listed.status, 403, 'the list names logins that are not members yet');
|
|
858
|
+
assert.equal(JSON.stringify(rows), snapshot, 'nothing was written');
|
|
859
|
+
assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), false);
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
695
863
|
// ===========================================================================
|
|
696
864
|
// Authority — "a builder cannot approve or decline for a project they don't own"
|
|
697
865
|
// ===========================================================================
|