|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `POST /packages/:id/adopt-orphans` — the ADR-0070 D2 read-only rule at the |
| 5 | + * orphan-adoption door (#24813). |
| 6 | + * |
| 7 | + * ## The defect |
| 8 | + * |
| 9 | + * The arm asked `requireManageMetadata` and the walled refusal, and then called |
| 10 | + * `protocol.reassignOrphanedMetadata({ targetPackageId: id })`, which asks no |
| 11 | + * writability question of its own: it rebinds every package-less |
| 12 | + * `sys_metadata` row to whatever id it is handed. So a `manage_metadata` |
| 13 | + * holder could bind the environment's loose items INTO a package the |
| 14 | + * deployment ships — booted by code, installed from the marketplace, or a |
| 15 | + * stored row whose manifest `scope` is `system` / `cloud` — and the door |
| 16 | + * answered `200` with the rows it moved. Every other state-changing arm on the |
| 17 | + * domain (install-overwrite, disable, publish, revert, the manifest PATCH and |
| 18 | + * delete) refuses that same id `422 WRITABLE_PACKAGE_REQUIRED`. |
| 19 | + * |
| 20 | + * ## What is asserted, and why it is not the status alone |
| 21 | + * |
| 22 | + * The harm is the rebinding, so every refusal reads the rows back: the |
| 23 | + * orphans the real producer minted (a package-less `saveMetaItem`, which still |
| 24 | + * lands `package_id = NULL` today) are still package-less after the refusal, |
| 25 | + * and the protocol's `reassignOrphanedMetadata` was never called. The store is |
| 26 | + * REAL — better-sqlite3 `:memory:`, the real `sys_metadata*` objects, a real |
| 27 | + * `ObjectStackProtocolImplementation` over a real `ObjectQL` — so the rows are |
| 28 | + * the store's, not a double's. The code-booted and marketplace ids are booted |
| 29 | + * through the engine's own `registerApp`; the two scope-only ids are installed |
| 30 | + * the way `sys_packages` hydration installs a stored row (`source: |
| 31 | + * 'authored'`, no booted manifest), so their refusal can only come from the |
| 32 | + * scope signal. |
| 33 | + * |
| 34 | + * ## Order |
| 35 | + * |
| 36 | + * The gate sits where its siblings put it: after the capability gate and the |
| 37 | + * walled refusal (both `403`, both still answer first on a read-only id), and |
| 38 | + * before the protocol is resolved, so a read-only id answers `422` even on a |
| 39 | + * deployment whose protocol cannot adopt (`501` for a writable id there). |
| 40 | + * |
| 41 | + * ## The gate is not an outage |
| 42 | + * |
| 43 | + * CONTROL: a writable package still adopts (`200`, and the rows really move), |
| 44 | + * and an id nothing installed is writable by the predicate's own rule, so it |
| 45 | + * still reaches the protocol, which binds the orphans to it exactly as before. |
| 46 | + */ |
| 47 | + |
| 48 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 49 | +import { ObjectQL } from '@objectstack/objectql'; |
| 50 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 51 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 52 | +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; |
| 53 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 54 | + |
| 55 | +/** Booted by the deployment's code: `registerApp`, default source `'code'`. */ |
| 56 | +const CODE_BOOTED = 'com.acme.codepkg'; |
| 57 | +/** Installed from the marketplace: `registerApp(…, 'marketplace')`. */ |
| 58 | +const MARKETPLACE = 'com.acme.marketpkg'; |
| 59 | +/** A stored row whose manifest declares `scope: 'system'`; nothing booted it. */ |
| 60 | +const SYSTEM_SCOPED = 'com.acme.systempkg'; |
| 61 | +/** A stored row whose manifest declares `scope: 'cloud'`; nothing booted it. */ |
| 62 | +const CLOUD_SCOPED = 'com.acme.cloudpkg'; |
| 63 | +/** An org's own base. */ |
| 64 | +const AUTHORED = 'com.acme.authoredpkg'; |
| 65 | +/** An id nothing has installed. */ |
| 66 | +const UNKNOWN = 'com.acme.nopkg'; |
| 67 | +/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ |
| 68 | +const TEST_PACKAGE_ID = 'objectstack-test'; |
| 69 | + |
| 70 | +/** The loose items the real producer leaves behind. */ |
| 71 | +const ORPHANS = ['loose_grid', 'loose_board'] as const; |
| 72 | + |
| 73 | +const manifest = (id: string, extra: Record<string, unknown> = {}) => ({ |
| 74 | + id, |
| 75 | + name: id, |
| 76 | + version: '1.0.0', |
| 77 | + type: 'app', |
| 78 | + ...extra, |
| 79 | +}); |
| 80 | + |
| 81 | +const TASK = { |
| 82 | + name: 'task', |
| 83 | + label: 'Task', |
| 84 | + fields: { |
| 85 | + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, |
| 86 | + name: { name: 'name', type: 'text' as const, label: 'Name' }, |
| 87 | + }, |
| 88 | +}; |
| 89 | + |
| 90 | +const view = (name: string) => ({ |
| 91 | + name, |
| 92 | + label: name, |
| 93 | + object: 'task', |
| 94 | + viewKind: 'list', |
| 95 | + columns: [{ field: 'name', label: 'Name' }], |
| 96 | +}); |
| 97 | + |
| 98 | +const silentLogger: any = { |
| 99 | + debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {}, |
| 100 | + child() { return silentLogger; }, |
| 101 | +}; |
| 102 | + |
| 103 | +const liveEngines: ObjectQL[] = []; |
| 104 | +afterEach(async () => { |
| 105 | + while (liveEngines.length) { |
| 106 | + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } |
| 107 | + } |
| 108 | +}); |
| 109 | + |
| 110 | +interface BootOptions { |
| 111 | + /** Compose a protocol that cannot adopt (no `reassignOrphanedMetadata`). */ |
| 112 | + protocolWithoutAdoption?: boolean; |
| 113 | + /** Compose a `tenancy` service answering this posture. */ |
| 114 | + posture?: 'isolated' | 'group'; |
| 115 | +} |
| 116 | + |
| 117 | +/** The composed door over a real store, with the orphans already minted. */ |
| 118 | +async function boot(options: BootOptions = {}) { |
| 119 | + const engine = new ObjectQL({ logger: silentLogger }); |
| 120 | + liveEngines.push(engine); |
| 121 | + (engine.registry as unknown as { logLevel: string }).logLevel = 'silent'; |
| 122 | + engine.registerDriver(new SqlDriver({ |
| 123 | + client: 'better-sqlite3', |
| 124 | + connection: { filename: ':memory:' }, |
| 125 | + useNullAsDefault: true, |
| 126 | + }), true); |
| 127 | + await engine.init(); |
| 128 | + engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID); |
| 129 | + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); |
| 130 | + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); |
| 131 | + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); |
| 132 | + await engine.syncSchemas(); |
| 133 | + |
| 134 | + engine.registerApp(manifest(CODE_BOOTED)); |
| 135 | + engine.registerApp(manifest(MARKETPLACE), undefined, 'marketplace'); |
| 136 | + engine.registry.installPackage(manifest(SYSTEM_SCOPED, { scope: 'system' }) as any, 'authored'); |
| 137 | + engine.registry.installPackage(manifest(CLOUD_SCOPED, { scope: 'cloud' }) as any, 'authored'); |
| 138 | + engine.registry.installPackage(manifest(AUTHORED, { scope: 'project' }) as any, 'authored'); |
| 139 | + |
| 140 | + const protocol = new ObjectStackProtocolImplementation(engine as any); |
| 141 | + // The real producer of an orphan: a save naming no package. |
| 142 | + for (const name of ORPHANS) { |
| 143 | + await protocol.saveMetaItem({ type: 'view', name, item: view(name), mode: 'publish' }); |
| 144 | + } |
| 145 | + const adopt = vi.spyOn(protocol, 'reassignOrphanedMetadata'); |
| 146 | + |
| 147 | + const served = options.protocolWithoutAdoption |
| 148 | + ? { saveMetaItem: protocol.saveMetaItem.bind(protocol) } |
| 149 | + : protocol; |
| 150 | + const services: Record<string, unknown> = { protocol: served, objectql: engine }; |
| 151 | + if (options.posture) services.tenancy = { posture: options.posture }; |
| 152 | + const get = (name: string) => services[name] ?? null; |
| 153 | + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; |
| 154 | + const dispatcher = new HttpDispatcher(kernel); |
| 155 | + |
| 156 | + /** Each loose item's binding, read off the store. */ |
| 157 | + const bindings = async (): Promise<Record<string, unknown>> => { |
| 158 | + const rows = (await engine.find('sys_metadata', { where: { type: 'view' } })) as any[]; |
| 159 | + return Object.fromEntries( |
| 160 | + rows.filter((r) => (ORPHANS as readonly string[]).includes(r.name)).map((r) => [r.name, r.package_id ?? null]), |
| 161 | + ); |
| 162 | + }; |
| 163 | + |
| 164 | + return { engine, dispatcher, adopt, bindings }; |
| 165 | +} |
| 166 | + |
| 167 | +type Caller = Record<string, unknown>; |
| 168 | +/** Holds `manage_metadata`; the operator rung, so no wall refuses it. */ |
| 169 | +const ADMIN: Caller = { userId: 'u_pkg_admin', systemPermissions: ['manage_metadata', 'studio.access'], posture: 'PLATFORM_ADMIN' }; |
| 170 | +/** Signed in, without `manage_metadata`. */ |
| 171 | +const NO_CAPABILITY: Caller = { userId: 'u_reader', systemPermissions: ['studio.access'] }; |
| 172 | +/** An organization's `manage_metadata` holder below the operator rung. */ |
| 173 | +const TENANT_AUTHOR: Caller = { userId: 'u_tenant', tenantId: 'org_a', systemPermissions: ['manage_metadata'], posture: 'TENANT_ADMIN' }; |
| 174 | + |
| 175 | +type Door = Awaited<ReturnType<typeof boot>>; |
| 176 | +const adoptInto = async (d: Door, id: string, caller: Caller = ADMIN) => { |
| 177 | + const r = await d.dispatcher.handlePackages( |
| 178 | + `/${encodeURIComponent(id)}/adopt-orphans`, 'POST', {}, {}, |
| 179 | + { request: {}, environmentId: 'pkg-adopt-orphans-readonly-test', executionContext: caller } as any, |
| 180 | + ); |
| 181 | + expect(r.handled).toBe(true); |
| 182 | + return { status: r.response?.status, body: r.response?.body as any }; |
| 183 | +}; |
| 184 | + |
| 185 | +const UNBOUND = Object.fromEntries(ORPHANS.map((n) => [n, null])); |
| 186 | +const boundTo = (id: string) => Object.fromEntries(ORPHANS.map((n) => [n, id])); |
| 187 | + |
| 188 | +// ══════════════════════════════════════════════════════════════════════════════ |
| 189 | +// 1. REFUSED: adopting into a read-only package binds nothing |
| 190 | +// ══════════════════════════════════════════════════════════════════════════════ |
| 191 | + |
| 192 | +const READ_ONLY: Array<{ label: string; id: string; booted: boolean }> = [ |
| 193 | + { label: 'code-booted (registerApp)', id: CODE_BOOTED, booted: true }, |
| 194 | + { label: 'marketplace-installed (registerApp, marketplace)', id: MARKETPLACE, booted: true }, |
| 195 | + { label: 'system-scoped stored row (scope signal only)', id: SYSTEM_SCOPED, booted: false }, |
| 196 | + { label: 'cloud-scoped stored row (scope signal only)', id: CLOUD_SCOPED, booted: false }, |
| 197 | +]; |
| 198 | + |
| 199 | +describe('POST /packages/:id/adopt-orphans on a read-only package is refused', () => { |
| 200 | + for (const pkg of READ_ONLY) { |
| 201 | + it(`422s the ${pkg.label} package and binds nothing`, async () => { |
| 202 | + const d = await boot(); |
| 203 | + expect(await d.bindings(), 'precondition: the producer left orphans').toEqual(UNBOUND); |
| 204 | + expect((d.engine as unknown as { manifests: Map<string, unknown> }).manifests.has(pkg.id), |
| 205 | + 'precondition: the boot signal').toBe(pkg.booted); |
| 206 | + |
| 207 | + const r = await adoptInto(d, pkg.id); |
| 208 | + |
| 209 | + expect(r.status).toBe(422); |
| 210 | + expect(r.body?.success).toBe(false); |
| 211 | + expect(r.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); |
| 212 | + expect(r.body?.error?.httpStatus).toBe(422); |
| 213 | + expect(r.body?.error?.details?.packageId).toBe(pkg.id); |
| 214 | + expect(r.body?.error?.details?.docs).toBe('docs/adr/0070-package-first-authoring.md'); |
| 215 | + // Nothing was rebound: the protocol was never asked, and the rows say so. |
| 216 | + expect(d.adopt).not.toHaveBeenCalled(); |
| 217 | + expect(await d.bindings()).toEqual(UNBOUND); |
| 218 | + }, 30_000); |
| 219 | + } |
| 220 | +}); |
| 221 | + |
| 222 | +// ══════════════════════════════════════════════════════════════════════════════ |
| 223 | +// 2. ORDER: which refusal answers when more than one applies to a read-only id |
| 224 | +// ══════════════════════════════════════════════════════════════════════════════ |
| 225 | + |
| 226 | +describe('POST /packages/:id/adopt-orphans — the gate\'s place among the refusals', () => { |
| 227 | + it('a caller without manage_metadata is refused 403 by the capability gate first', async () => { |
| 228 | + const d = await boot(); |
| 229 | + |
| 230 | + const r = await adoptInto(d, CODE_BOOTED, NO_CAPABILITY); |
| 231 | + |
| 232 | + expect(r.status).toBe(403); |
| 233 | + expect(r.body?.error?.code).not.toBe('WRITABLE_PACKAGE_REQUIRED'); |
| 234 | + expect(String(r.body?.error?.message)).toContain('manage_metadata'); |
| 235 | + expect(d.adopt).not.toHaveBeenCalled(); |
| 236 | + expect(await d.bindings()).toEqual(UNBOUND); |
| 237 | + }, 30_000); |
| 238 | + |
| 239 | + it('under a wall, a tenant holder is refused 403 PERMISSION_DENIED by the walled refusal first', async () => { |
| 240 | + const d = await boot({ posture: 'isolated' }); |
| 241 | + |
| 242 | + const r = await adoptInto(d, CODE_BOOTED, TENANT_AUTHOR); |
| 243 | + |
| 244 | + expect(r.status).toBe(403); |
| 245 | + expect(r.body?.error?.code).toBe('PERMISSION_DENIED'); |
| 246 | + expect(String(r.body?.error?.message)).toContain('tenancy posture'); |
| 247 | + expect(d.adopt).not.toHaveBeenCalled(); |
| 248 | + expect(await d.bindings()).toEqual(UNBOUND); |
| 249 | + }, 30_000); |
| 250 | + |
| 251 | + it('the gate precedes the protocol: a read-only id answers 422 where a writable id answers 501', async () => { |
| 252 | + const d = await boot({ protocolWithoutAdoption: true }); |
| 253 | + |
| 254 | + const readOnly = await adoptInto(d, CODE_BOOTED); |
| 255 | + expect(readOnly.status).toBe(422); |
| 256 | + expect(readOnly.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); |
| 257 | + |
| 258 | + const writable = await adoptInto(d, AUTHORED); |
| 259 | + expect(writable.status).toBe(501); |
| 260 | + expect(writable.body?.error?.code).not.toBe('WRITABLE_PACKAGE_REQUIRED'); |
| 261 | + expect(await d.bindings()).toEqual(UNBOUND); |
| 262 | + }, 30_000); |
| 263 | +}); |
| 264 | + |
| 265 | +// ══════════════════════════════════════════════════════════════════════════════ |
| 266 | +// 3. CONTROL: the gate is not an outage |
| 267 | +// ══════════════════════════════════════════════════════════════════════════════ |
| 268 | + |
| 269 | +describe('POST /packages/:id/adopt-orphans still adopts into what an org owns', () => { |
| 270 | + it('a writable package adopts: 200, and every orphan is really bound to it', async () => { |
| 271 | + const d = await boot(); |
| 272 | + |
| 273 | + const r = await adoptInto(d, AUTHORED); |
| 274 | + |
| 275 | + expect(r.status).toBe(200); |
| 276 | + expect(r.body?.data).toMatchObject({ success: true, reassignedCount: ORPHANS.length, targetPackageId: AUTHORED }); |
| 277 | + expect(d.adopt).toHaveBeenCalledTimes(1); |
| 278 | + expect(await d.bindings()).toEqual(boundTo(AUTHORED)); |
| 279 | + }, 30_000); |
| 280 | + |
| 281 | + it('an id nothing installed is writable by the predicate\'s own rule, so it still reaches the protocol, as before', async () => { |
| 282 | + const d = await boot(); |
| 283 | + expect(d.engine.registry.getPackage(UNKNOWN), 'precondition: nothing installed it').toBeUndefined(); |
| 284 | + |
| 285 | + const r = await adoptInto(d, UNKNOWN); |
| 286 | + |
| 287 | + expect(r.status).toBe(200); |
| 288 | + expect(r.body?.error?.code).toBeUndefined(); |
| 289 | + expect(d.adopt).toHaveBeenCalledTimes(1); |
| 290 | + expect(await d.bindings()).toEqual(boundTo(UNKNOWN)); |
| 291 | + }, 30_000); |
| 292 | +}); |
0 commit comments