|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21727] `POST /api/v1/packages` answers ADR-0087 D1's protocol refusal as a |
| 5 | + * 422 carrying its structured diagnostic, and still installs nothing. |
| 6 | + * |
| 7 | + * ## The defect this file pins shut |
| 8 | + * |
| 9 | + * A manifest whose `engines.protocol` range excludes this runtime's major was |
| 10 | + * refused, but the refusal reached the caller as |
| 11 | + * `500 OS_PROTOCOL_INCOMPATIBLE`, with `requiredRange`, `rangeSource`, |
| 12 | + * `protocolVersion`, `targetMajor` and `migrateCommand` present only inside the |
| 13 | + * prose. That broke two promises. A manifest the caller wrote was reported as |
| 14 | + * a server fault. And the machine-readable diagnostic ADR-0087 D1 promises |
| 15 | + * ("a structured diagnostic … a stable error code, the two versions … the exact |
| 16 | + * replay command") was not machine-readable. |
| 17 | + * |
| 18 | + * ## Why the door, and not the shared resolver |
| 19 | + * |
| 20 | + * `resolveThrownHttpError` (`@objectstack/types`) builds `details` from a |
| 21 | + * closed list (`.code` when it is not a string, `.issues`, a validation's |
| 22 | + * `.fields`) and drops a thrown `.diagnostic`. The list is closed by the |
| 23 | + * maintainer's #9585 ruling, so the install branch recognises the typed |
| 24 | + * refusal ahead of its generic catch, which is the `FlowActionRefusal` idiom. |
| 25 | + * This is the only HTTP door that reaches `assertProtocolCompat`. The other |
| 26 | + * caller, `AppPlugin.init`, is a boot seam, and `app-plugin.test.ts` pins its |
| 27 | + * thrown value. |
| 28 | + * |
| 29 | + * ## The harness |
| 30 | + * |
| 31 | + * The protocol primitive and the registry are REAL |
| 32 | + * (`ObjectStackProtocolImplementation` over a `SchemaRegistry`), so the |
| 33 | + * refusal under test is the one production throws, and "not installed" is read |
| 34 | + * back through the same door's `GET`. Each refusal asserts the ADR-0112 |
| 35 | + * envelope (`code` and `status`), never a bare throw. `OS_HOME` is redirected |
| 36 | + * because the lit controls really install, and the door persists enable state |
| 37 | + * under it. |
| 38 | + */ |
| 39 | + |
| 40 | +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; |
| 41 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 42 | +import { tmpdir } from 'node:os'; |
| 43 | +import { join } from 'node:path'; |
| 44 | +import { SchemaRegistry } from '@objectstack/objectql'; |
| 45 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 46 | +import { ApiErrorSchema } from '@objectstack/spec/api'; |
| 47 | +import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel'; |
| 48 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 49 | + |
| 50 | +let home: string; |
| 51 | +let priorHome: string | undefined; |
| 52 | + |
| 53 | +beforeAll(() => { |
| 54 | + priorHome = process.env.OS_HOME; |
| 55 | + home = mkdtempSync(join(tmpdir(), 'os-install-protocol-')); |
| 56 | + process.env.OS_HOME = home; |
| 57 | +}); |
| 58 | + |
| 59 | +afterAll(() => { |
| 60 | + if (priorHome === undefined) delete process.env.OS_HOME; |
| 61 | + else process.env.OS_HOME = priorHome; |
| 62 | + rmSync(home, { recursive: true, force: true }); |
| 63 | +}); |
| 64 | + |
| 65 | +const ADMIN = () => ({ |
| 66 | + request: {}, |
| 67 | + environmentId: 'pkg-install-protocol-test', |
| 68 | + executionContext: { |
| 69 | + userId: 'u_pkg_admin', |
| 70 | + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], |
| 71 | + }, |
| 72 | +}) as any; |
| 73 | + |
| 74 | +/** A major this runtime no longer accepts, derived from the runtime, never a literal. */ |
| 75 | +const OLD_MAJOR = PROTOCOL_MAJOR - 1; |
| 76 | +const OLD_RANGE = `^${OLD_MAJOR}`; |
| 77 | + |
| 78 | +/** The card's reproduction, with the range derived from the running protocol. */ |
| 79 | +const INCOMPATIBLE = { |
| 80 | + id: 'com.example.qaold', name: 'Old', version: '1.0.0', scope: 'project', type: 'app', |
| 81 | + engines: { protocol: OLD_RANGE }, |
| 82 | +}; |
| 83 | +/** The lit control: the same manifest under a range this runtime admits. */ |
| 84 | +const COMPATIBLE = { ...INCOMPATIBLE, id: 'com.example.qacurrent', engines: { protocol: `^${PROTOCOL_MAJOR}` } }; |
| 85 | + |
| 86 | +/** The five diagnostic members the wire carries: a CLOSED set. */ |
| 87 | +const DETAIL_KEYS = ['migrateCommand', 'protocolVersion', 'rangeSource', 'requiredRange', 'targetMajor']; |
| 88 | + |
| 89 | +function silentRegistry(): SchemaRegistry { |
| 90 | + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); |
| 91 | + (registry as unknown as { logLevel: string }).logLevel = 'silent'; |
| 92 | + return registry; |
| 93 | +} |
| 94 | + |
| 95 | +/** |
| 96 | + * The composed door: the real protocol primitive over a real registry. Pass |
| 97 | + * `protocol: null` for the composition with no `protocol` service, which takes |
| 98 | + * the door's fallback arm. |
| 99 | + */ |
| 100 | +function door(options: { protocol?: unknown } = {}) { |
| 101 | + const registry = silentRegistry(); |
| 102 | + const engine: any = { registry, find: async () => [], manifests: new Map() }; |
| 103 | + const protocol = options.protocol === undefined |
| 104 | + ? new ObjectStackProtocolImplementation(engine, () => new Map()) |
| 105 | + : options.protocol; |
| 106 | + const ql: any = { registry, manifests: new Map() }; |
| 107 | + const kernel: any = { |
| 108 | + getService: (name: string) => { |
| 109 | + if (name === 'protocol') return protocol ? Promise.resolve(protocol) : null; |
| 110 | + if (name === 'objectql') return Promise.resolve(ql); |
| 111 | + return null; |
| 112 | + }, |
| 113 | + context: { getService: (name: string) => (name === 'objectql' ? ql : null) }, |
| 114 | + }; |
| 115 | + return { dispatcher: new HttpDispatcher(kernel), registry }; |
| 116 | +} |
| 117 | + |
| 118 | +const install = (d: ReturnType<typeof door>, manifest: unknown) => |
| 119 | + d.dispatcher.handlePackages('', 'POST', { manifest }, {}, ADMIN()); |
| 120 | +const read = (d: ReturnType<typeof door>, id: string) => |
| 121 | + d.dispatcher.handlePackages(`/${id}`, 'GET', undefined, {}, ADMIN()); |
| 122 | + |
| 123 | +/** Pin 1's assertions, shared by both arms of the door. */ |
| 124 | +function expectStructuredRefusal(r: any) { |
| 125 | + expect(r.handled).toBe(true); |
| 126 | + expect(r.response.status).toBe(422); |
| 127 | + const error = r.response.body.error; |
| 128 | + expect(r.response.body.success).toBe(false); |
| 129 | + expect(error.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); |
| 130 | + expect(error.httpStatus).toBe(422); |
| 131 | + expect(ApiErrorSchema.safeParse(error).success).toBe(true); |
| 132 | + |
| 133 | + // Exactly the five members, so `code` is in `error.code` (ADR-0112 D5), |
| 134 | + // never in `error.details.code`, and nothing beyond the ruled set leaks. |
| 135 | + expect(Object.keys(error.details).sort()).toEqual(DETAIL_KEYS); |
| 136 | + expect(error.details).toEqual({ |
| 137 | + requiredRange: OLD_RANGE, |
| 138 | + rangeSource: 'engines.protocol', |
| 139 | + protocolVersion: PROTOCOL_VERSION, |
| 140 | + targetMajor: OLD_MAJOR, |
| 141 | + migrateCommand: `objectstack migrate meta --from ${OLD_MAJOR}`, |
| 142 | + }); |
| 143 | + |
| 144 | + // Each member equals what the prose states, so the two channels cannot drift. |
| 145 | + const { requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand } = error.details; |
| 146 | + expect(error.message).toContain(`targets protocol ${requiredRange} (${rangeSource})`); |
| 147 | + expect(error.message).toContain(`this runtime is protocol ${protocolVersion}.`); |
| 148 | + expect(error.message.endsWith(`Run: ${migrateCommand}`)).toBe(true); |
| 149 | + expect(migrateCommand.endsWith(`--from ${targetMajor}`)).toBe(true); |
| 150 | +} |
| 151 | + |
| 152 | +describe('POST /api/v1/packages: the protocol refusal is a 422 with its diagnostic (composed door)', () => { |
| 153 | + it('pin 1: answers 422 OS_PROTOCOL_INCOMPATIBLE with all five diagnostic fields in error.details', async () => { |
| 154 | + expectStructuredRefusal(await install(door(), INCOMPATIBLE)); |
| 155 | + }); |
| 156 | + |
| 157 | + it('pin 2: the refused package is still not installed (GET → 404)', async () => { |
| 158 | + const d = door(); |
| 159 | + await install(d, INCOMPATIBLE); |
| 160 | + const r: any = await read(d, INCOMPATIBLE.id); |
| 161 | + expect(r.response.status).toBe(404); |
| 162 | + expect(r.response.body.error.code).toBe('RESOURCE_NOT_FOUND'); |
| 163 | + expect(d.registry.getPackage(INCOMPATIBLE.id)).toBeUndefined(); |
| 164 | + }); |
| 165 | + |
| 166 | + it('lit control: a compatible range installs and reads back', async () => { |
| 167 | + const d = door(); |
| 168 | + const r: any = await install(d, COMPATIBLE); |
| 169 | + expect(r.response.status).toBe(201); |
| 170 | + expect((await read(d, COMPATIBLE.id) as any).response.status).toBe(200); |
| 171 | + }); |
| 172 | + |
| 173 | + it('recognises the BRAND, not the field names: a lookalike throw is served by the generic catch', async () => { |
| 174 | + // A plain Error that copies the code and the diagnostic is not the |
| 175 | + // refusal. It must not reach the structured channel; it gets the |
| 176 | + // shared resolver's answer (the 500 fallback, no diagnostic). |
| 177 | + const lookalike = Object.assign(new Error('lookalike refusal'), { |
| 178 | + code: 'OS_PROTOCOL_INCOMPATIBLE', |
| 179 | + diagnostic: { requiredRange: OLD_RANGE, migrateCommand: 'x' }, |
| 180 | + }); |
| 181 | + const installPackage = vi.fn(async () => { throw lookalike; }); |
| 182 | + const r: any = await install(door({ protocol: { installPackage } }), INCOMPATIBLE); |
| 183 | + expect(installPackage).toHaveBeenCalledTimes(1); |
| 184 | + expect(r.response.status).toBe(500); |
| 185 | + expect(r.response.body.error.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); |
| 186 | + expect(r.response.body.error.details).toBeUndefined(); |
| 187 | + }); |
| 188 | +}); |
| 189 | + |
| 190 | +describe('POST /api/v1/packages: the no-protocol-service fallback runs the same handshake', () => { |
| 191 | + it('refuses an incompatible range with the same 422 and diagnostic, and writes nothing', async () => { |
| 192 | + const d = door({ protocol: null }); |
| 193 | + expectStructuredRefusal(await install(d, INCOMPATIBLE)); |
| 194 | + expect(d.registry.getPackage(INCOMPATIBLE.id)).toBeUndefined(); |
| 195 | + expect((await read(d, INCOMPATIBLE.id) as any).response.status).toBe(404); |
| 196 | + }); |
| 197 | + |
| 198 | + it('lit control: a compatible range still installs through the fallback', async () => { |
| 199 | + const d = door({ protocol: null }); |
| 200 | + const r: any = await install(d, COMPATIBLE); |
| 201 | + expect(r.response.status).toBe(201); |
| 202 | + expect(d.registry.getPackage(COMPATIBLE.id)).toBeDefined(); |
| 203 | + }); |
| 204 | +}); |
0 commit comments