|
| 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