|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The ADR-0131 D10 `--apply` on walled organization-less self-registration |
| 5 | + * grants: before the grant waited for the registrant's first membership (ruling |
| 6 | + * record 6257510973 item 1, Q1 A), a walled sign-up wrote it with no |
| 7 | + * organization. The ceremony gives such a grant its user's sole membership's |
| 8 | + * organization — where the runtime settles the grant today — and reports the |
| 9 | + * rest, never guessing (D10 fate 4, ruling C on the C8 decision). |
| 10 | + * |
| 11 | + * The fixture is the measured legacy shape: one NULL `member_default` grant per |
| 12 | + * self-registrant (id, user and set only), beside a grant already stamped. |
| 13 | + * |
| 14 | + * - Walled (`isolated`, `group`): the one-membership user's grant takes that |
| 15 | + * organization; the grants of a user in two organizations, of a user in |
| 16 | + * none, and of a user whose set an administrator already granted in the |
| 17 | + * sole organization are reported and hold certification; nothing is |
| 18 | + * deleted. |
| 19 | + * - `single`: the Default Organization takes every NULL grant, as before. |
| 20 | + */ |
| 21 | + |
| 22 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 23 | +import Database from 'better-sqlite3'; |
| 24 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 25 | +import { tmpdir } from 'node:os'; |
| 26 | +import { join } from 'node:path'; |
| 27 | +import { buildOrganizationOwnershipPlan, inventoryDigest, type OrganizationOwnershipPlan } from './organization-ownership-plan.js'; |
| 28 | +import { |
| 29 | + applyOrganizationOwnership, |
| 30 | + reviewedConflictsOf, |
| 31 | + type ApplyExecutor, |
| 32 | + type CeremonyCheckpoint, |
| 33 | + type CeremonyLedgerPort, |
| 34 | + type ColumnDropPort, |
| 35 | + type OrganizationOwnershipApplyReport, |
| 36 | +} from './organization-ownership-apply.js'; |
| 37 | + |
| 38 | +let dir: string; |
| 39 | +let db: Database.Database; |
| 40 | +/** Every write statement the ceremony issued, in order. */ |
| 41 | +let writes: string[]; |
| 42 | + |
| 43 | +function executor(): ApplyExecutor { |
| 44 | + return { |
| 45 | + dialect: 'sqlite', |
| 46 | + client: 'better-sqlite3', |
| 47 | + async query(sql, params = []) { |
| 48 | + return db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[]; |
| 49 | + }, |
| 50 | + async execute(sql, params = []) { |
| 51 | + writes.push(sql); |
| 52 | + db.prepare(sql).run(...(params as unknown[])); |
| 53 | + }, |
| 54 | + }; |
| 55 | +} |
| 56 | + |
| 57 | +const columnDrops: ColumnDropPort = { |
| 58 | + async drop(columns) { |
| 59 | + return columns.map(({ table, column }) => { |
| 60 | + db.exec(`ALTER TABLE "${table}" DROP COLUMN "${column}"`); |
| 61 | + return { table, column, outcome: 'dropped' as const }; |
| 62 | + }); |
| 63 | + }, |
| 64 | +}; |
| 65 | + |
| 66 | +function ledger(): CeremonyLedgerPort { |
| 67 | + let stored: CeremonyCheckpoint | null = null; |
| 68 | + return { |
| 69 | + async read() { |
| 70 | + return stored ? (JSON.parse(JSON.stringify(stored)) as CeremonyCheckpoint) : null; |
| 71 | + }, |
| 72 | + async write(checkpoint) { |
| 73 | + stored = JSON.parse(JSON.stringify(checkpoint)) as CeremonyCheckpoint; |
| 74 | + }, |
| 75 | + }; |
| 76 | +} |
| 77 | + |
| 78 | +const database = (): string => join(dir, 'fixture.db'); |
| 79 | + |
| 80 | +const plan = (posture: string): Promise<OrganizationOwnershipPlan> => |
| 81 | + buildOrganizationOwnershipPlan({ reader: executor(), posture, database: database() }); |
| 82 | + |
| 83 | +/** Plan, then apply that reviewed plan, as the command does. */ |
| 84 | +async function apply(posture: string, l = ledger()): Promise<OrganizationOwnershipApplyReport> { |
| 85 | + const reviewed = await plan(posture); |
| 86 | + return applyOrganizationOwnership({ |
| 87 | + executor: executor(), |
| 88 | + posture, |
| 89 | + database: database(), |
| 90 | + reviewedPlan: { |
| 91 | + file: 'plan.json', |
| 92 | + generatedAt: reviewed.generatedAt, |
| 93 | + database: database(), |
| 94 | + inventoryDigest: inventoryDigest(), |
| 95 | + posture, |
| 96 | + conflicts: reviewedConflictsOf(reviewed), |
| 97 | + }, |
| 98 | + // The grants' sets are declared: the rewrite verifies each name resolves. |
| 99 | + catalog: { |
| 100 | + async resolve(type, name) { |
| 101 | + return type === 'permission' && ['member_default', 'viewer_readonly'].includes(name) ? { definition: { name } } : undefined; |
| 102 | + }, |
| 103 | + }, |
| 104 | + door: { |
| 105 | + async save(type, name) { |
| 106 | + throw new Error(`no environment save is expected here (${type} ${name})`); |
| 107 | + }, |
| 108 | + }, |
| 109 | + templates: { async find() { return undefined; } }, |
| 110 | + views: { async list() { return []; } }, |
| 111 | + lock: { async verdict() { return { editable: true }; } }, |
| 112 | + columnDrops, |
| 113 | + ledger: l, |
| 114 | + now: () => new Date('2026-10-19T00:00:00Z'), |
| 115 | + }); |
| 116 | +} |
| 117 | + |
| 118 | +const grants = (): Record<string, unknown>[] => |
| 119 | + db.prepare(`SELECT id, organization_id FROM sys_user_permission_set ORDER BY id`).all() as Record<string, unknown>[]; |
| 120 | + |
| 121 | +beforeEach(() => { |
| 122 | + dir = mkdtempSync(join(tmpdir(), 'os-d10-sole-membership-')); |
| 123 | + db = new Database(database()); |
| 124 | + writes = []; |
| 125 | + db.exec(` |
| 126 | + CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT); |
| 127 | + INSERT INTO sys_organization VALUES ('org_default', 'default'), ('org_a', 'acme'), ('org_b', 'beta'); |
| 128 | + CREATE TABLE sys_user (id TEXT PRIMARY KEY, name TEXT, email TEXT); |
| 129 | + INSERT INTO sys_user VALUES ('usr_one', 'One', 'one@example.com'), ('usr_two', 'Two', 'two@example.com'), |
| 130 | + ('usr_none', 'None', 'none@example.com'), ('usr_held', 'Held', 'held@example.com'); |
| 131 | + CREATE TABLE sys_member (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT, role TEXT); |
| 132 | + INSERT INTO sys_member VALUES |
| 133 | + ('mem_one', 'org_a', 'usr_one', 'member'), |
| 134 | + ('mem_two_a', 'org_a', 'usr_two', 'member'), |
| 135 | + ('mem_two_b', 'org_b', 'usr_two', 'member'), |
| 136 | + ('mem_held', 'org_a', 'usr_held', 'member'); |
| 137 | + CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT, permission_set TEXT); |
| 138 | + CREATE UNIQUE INDEX uniq_grant ON sys_user_permission_set (user_id, permission_set, COALESCE(organization_id, '__global__')); |
| 139 | + INSERT INTO sys_user_permission_set VALUES |
| 140 | + ('ups_one', NULL, 'usr_one', 'member_default'), |
| 141 | + ('ups_two', NULL, 'usr_two', 'member_default'), |
| 142 | + ('ups_none', NULL, 'usr_none', 'member_default'), |
| 143 | + ('ups_held', NULL, 'usr_held', 'member_default'), |
| 144 | + ('ups_held_admin', 'org_a', 'usr_held', 'member_default'), |
| 145 | + ('ups_stamped', 'org_b', 'usr_two', 'viewer_readonly'); |
| 146 | + `); |
| 147 | +}); |
| 148 | + |
| 149 | +afterEach(() => { |
| 150 | + db.close(); |
| 151 | + rmSync(dir, { recursive: true, force: true }); |
| 152 | +}); |
| 153 | + |
| 154 | +describe.each(['isolated', 'group'])('walled (%s): a NULL self-registration grant takes its user\'s sole membership\'s organization', (posture) => { |
| 155 | + it('the plan attributes the one-membership grant and reports the others, each with its reason', async () => { |
| 156 | + const reviewed = await plan(posture); |
| 157 | + const table = reviewed.tables.find((t) => t.object === 'sys_user_permission_set')!; |
| 158 | + expect(table.fateCounts).toMatchObject({ rowsToAttribute: 1, unattributableRows: 3 }); |
| 159 | + expect(table.attribution).toEqual({ 'sole-membership:user_id->sys_member': 1 }); |
| 160 | + expect(table.unattributable).toEqual([ |
| 161 | + { id: 'ups_held', reason: expect.stringMatching(/user usr_held's sole membership names org_a, where row ups_held_admin already holds this key$/) }, |
| 162 | + { id: 'ups_none', reason: expect.stringMatching(/user usr_none is a member of no organization$/) }, |
| 163 | + { id: 'ups_two', reason: expect.stringMatching(/user usr_two is a member of 2 organizations, so the grant's is not derivable$/) }, |
| 164 | + ]); |
| 165 | + }); |
| 166 | + |
| 167 | + it('⭐ --apply writes the one-membership grant\'s organization; the reported grants stay NULL, hold certification, and none is deleted', async () => { |
| 168 | + const report = await apply(posture); |
| 169 | + expect(report.complete).toBe(true); |
| 170 | + expect(report.blocking).toBe(3); |
| 171 | + const table = report.tables.find((t) => t.object === 'sys_user_permission_set')!; |
| 172 | + expect(table).toMatchObject({ attributed: 1, remaining: 3, held: { rows: 3 } }); |
| 173 | + expect(grants()).toEqual([ |
| 174 | + { id: 'ups_held', organization_id: null }, |
| 175 | + { id: 'ups_held_admin', organization_id: 'org_a' }, |
| 176 | + { id: 'ups_none', organization_id: null }, |
| 177 | + { id: 'ups_one', organization_id: 'org_a' }, |
| 178 | + { id: 'ups_stamped', organization_id: 'org_b' }, |
| 179 | + { id: 'ups_two', organization_id: null }, |
| 180 | + ]); |
| 181 | + expect(writes.filter((w) => /^DELETE/i.test(w) && /sys_user_permission_set/.test(w))).toEqual([]); |
| 182 | + }); |
| 183 | +}); |
| 184 | + |
| 185 | +describe('single: the Default Organization takes every NULL grant, as before', () => { |
| 186 | + it('no grant is attributed through a membership', async () => { |
| 187 | + const reviewed = await plan('single'); |
| 188 | + const table = reviewed.tables.find((t) => t.object === 'sys_user_permission_set')!; |
| 189 | + expect(table.attribution).toEqual({ 'default-organization': 4 }); |
| 190 | + expect(table.unattributable).toEqual([]); |
| 191 | + }); |
| 192 | +}); |
0 commit comments