@bongos/core 1.19.239 → 1.19.241

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.
@@ -80,16 +80,18 @@ t('buildIdpAuthorizeUrl throws when the IdP is not configured (never silently mi
80
80
  });
81
81
  });
82
82
 
83
- console.log('\nmembership check-in role (§3 — coarse, never a rank):');
83
+ console.log('\nmembership check-in kind (§3 — coarse, never a rank, never a craft):');
84
84
 
85
- t('membershipRole is builder by default, owner for the configured firstAdmin', () => {
85
+ t('membershipKind is member by default, owner for the configured firstAdmin', () => {
86
86
  const prev = process.env.CLOUDBONGOS_FIRST_ADMIN;
87
87
  process.env.CLOUDBONGOS_FIRST_ADMIN = 'ownerlogin';
88
88
  try {
89
- assert.equal(auth.membershipRole({ githubLogin: 'ownerlogin' }), 'owner');
90
- assert.equal(auth.membershipRole({ githubLogin: 'OwnerLogin' }), 'owner', 'case-insensitive');
91
- assert.equal(auth.membershipRole({ githubLogin: 'someone-else' }), 'builder');
92
- assert.equal(auth.membershipRole({}), 'builder');
89
+ assert.equal(auth.membershipKind({ githubLogin: 'ownerlogin' }), 'owner');
90
+ assert.equal(auth.membershipKind({ githubLogin: 'OwnerLogin' }), 'owner', 'case-insensitive');
91
+ // 'member', not 'builder' — the craft name left the coarse membership vocabulary
92
+ // with task 1003090 (ADR 0177 §"the fifth use").
93
+ assert.equal(auth.membershipKind({ githubLogin: 'someone-else' }), 'member');
94
+ assert.equal(auth.membershipKind({}), 'member');
93
95
  } finally {
94
96
  if (prev === undefined) delete process.env.CLOUDBONGOS_FIRST_ADMIN;
95
97
  else process.env.CLOUDBONGOS_FIRST_ADMIN = prev;
@@ -0,0 +1,99 @@
1
+ // tests/membership_kind_lockstep.mjs — the expand-phase lockstep triggers, and the
2
+ // Postgres semantic that makes their shape load-bearing (task 1003090, ADR 0177).
3
+ //
4
+ // Both membership renames keep the legacy column alive for one release, kept in step
5
+ // by a BEFORE INSERT OR UPDATE trigger. The INSERT branch has to answer "did the
6
+ // caller write the NEW column, or is this an old release writing the legacy one?" —
7
+ // and there is exactly ONE way to answer it:
8
+ //
9
+ // POSTGRES MATERIALIZES A COLUMN DEFAULT INTO `NEW` BEFORE ANY BEFORE ROW TRIGGER
10
+ // FIRES. So if the new column carries a DEFAULT, `NEW.<new> IS NULL` is never true
11
+ // and the trigger cannot distinguish "explicitly written" from "default filled".
12
+ //
13
+ // That is not theoretical. With a DEFAULT in place, an old-release
14
+ // `INSERT … (client_id, github_id, role) VALUES (…, 'pending')` — inviteToProject's
15
+ // exact statement — took the "new code wrote it" branch and overwrote `role` with the
16
+ // default's mapping, turning a pending INVITE into an active membership. Caught by
17
+ // the grader on task 1003090 and reproduced against a real Postgres:
18
+ //
19
+ // A old-invite-pending | role=builder | membership_kind=member ← WRONG (before)
20
+ // A old-invite-pending | role=pending | membership_kind=pending ← after the fix
21
+ //
22
+ // The fix is the absence of a DEFAULT while the legacy column is still written, so
23
+ // NULL means exactly "not written". The DEFAULT (and NOT NULL) belong to step 2,
24
+ // when the legacy column is dropped. This file pins that invariant statically,
25
+ // because the trigger only misbehaves against a live Postgres — which CI has none of.
26
+ //
27
+ // Run: node --test tests/membership_kind_lockstep.mjs
28
+ import assert from 'node:assert/strict';
29
+ import { test } from 'node:test';
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
35
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
36
+
37
+ // core_220 (goal membership) carries the SAME defect and is fixed under task
38
+ // 1003093 — filed, not forgotten. It is deliberately absent from this list rather
39
+ // than silently passing: a class guard that quietly excludes a known-broken member
40
+ // of the class is worse than no guard.
41
+ const EXPAND_MIGRATIONS = [
42
+ {
43
+ rel: 'modules/platform-identity/migrations/platform_identity_010_membership_kind.sql',
44
+ table: 'platform_identity_project_memberships',
45
+ legacy: 'role',
46
+ },
47
+ ];
48
+
49
+ for (const m of EXPAND_MIGRATIONS) {
50
+ test(`${path.basename(m.rel)}: the new column has NO default while ${m.legacy} is still written`, () => {
51
+ const sql = read(m.rel);
52
+ assert.ok(/ADD COLUMN IF NOT EXISTS membership_kind text/.test(sql), 'the expand step adds the column');
53
+ assert.ok(!/ALTER COLUMN membership_kind SET DEFAULT/.test(sql),
54
+ 'a DEFAULT here is materialized into NEW before the BEFORE trigger runs, so the trigger '
55
+ + 'could no longer tell an explicit write from a filled default — and an old-release INSERT '
56
+ + `naming only ${m.legacy} would be silently rewritten (task 1003090)`);
57
+ assert.ok(!/ALTER COLUMN membership_kind SET NOT NULL/.test(sql),
58
+ 'NOT NULL belongs to step 2, once nothing writes the legacy column (and the forward-safety '
59
+ + 'linter flags it here for the same reason)');
60
+ });
61
+
62
+ test(`${path.basename(m.rel)}: the trigger's INSERT branch keys off NULL, not off truthiness`, () => {
63
+ const sql = read(m.rel);
64
+ const fn = sql.slice(sql.indexOf('RETURNS trigger AS'), sql.indexOf('$$ LANGUAGE plpgsql'));
65
+ const insertBranch = fn.slice(fn.indexOf("IF TG_OP = 'INSERT'"), fn.indexOf('ELSE', fn.indexOf('END IF')));
66
+ assert.match(insertBranch, /IF NEW\.membership_kind IS NULL THEN/,
67
+ 'NULL is the only reliable signal that the caller did not write the new column');
68
+ assert.ok(!/IF NEW\.membership_kind IS NOT NULL THEN/.test(insertBranch),
69
+ 'the inverted form is the task-1003090 bug: it treats a filled default as an explicit write');
70
+ // The UPDATE branch has OLD to compare against, so it may (and does) use IS DISTINCT FROM.
71
+ assert.match(fn, /NEW\.membership_kind IS DISTINCT FROM OLD\.membership_kind/,
72
+ 'the UPDATE branch decides direction by which side actually changed');
73
+ assert.ok(/BEFORE INSERT OR UPDATE/.test(sql), 'a BEFORE trigger, so both columns are written by one statement');
74
+ });
75
+ }
76
+
77
+ test('the value mapping is one shared function, not repeated inline', () => {
78
+ // Route, sender and trigger must agree that 'builder' ⇄ 'member'; three copies of a
79
+ // ternary is how they would stop agreeing.
80
+ const pi = read('modules/platform-identity/platform-identity.js');
81
+ assert.match(pi, /function membershipKindFromLegacy\(/);
82
+ assert.match(pi, /function legacyRoleFromKind\(/);
83
+ const sso = read('modules/platform-identity/routes/sso.js');
84
+ assert.match(sso, /pi\.membershipKindFromLegacy\(role\)/,
85
+ 'the check-in translates the legacy field through the shared mapping');
86
+ const sql = read('modules/platform-identity/migrations/platform_identity_010_membership_kind.sql');
87
+ assert.match(sql, /CASE WHEN NEW\.role = 'builder' THEN 'member'/, 'the trigger maps the same pair');
88
+ assert.match(sql, /CASE WHEN NEW\.membership_kind = 'member' THEN 'builder'/);
89
+ });
90
+
91
+ test('the sender writes BOTH spellings, computing the kind once (the cross-version window)', () => {
92
+ const auth = read('src/bongos/auth.js');
93
+ const body = auth.slice(auth.indexOf('async function reportMembershipCheckIn'), auth.indexOf('async function reportMembershipCheckIn') + 2400);
94
+ assert.match(body, /membership_kind: kind/, 'the new field goes on the wire');
95
+ assert.match(body, /role: kind === 'member' \? 'builder' : kind/,
96
+ 'and the legacy field too — an un-upgraded hub would otherwise drop the owner flag');
97
+ assert.equal((body.match(/membershipKind\(\{ githubLogin \}\)/g) || []).length, 1,
98
+ 'the kind is computed ONCE per check-in, not per field');
99
+ });