Skip to content

Commit ade20c1

Browse files
committed
test(runtime): pin PATCH /packages/:id against a read-only package
The pins for the manifest-edit door's ADR-0070 D2 read-only refusal, ahead of the gate itself: four read-only kinds x four bodies on both limbs, plus the writable, body-check and unknown-id controls. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c1144f commit ade20c1

1 file changed

Lines changed: 229 additions & 0 deletions

File tree

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `PATCH /packages/:id` — the ADR-0070 D2 read-only rule at the manifest-edit
5+
* door (#24705).
6+
*
7+
* ## The defect
8+
*
9+
* The arm asked `requireManageMetadata` and the walled refusal, and nothing
10+
* else, so a `manage_metadata` holder could edit the manifest of a package the
11+
* deployment ships: `{ description }` on a package whose manifest declares
12+
* `scope: 'system'` answered `200` and edited it, and with a `package` service
13+
* composed the edited manifest was re-persisted to `sys_packages`. Every other
14+
* state-changing arm on the domain — install-overwrite, disable, publish,
15+
* revert and delete — refuses that same id `422 WRITABLE_PACKAGE_REQUIRED`.
16+
*
17+
* ## What is asserted, and why it is not the status alone
18+
*
19+
* The harm is the edited manifest and its persisted row, so every refusal
20+
* reads the observables: the registry row's content is what it was (the
21+
* registry's `updatePackageManifest` edits the row IN PLACE, so the snapshot is
22+
* taken by value), `updatePackageManifest` was never called, and on the
23+
* protocol limb neither `protocol.updatePackage` nor the `package` service's
24+
* `publish` (the `sys_packages` write) ran. The engine is a REAL `ObjectQL`:
25+
* the code-booted and marketplace ids are booted through its own `registerApp`,
26+
* and the two scope-only ids are installed the way `sys_packages` hydration
27+
* installs a stored row (`source: 'authored'`, no booted manifest), so their
28+
* refusal can only come from the scope signal.
29+
*
30+
* ## The gate precedes the body
31+
*
32+
* The refusal is the whole arm's, asked before the body is read, so it is the
33+
* same answer whatever keys the body carries: an editable key, `dependencies`,
34+
* the `{ manifest }` wrapper, and a body the arm would itself refuse `400`.
35+
*
36+
* ## The gate is not an outage
37+
*
38+
* CONTROL: a writable package still edits (`200`, and the row and
39+
* `sys_packages` really move, on the same spies), its body checks still answer
40+
* `400`, and an id nothing installed still answers the arm's own `404` — the
41+
* predicate treats an unknown id as writable, so the gate never re-labels it.
42+
*/
43+
44+
import { describe, it, expect, vi } from 'vitest';
45+
import { ObjectQL } from '@objectstack/objectql';
46+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
47+
import { HttpDispatcher } from '../http-dispatcher.js';
48+
49+
/** Booted by the deployment's code: `registerApp`, default source `'code'`. */
50+
const CODE_BOOTED = 'com.acme.codepkg';
51+
/** Installed from the marketplace: `registerApp(…, 'marketplace')`. */
52+
const MARKETPLACE = 'com.acme.marketpkg';
53+
/** A stored row whose manifest declares `scope: 'system'`; nothing booted it. */
54+
const SYSTEM_SCOPED = 'com.acme.systempkg';
55+
/** A stored row whose manifest declares `scope: 'cloud'`; nothing booted it. */
56+
const CLOUD_SCOPED = 'com.acme.cloudpkg';
57+
/** An org's own base. */
58+
const AUTHORED = 'com.acme.authoredpkg';
59+
/** An id nothing has installed. */
60+
const UNKNOWN = 'com.acme.nopkg';
61+
62+
const manifest = (id: string, extra: Record<string, unknown> = {}) => ({
63+
id,
64+
name: id,
65+
version: '1.0.0',
66+
description: 'as shipped',
67+
type: 'app',
68+
...extra,
69+
});
70+
71+
const silentLogger: any = {
72+
debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {},
73+
child() { return silentLogger; },
74+
};
75+
76+
type Limb = 'protocol' | 'fallback';
77+
const LIMBS: Limb[] = ['protocol', 'fallback'];
78+
79+
/**
80+
* The composed door over a real engine. `protocol` resolves the real protocol
81+
* primitive with a `package` service double standing in for `sys_packages`;
82+
* `fallback` resolves no protocol service, so the door takes its bare
83+
* registry edit.
84+
*/
85+
function door(limb: Limb) {
86+
const engine = new ObjectQL({ logger: silentLogger });
87+
(engine.registry as unknown as { logLevel: string }).logLevel = 'silent';
88+
engine.registerApp(manifest(CODE_BOOTED));
89+
engine.registerApp(manifest(MARKETPLACE), undefined, 'marketplace');
90+
engine.registry.installPackage(manifest(SYSTEM_SCOPED, { scope: 'system' }) as any, 'authored');
91+
engine.registry.installPackage(manifest(CLOUD_SCOPED, { scope: 'cloud' }) as any, 'authored');
92+
engine.registry.installPackage(manifest(AUTHORED, { scope: 'project' }) as any, 'authored');
93+
94+
const packageService = { publish: vi.fn(async () => ({ success: true })) };
95+
const services = new Map<string, unknown>([['package', packageService]]);
96+
const protocol = limb === 'protocol'
97+
? new ObjectStackProtocolImplementation(engine as any, () => services)
98+
: null;
99+
const kernel: any = {
100+
getService: (name: string) => {
101+
if (name === 'protocol') return protocol ? Promise.resolve(protocol) : null;
102+
if (name === 'objectql') return Promise.resolve(engine);
103+
return null;
104+
},
105+
context: { getService: (name: string) => (name === 'objectql' ? engine : null) },
106+
};
107+
const registryEdit = vi.spyOn(engine.registry, 'updatePackageManifest');
108+
const protocolEdit = protocol ? vi.spyOn(protocol, 'updatePackage') : undefined;
109+
return {
110+
dispatcher: new HttpDispatcher(kernel),
111+
engine,
112+
registryEdit,
113+
protocolEdit,
114+
publish: packageService.publish,
115+
};
116+
}
117+
118+
const ADMIN = (): any => ({
119+
request: {},
120+
environmentId: 'pkg-manifest-patch-readonly-test',
121+
executionContext: {
122+
userId: 'u_pkg_admin',
123+
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
124+
},
125+
});
126+
127+
type Door = ReturnType<typeof door>;
128+
const patch = (d: Door, id: string, body: unknown) =>
129+
d.dispatcher.handlePackages(`/${encodeURIComponent(id)}`, 'PATCH', body, {}, ADMIN());
130+
131+
/** The row by value: `updatePackageManifest` edits the row object in place. */
132+
const snapshot = (d: Door, id: string) => JSON.parse(JSON.stringify(d.engine.registry.getPackage(id) ?? null));
133+
134+
// ══════════════════════════════════════════════════════════════════════════════
135+
// 1. REFUSED: a read-only package's manifest, on both limbs, whatever the body
136+
// ══════════════════════════════════════════════════════════════════════════════
137+
138+
const READ_ONLY: Array<{ label: string; id: string; booted: boolean }> = [
139+
{ label: 'code-booted (registerApp)', id: CODE_BOOTED, booted: true },
140+
{ label: 'marketplace-installed (registerApp, marketplace)', id: MARKETPLACE, booted: true },
141+
{ label: 'system-scoped stored row (scope signal only)', id: SYSTEM_SCOPED, booted: false },
142+
{ label: 'cloud-scoped stored row (scope signal only)', id: CLOUD_SCOPED, booted: false },
143+
];
144+
145+
const BODIES: Array<{ label: string; body: unknown }> = [
146+
{ label: '{ description }', body: { description: 'edited over the API' } },
147+
{ label: '{ dependencies }', body: { dependencies: {} } },
148+
{ label: 'the { manifest } wrapper', body: { manifest: { name: 'Renamed', version: '2.0.0' } } },
149+
{ label: 'an empty body (the arm\'s own 400 sits behind the gate)', body: {} },
150+
];
151+
152+
for (const limb of LIMBS) {
153+
describe(`PATCH /packages/:id on a read-only package is refused (${limb} limb)`, () => {
154+
for (const pkg of READ_ONLY) {
155+
for (const b of BODIES) {
156+
it(`422s ${b.label} on the ${pkg.label} package and writes nothing`, async () => {
157+
const d = door(limb);
158+
const before = snapshot(d, pkg.id);
159+
expect(before?.manifest?.description, 'precondition: the shipped row').toBe('as shipped');
160+
expect((d.engine as unknown as { manifests: Map<string, unknown> }).manifests.has(pkg.id),
161+
'precondition: the boot signal').toBe(pkg.booted);
162+
163+
const r = await patch(d, pkg.id, b.body);
164+
165+
expect(r.response?.status).toBe(422);
166+
expect(r.response?.body?.success).toBe(false);
167+
expect(r.response?.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED');
168+
expect(r.response?.body?.error?.httpStatus).toBe(422);
169+
expect(r.response?.body?.error?.details?.packageId).toBe(pkg.id);
170+
expect(r.response?.body?.error?.details?.docs).toBe('docs/adr/0070-package-first-authoring.md');
171+
// Neither the registry nor `sys_packages` moved.
172+
expect(snapshot(d, pkg.id)).toEqual(before);
173+
expect(d.registryEdit).not.toHaveBeenCalled();
174+
if (d.protocolEdit) expect(d.protocolEdit).not.toHaveBeenCalled();
175+
expect(d.publish).not.toHaveBeenCalled();
176+
});
177+
}
178+
}
179+
});
180+
}
181+
182+
// ══════════════════════════════════════════════════════════════════════════════
183+
// 2. CONTROL: the gate is not an outage
184+
// ══════════════════════════════════════════════════════════════════════════════
185+
186+
for (const limb of LIMBS) {
187+
describe(`PATCH /packages/:id still edits what an org owns (${limb} limb)`, () => {
188+
it('edits a writable package: 200, and the row (and sys_packages) really move', async () => {
189+
const d = door(limb);
190+
191+
const r = await patch(d, AUTHORED, { description: 'edited by its owner', version: '1.1.0' });
192+
193+
expect(r.response?.status).toBe(200);
194+
expect(d.registryEdit).toHaveBeenCalledTimes(1);
195+
expect(d.engine.registry.getPackage(AUTHORED)?.manifest?.description).toBe('edited by its owner');
196+
expect(d.engine.registry.getPackage(AUTHORED)?.manifest?.version).toBe('1.1.0');
197+
if (limb === 'protocol') {
198+
expect(d.protocolEdit).toHaveBeenCalledTimes(1);
199+
expect(d.publish).toHaveBeenCalledTimes(1);
200+
}
201+
});
202+
203+
it('a writable package still reaches the body checks: an empty body answers 400 and writes nothing', async () => {
204+
const d = door(limb);
205+
const before = snapshot(d, AUTHORED);
206+
207+
const r = await patch(d, AUTHORED, {});
208+
209+
expect(r.response?.status).toBe(400);
210+
expect(r.response?.body?.error?.code).not.toBe('WRITABLE_PACKAGE_REQUIRED');
211+
expect(snapshot(d, AUTHORED)).toEqual(before);
212+
expect(d.registryEdit).not.toHaveBeenCalled();
213+
expect(d.publish).not.toHaveBeenCalled();
214+
});
215+
216+
it('an id nothing installed keeps the arm\'s own 404 — the gate is no existence oracle', async () => {
217+
const d = door(limb);
218+
expect(d.engine.registry.getPackage(UNKNOWN), 'precondition: nothing installed it').toBeUndefined();
219+
220+
const r = await patch(d, UNKNOWN, { description: 'x' });
221+
222+
expect(r.response?.status).toBe(404);
223+
expect(r.response?.body?.error?.code).not.toBe('WRITABLE_PACKAGE_REQUIRED');
224+
// It reached the edit, which is where the 404 comes from.
225+
expect(d.registryEdit).toHaveBeenCalledTimes(1);
226+
expect(d.publish).not.toHaveBeenCalled();
227+
});
228+
});
229+
}

0 commit comments

Comments
 (0)