|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The ownership ceremony's catalog is the one `os migrate organization-ownership |
| 5 | + * --apply` boots: the deployment's served composition, every platform provider |
| 6 | + * in it composed for its declarations only (`init()` runs; `start()` and every |
| 7 | + * hook do not). `authored-conversion` and the rewrite gate resolve the position |
| 8 | + * bindings an older release wrote against THAT catalog, so it must hold the |
| 9 | + * built-in positions and the `everyone` baseline a started kernel serves. |
| 10 | + * |
| 11 | + * ## The fixture |
| 12 | + * |
| 13 | + * The catalog: the apply command's boot (`bootSchemaStack` with its four |
| 14 | + * composition flags) over a compiled artifact, with an auth secret set so the |
| 15 | + * security plugin is composed, read through core's security catalog reader as |
| 16 | + * the command reads it. The database: the 17.x catalog rows every environment |
| 17 | + * database holds — the seeded `everyone` position, the seeded `member_default` |
| 18 | + * set and the seeded binding between them — plus the control: a seeded binding |
| 19 | + * that names a position no catalog declares. |
| 20 | + * |
| 21 | + * ## What each case measures |
| 22 | + * |
| 23 | + * The catalog holds the six built-ins and `everyone`'s baseline. Under |
| 24 | + * `single`, `authored-conversion` blocks on the control and on nothing else; |
| 25 | + * under a wall it reports, and the rewrite gate blocks on the control and on |
| 26 | + * nothing else. With the declarations made from `start()` and `kernel:ready`, |
| 27 | + * which this boot never runs, both block on `everyone` as well. |
| 28 | + */ |
| 29 | + |
| 30 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 31 | +import Database from 'better-sqlite3'; |
| 32 | +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; |
| 33 | +import { tmpdir } from 'node:os'; |
| 34 | +import { join } from 'node:path'; |
| 35 | +import { createSecurityCatalogReader } from '@objectstack/core'; |
| 36 | +import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_NAMES } from '@objectstack/spec'; |
| 37 | +import { bootSchemaStack, type SchemaStack } from './schema-migrate.js'; |
| 38 | +import { |
| 39 | + applyOrganizationOwnership, |
| 40 | + type CatalogPort, |
| 41 | + type CeremonyCheckpoint, |
| 42 | + type OrganizationOwnershipApplyReport, |
| 43 | + type StepReport, |
| 44 | +} from './organization-ownership-apply.js'; |
| 45 | + |
| 46 | +// [#10126] Pay the first load of what the boot reaches through dynamic |
| 47 | +// `import()`s at MODULE LOAD, outside the clocked hook. |
| 48 | +import '@objectstack/runtime'; |
| 49 | +import '@objectstack/metadata-protocol'; |
| 50 | +import '@objectstack/platform-objects/plugin'; |
| 51 | +import '@objectstack/plugin-security'; |
| 52 | + |
| 53 | +const CASE_TIMEOUT_MS = 180_000; |
| 54 | +const ENV_KEYS = ['OS_ARTIFACT_PATH', 'OS_DATABASE_URL', 'DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'NODE_ENV', |
| 55 | + 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ENVIRONMENT_ID', 'OS_MULTI_ORG_ENABLED'] as const; |
| 56 | +const savedEnv: Record<string, string | undefined> = {}; |
| 57 | + |
| 58 | +let dir: string; |
| 59 | +let stack: SchemaStack; |
| 60 | +let catalog: CatalogPort; |
| 61 | + |
| 62 | +beforeAll(async () => { |
| 63 | + for (const key of ENV_KEYS) savedEnv[key] = process.env[key]; |
| 64 | + for (const key of ENV_KEYS) delete process.env[key]; |
| 65 | + process.env.NODE_ENV = 'production'; |
| 66 | + process.env.OS_AUTH_SECRET = 'organization-ownership-catalog-'.padEnd(40, 'x'); |
| 67 | + dir = mkdtempSync(join(tmpdir(), 'os-ownership-catalog-')); |
| 68 | + mkdirSync(join(dir, 'dist'), { recursive: true }); |
| 69 | + process.env.OS_HOME = join(dir, 'home'); |
| 70 | + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); |
| 71 | + writeFileSync(process.env.OS_ARTIFACT_PATH, JSON.stringify({ |
| 72 | + manifest: { id: 'com.example.ownership-catalog', name: 'ownership-catalog', version: '1.0.0', type: 'app' }, |
| 73 | + })); |
| 74 | + // The apply command's boot, flag for flag. |
| 75 | + stack = await bootSchemaStack({ |
| 76 | + jsonOutput: false, |
| 77 | + databaseUrl: `file:${join(dir, 'boot.db')}`, |
| 78 | + projectRoot: dir, |
| 79 | + deferSchemaDdl: true, |
| 80 | + composeHostStack: true, |
| 81 | + composeServedPlatform: true, |
| 82 | + composeAuthGatedSecurity: true, |
| 83 | + }); |
| 84 | + const kernel = stack.kernel as { getService(name: string): unknown }; |
| 85 | + const engine = kernel.getService('objectql') as { registry: never }; |
| 86 | + catalog = createSecurityCatalogReader({ registry: engine.registry, metadata: kernel.getService('metadata') as never }); |
| 87 | +}, CASE_TIMEOUT_MS); |
| 88 | + |
| 89 | +afterAll(async () => { |
| 90 | + await stack?.shutdown(); |
| 91 | + for (const [key, value] of Object.entries(savedEnv)) { |
| 92 | + if (value === undefined) delete process.env[key]; |
| 93 | + else process.env[key] = value; |
| 94 | + } |
| 95 | + rmSync(dir, { recursive: true, force: true }); |
| 96 | +}); |
| 97 | + |
| 98 | +/** The 17.x catalog rows, and the control binding to a position no catalog declares. */ |
| 99 | +function v17Database(name: string): Database.Database { |
| 100 | + const db = new Database(join(dir, `${name}.db`)); |
| 101 | + db.exec(` |
| 102 | + CREATE TABLE sys_position (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, managed_by TEXT, |
| 103 | + delegatable INTEGER, active INTEGER); |
| 104 | + INSERT INTO sys_position VALUES |
| 105 | + ('pos_everyone', NULL, 'everyone', 'Everyone', 'platform', 0, 1), |
| 106 | + ('pos_gone', NULL, 'zz_undeclared', 'Undeclared', 'platform', 0, 1); |
| 107 | + CREATE TABLE sys_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, managed_by TEXT, |
| 108 | + active INTEGER, object_permissions TEXT, system_permissions TEXT); |
| 109 | + INSERT INTO sys_permission_set VALUES ('ps_member_default', NULL, 'member_default', 'Member', 'platform', 1, '{}', '[]'); |
| 110 | + CREATE TABLE sys_position_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, position_id TEXT, |
| 111 | + permission_set_id TEXT); |
| 112 | + INSERT INTO sys_position_permission_set VALUES |
| 113 | + ('bind_everyone', NULL, 'pos_everyone', 'ps_member_default'), |
| 114 | + ('bind_gone', NULL, 'pos_gone', 'ps_member_default'); |
| 115 | + `); |
| 116 | + return db; |
| 117 | +} |
| 118 | + |
| 119 | +async function ceremony(posture: string, db: Database.Database): Promise<OrganizationOwnershipApplyReport> { |
| 120 | + let checkpoint: CeremonyCheckpoint | null = null; |
| 121 | + return applyOrganizationOwnership({ |
| 122 | + executor: { |
| 123 | + client: 'better-sqlite3', |
| 124 | + query: async (sql, params = []) => db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[], |
| 125 | + execute: async (sql, params = []) => { db.prepare(sql).run(...(params as unknown[])); }, |
| 126 | + }, |
| 127 | + posture, |
| 128 | + database: 'v17', |
| 129 | + catalog, |
| 130 | + door: { save: async (type, name) => { throw new Error(`the door was asked to save ${type} ${name}`); } }, |
| 131 | + templates: { find: async () => undefined }, |
| 132 | + columnDrops: { drop: async (tables) => tables.map((table) => ({ table, outcome: 'not-proposed' as const })) }, |
| 133 | + ledger: { read: async () => checkpoint, write: async (cp) => { checkpoint = cp; } }, |
| 134 | + }); |
| 135 | +} |
| 136 | + |
| 137 | +const step = (report: OrganizationOwnershipApplyReport, name: StepReport['step']): StepReport => |
| 138 | + report.steps.find((s) => s.step === name)!; |
| 139 | + |
| 140 | +describe('the ownership ceremony resolves bindings against what a started kernel serves', () => { |
| 141 | + it('the apply boot\'s catalog holds the six built-in positions, and everyone names member_default', async () => { |
| 142 | + for (const name of [...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]) { |
| 143 | + expect((await catalog.resolve('position', name))?.definition.name, name).toBe(name); |
| 144 | + } |
| 145 | + expect((await catalog.resolve('position', 'everyone'))?.definition.permissionSets).toContain('member_default'); |
| 146 | + }); |
| 147 | + |
| 148 | + it('single: authored-conversion blocks on the undeclared position, and not on everyone', async () => { |
| 149 | + const report = await ceremony('single', v17Database('single')); |
| 150 | + const conversion = step(report, 'authored-conversion'); |
| 151 | + expect(conversion.status).toBe('blocked'); |
| 152 | + expect(conversion.blockers).toHaveLength(1); |
| 153 | + expect(conversion.blockers[0]).toContain('position zz_undeclared'); |
| 154 | + expect(conversion.blockers.join('\n')).not.toContain('everyone'); |
| 155 | + }, CASE_TIMEOUT_MS); |
| 156 | + |
| 157 | + it('walled: the rewrite gate blocks on the undeclared position, and not on everyone', async () => { |
| 158 | + const report = await ceremony('isolated', v17Database('walled')); |
| 159 | + expect(step(report, 'authored-conversion').status).toBe('done'); |
| 160 | + const rewrite = step(report, 'rewrite'); |
| 161 | + expect(rewrite.status).toBe('blocked'); |
| 162 | + expect(rewrite.blockers).toHaveLength(1); |
| 163 | + expect(rewrite.blockers[0]).toContain('position zz_undeclared'); |
| 164 | + expect(rewrite.blockers.join('\n')).not.toContain('everyone'); |
| 165 | + }, CASE_TIMEOUT_MS); |
| 166 | +}); |
0 commit comments