|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#22220, #8184] The package door answers BEFORE the checks that judge the |
| 5 | + * body, on both kernel topologies — one request, one refusal. |
| 6 | + * |
| 7 | + * `saveMetaItem`'s package door (`refusePackagedBaseOverride`) refuses an |
| 8 | + * in-place write onto an item a code package ships, on a type with no per-org |
| 9 | + * overlay channel (`allowOrgOverride: false`). It used to be asked only on an |
| 10 | + * environment kernel (`environmentId !== undefined`); a host-config kernel — |
| 11 | + * the CLI's assembler, the showcase's boot shape — met the same predicate only |
| 12 | + * at the repository write (`SysMetadataRepository.assertAllowed`, the first |
| 13 | + * statement of `repo.put`), which is that method's last act. So on that kernel |
| 14 | + * every refusal in between answered first: a publish of a packaged object |
| 15 | + * whose body the runtime authoring gate refuses answered `422 |
| 16 | + * INVALID_METADATA`, while an environment kernel answered `403 |
| 17 | + * NOT_OVERRIDABLE` for the same request; a body the spec-conformance parse |
| 18 | + * refuses did the same in draft and publish mode. The author was told to fix |
| 19 | + * findings no write through this door could ever land. |
| 20 | + * |
| 21 | + * This file pins the request against BOTH kernels, row by row, asserting the |
| 22 | + * ADR-0112 envelope (`code` + `status`) per kernel: |
| 23 | + * |
| 24 | + * 1. a packaged `object`, `position` and `permission` — whatever the body — |
| 25 | + * answers the package door's refusal on both kernels, and the gate that |
| 26 | + * would have judged the body is never reached; |
| 27 | + * 2. controls, unchanged: an environment-local object with a gate-refused or |
| 28 | + * a spec-refused body still answers `422 INVALID_METADATA` on both |
| 29 | + * kernels; a packaged item of a type that allows overlays |
| 30 | + * (`allowOrgOverride: true`) and a packaged object with the |
| 31 | + * `OS_METADATA_WRITABLE` hatch open are still judged by the gates; |
| 32 | + * 3. every type in `DEFAULT_METADATA_TYPE_REGISTRY`: the same packaged |
| 33 | + * publish gives the same envelope on both kernels, and every type the |
| 34 | + * door governs answers it. |
| 35 | + * |
| 36 | + * Nothing in a refused row is persisted. `@objectstack/objectql` cannot be |
| 37 | + * imported here: it depends on this package. |
| 38 | + */ |
| 39 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 40 | +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; |
| 41 | +import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; |
| 42 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 43 | +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; |
| 44 | + |
| 45 | +const PACKAGE_ID = 'com.example.pkg'; |
| 46 | +const ENV_ID = 'env_1'; |
| 47 | + |
| 48 | +type Kernel = 'environment' | 'host-config'; |
| 49 | +const KERNELS: ReadonlyArray<readonly [Kernel, string | undefined]> = [ |
| 50 | + ['environment', ENV_ID], |
| 51 | + ['host-config', undefined], |
| 52 | +]; |
| 53 | + |
| 54 | +interface Envelope { code: unknown; status: unknown } |
| 55 | + |
| 56 | +/** `field` artifacts are nested in their object, so the packaged field is the packaged object's own. */ |
| 57 | +const packagedName = (type: string): string => (type === 'field' ? 'pkg_object.title' : `pkg_${type}`); |
| 58 | + |
| 59 | +/** What a code package's loader registered: one artifact per registry type, package-stamped. */ |
| 60 | +function packagedArtifacts(): Map<string, Map<string, Record<string, unknown>>> { |
| 61 | + const out = new Map<string, Map<string, Record<string, unknown>>>(); |
| 62 | + for (const { type } of DEFAULT_METADATA_TYPE_REGISTRY) { |
| 63 | + if (type === 'field') continue; // shipped inside `pkg_object`, below |
| 64 | + const name = packagedName(type); |
| 65 | + out.set(type, new Map([[name, { |
| 66 | + name, |
| 67 | + label: name, |
| 68 | + ...(type === 'object' ? { fields: { title: { type: 'text', label: 'Title' } } } : {}), |
| 69 | + _packageId: PACKAGE_ID, |
| 70 | + _provenance: 'package', |
| 71 | + }]])); |
| 72 | + } |
| 73 | + return out; |
| 74 | +} |
| 75 | + |
| 76 | +/** |
| 77 | + * The engine double: the registry answers what the loader registered; the |
| 78 | + * store holds nothing; `insert` records every row it is handed, so a refused |
| 79 | + * row can be proven unpersisted. `manifests` holds the booted code package, |
| 80 | + * which makes it a read-only base (`isWritablePackage`). |
| 81 | + */ |
| 82 | +function harness(environmentId: string | undefined) { |
| 83 | + const artifacts = packagedArtifacts(); |
| 84 | + const inserted: Array<{ table: string; data: Record<string, unknown> }> = []; |
| 85 | + const registry = { |
| 86 | + getArtifactItem(type: string, name: string) { |
| 87 | + const hit = artifacts.get(type)?.get(name); |
| 88 | + return hit && isCodeArtifactBody(hit) ? hit : undefined; |
| 89 | + }, |
| 90 | + getItem(type: string, name: string) { |
| 91 | + return artifacts.get(type)?.get(name); |
| 92 | + }, |
| 93 | + listItems(type: string) { |
| 94 | + return [...(artifacts.get(type)?.values() ?? [])]; |
| 95 | + }, |
| 96 | + getObject: () => undefined, |
| 97 | + registerObject: () => undefined, |
| 98 | + registerItem: () => undefined, |
| 99 | + getPackage: () => undefined, |
| 100 | + isPackageDisabled: () => false, |
| 101 | + applyNavContributions: (app: unknown) => app, |
| 102 | + }; |
| 103 | + const engine: any = { |
| 104 | + async find(_table: string, opts?: { limit?: number }) { |
| 105 | + // `check:objectql-double-limit` — the caller's bound, applied after the (empty) filter. |
| 106 | + const matched: unknown[] = []; |
| 107 | + return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); |
| 108 | + }, |
| 109 | + async findOne(table: string, opts?: { where?: Record<string, unknown> }) { |
| 110 | + // `check:engine-double-contract` — refuses what the real engine refuses. |
| 111 | + assertEngineFindOnePredicate(table, opts); |
| 112 | + return null; |
| 113 | + }, |
| 114 | + async insert(table: string, data: Record<string, unknown>) { |
| 115 | + inserted.push({ table, data }); |
| 116 | + return { id: `r_${inserted.length}` }; |
| 117 | + }, |
| 118 | + manifests: new Map([[PACKAGE_ID, {}]]), |
| 119 | + registry, |
| 120 | + }; |
| 121 | + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId); |
| 122 | + const persisted = () => inserted.filter((r) => r.table === 'sys_metadata'); |
| 123 | + return { protocol, persisted }; |
| 124 | +} |
| 125 | + |
| 126 | +/** A body the runtime authoring gate refuses and the spec parse accepts: the autonumber names a field the object lacks. */ |
| 127 | +const gateRefusedObject = (name: string) => ({ |
| 128 | + name, |
| 129 | + label: name, |
| 130 | + sharingModel: 'private', |
| 131 | + fields: { |
| 132 | + title: { type: 'text', label: 'Title' }, |
| 133 | + task_no: { type: 'autonumber', label: 'Task No', autonumberFormat: '{plan_no}{000}' }, |
| 134 | + }, |
| 135 | +}); |
| 136 | + |
| 137 | +/** A body the spec-conformance parse refuses: an undeclared top-level key. */ |
| 138 | +const specRefused = (body: Record<string, unknown>) => ({ ...body, zz_undeclared_key: 1 }); |
| 139 | + |
| 140 | +/** A body every check before the store accepts (the `sharingModel` keeps `security-owd-unset` quiet). */ |
| 141 | +const servedObject = (name: string) => ({ |
| 142 | + name, |
| 143 | + label: name, |
| 144 | + sharingModel: 'private', |
| 145 | + fields: { title: { type: 'text', label: 'Title' } }, |
| 146 | +}); |
| 147 | + |
| 148 | +interface Probe { |
| 149 | + envelope: Envelope; |
| 150 | + issues: unknown; |
| 151 | + gateReached: boolean; |
| 152 | + persistedRows: number; |
| 153 | +} |
| 154 | + |
| 155 | +async function probe( |
| 156 | + environmentId: string | undefined, |
| 157 | + request: { type: string; name: string; item: unknown; mode?: 'draft' | 'publish'; packageId?: string }, |
| 158 | +): Promise<Probe> { |
| 159 | + const { protocol, persisted } = harness(environmentId); |
| 160 | + const gate = vi.spyOn(protocol as any, 'assertRuntimeAuthoringRules'); |
| 161 | + try { |
| 162 | + const outcome = await protocol.saveMetaItem(request).then( |
| 163 | + () => null, |
| 164 | + (e: unknown) => e as Record<string, unknown>, |
| 165 | + ); |
| 166 | + expect(outcome, `${request.type}/${request.name}: the save was ADMITTED`).not.toBeNull(); |
| 167 | + return { |
| 168 | + envelope: { code: outcome!.code, status: outcome!.status }, |
| 169 | + issues: outcome!.issues, |
| 170 | + gateReached: gate.mock.calls.length > 0, |
| 171 | + persistedRows: persisted().length, |
| 172 | + }; |
| 173 | + } finally { |
| 174 | + gate.mockRestore(); |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +const NOT_OVERRIDABLE: Envelope = { code: 'NOT_OVERRIDABLE', status: 403 }; |
| 179 | +const ITEM_LOCKED: Envelope = { code: 'ITEM_LOCKED', status: 403 }; |
| 180 | +const INVALID_METADATA: Envelope = { code: 'INVALID_METADATA', status: 422 }; |
| 181 | + |
| 182 | +let warn: ReturnType<typeof vi.spyOn>; |
| 183 | +beforeEach(() => { |
| 184 | + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 185 | +}); |
| 186 | +afterEach(() => { |
| 187 | + warn.mockRestore(); |
| 188 | + delete process.env.OS_METADATA_WRITABLE; |
| 189 | + ObjectStackProtocolImplementation.resetEnvWritableCache(); |
| 190 | + resetEnvWritableMetadataTypes(); |
| 191 | +}); |
| 192 | + |
| 193 | +describe('[#22220] a packaged publish answers the package door on both kernels', () => { |
| 194 | + interface Row { |
| 195 | + label: string; |
| 196 | + request: { type: string; name: string; item: unknown; mode?: 'draft' | 'publish'; packageId?: string }; |
| 197 | + expected: Envelope; |
| 198 | + } |
| 199 | + const rows: Row[] = [ |
| 200 | + { |
| 201 | + label: 'packaged object, the served body, publish', |
| 202 | + request: { type: 'object', name: 'pkg_object', item: servedObject('pkg_object') }, |
| 203 | + expected: NOT_OVERRIDABLE, |
| 204 | + }, |
| 205 | + { |
| 206 | + label: 'packaged object, a body the authoring gate refuses, publish', |
| 207 | + request: { type: 'object', name: 'pkg_object', item: gateRefusedObject('pkg_object') }, |
| 208 | + expected: NOT_OVERRIDABLE, |
| 209 | + }, |
| 210 | + { |
| 211 | + label: 'packaged object, a body the authoring gate refuses, draft (drafts stay ungated)', |
| 212 | + request: { type: 'object', name: 'pkg_object', item: gateRefusedObject('pkg_object'), mode: 'draft' }, |
| 213 | + expected: NOT_OVERRIDABLE, |
| 214 | + }, |
| 215 | + { |
| 216 | + label: 'packaged object, a body the spec parse refuses, publish', |
| 217 | + request: { type: 'object', name: 'pkg_object', item: specRefused(servedObject('pkg_object')) }, |
| 218 | + expected: NOT_OVERRIDABLE, |
| 219 | + }, |
| 220 | + { |
| 221 | + label: 'packaged object, a body the spec parse refuses, draft', |
| 222 | + request: { type: 'object', name: 'pkg_object', item: specRefused(servedObject('pkg_object')), mode: 'draft' }, |
| 223 | + expected: NOT_OVERRIDABLE, |
| 224 | + }, |
| 225 | + { |
| 226 | + label: 'packaged object named under its read-only package, a body the authoring gate refuses, publish', |
| 227 | + request: { type: 'object', name: 'pkg_object', item: gateRefusedObject('pkg_object'), packageId: PACKAGE_ID }, |
| 228 | + expected: ITEM_LOCKED, |
| 229 | + }, |
| 230 | + { |
| 231 | + label: 'packaged position (security domain), a body the spec parse refuses, publish', |
| 232 | + request: { type: 'position', name: 'pkg_position', item: specRefused({ name: 'pkg_position', label: 'Pkg' }) }, |
| 233 | + expected: NOT_OVERRIDABLE, |
| 234 | + }, |
| 235 | + { |
| 236 | + label: 'packaged permission (security domain), a body the spec parse refuses, publish', |
| 237 | + request: { type: 'permission', name: 'pkg_permission', item: specRefused({ name: 'pkg_permission', label: 'Pkg' }) }, |
| 238 | + expected: NOT_OVERRIDABLE, |
| 239 | + }, |
| 240 | + ]; |
| 241 | + |
| 242 | + for (const row of rows) { |
| 243 | + for (const [kernel, environmentId] of KERNELS) { |
| 244 | + it(`${row.label} → ${row.expected.status} ${row.expected.code} (${kernel} kernel)`, async () => { |
| 245 | + const result = await probe(environmentId, row.request); |
| 246 | + expect(result.envelope).toEqual(row.expected); |
| 247 | + expect(result.gateReached, 'the door answers before the authoring gate').toBe(false); |
| 248 | + expect(result.persistedRows, 'a refusal persists nothing').toBe(0); |
| 249 | + }); |
| 250 | + } |
| 251 | + } |
| 252 | +}); |
| 253 | + |
| 254 | +describe('[#22220] controls: the gates still judge what the door does not refuse', () => { |
| 255 | + it('an environment-local object with a body the authoring gate refuses → 422 INVALID_METADATA, gate reached (both kernels)', async () => { |
| 256 | + for (const [kernel, environmentId] of KERNELS) { |
| 257 | + const result = await probe(environmentId, { type: 'object', name: 'local_object', item: gateRefusedObject('local_object') }); |
| 258 | + expect(result.envelope, kernel).toEqual(INVALID_METADATA); |
| 259 | + expect(result.gateReached, kernel).toBe(true); |
| 260 | + // The ONE finding, so the rest of the body is gate-clean: the packaged rows above are refused for the door alone. |
| 261 | + const rules = (result.issues as Array<{ rule?: string }>).map((i) => i.rule); |
| 262 | + expect(rules, kernel).toEqual(['autonumber-references-unknown-field']); |
| 263 | + expect(result.persistedRows, kernel).toBe(0); |
| 264 | + } |
| 265 | + }); |
| 266 | + |
| 267 | + it('an environment-local object with a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => { |
| 268 | + for (const [kernel, environmentId] of KERNELS) { |
| 269 | + const result = await probe(environmentId, { type: 'object', name: 'local_object', item: specRefused(servedObject('local_object')) }); |
| 270 | + expect(result.envelope, kernel).toEqual(INVALID_METADATA); |
| 271 | + const codes = (result.issues as Array<{ code?: string }>).map((i) => i.code); |
| 272 | + expect(codes, kernel).toContain('unrecognized_keys'); |
| 273 | + expect(result.persistedRows, kernel).toBe(0); |
| 274 | + } |
| 275 | + }); |
| 276 | + |
| 277 | + it('a packaged item of a type that allows overlays (view) with a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => { |
| 278 | + for (const [kernel, environmentId] of KERNELS) { |
| 279 | + const result = await probe(environmentId, { |
| 280 | + type: 'view', name: 'pkg_view', item: specRefused({ name: 'pkg_view', label: 'Pkg' }), |
| 281 | + }); |
| 282 | + expect(result.envelope, kernel).toEqual(INVALID_METADATA); |
| 283 | + expect(result.persistedRows, kernel).toBe(0); |
| 284 | + } |
| 285 | + }); |
| 286 | + |
| 287 | + it('a packaged object with the OS_METADATA_WRITABLE hatch open and a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => { |
| 288 | + process.env.OS_METADATA_WRITABLE = 'object'; |
| 289 | + ObjectStackProtocolImplementation.resetEnvWritableCache(); |
| 290 | + resetEnvWritableMetadataTypes(); |
| 291 | + for (const [kernel, environmentId] of KERNELS) { |
| 292 | + const result = await probe(environmentId, { type: 'object', name: 'pkg_object', item: specRefused(servedObject('pkg_object')) }); |
| 293 | + expect(result.envelope, kernel).toEqual(INVALID_METADATA); |
| 294 | + expect(result.persistedRows, kernel).toBe(0); |
| 295 | + } |
| 296 | + }); |
| 297 | +}); |
| 298 | + |
| 299 | +describe('[#22220] every registry type: one packaged publish, one envelope, on both kernels', () => { |
| 300 | + const governed = new Set( |
| 301 | + DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => !e.allowOrgOverride && e.allowRuntimeCreate).map((e) => e.type), |
| 302 | + ); |
| 303 | + |
| 304 | + for (const { type } of DEFAULT_METADATA_TYPE_REGISTRY) { |
| 305 | + it(`${type}${governed.has(type) ? ' (the door governs it)' : ''}`, async () => { |
| 306 | + const name = packagedName(type); |
| 307 | + const request = { type, name, item: specRefused({ name, label: name }) }; |
| 308 | + const byKernel: Record<string, Envelope> = {}; |
| 309 | + for (const [kernel, environmentId] of KERNELS) { |
| 310 | + const result = await probe(environmentId, request); |
| 311 | + byKernel[kernel] = result.envelope; |
| 312 | + expect(result.persistedRows, `${type} (${kernel})`).toBe(0); |
| 313 | + } |
| 314 | + expect(byKernel['host-config'], `${type}: the kernels disagree`).toEqual(byKernel.environment); |
| 315 | + if (governed.has(type)) expect(byKernel.environment, type).toEqual(NOT_OVERRIDABLE); |
| 316 | + }); |
| 317 | + } |
| 318 | + |
| 319 | + it('lit control: the door governs object, position and permission, and leaves view to the gates', () => { |
| 320 | + expect([...governed]).toEqual(expect.arrayContaining(['object', 'position', 'permission'])); |
| 321 | + expect(governed.has('view')).toBe(false); |
| 322 | + }); |
| 323 | +}); |
0 commit comments