Skip to content

Commit 9c77155

Browse files
committed
test(runtime): pin adopt-orphans against a read-only package (red on main)
POST /packages/:id/adopt-orphans binds package-less sys_metadata rows into any id it is handed. These pins read the rows back over a real store: a code-booted, marketplace, system-scoped or cloud-scoped target answers 200 and the orphans are rebound into it today. Committed ahead of the gate so the reproduction is a commit. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41646a5 commit 9c77155

1 file changed

Lines changed: 292 additions & 0 deletions

File tree

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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

Comments
 (0)