@bongos/core 1.19.1026 → 1.19.1027
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +60 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +38 -0
- package/clients/bongos-client/index.cjs +38 -0
- package/clients/bongos-client/index.d.ts +60 -0
- package/clients/bongos-client/index.mjs +38 -0
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +4 -2
- package/docs/api/openapi.json +1345 -182
- package/docs/api-reference.md +29 -2
- package/docs/copy-inventory.md +3 -3
- package/docs/copy-registry.json +6 -6
- package/docs/module-api-changelog.md +2 -0
- package/modules/platform-identity/connections.js +1 -0
- package/modules/platform-identity/guilds.js +705 -0
- package/modules/platform-identity/migrations/platform_identity_025_guilds.sql +100 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/read-rate-limit.js +3 -2
- package/modules/platform-identity/routes/guilds-public.js +57 -0
- package/modules/platform-identity/routes/guilds.js +393 -0
- package/modules/platform-identity/terms-acceptance.js +14 -5
- package/modules/public-landing/public/terms.html +3 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/guilds_api.mjs +521 -0
- package/tests/guilds_db.mjs +343 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/terms_acceptance.mjs +74 -28
- package/tests/terms_grandfather_db.mjs +2 -2
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// tests/guilds_db.mjs — guild CRUD, membership and visibility against REAL SQL
|
|
2
|
+
// (task 1002300, R24; ADR 0336 D1–D4 + D6, ADR 0337 D4.4).
|
|
3
|
+
//
|
|
4
|
+
// Why a real database: the rules that matter here live in the schema and in
|
|
5
|
+
// single statements a fake pool cannot execute — the one-owner PARTIAL unique
|
|
6
|
+
// index, the open-request partial index, the expiry CTE that must be drained
|
|
7
|
+
// before its INSERT, the FK cascade on dissolve, the two-statement transfer, and
|
|
8
|
+
// an accept that resolves a row and inserts a membership in one statement. The
|
|
9
|
+
// DB-free sibling, tests/guilds_api.mjs, covers the route layer.
|
|
10
|
+
//
|
|
11
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
12
|
+
// INTEGRATION set so the default DB-free lane never runs it. Fixture ids are
|
|
13
|
+
// high-numbered and every guild slug starts r24-, so a shared DB is safe; all
|
|
14
|
+
// fixtures are deleted before and after.
|
|
15
|
+
//
|
|
16
|
+
// Run: DATABASE_URL=... node tests/guilds_db.mjs
|
|
17
|
+
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { Pool } = require('pg');
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
29
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
30
|
+
|
|
31
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
32
|
+
try {
|
|
33
|
+
await pool.query('SELECT 1');
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.log(`guilds_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
36
|
+
await pool.end().catch(() => {});
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const guilds = require('../modules/platform-identity/guilds.js');
|
|
41
|
+
|
|
42
|
+
const OWNER = 9002300001; // public, active — creates the guilds
|
|
43
|
+
const ALICE = 9002300002; // public, active
|
|
44
|
+
const PRIV = 9002300003; // private, active, NOT connected to OWNER
|
|
45
|
+
const PRIVC = 9002300004; // private, active, connected to OWNER
|
|
46
|
+
const PROV = 9002300005; // provisional — can take part in nothing
|
|
47
|
+
const BOB = 9002300006; // public, active
|
|
48
|
+
const CARL = 9002300007; // public, active
|
|
49
|
+
const PRIV2 = 9002300008; // private, active, connected to OWNER only
|
|
50
|
+
const ALL_IDS = [OWNER, ALICE, PRIV, PRIVC, PROV, BOB, CARL, PRIV2];
|
|
51
|
+
|
|
52
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
53
|
+
async function cleanup() {
|
|
54
|
+
await q(`DELETE FROM platform_identity_guilds WHERE slug LIKE 'r24-%'`).catch(() => {});
|
|
55
|
+
await q(`DELETE FROM platform_identity_connections
|
|
56
|
+
WHERE requester_github_id = ANY($1::bigint[]) OR addressee_github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
57
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let pass = 0;
|
|
61
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
62
|
+
async function rejects(promise, code, msg) {
|
|
63
|
+
let caught = null;
|
|
64
|
+
try { await promise; } catch (e) { caught = e; }
|
|
65
|
+
ok(caught && caught.code === code, `${msg} (expected ${code}, got ${caught ? caught.code : 'no error'})`);
|
|
66
|
+
}
|
|
67
|
+
const memberRow = async (slug, gid) => (await q(
|
|
68
|
+
`SELECT m.* FROM platform_identity_guild_members m JOIN platform_identity_guilds g ON g.id = m.guild_id
|
|
69
|
+
WHERE g.slug = $1 AND m.github_id = $2`, [slug, gid])).rows[0] || null;
|
|
70
|
+
const guildId = async (slug) => (await q(`SELECT id FROM platform_identity_guilds WHERE slug = $1`, [slug])).rows[0]?.id;
|
|
71
|
+
const openRows = async (slug, gid) => (await q(
|
|
72
|
+
`SELECT r.* FROM platform_identity_guild_requests r JOIN platform_identity_guilds g ON g.id = r.guild_id
|
|
73
|
+
WHERE g.slug = $1 AND r.github_id = $2 ORDER BY r.id`, [slug, gid])).rows;
|
|
74
|
+
const pendingId = async (slug, gid) => (await openRows(slug, gid)).find((r) => r.status === 'pending')?.id;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// The migrations this domain reads, all idempotent: accounts + consent,
|
|
78
|
+
// handles, privacy, terms, connections (addressing), and guilds.
|
|
79
|
+
for (const f of [
|
|
80
|
+
'platform_identity_001_tables.sql', 'platform_identity_007_profile_consent.sql',
|
|
81
|
+
'platform_identity_008_handle.sql', 'platform_identity_012_account_privacy.sql',
|
|
82
|
+
'platform_identity_015_connections.sql', 'platform_identity_019_terms_acceptance.sql',
|
|
83
|
+
'platform_identity_025_guilds.sql',
|
|
84
|
+
]) await q(migrationSql(f));
|
|
85
|
+
await q(migrationSql('platform_identity_025_guilds.sql')); // re-runnable
|
|
86
|
+
await cleanup();
|
|
87
|
+
|
|
88
|
+
await q(
|
|
89
|
+
`INSERT INTO platform_identity_accounts
|
|
90
|
+
(github_id, github_login, handle, display_name, profile_state, terms_accepted_at, account_visibility)
|
|
91
|
+
VALUES ($1, 'r24own', 'r24own', 'Owner', 'active', now(), 'public'),
|
|
92
|
+
($2, 'r24alice', 'r24alice', 'Alice', 'active', now(), 'public'),
|
|
93
|
+
($3, 'r24priv', 'r24priv', 'Priv', 'active', now(), 'private'),
|
|
94
|
+
($4, 'r24privc', 'r24privc', 'PrivC', 'active', now(), 'private'),
|
|
95
|
+
($5, 'r24prov', 'r24prov', 'Prov', 'provisional', NULL, 'public'),
|
|
96
|
+
($6, 'r24bob', 'r24bob', 'Bob', 'active', now(), 'public'),
|
|
97
|
+
($7, 'r24carl', 'r24carl', 'Carl', 'active', now(), 'public'),
|
|
98
|
+
($8, 'r24priv2', 'r24priv2', 'Priv2', 'active', now(), 'private')`,
|
|
99
|
+
ALL_IDS,
|
|
100
|
+
);
|
|
101
|
+
await q(`INSERT INTO platform_identity_connections (requester_github_id, addressee_github_id, status, resolved_at)
|
|
102
|
+
VALUES ($1, $2, 'accepted', now()), ($1, $3, 'accepted', now())`, [OWNER, PRIVC, PRIV2]);
|
|
103
|
+
|
|
104
|
+
// --- 1. the schema holds its own rules (ADR 0336 D1) ---------------------
|
|
105
|
+
await rejects(q(`INSERT INTO platform_identity_guilds (slug, name) VALUES ('r24-nodefault', 'x')`),
|
|
106
|
+
'23502', 'visibility has NO default — the creator must choose');
|
|
107
|
+
await rejects(q(`INSERT INTO platform_identity_guilds (slug, name, visibility) VALUES ('r24-bad', 'x', 'private')`),
|
|
108
|
+
'23514', 'visibility is public or unlisted, nothing else');
|
|
109
|
+
await rejects(q(`INSERT INTO platform_identity_guilds (slug, name, visibility) VALUES ('R24-Upper', 'x', 'public')`),
|
|
110
|
+
'23514', 'the slug is stored in canonical lowercase form only');
|
|
111
|
+
const cols = (await q(
|
|
112
|
+
`SELECT column_name, column_default, is_nullable FROM information_schema.columns
|
|
113
|
+
WHERE table_name = 'platform_identity_guild_members'`)).rows;
|
|
114
|
+
const col = (n) => cols.find((c) => c.column_name === n);
|
|
115
|
+
ok(col('shown_publicly').is_nullable === 'NO' && col('shown_publicly').column_default === null,
|
|
116
|
+
'shown_publicly is NOT NULL with no default — every join must say what its card showed');
|
|
117
|
+
ok(col('counted_in_totals').is_nullable === 'NO' && col('counted_in_totals').column_default === 'true',
|
|
118
|
+
'counted_in_totals is NOT NULL DEFAULT true (ADR 0337 D4.4)');
|
|
119
|
+
ok(!col('role') && !!col('membership_kind'), 'membership_kind, never role (ADR 0174)');
|
|
120
|
+
|
|
121
|
+
// --- 2. create ------------------------------------------------------------
|
|
122
|
+
const pub = await guilds.createGuild({ githubId: OWNER, slug: 'r24-pub', name: 'Public Guild', visibility: 'public' }, { pool });
|
|
123
|
+
ok(pub.guild && pub.guild.slug === 'r24-pub' && pub.guild.id === undefined, 'createGuild returns the guild without its row id');
|
|
124
|
+
const ownerPub = await memberRow('r24-pub', OWNER);
|
|
125
|
+
ok(ownerPub.membership_kind === 'owner' && ownerPub.shown_publicly === true && ownerPub.counted_in_totals === true,
|
|
126
|
+
'the creator is the one owner, shown on a public guild, counted by default');
|
|
127
|
+
const unl = await guilds.createGuild({ githubId: OWNER, slug: 'r24-unl', name: 'Quiet Guild', visibility: 'unlisted' }, { pool });
|
|
128
|
+
ok(unl.guild.visibility === 'unlisted' && (await memberRow('r24-unl', OWNER)).shown_publicly === false,
|
|
129
|
+
'creating an unlisted guild leaves its owner unshown');
|
|
130
|
+
ok((await guilds.createGuild({ githubId: ALICE, slug: 'r24-pub', name: 'Dup', visibility: 'public' }, { pool })).taken === true,
|
|
131
|
+
'a taken slug is refused');
|
|
132
|
+
await rejects(q(`INSERT INTO platform_identity_guild_members (guild_id, github_id, membership_kind, shown_publicly)
|
|
133
|
+
VALUES ($1, $2, 'owner', true)`, [await guildId('r24-pub'), ALICE]),
|
|
134
|
+
'23505', 'the database refuses a second owner');
|
|
135
|
+
|
|
136
|
+
// --- 3. the public read (D4) ------------------------------------------------
|
|
137
|
+
ok((await guilds.getPublicGuild('r24-unl', { pool })) === null, 'an unlisted guild has no public existence');
|
|
138
|
+
ok((await guilds.getPublicGuild('r24-nope', { pool })) === null, '…the same null as a guild that does not exist');
|
|
139
|
+
|
|
140
|
+
// --- 4. join requests: public only, two consents, silent decline -----------
|
|
141
|
+
ok((await guilds.requestToJoin({ slug: 'r24-unl', githubId: ALICE }, { pool })).outcome === 'not_found',
|
|
142
|
+
'an unlisted guild takes no requests, and says so as not_found');
|
|
143
|
+
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: ALICE, countedInTotals: false }, { pool })).outcome === 'created',
|
|
144
|
+
'a builder may ask a public guild');
|
|
145
|
+
ok(!(await memberRow('r24-pub', ALICE)), 'asking is not joining — no self-join');
|
|
146
|
+
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: ALICE }, { pool })).outcome === 'already_open',
|
|
147
|
+
'a second open request for the pair is refused by the open-row index');
|
|
148
|
+
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: OWNER }, { pool })).outcome === 'already_member',
|
|
149
|
+
'a member cannot ask to join');
|
|
150
|
+
|
|
151
|
+
const aliceReq = await pendingId('r24-pub', ALICE);
|
|
152
|
+
ok((await guilds.acceptRequest({ id: aliceReq, githubId: BOB }, { pool })).outcome === 'not_found',
|
|
153
|
+
'only the owner may accept a request — anyone else is not_found');
|
|
154
|
+
ok((await guilds.acceptRequest({ id: aliceReq, githubId: OWNER, countedInTotals: true }, { pool })).outcome === 'accepted',
|
|
155
|
+
'the owner accepts the request');
|
|
156
|
+
const alicePub = await memberRow('r24-pub', ALICE);
|
|
157
|
+
ok(alicePub && alicePub.membership_kind === 'member' && alicePub.shown_publicly === true,
|
|
158
|
+
'a request to a public guild joins shown');
|
|
159
|
+
ok(alicePub.counted_in_totals === false,
|
|
160
|
+
'the requester\'s own counted_in_totals choice rides the request — the owner cannot override it');
|
|
161
|
+
|
|
162
|
+
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'created', 'Bob asks');
|
|
163
|
+
ok(await guilds.declineRequest({ id: await pendingId('r24-pub', BOB), githubId: OWNER }, { pool }), 'the owner declines');
|
|
164
|
+
const before = (await openRows('r24-pub', BOB)).length;
|
|
165
|
+
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'swallowed',
|
|
166
|
+
'a re-ask inside the cooldown is swallowed…');
|
|
167
|
+
ok((await openRows('r24-pub', BOB)).length === before, '…and writes nothing');
|
|
168
|
+
|
|
169
|
+
// --- 5. invites: addressing, batch, the card (D3/D4) ------------------------
|
|
170
|
+
const unlId = await guildId('r24-unl');
|
|
171
|
+
const res = await guilds.inviteHandles({
|
|
172
|
+
guildId: unlId, ownerGithubId: OWNER,
|
|
173
|
+
handles: ['r24alice', 'R24PRIV', 'r24privc', 'r24prov', 'r24own', 'r24-ghost', 'r24alice'],
|
|
174
|
+
}, { pool });
|
|
175
|
+
const outcome = (h) => res.find((r) => r.handle === h)?.outcome;
|
|
176
|
+
ok(outcome('r24alice') === 'invited', 'a public account is invitable into an unlisted guild');
|
|
177
|
+
ok(outcome('r24priv') === 'not_found', 'a private stranger is not_found');
|
|
178
|
+
ok(outcome('r24privc') === 'invited', 'a private account the owner is connected to is invitable (ADR 0202 D4 §2)');
|
|
179
|
+
ok(outcome('r24prov') === 'not_found', 'a provisional account cannot be invited');
|
|
180
|
+
ok(outcome('r24own') === 'already_member', 'the owner is already a member');
|
|
181
|
+
ok(outcome('r24-ghost') === 'not_found', 'an unknown handle is not_found — one answer with private and provisional');
|
|
182
|
+
ok(res.filter((r) => r.handle === 'r24alice').length === 1, 'a repeated handle in one batch is invited once');
|
|
183
|
+
await guilds.inviteHandles({ guildId: unlId, ownerGithubId: ALICE, handles: ['r24bob'] }, { pool });
|
|
184
|
+
ok((await openRows('r24-unl', BOB)).length === 0,
|
|
185
|
+
'a non-owner invite writes nothing (the owner gate is in the statement, not only the route)');
|
|
186
|
+
ok((await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24priv2'] }, { pool }))[0].outcome === 'invited',
|
|
187
|
+
'the owner invites a second private account they are connected to');
|
|
188
|
+
|
|
189
|
+
const aliceInv = await pendingId('r24-unl', ALICE);
|
|
190
|
+
ok((await guilds.acceptRequest({ id: aliceInv, githubId: ALICE }, { pool })).outcome === 'card_required',
|
|
191
|
+
'an invite accept must carry the visibility its card showed');
|
|
192
|
+
const changed = await guilds.acceptRequest({ id: aliceInv, githubId: ALICE, cardVisibility: 'public' }, { pool });
|
|
193
|
+
ok(changed.outcome === 'visibility_changed' && changed.visibility === 'unlisted',
|
|
194
|
+
'a card that does not match the guild is refused, naming the current visibility');
|
|
195
|
+
ok((await guilds.acceptRequest({ id: aliceInv, githubId: OWNER, cardVisibility: 'unlisted' }, { pool })).outcome === 'not_found',
|
|
196
|
+
'only the invitee may accept an invite');
|
|
197
|
+
ok((await guilds.acceptRequest({ id: aliceInv, githubId: ALICE, cardVisibility: 'unlisted', countedInTotals: false }, { pool })).outcome === 'accepted',
|
|
198
|
+
'the invitee accepts with the card they saw');
|
|
199
|
+
const aliceUnl = await memberRow('r24-unl', ALICE);
|
|
200
|
+
ok(aliceUnl.shown_publicly === false && aliceUnl.counted_in_totals === false,
|
|
201
|
+
'joining an unlisted guild leaves the member unshown; the card switch is honoured');
|
|
202
|
+
|
|
203
|
+
const privcInv = await pendingId('r24-unl', PRIVC);
|
|
204
|
+
ok((await guilds.acceptRequest({ id: privcInv, githubId: PRIVC, cardVisibility: 'unlisted' }, { pool })).outcome === 'accepted',
|
|
205
|
+
'a private account can join');
|
|
206
|
+
ok((await guilds.acceptRequest({ id: privcInv, githubId: PRIVC, cardVisibility: 'unlisted' }, { pool })).outcome === 'not_found',
|
|
207
|
+
'an accepted row cannot be accepted twice');
|
|
208
|
+
|
|
209
|
+
// Decline cooldown on invites, and the owner's queue.
|
|
210
|
+
await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24bob', 'r24carl'] }, { pool });
|
|
211
|
+
const q1 = await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: OWNER }, { pool });
|
|
212
|
+
ok(q1.outgoing.length === 3 && q1.outgoing.some((r) => r.handle === 'r24priv2') && q1.outgoing.every((r) => !('seen_at' in r) && r.handle && !('github_id' in r)),
|
|
213
|
+
'the owner sees invites still out — named by handle, never github_id, never the invitee\'s seen_at');
|
|
214
|
+
ok((await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: ALICE }, { pool })).outgoing.length === 0,
|
|
215
|
+
'a non-owner reads an empty queue (the owner gate is in the statement)');
|
|
216
|
+
const bobQ = await guilds.listMyRequests({ githubId: BOB }, { pool });
|
|
217
|
+
ok(bobQ.incoming.length === 1 && bobQ.incoming[0].visibility === 'unlisted' && bobQ.incoming[0].slug === 'r24-unl',
|
|
218
|
+
'the invitee\'s queue carries the card: slug and current visibility');
|
|
219
|
+
ok((await openRows('r24-unl', BOB))[0].seen_at !== null, 'reading the queue stamps seen_at on the invite');
|
|
220
|
+
ok(await guilds.declineRequest({ id: await pendingId('r24-unl', BOB), githubId: BOB }, { pool }), 'Bob declines — silently');
|
|
221
|
+
await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24bob'] }, { pool });
|
|
222
|
+
ok((await openRows('r24-unl', BOB)).length === 1, 'a re-invite inside the cooldown is swallowed: no new row');
|
|
223
|
+
|
|
224
|
+
// Cancel: the opener only.
|
|
225
|
+
const carlInv = await pendingId('r24-unl', CARL);
|
|
226
|
+
ok(!(await guilds.cancelRequest({ id: carlInv, githubId: CARL }, { pool })), 'an invitee cannot cancel an invite (that is a decline)');
|
|
227
|
+
ok(await guilds.cancelRequest({ id: carlInv, githubId: OWNER }, { pool }), 'the owner cancels an invite');
|
|
228
|
+
|
|
229
|
+
// Expiry is read-time, and the next write resolves the stale row in the same statement.
|
|
230
|
+
await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24carl'] }, { pool });
|
|
231
|
+
await q(`UPDATE platform_identity_guild_requests SET created_at = now() - interval '31 days'
|
|
232
|
+
WHERE github_id = $1 AND status = 'pending'`, [CARL]);
|
|
233
|
+
const stale = await pendingId('r24-unl', CARL);
|
|
234
|
+
ok((await guilds.acceptRequest({ id: stale, githubId: CARL, cardVisibility: 'unlisted' }, { pool })).outcome === 'not_found',
|
|
235
|
+
'an invite older than the expiry cannot be accepted');
|
|
236
|
+
const again = await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24carl'] }, { pool });
|
|
237
|
+
ok(again[0].outcome === 'invited' && (await openRows('r24-unl', CARL)).some((r) => r.status === 'expired'),
|
|
238
|
+
'a fresh invite resolves the stale row as expired in the same statement, instead of hitting the open-row index');
|
|
239
|
+
|
|
240
|
+
// --- 6. visibility changes flip only the owner (D4 §3) ---------------------
|
|
241
|
+
const flipped = await guilds.updateGuild({ guildId: unlId, githubId: OWNER, visibility: 'public' }, { pool });
|
|
242
|
+
ok(flipped && flipped.visibility === 'public', 'the owner makes the guild public');
|
|
243
|
+
ok((await memberRow('r24-unl', OWNER)).shown_publicly === true, 'the owner who flipped it is now shown');
|
|
244
|
+
ok((await memberRow('r24-unl', ALICE)).shown_publicly === false, 'a member who joined it unlisted is NOT shown until they agree');
|
|
245
|
+
ok(!(await guilds.updateGuild({ guildId: unlId, githubId: ALICE, name: 'Hijack' }, { pool })), 'a non-owner cannot edit the guild');
|
|
246
|
+
const carlCard = await guilds.acceptRequest({ id: await pendingId('r24-unl', CARL), githubId: CARL, cardVisibility: 'unlisted' }, { pool });
|
|
247
|
+
ok(carlCard.outcome === 'visibility_changed', 'an invite accepted from a card that predates the flip is refused');
|
|
248
|
+
|
|
249
|
+
let page = await guilds.getPublicGuild('r24-unl', { pool });
|
|
250
|
+
ok(page && page.members.map((m) => m.handle).join(',') === 'r24own' && page.size === 1,
|
|
251
|
+
'the public roster shows only members who agreed to be shown; size is its length');
|
|
252
|
+
ok(page.members.every((m) => !('github_id' in m)), 'the public roster never carries github_id');
|
|
253
|
+
|
|
254
|
+
ok((await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: ALICE, shownPublicly: true }, { pool })).shown_publicly === true,
|
|
255
|
+
'a member agrees to be shown');
|
|
256
|
+
await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: PRIVC, shownPublicly: true }, { pool });
|
|
257
|
+
page = await guilds.getPublicGuild('r24-unl', { pool });
|
|
258
|
+
ok(page.members.map((m) => m.handle).sort().join(',') === 'r24alice,r24own' && page.size === 2,
|
|
259
|
+
'a private account is never on a public roster, whatever its flag');
|
|
260
|
+
ok(page.members[0].handle === 'r24own', 'the owner leads; the rest are ordered by handle, a published field');
|
|
261
|
+
const roster = await guilds.listMemberRoster({ guildId: unlId }, { pool });
|
|
262
|
+
ok(roster.some((m) => m.handle === 'r24privc'), 'fellow members still see the private member in the own-scope roster');
|
|
263
|
+
ok((await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: ALICE, countedInTotals: true }, { pool })).counted_in_totals === true,
|
|
264
|
+
'a member flips their own counted_in_totals');
|
|
265
|
+
ok((await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: BOB, countedInTotals: false }, { pool })) === null,
|
|
266
|
+
'a non-member has no switch to flip');
|
|
267
|
+
ok((await guilds.listMyGuilds({ githubId: ALICE }, { pool })).length === 2, 'my guilds lists both memberships');
|
|
268
|
+
|
|
269
|
+
// --- 7. transfer, remove, leave, dissolve (D2) -----------------------------
|
|
270
|
+
ok(await guilds.transferOwnership({ guildId: unlId, ownerGithubId: ALICE, handle: 'r24privc' }, { pool }) === 'not_owner',
|
|
271
|
+
'a member cannot transfer ownership');
|
|
272
|
+
ok((await memberRow('r24-unl', ALICE)).membership_kind === 'member', '…and the refused attempt changed nothing');
|
|
273
|
+
ok(await guilds.transferOwnership({ guildId: unlId, ownerGithubId: OWNER, handle: 'r24bob' }, { pool }) === 'not_member',
|
|
274
|
+
'ownership goes only to an existing member');
|
|
275
|
+
ok(await guilds.transferOwnership({ guildId: unlId, ownerGithubId: OWNER, handle: 'r24alice' }, { pool }) === 'transferred',
|
|
276
|
+
'the owner transfers to a member');
|
|
277
|
+
ok((await memberRow('r24-unl', ALICE)).membership_kind === 'owner' && (await memberRow('r24-unl', OWNER)).membership_kind === 'member',
|
|
278
|
+
'demote then promote: exactly one owner, and it is the new one');
|
|
279
|
+
const newOwnerQ = await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: ALICE }, { pool });
|
|
280
|
+
ok(newOwnerQ.outgoing.some((r) => r.handle === 'r24carl') && !newOwnerQ.outgoing.some((r) => r.handle === 'r24priv2'),
|
|
281
|
+
'after a transfer, the new owner sees public invitees but not a private one the previous owner invited');
|
|
282
|
+
ok((await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: OWNER }, { pool })).outgoing.length === 0,
|
|
283
|
+
'and the previous owner no longer reads the queue at all');
|
|
284
|
+
|
|
285
|
+
ok(!(await guilds.removeMember({ guildId: unlId, ownerGithubId: OWNER, handle: 'r24privc' }, { pool })),
|
|
286
|
+
'the former owner can no longer remove members');
|
|
287
|
+
ok(await guilds.removeMember({ guildId: unlId, ownerGithubId: ALICE, handle: 'r24privc' }, { pool }),
|
|
288
|
+
'the owner removes a member');
|
|
289
|
+
ok(!(await memberRow('r24-unl', PRIVC)), 'removal is a hard delete');
|
|
290
|
+
ok(!(await openRows('r24-unl', PRIVC)).some((r) => r.status === 'accepted'),
|
|
291
|
+
'removal also deletes the accepted invite that admitted them — no record they were ever here');
|
|
292
|
+
ok(!(await guilds.removeMember({ guildId: unlId, ownerGithubId: ALICE, handle: 'r24alice' }, { pool })),
|
|
293
|
+
'the owner cannot remove themselves');
|
|
294
|
+
|
|
295
|
+
ok(await guilds.leaveGuild({ guildId: unlId, githubId: ALICE }, { pool }) === 'must_transfer',
|
|
296
|
+
'an owner with members must transfer before leaving');
|
|
297
|
+
ok(await guilds.leaveGuild({ guildId: unlId, githubId: OWNER }, { pool }) === 'left', 'a member leaves');
|
|
298
|
+
ok(await guilds.leaveGuild({ guildId: unlId, githubId: OWNER }, { pool }) === null, 'a non-member cannot leave');
|
|
299
|
+
ok(await guilds.leaveGuild({ guildId: unlId, githubId: ALICE }, { pool }) === 'dissolved',
|
|
300
|
+
'the last member leaving dissolves the guild');
|
|
301
|
+
ok(!(await guildId('r24-unl')) && (await openRows('r24-unl', CARL)).length === 0,
|
|
302
|
+
'dissolving hard-deletes the guild and cascades to its open requests');
|
|
303
|
+
|
|
304
|
+
const pubId = await guildId('r24-pub');
|
|
305
|
+
|
|
306
|
+
// A leave racing a transfer that promotes the leaver. The transfer holds the
|
|
307
|
+
// leaver's row locked; the leave must wait, then see 'owner', and refuse —
|
|
308
|
+
// never delete the new owner from a stale snapshot and orphan the guild.
|
|
309
|
+
const tx = await pool.connect();
|
|
310
|
+
let raced;
|
|
311
|
+
try {
|
|
312
|
+
await tx.query('BEGIN');
|
|
313
|
+
await tx.query(`SELECT 1 FROM platform_identity_guild_members WHERE guild_id = $1 AND github_id = $2 FOR UPDATE`, [pubId, ALICE]);
|
|
314
|
+
await tx.query(`UPDATE platform_identity_guild_members SET membership_kind = 'member' WHERE guild_id = $1 AND github_id = $2`, [pubId, OWNER]);
|
|
315
|
+
await tx.query(`UPDATE platform_identity_guild_members SET membership_kind = 'owner' WHERE guild_id = $1 AND github_id = $2`, [pubId, ALICE]);
|
|
316
|
+
raced = guilds.leaveGuild({ guildId: pubId, githubId: ALICE }, { pool });
|
|
317
|
+
let waiting = false;
|
|
318
|
+
for (let i = 0; i < 50 && !waiting; i++) {
|
|
319
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
320
|
+
const { rows } = await q(`SELECT 1 FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND query LIKE '%WITH mine AS%'`);
|
|
321
|
+
waiting = rows.length > 0;
|
|
322
|
+
}
|
|
323
|
+
ok(waiting, 'the racing leave is blocked on the transfer\'s row lock');
|
|
324
|
+
await tx.query('COMMIT');
|
|
325
|
+
} finally { tx.release(); }
|
|
326
|
+
ok(await raced === 'must_transfer', 'once the transfer commits, the leave re-reads the row as owner and refuses');
|
|
327
|
+
ok((await memberRow('r24-pub', ALICE)).membership_kind === 'owner', 'the guild still has its (new) owner');
|
|
328
|
+
|
|
329
|
+
ok(await guilds.transferOwnership({ guildId: pubId, ownerGithubId: ALICE, handle: 'r24own' }, { pool }) === 'transferred',
|
|
330
|
+
'ownership is handed back');
|
|
331
|
+
ok(await guilds.leaveGuild({ guildId: pubId, githubId: ALICE }, { pool }) === 'left', 'the member leaves');
|
|
332
|
+
ok(!(await openRows('r24-pub', ALICE)).some((r) => r.status === 'accepted'),
|
|
333
|
+
'leaving deletes the accepted request that admitted them');
|
|
334
|
+
ok(!(await guilds.dissolveGuild({ guildId: pubId, githubId: ALICE }, { pool })), 'a non-owner cannot dissolve');
|
|
335
|
+
ok(await guilds.dissolveGuild({ guildId: pubId, githubId: OWNER }, { pool }), 'the owner dissolves');
|
|
336
|
+
ok((await q(`SELECT count(*)::int AS n FROM platform_identity_guild_members WHERE guild_id = $1`, [pubId])).rows[0].n === 0,
|
|
337
|
+
'dissolving cascades to every membership');
|
|
338
|
+
|
|
339
|
+
console.log(`\nguilds_db.mjs: ${pass} passed`);
|
|
340
|
+
} finally {
|
|
341
|
+
await cleanup();
|
|
342
|
+
await pool.end();
|
|
343
|
+
}
|
|
@@ -123,6 +123,11 @@ const OWN_SCOPED_SURFACES = [
|
|
|
123
123
|
why: 'the connection graph (ADR 0202, task 1002295). The ACTING account is always the session; a counterparty is named by public handle or by the connection row id, never by an account identifier — and the degree label is a viewer-relative own-scope read (D4 §4).',
|
|
124
124
|
crossOwner: [],
|
|
125
125
|
},
|
|
126
|
+
{
|
|
127
|
+
file: 'modules/platform-identity/routes/guilds.js',
|
|
128
|
+
why: 'guilds (ADR 0336, task 1002300). The ACTING account is always the session: a guild is named by slug, a person by public handle, an invite or request by its row id, and owner-only acts are re-asserted against the session inside each statement. The one public read, GET /guilds/:slug, is deliberately in routes/guilds-public.js so this whole file can be held to the rule.',
|
|
129
|
+
crossOwner: [],
|
|
130
|
+
},
|
|
126
131
|
{
|
|
127
132
|
file: 'modules/platform-identity/routes/my-projects.js',
|
|
128
133
|
why: "the caller's OWN cross-project memberships, invitations, applications and leave (ADR 0141 §3; the application echo, ADR 0201 D1.3, task 1002284) — every route keyed on req.builder.github_id / github_login. The only path param is :clientId, which names a PROJECT and never a builder: a project is the resource being acted on, and the actor is always the session, which is why join can relay an application for nobody but the caller.",
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// tests/terms_acceptance.mjs
|
|
2
2
|
//
|
|
3
|
-
// THE PLATFORM T&C ACCEPTANCE RECORD — "the hub
|
|
4
|
-
// that can say a human accepted the platform terms, it says so once,
|
|
5
|
-
// publishes without it" (ADR 0213, task 1002913; the artifact ADR
|
|
6
|
-
// deferred cross-project-aggregation consent to and ADR 0171 D3 reserved
|
|
7
|
-
// for).
|
|
3
|
+
// THE PLATFORM T&C ACCEPTANCE RECORD — "a sign-in the hub itself witnessed is the
|
|
4
|
+
// only thing that can say a human accepted the platform terms, it says so once,
|
|
5
|
+
// and nothing publishes without it" (ADR 0213, task 1002913; the artifact ADR
|
|
6
|
+
// 0141 §4 deferred cross-project-aggregation consent to and ADR 0171 D3 reserved
|
|
7
|
+
// a place for). That sign-in is GET /sso/authorize — a builder using their
|
|
8
|
+
// platform account to sign in to a PROJECT. Signing in to the hub itself stamps
|
|
9
|
+
// nothing yet (blocker 1000124); task 1003361 adds it as a second capture point.
|
|
8
10
|
//
|
|
9
11
|
// WHY THIS EXISTS. An acceptance record is only worth the evidence behind it. If
|
|
10
12
|
// a caller can assert one — a body field, a query param, a header — then the
|
|
@@ -111,24 +113,44 @@ t('the terms page states what crosses between projects', () => {
|
|
|
111
113
|
t('the acceptance the page promises is the one the code actually keeps', () => {
|
|
112
114
|
assert.match(termsHtml, /stores the date you accepted these terms/i,
|
|
113
115
|
'the page must tell the reader an acceptance record is kept');
|
|
114
|
-
assert.match(termsHtml, /signing in again does not change the
|
|
116
|
+
assert.match(termsHtml, /signing in again does not change the date you accepted/i,
|
|
115
117
|
'and that it is written once — the property the COALESCE below actually provides');
|
|
116
118
|
});
|
|
117
119
|
|
|
120
|
+
// The capture point is GET /sso/authorize — reached when a builder uses their
|
|
121
|
+
// platform account to sign in to a PROJECT. Signing in to the hub itself stamps
|
|
122
|
+
// nothing (blocker 1000124, task 1003353), so the page must not say "here".
|
|
123
|
+
// Task 1003361 adds the hub's own sign-in as a second capture point; when it
|
|
124
|
+
// lands, this wording and §3 below change together.
|
|
125
|
+
t('the page names the sign-in the code actually watches — a project sign-in, not "here" (blocker 1000124)', () => {
|
|
126
|
+
const section = termsHtml.slice(termsHtml.indexOf('Your work across projects'), termsHtml.indexOf('Fair use'));
|
|
127
|
+
assert.doesNotMatch(section, /sign(?:ed|ing)? in here/i,
|
|
128
|
+
'the page promises that signing in to this site records acceptance — the code never stamps that sign-in');
|
|
129
|
+
assert.match(section, /Once you have signed in to a project with your platform account and accepted these terms/,
|
|
130
|
+
'what makes a profile public must name the moment the code captures');
|
|
131
|
+
assert.match(section, /never signed in to a project that way stays provisional/,
|
|
132
|
+
'and the provisional case must be the same moment, negated');
|
|
133
|
+
assert.match(section, /written when you sign in to a project with your platform account, and only once/,
|
|
134
|
+
'the acceptance paragraph must name the same capture point');
|
|
135
|
+
});
|
|
136
|
+
|
|
118
137
|
t('the page says which acceptances were given on a builder’s behalf, and that the record marks them (task 1003360)', () => {
|
|
119
138
|
assert.match(termsHtml, /accepted it on your behalf/i,
|
|
120
139
|
'the owner chose to grandfather AND to say so on the page — the page must tell a pre-existing builder what was recorded for them');
|
|
121
140
|
assert.match(termsHtml, /marks that acceptance as grandfathered/i,
|
|
122
141
|
'and that the record keeps it distinguishable from one they gave');
|
|
123
|
-
assert.match(termsHtml, /your own acceptance replaces it the next time you sign in/i,
|
|
142
|
+
assert.match(termsHtml, /your own acceptance replaces it the next time you sign in to a project/i,
|
|
124
143
|
'and what happens when they do sign in — the conflict branch below really does replace it');
|
|
125
144
|
assert.doesNotMatch(termsHtml, /written only by signing in/i,
|
|
126
145
|
'the page still says acceptance is written ONLY by signing in, which is false for every grandfathered account');
|
|
127
|
-
assert.doesNotMatch(stripApexOnly(termsHtml), /grandfathered/i, 'a claim about the platform record survived into a child project page');
|
|
146
|
+
assert.doesNotMatch(stripApexOnly(termsHtml, false), /grandfathered/i, 'a claim about the platform record survived into a child project page');
|
|
128
147
|
});
|
|
129
148
|
|
|
130
149
|
t('the cross-project claim is apex-only — it never ships in a child project page', () => {
|
|
131
|
-
|
|
150
|
+
// The hub flag is passed explicitly: its default reads config/modules.json, so
|
|
151
|
+
// an implicit call passes or fails with whatever this checkout has enabled.
|
|
152
|
+
assert.equal(stripApexOnly(termsHtml, true), termsHtml, 'the hub keeps every apex-only passage');
|
|
153
|
+
const stripped = stripApexOnly(termsHtml, false);
|
|
132
154
|
assert.doesNotMatch(stripped, /Your work across projects/,
|
|
133
155
|
'a claim about THE PLATFORM survived into a child project page (task 1002748)');
|
|
134
156
|
// ...and the stripper did not simply eat the page.
|
|
@@ -165,7 +187,9 @@ t('the conflict fragment keeps the first acceptance, and replaces only a grandfa
|
|
|
165
187
|
t('the insert fragment writes NULL for a caller the hub did not witness, and marks a witnessed one as a sign-in', () => {
|
|
166
188
|
const values = acceptanceInsertValues('$5', '$6');
|
|
167
189
|
assert.match(values, /CASE WHEN \$5::boolean THEN now\(\) ELSE NULL END/,
|
|
168
|
-
'
|
|
190
|
+
'an account no sign-in witnessed has no acceptance — fail-closed');
|
|
191
|
+
assert.match(values, /CASE WHEN \$5::boolean THEN \$6::text ELSE NULL END/,
|
|
192
|
+
'and no version either: the pair is written together or not at all');
|
|
169
193
|
assert.match(values, /CASE WHEN \$5::boolean THEN 'signin' ELSE NULL END/,
|
|
170
194
|
'an acceptance written at sign-in must say so (task 1003360) — an unmarked one is indistinguishable from a grandfather');
|
|
171
195
|
assert.equal(acceptanceInsertColumns(), ', terms_accepted_at, terms_version, terms_accepted_source');
|
|
@@ -199,8 +223,12 @@ function splitTopLevel(s) {
|
|
|
199
223
|
// permissive `.*?` match: every shape this understands is spelled out, and an
|
|
200
224
|
// unrecognised one THROWS. A SET list matched loosely is how a deleted audit
|
|
201
225
|
// trail passes a test that looks like it is checking one.
|
|
202
|
-
function evalAcceptance(expr, { col,
|
|
226
|
+
function evalAcceptance(expr, { col, existing, oldRow = {}, params, now }) {
|
|
203
227
|
const self = `platform_identity_accounts.${col}`;
|
|
228
|
+
// A boolean placeholder resolves by its OWN number, exactly as `$N::text` does
|
|
229
|
+
// below — so a fragment gated on the wrong parameter reads as never activating,
|
|
230
|
+
// rather than scoring as correct because the interpreter assumed which one it was.
|
|
231
|
+
const on = (n) => params[Number(n) - 1] === true;
|
|
204
232
|
const src = (tok) => {
|
|
205
233
|
if (/^now\(\)$/i.test(tok)) return now;
|
|
206
234
|
if (tok === "'signin'") return 'signin';
|
|
@@ -216,24 +244,24 @@ function evalAcceptance(expr, { col, activate, existing, oldRow = {}, params, no
|
|
|
216
244
|
// right-hand side against the row before the update.
|
|
217
245
|
m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean AND platform_identity_accounts\\.terms_accepted_source = 'grandfathered' THEN (.+?) WHEN \\$(\\d+)::boolean THEN COALESCE\\(${esc}, (.+?)\\) ELSE ${esc} END$`, 'i').exec(expr);
|
|
218
246
|
if (m) {
|
|
219
|
-
if (
|
|
220
|
-
if (
|
|
221
|
-
return existing
|
|
247
|
+
if (on(m[1]) && oldRow.terms_accepted_source === 'grandfathered') return src(m[2]);
|
|
248
|
+
if (on(m[3])) return existing != null ? existing : src(m[4]);
|
|
249
|
+
return existing;
|
|
222
250
|
}
|
|
223
251
|
|
|
224
252
|
// The ADR 0213 shape: activate ? (existing ?? src) : existing. Recognised so a
|
|
225
253
|
// regression to it is caught by behaviour (a grandfather that never gives way).
|
|
226
254
|
m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean THEN COALESCE\\(${esc}, (.+?)\\) ELSE ${esc} END$`, 'i').exec(expr);
|
|
227
|
-
if (m) return
|
|
255
|
+
if (m) return on(m[1]) ? (existing != null ? existing : src(m[2])) : existing;
|
|
228
256
|
|
|
229
257
|
// The overwriting shape — a real possibility, and the one the idempotence
|
|
230
258
|
// assertion below must be able to tell apart from the shipped one.
|
|
231
259
|
m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean THEN (.+?) ELSE ${esc} END$`, 'i').exec(expr);
|
|
232
|
-
if (m) return
|
|
260
|
+
if (m) return on(m[1]) ? src(m[2]) : existing;
|
|
233
261
|
|
|
234
262
|
// The INSERT-branch shape: activate ? src : NULL
|
|
235
263
|
m = /^CASE WHEN \$(\d+)::boolean THEN (.+?) ELSE NULL END$/i.exec(expr);
|
|
236
|
-
if (m) return
|
|
264
|
+
if (m) return on(m[1]) ? src(m[2]) : null;
|
|
237
265
|
|
|
238
266
|
// Unconditional — a caller-independent write. Recognised so the "non-activating
|
|
239
267
|
// paths never stamp" assertion can catch it rather than erroring out.
|
|
@@ -272,16 +300,28 @@ function makePool() {
|
|
|
272
300
|
const id = String(params[0]);
|
|
273
301
|
const existing = accounts.get(id);
|
|
274
302
|
|
|
275
|
-
if (/ON CONFLICT \(github_id\) DO NOTHING/i.test(sql)) {
|
|
276
|
-
if (!existing) accounts.set(id, { github_id: id, github_login: params[1], profile_state: 'provisional', terms_accepted_at: null, terms_version: null, terms_accepted_source: null, account_visibility: 'public', hide_stats: false, handle: 'h' });
|
|
277
|
-
return { rows: [], rowCount: existing ? 0 : 1 };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
303
|
const valuesRaw = /VALUES \((.+?)\) ON CONFLICT/i.exec(sql);
|
|
281
304
|
assert.ok(valuesRaw, `the upsert changed shape — update the interpreter, do not bypass it: ${sql.slice(0, 200)}`);
|
|
282
305
|
const values = splitTopLevel(valuesRaw[1]);
|
|
283
306
|
assert.equal(values.length, cols.length, 'the INSERT column list and VALUES list must line up');
|
|
284
307
|
|
|
308
|
+
// ensureAccount's insert-only write. Built from the statement's OWN column
|
|
309
|
+
// list, not a hardcoded row: were an acceptance column added to it, the
|
|
310
|
+
// row below would really carry it and the assertions would really fail.
|
|
311
|
+
if (/ON CONFLICT \(github_id\) DO NOTHING/i.test(sql)) {
|
|
312
|
+
if (existing) return { rows: [], rowCount: 0 };
|
|
313
|
+
const row = { github_id: id, github_login: params[1], account_visibility: 'public', hide_stats: false, handle: 'h' };
|
|
314
|
+
for (const col of ACCEPTANCE_COLUMNS) {
|
|
315
|
+
const i = cols.indexOf(col);
|
|
316
|
+
row[col] = i === -1 ? null : evalAcceptance(values[i], { col, existing: null, params, now: clock });
|
|
317
|
+
}
|
|
318
|
+
const ps = /^'(\w+)'$/.exec(values[cols.indexOf('profile_state')] || '');
|
|
319
|
+
assert.ok(ps, 'the insert-only write stopped stating its profile_state literally — update the interpreter');
|
|
320
|
+
row.profile_state = ps[1];
|
|
321
|
+
accounts.set(id, row);
|
|
322
|
+
return { rows: [], rowCount: 1 };
|
|
323
|
+
}
|
|
324
|
+
|
|
285
325
|
const activate = params[4] === true;
|
|
286
326
|
let row;
|
|
287
327
|
if (!existing) {
|
|
@@ -289,7 +329,7 @@ function makePool() {
|
|
|
289
329
|
for (const col of ACCEPTANCE_COLUMNS) {
|
|
290
330
|
const i = cols.indexOf(col);
|
|
291
331
|
assert.notEqual(i, -1, `the upsert stopped writing ${col} on insert`);
|
|
292
|
-
row[col] = evalAcceptance(values[i], { col,
|
|
332
|
+
row[col] = evalAcceptance(values[i], { col, existing: null, params, now: clock });
|
|
293
333
|
}
|
|
294
334
|
row.profile_state = activate ? 'active' : 'provisional';
|
|
295
335
|
accounts.set(id, row);
|
|
@@ -305,7 +345,7 @@ function makePool() {
|
|
|
305
345
|
const oldRow = { ...row };
|
|
306
346
|
for (const col of ACCEPTANCE_COLUMNS) {
|
|
307
347
|
assert.ok(assigns.has(col), `the conflict branch stopped writing ${col} — an account that signs in again would never record its acceptance`);
|
|
308
|
-
row[col] = evalAcceptance(assigns.get(col), { col,
|
|
348
|
+
row[col] = evalAcceptance(assigns.get(col), { col, existing: oldRow[col], oldRow, params, now: clock });
|
|
309
349
|
}
|
|
310
350
|
if (activate) row.profile_state = 'active';
|
|
311
351
|
}
|
|
@@ -392,7 +432,7 @@ const authorize = (qs = '') => request(
|
|
|
392
432
|
);
|
|
393
433
|
|
|
394
434
|
// ===========================================================================
|
|
395
|
-
// 3. The capture point — the
|
|
435
|
+
// 3. The capture point — the project sign-in leg (/sso/authorize), and nothing else
|
|
396
436
|
// ===========================================================================
|
|
397
437
|
|
|
398
438
|
console.log('\nthe capture point (only the hub can say a human accepted):');
|
|
@@ -405,7 +445,7 @@ await ta('CONTROL: a hub-session authorize records the acceptance', async () =>
|
|
|
405
445
|
assert.equal(res.status, 302, 'the authorize leg should hand back a code');
|
|
406
446
|
const row = pool.accounts.get('4242');
|
|
407
447
|
assert.ok(row, 'no account was written at all — the rest of this section would pass vacuously');
|
|
408
|
-
assert.equal(row.terms_accepted_at, NOW, 'the acceptance was not stamped by the
|
|
448
|
+
assert.equal(row.terms_accepted_at, NOW, 'the acceptance was not stamped by the platform-account sign-in to a project');
|
|
409
449
|
assert.equal(row.terms_version, TERMS_VERSION, 'the record must name WHICH text was accepted');
|
|
410
450
|
assert.equal(row.terms_accepted_source, 'signin',
|
|
411
451
|
'and HOW it was given — a sign-in, queryably distinct from a grandfather (task 1003360)');
|
|
@@ -494,13 +534,19 @@ await ta('a NON-activating write never touches an existing acceptance', async ()
|
|
|
494
534
|
pool.setClock(NOW);
|
|
495
535
|
await authorize();
|
|
496
536
|
|
|
497
|
-
//
|
|
498
|
-
//
|
|
537
|
+
// Neither non-activating writer may create, restamp, or clear an acceptance:
|
|
538
|
+
// ensureAccount (the federated membership check-in, insert-only) and the
|
|
539
|
+
// non-activating upsertAccount (POST /sso/device/poll and PATCH /me/handle),
|
|
540
|
+
// whose conflict branch really runs here — its "leave it alone" ELSE half.
|
|
499
541
|
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
500
542
|
pool.setClock(LATER);
|
|
501
543
|
await pi.ensureAccount({ githubId: '4242', githubLogin: 'octo', displayName: 'x', avatarUrl: null }, { pool });
|
|
502
544
|
assert.equal(pool.accounts.get('4242').terms_accepted_at, NOW,
|
|
503
545
|
'a federated check-in changed the acceptance record');
|
|
546
|
+
await pi.upsertAccount({ githubId: '4242', githubLogin: 'octo', displayName: 'x', avatarUrl: null }, { pool });
|
|
547
|
+
const row = pool.accounts.get('4242');
|
|
548
|
+
assert.deepEqual([row.terms_accepted_at, row.terms_version, row.terms_accepted_source], [NOW, TERMS_VERSION, 'signin'],
|
|
549
|
+
'a non-activating upsert (device poll, handle change) changed the acceptance record');
|
|
504
550
|
});
|
|
505
551
|
|
|
506
552
|
await ta('a federated check-in creates an account with NO acceptance at all', async () => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Proves migration platform_identity_024 against real SQL: an account the hub
|
|
5
5
|
// had already witnessed is published immediately after the migration, without
|
|
6
6
|
// signing in again; its acceptance is queryably distinct from a real sign-in;
|
|
7
|
-
// the grandfather runs once; and the owner's next
|
|
7
|
+
// the grandfather runs once; and the owner's next project sign-in replaces it.
|
|
8
8
|
// tests/terms_acceptance.mjs pins the same rules DB-free (the statement shapes
|
|
9
9
|
// and the executed conflict branch); only a transaction engine can run the
|
|
10
10
|
// migration's DO block and its UPDATEs.
|
|
@@ -106,7 +106,7 @@ try {
|
|
|
106
106
|
await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
|
|
107
107
|
ok((await row(LATE)).terms_accepted_at === null, 'replaying 024 grandfathers nobody new — the grandfather was an event, not a rule');
|
|
108
108
|
|
|
109
|
-
// The owner's next
|
|
109
|
+
// The owner's next project sign-in replaces the grandfather with their own acceptance.
|
|
110
110
|
await pi.upsertAccount({ githubId: LEGACY, githubLogin: 'legacy-b', activate: true }, { pool: client });
|
|
111
111
|
const after = await row(LEGACY);
|
|
112
112
|
ok(after.terms_accepted_source === 'signin', 'the next sign-in replaces the grandfather with a signin');
|