|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#24670] `PATCH /api/v1/packages/:id` accepts the manifest's `dependencies` |
| 5 | + * row, judged by `ManifestSchema`'s own `dependencies` row from |
| 6 | + * `@objectstack/spec` and never restated here. |
| 7 | + * |
| 8 | + * ## The defect |
| 9 | + * |
| 10 | + * The door built its patch from `name`, `description` and `version` only, so a |
| 11 | + * package's `dependencies` could be set at create and never again. The save |
| 12 | + * door's closure verdict reads exactly that declaration, and its refusal tells |
| 13 | + * the author to "declare a dependency … in the `dependencies` of P's |
| 14 | + * manifest" — a remedy no door applied to a package that already exists. |
| 15 | + * |
| 16 | + * ## What this file pins (the in-memory registry and the door's own judgment) |
| 17 | + * |
| 18 | + * - the row is persisted: flat or under the `manifest` wrapper, the registry's |
| 19 | + * manifest carries it and `GET /packages/:id` serves it; the row replaces |
| 20 | + * the stored one whole; |
| 21 | + * - a malformed row is refused `400 VALIDATION_ERROR` — the envelope |
| 22 | + * `POST /packages` answers for the same row at create — and NOTHING is |
| 23 | + * written: not the row, not a valid `name` sent beside it, and on the |
| 24 | + * protocol branch the protocol is never called; |
| 25 | + * - `name`, `description` and `version` behave as before, and a patch that |
| 26 | + * does not send `dependencies` leaves the stored row alone. |
| 27 | + * |
| 28 | + * The protocol branch's durable half (`sys_packages`, a restart) and the |
| 29 | + * remedy end to end are pinned over the shipped pieces in |
| 30 | + * `../package-dependencies-patch.integration.test.ts`. |
| 31 | + * |
| 32 | + * ## Harness |
| 33 | + * |
| 34 | + * The real `HttpDispatcher` over a real `SchemaRegistry`. `@objectstack/objectql` |
| 35 | + * resolves through `exports` to its `dist/`: rebuild it before reading a |
| 36 | + * result here after touching `registry.ts`. |
| 37 | + */ |
| 38 | + |
| 39 | +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; |
| 40 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 41 | +import { tmpdir } from 'node:os'; |
| 42 | +import { join } from 'node:path'; |
| 43 | +import { SchemaRegistry } from '@objectstack/objectql'; |
| 44 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 45 | + |
| 46 | +const PKG_ADMIN = () => ({ |
| 47 | + request: {}, |
| 48 | + executionContext: { |
| 49 | + userId: 'u_pkg_admin', |
| 50 | + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], |
| 51 | + }, |
| 52 | +}) as any; |
| 53 | + |
| 54 | +let home: string; |
| 55 | +let priorHome: string | undefined; |
| 56 | + |
| 57 | +beforeAll(() => { |
| 58 | + priorHome = process.env.OS_HOME; |
| 59 | + home = mkdtempSync(join(tmpdir(), 'os-24670-')); |
| 60 | + process.env.OS_HOME = home; |
| 61 | +}); |
| 62 | + |
| 63 | +afterAll(() => { |
| 64 | + if (priorHome === undefined) delete process.env.OS_HOME; |
| 65 | + else process.env.OS_HOME = priorHome; |
| 66 | + rmSync(home, { recursive: true, force: true }); |
| 67 | +}); |
| 68 | + |
| 69 | +const ID = 'com.acme.crm'; |
| 70 | +const DECLARED = { 'com.acme.core': '^1.0.0' }; |
| 71 | + |
| 72 | +/** A complete authoring-stage manifest, declaring one dependency. */ |
| 73 | +const manifest = () => ({ |
| 74 | + id: ID, |
| 75 | + name: 'Acme CRM', |
| 76 | + namespace: 'crm', |
| 77 | + version: '1.0.0', |
| 78 | + type: 'app', |
| 79 | + description: 'before', |
| 80 | + dependencies: { ...DECLARED }, |
| 81 | +}); |
| 82 | + |
| 83 | +/** |
| 84 | + * One process: a registry holding the package, and the door. With |
| 85 | + * `protocol`, the door's protocol branch reaches that double; without it, the |
| 86 | + * door takes its in-memory fallback. |
| 87 | + */ |
| 88 | +function boot(protocol?: { updatePackage: (...args: any[]) => any }) { |
| 89 | + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); |
| 90 | + (registry as any).logLevel = 'silent'; |
| 91 | + registry.installPackage(manifest() as any, 'authored'); |
| 92 | + const kernel: any = { |
| 93 | + getService: (name: string) => { |
| 94 | + if (name === 'objectql') return Promise.resolve({ registry }); |
| 95 | + if (name === 'protocol' && protocol) return Promise.resolve(protocol); |
| 96 | + return null; |
| 97 | + }, |
| 98 | + context: { getService: () => null }, |
| 99 | + }; |
| 100 | + const dispatcher = new HttpDispatcher(kernel); |
| 101 | + const call = async (method: string, path: string, body?: unknown) => { |
| 102 | + const res = await dispatcher.handlePackages(path, method, body, {}, PKG_ADMIN()); |
| 103 | + const json = JSON.parse(JSON.stringify(res.response?.body ?? null)); |
| 104 | + return { status: res.response?.status ?? 0, code: json?.error?.code as string | undefined, body: json }; |
| 105 | + }; |
| 106 | + /** The manifest the registry holds, as a plain value. */ |
| 107 | + const stored = () => JSON.parse(JSON.stringify(registry.getPackage(ID)?.manifest ?? null)); |
| 108 | + return { registry, call, stored }; |
| 109 | +} |
| 110 | + |
| 111 | +/** Rows `ManifestSchema`'s `dependencies` row refuses: a non-string version, a list, a scalar, a null. */ |
| 112 | +const MALFORMED: ReadonlyArray<[string, unknown]> = [ |
| 113 | + ['a non-string version requirement', { 'com.acme.core': 1 }], |
| 114 | + ['a list of ids', ['com.acme.core']], |
| 115 | + ['a bare string', 'com.acme.core'], |
| 116 | + ['null', null], |
| 117 | +]; |
| 118 | + |
| 119 | +describe('[#24670] PATCH /packages/:id — `dependencies` is persisted (in-memory fallback)', () => { |
| 120 | + for (const [form, wrap] of [ |
| 121 | + ['flat', (patch: object) => patch], |
| 122 | + ['under the `manifest` wrapper', (patch: object) => ({ manifest: patch })], |
| 123 | + ] as const) { |
| 124 | + it(`${form}: the row reaches the registry's manifest and GET /packages/:id serves it`, async () => { |
| 125 | + const p = boot(); |
| 126 | + const row = { 'com.acme.core': '^2.0.0', 'com.acme.billing': '^1.0.0' }; |
| 127 | + |
| 128 | + const patched = await p.call('PATCH', `/${ID}`, wrap({ dependencies: row })); |
| 129 | + |
| 130 | + expect(patched.status, JSON.stringify(patched.body)).toBe(200); |
| 131 | + expect(p.stored().dependencies).toEqual(row); |
| 132 | + const read = await p.call('GET', `/${ID}`); |
| 133 | + expect(read.status).toBe(200); |
| 134 | + expect(read.body?.data?.manifest?.dependencies).toEqual(row); |
| 135 | + }); |
| 136 | + } |
| 137 | + |
| 138 | + it('the row replaces the stored one whole: an entry it leaves out is removed, and `{}` clears it', async () => { |
| 139 | + const p = boot(); |
| 140 | + |
| 141 | + await p.call('PATCH', `/${ID}`, { dependencies: { 'com.acme.billing': '^1.0.0' } }); |
| 142 | + expect(p.stored().dependencies).toEqual({ 'com.acme.billing': '^1.0.0' }); |
| 143 | + |
| 144 | + const cleared = await p.call('PATCH', `/${ID}`, { dependencies: {} }); |
| 145 | + expect(cleared.status).toBe(200); |
| 146 | + expect(p.stored().dependencies).toEqual({}); |
| 147 | + }); |
| 148 | +}); |
| 149 | + |
| 150 | +describe('[#24670] PATCH /packages/:id — a malformed `dependencies` row is refused, and nothing is written', () => { |
| 151 | + for (const [what, row] of MALFORMED) { |
| 152 | + it(`${what}: 400 VALIDATION_ERROR naming \`dependencies\`, the stored manifest unchanged`, async () => { |
| 153 | + const p = boot(); |
| 154 | + const before = p.stored(); |
| 155 | + |
| 156 | + const answer = await p.call('PATCH', `/${ID}`, { dependencies: row }); |
| 157 | + |
| 158 | + expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); |
| 159 | + expect(answer.body?.error?.message).toContain('dependencies'); |
| 160 | + expect(p.stored()).toEqual(before); |
| 161 | + }); |
| 162 | + } |
| 163 | + |
| 164 | + it('a valid `name` sent beside a malformed row is not written either', async () => { |
| 165 | + const p = boot(); |
| 166 | + const before = p.stored(); |
| 167 | + |
| 168 | + const answer = await p.call('PATCH', `/${ID}`, { manifest: { name: 'Renamed', dependencies: { x: 2 } } }); |
| 169 | + |
| 170 | + expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); |
| 171 | + expect(p.stored()).toEqual(before); |
| 172 | + }); |
| 173 | + |
| 174 | + it('the protocol branch: refused before the protocol is called', async () => { |
| 175 | + const updatePackage = vi.fn(); |
| 176 | + const p = boot({ updatePackage }); |
| 177 | + |
| 178 | + const answer = await p.call('PATCH', `/${ID}`, { dependencies: { 'com.acme.core': 1 } }); |
| 179 | + |
| 180 | + expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); |
| 181 | + expect(updatePackage).not.toHaveBeenCalled(); |
| 182 | + }); |
| 183 | + |
| 184 | + it('the same envelope `POST /packages` answers for the same row at create', async () => { |
| 185 | + const p = boot(); |
| 186 | + const row = { 'com.acme.core': 1 }; |
| 187 | + |
| 188 | + const atCreate = await p.call('POST', '', { manifest: { ...manifest(), id: 'com.acme.other', namespace: 'other', dependencies: row } }); |
| 189 | + const atEdit = await p.call('PATCH', `/${ID}`, { dependencies: row }); |
| 190 | + |
| 191 | + expect({ status: atCreate.status, code: atCreate.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); |
| 192 | + expect({ status: atEdit.status, code: atEdit.code }).toEqual({ status: atCreate.status, code: atCreate.code }); |
| 193 | + }); |
| 194 | +}); |
| 195 | + |
| 196 | +describe('[#24670] PATCH /packages/:id — the three existing keys behave as before', () => { |
| 197 | + it('a name / description / version patch persists exactly those and leaves `dependencies` as stored', async () => { |
| 198 | + const p = boot(); |
| 199 | + |
| 200 | + const answer = await p.call('PATCH', `/${ID}`, { name: ' Renamed ', description: 'after', version: '2.0.0' }); |
| 201 | + |
| 202 | + expect(answer.status).toBe(200); |
| 203 | + expect(p.stored()).toMatchObject({ name: 'Renamed', description: 'after', version: '2.0.0', dependencies: DECLARED }); |
| 204 | + }); |
| 205 | + |
| 206 | + it('an empty name and a non-SemVer version are still refused 400, and an empty body names `dependencies` among the keys', async () => { |
| 207 | + const p = boot(); |
| 208 | + const before = p.stored(); |
| 209 | + |
| 210 | + expect((await p.call('PATCH', `/${ID}`, { name: ' ' })).status).toBe(400); |
| 211 | + expect((await p.call('PATCH', `/${ID}`, { version: '1.2' })).status).toBe(400); |
| 212 | + const empty = await p.call('PATCH', `/${ID}`, {}); |
| 213 | + expect({ status: empty.status, code: empty.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); |
| 214 | + expect(empty.body?.error?.message).toContain('dependencies'); |
| 215 | + expect(p.stored()).toEqual(before); |
| 216 | + }); |
| 217 | + |
| 218 | + it('the protocol branch receives the parsed row beside the other keys', async () => { |
| 219 | + const updatePackage = vi.fn().mockResolvedValue({ package: { id: ID } }); |
| 220 | + const p = boot({ updatePackage }); |
| 221 | + |
| 222 | + const answer = await p.call('PATCH', `/${ID}`, { manifest: { description: 'after', dependencies: DECLARED } }); |
| 223 | + |
| 224 | + expect(answer.status).toBe(200); |
| 225 | + expect(updatePackage).toHaveBeenCalledWith({ packageId: ID, patch: { description: 'after', dependencies: DECLARED } }); |
| 226 | + }); |
| 227 | +}); |
0 commit comments