|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#21669] A permission set the environment CLONED is not reported, on the next |
| 4 | +// boot, as a package declaration with no owner — over the real showcase |
| 5 | +// composition, across a cold boot on one database file. |
| 6 | +// |
| 7 | +// ## What was broken |
| 8 | +// |
| 9 | +// The declared-permission boot loop (`bootstrapDeclaredPermissions`, |
| 10 | +// plugin-security) walks every `permission` item in the engine registry. That |
| 11 | +// registry holds more than package declarations: `loadMetaFromDb` hydrates every |
| 12 | +// env-wide `sys_metadata` `permission` row into the same collection, and a set |
| 13 | +// made with Setup's Clone action is one — with no package id, because it has |
| 14 | +// none. The loop judged "unowned" before it looked at the row, so every boot |
| 15 | +// (and every `metadata:reloaded`) logged |
| 16 | +// `[permission_set_declaration_unowned] … the Setup admin surface … cannot see |
| 17 | +// this set` for a set whose `managed_by:'admin'` row Setup lists and edits. |
| 18 | +// Measured on `origin/main` before the fix through this file's own steps: one |
| 19 | +// such line naming the clone, `skippedUnowned: 1`. |
| 20 | +// |
| 21 | +// ## Why a booted stack, booted twice |
| 22 | +// |
| 23 | +// The unit pins (`bootstrap-declared-permissions.test.ts`, `[#21669]`) hand the |
| 24 | +// loop the item shape this boot produces. What they cannot show is that the |
| 25 | +// real boot puts the clone into the walk at all — that is a property of the |
| 26 | +// hydration, not of the loop, and only a restart on the same file shows it. So |
| 27 | +// this file asserts the walk holds the clone with no package id BEFORE it |
| 28 | +// asserts the warning is absent; without that precondition the absence would be |
| 29 | +// vacuous. |
| 30 | +// |
| 31 | +// ## The clone is made the way the Setup dialog makes it |
| 32 | +// |
| 33 | +// The payload is built from the shipped `clone_permission_set` action's own |
| 34 | +// declaration — its `target`, its `bodyExtra` and its `params` (the typed |
| 35 | +// `label`/`name`, and every `defaultFromRow` facet copied from the base row) — |
| 36 | +// and POSTed to the data door as the signed-in admin. Read, never restated: a |
| 37 | +// facet added to the action reaches this pin without an edit here. |
| 38 | + |
| 39 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 40 | +import showcaseStack from '@objectstack/example-showcase'; |
| 41 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 42 | +import { securityObjects } from '@objectstack/plugin-security'; |
| 43 | +import { fileURLToPath } from 'node:url'; |
| 44 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 45 | +import { tmpdir } from 'node:os'; |
| 46 | +import { join } from 'node:path'; |
| 47 | + |
| 48 | +/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ |
| 49 | +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); |
| 50 | +const SYS = { context: { isSystem: true } } as const; |
| 51 | +const TOKEN = 'permission_set_declaration_unowned'; |
| 52 | +/** A set the showcase package ships (`com.example.showcase`) — the clone's base. */ |
| 53 | +const BASE = 'showcase_contributor'; |
| 54 | +/** A name no package ships. */ |
| 55 | +const CLONE = 'showcase_contributor_local_21669'; |
| 56 | +const API_BASE = '/api/v1'; |
| 57 | + |
| 58 | +interface CloneParam { name?: string; field?: string; defaultFromRow?: boolean } |
| 59 | +interface CloneAction { method: string; target: string; bodyExtra?: Record<string, unknown>; params: CloneParam[] } |
| 60 | + |
| 61 | +/** The shipped Clone action, read off the object the security plugin registers. */ |
| 62 | +function cloneAction(): CloneAction { |
| 63 | + const object = (securityObjects as any[]).find((o) => o?.name === 'sys_permission_set'); |
| 64 | + const action = (object?.actions ?? []).find((a: any) => a?.name === 'clone_permission_set'); |
| 65 | + if (!action) throw new Error('clone_permission_set is missing from sys_permission_set.actions'); |
| 66 | + return action as CloneAction; |
| 67 | +} |
| 68 | + |
| 69 | +/** Every line the process writes while `run` is in flight — the kernel logger writes to the streams. */ |
| 70 | +async function captureOutput<T>(run: () => Promise<T>): Promise<{ value: T; lines: string[] }> { |
| 71 | + const lines: string[] = []; |
| 72 | + const stdout = process.stdout.write.bind(process.stdout); |
| 73 | + const stderr = process.stderr.write.bind(process.stderr); |
| 74 | + const warn = console.warn; |
| 75 | + (process.stdout as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stdout(chunk as any, ...rest); }; |
| 76 | + (process.stderr as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stderr(chunk as any, ...rest); }; |
| 77 | + console.warn = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); warn(...args); }; |
| 78 | + try { |
| 79 | + return { value: await run(), lines }; |
| 80 | + } finally { |
| 81 | + (process.stdout as any).write = stdout; |
| 82 | + (process.stderr as any).write = stderr; |
| 83 | + console.warn = warn; |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +describe('[#21669] a cloned permission set boots without the unowned-declaration warning (showcase, cold boot)', () => { |
| 88 | + let prevCwd: string; |
| 89 | + let dir: string; |
| 90 | + let dbFile: string; |
| 91 | + let stack: VerifyStack | undefined; |
| 92 | + /** Everything boot 2 wrote. */ |
| 93 | + let bootLines: string[] = []; |
| 94 | + /** Boot 2's engine. */ |
| 95 | + let ql: any; |
| 96 | + |
| 97 | + beforeAll(async () => { |
| 98 | + prevCwd = process.cwd(); |
| 99 | + process.chdir(SHOWCASE_DIR); |
| 100 | + dir = mkdtempSync(join(tmpdir(), 'dogfood-21669-')); |
| 101 | + dbFile = join(dir, 'showcase.db'); |
| 102 | + |
| 103 | + // ── boot 1: clone a packaged set through the Setup dialog's own path ── |
| 104 | + stack = await bootStack(showcaseStack, { databaseFile: dbFile }); |
| 105 | + const token = await stack.signIn(); |
| 106 | + const engine: any = await stack.kernel.getServiceAsync('objectql'); |
| 107 | + const [base] = await engine.find('sys_permission_set', { where: { name: BASE }, limit: 1 }, SYS); |
| 108 | + expect(base?.managed_by, 'the base is a package-declared set').toBe('package'); |
| 109 | + |
| 110 | + const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`); |
| 111 | + const served: any = await read.json(); |
| 112 | + const row = served?.record ?? served?.data?.record ?? served?.data; |
| 113 | + const action = cloneAction(); |
| 114 | + const body: Record<string, unknown> = { ...(action.bodyExtra ?? {}) }; |
| 115 | + for (const p of action.params) { |
| 116 | + if (p.name === 'label') body.label = 'Contributor (local)'; |
| 117 | + else if (p.name === 'name') body.name = CLONE; |
| 118 | + else if (p.field && p.defaultFromRow) body[p.field] = row?.[p.field]; |
| 119 | + } |
| 120 | + expect(action.target.startsWith(API_BASE), action.target).toBe(true); |
| 121 | + const created = await stack.apiAs(token, action.method, action.target.slice(API_BASE.length), body); |
| 122 | + expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); |
| 123 | + |
| 124 | + // Harness health: the clone is the environment's own set — the row |
| 125 | + // Setup lists, and the env-wide metadata row the next boot hydrates. |
| 126 | + const [clone] = await engine.find('sys_permission_set', { where: { name: CLONE }, limit: 1 }, SYS); |
| 127 | + expect({ managed_by: clone?.managed_by, package_id: clone?.package_id ?? null }) |
| 128 | + .toEqual({ managed_by: 'admin', package_id: null }); |
| 129 | + const stored = await engine.find('sys_metadata', { where: { type: 'permission', name: CLONE } }, SYS); |
| 130 | + expect(stored.map((r: any) => ({ state: r.state, organization_id: r.organization_id ?? null }))) |
| 131 | + .toEqual([{ state: 'active', organization_id: null }]); |
| 132 | + await stack.stop(); |
| 133 | + stack = undefined; |
| 134 | + |
| 135 | + // ── boot 2: same file, nothing authored ─────────────────────────────── |
| 136 | + const second = await captureOutput(() => bootStack(showcaseStack, { databaseFile: dbFile })); |
| 137 | + stack = second.value; |
| 138 | + bootLines = second.lines; |
| 139 | + ql = await stack.kernel.getServiceAsync('objectql'); |
| 140 | + }, 300_000); |
| 141 | + |
| 142 | + afterAll(async () => { |
| 143 | + await stack?.stop(); |
| 144 | + if (prevCwd) process.chdir(prevCwd); |
| 145 | + if (dir) rmSync(dir, { recursive: true, force: true }); |
| 146 | + }); |
| 147 | + |
| 148 | + it('precondition: the boot loop walks the clone, with no package id', () => { |
| 149 | + // ⛔ Without this the absence below proves nothing: a boot that stopped |
| 150 | + // hydrating the clone would also log no warning for it. |
| 151 | + const item = (ql.registry.listItems('permission') ?? []).find((i: any) => i?.name === CLONE); |
| 152 | + expect(item, `${CLONE} is in the registry the loop walks`).toBeDefined(); |
| 153 | + expect(item._packageId ?? item.packageId ?? null).toBeNull(); |
| 154 | + }); |
| 155 | + |
| 156 | + it('control: the capture saw the loop report its pass', () => { |
| 157 | + // The seeding pass logs one summary line per run, through the same |
| 158 | + // logger the warning used — so an empty capture cannot pass the next case. |
| 159 | + expect(bootLines.some((l) => l.includes('declared permission sets seeded into sys_permission_set'))).toBe(true); |
| 160 | + }); |
| 161 | + |
| 162 | + it('the cloned set is not reported as an unowned declaration', () => { |
| 163 | + const unowned = bootLines.filter((l) => l.includes(TOKEN) && l.includes(CLONE)); |
| 164 | + expect(unowned).toEqual([]); |
| 165 | + }); |
| 166 | +}); |
0 commit comments