|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0010 §3.3 — artifact protection envelope vs. registry shadows. |
| 5 | + * |
| 6 | + * Regression suite for the "registry pollution" bug: on a control-plane |
| 7 | + * kernel (`environmentId === undefined`), PUT /meta/app/<name> on a |
| 8 | + * `_lock: full` artifact-backed app succeeded (the L3 gate is |
| 9 | + * intentionally bypassed there), and the next GET list hydrated the |
| 10 | + * overlay body into the SchemaRegistry under the PLAIN key — shadowing |
| 11 | + * the packaged artifact registered under `<packageId>:<name>`. Every |
| 12 | + * envelope reader (`lookupArtifactItem` / `getEffectiveLock` / |
| 13 | + * `isArtifactBacked`) resolved the shadow instead of the artifact, so |
| 14 | + * `_lock`/`_packageId`/`_provenance` read back as undefined. A |
| 15 | + * subsequent DELETE (reset) removed the sys_metadata row but left the |
| 16 | + * shadow in place — the lock stayed lost until restart. |
| 17 | + * |
| 18 | + * Pinned here: |
| 19 | + * 1. The hydrated shadow carries the artifact's protection envelope. |
| 20 | + * 2. The list/get surfaces keep `_lock` through PUT → GET → DELETE. |
| 21 | + * 3. Reset heals the registry: the artifact value is visible again. |
| 22 | + * 4. Lock enforcement on scoped kernels is shadow-immune. |
| 23 | + */ |
| 24 | + |
| 25 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 26 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 27 | +import { ObjectQL } from './engine.js'; |
| 28 | +import { SchemaRegistry } from './registry.js'; |
| 29 | + |
| 30 | +const PKG = 'com.objectstack.test-pkg'; |
| 31 | + |
| 32 | +const sysMetadataObject = { |
| 33 | + name: 'sys_metadata', |
| 34 | + label: 'System Metadata', |
| 35 | + fields: { |
| 36 | + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, |
| 37 | + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, |
| 38 | + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, |
| 39 | + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, |
| 40 | + metadata: { name: 'metadata', label: 'Body', type: 'longtext' as const }, |
| 41 | + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, |
| 42 | + state: { name: 'state', label: 'State', type: 'text' as const }, |
| 43 | + version: { name: 'version', label: 'Version', type: 'number' as const }, |
| 44 | + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, |
| 45 | + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, |
| 46 | + }, |
| 47 | +}; |
| 48 | + |
| 49 | +const sysMetadataHistoryObject = { |
| 50 | + name: 'sys_metadata_history', |
| 51 | + label: 'Metadata History', |
| 52 | + fields: { |
| 53 | + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, |
| 54 | + event_seq: { name: 'event_seq', label: 'Seq', type: 'number' as const, required: true }, |
| 55 | + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, |
| 56 | + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, |
| 57 | + version: { name: 'version', label: 'Version', type: 'number' as const, required: true }, |
| 58 | + operation_type: { name: 'operation_type', label: 'Op', type: 'text' as const, required: true }, |
| 59 | + metadata: { name: 'metadata', label: 'Body', type: 'longtext' as const }, |
| 60 | + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, |
| 61 | + previous_checksum: { name: 'previous_checksum', label: 'Prev', type: 'text' as const, maxLength: 71 }, |
| 62 | + change_note: { name: 'change_note', label: 'Note', type: 'longtext' as const }, |
| 63 | + source: { name: 'source', label: 'Source', type: 'text' as const }, |
| 64 | + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, |
| 65 | + recorded_by: { name: 'recorded_by', label: 'By', type: 'text' as const }, |
| 66 | + recorded_at: { name: 'recorded_at', label: 'At', type: 'datetime' as const, required: true }, |
| 67 | + }, |
| 68 | +}; |
| 69 | + |
| 70 | +/** Equality-only in-memory driver — same shape as the PR-10d.4 suite. */ |
| 71 | +function makeMemoryDriver() { |
| 72 | + const stores = new Map<string, Map<string, Record<string, unknown>>>(); |
| 73 | + const storeFor = (obj: string) => { |
| 74 | + let s = stores.get(obj); |
| 75 | + if (!s) { s = new Map(); stores.set(obj, s); } |
| 76 | + return s; |
| 77 | + }; |
| 78 | + let nextId = 0; |
| 79 | + |
| 80 | + const matchesWhere = (row: Record<string, unknown>, where: any): boolean => { |
| 81 | + if (!where || typeof where !== 'object') return true; |
| 82 | + if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w)); |
| 83 | + if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w)); |
| 84 | + for (const [k, v] of Object.entries(where)) { |
| 85 | + if (k.startsWith('$')) continue; |
| 86 | + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; |
| 87 | + const a = row[k] === undefined ? null : row[k]; |
| 88 | + const b = expected === undefined ? null : expected; |
| 89 | + if (a !== b) return false; |
| 90 | + } |
| 91 | + return true; |
| 92 | + }; |
| 93 | + |
| 94 | + const driver: any = { |
| 95 | + name: 'memory', |
| 96 | + version: '0.0.0', |
| 97 | + supports: {} as any, |
| 98 | + async connect() {}, |
| 99 | + async disconnect() {}, |
| 100 | + async checkHealth() { return true; }, |
| 101 | + async execute() { return null; }, |
| 102 | + async find(object: string, ast: any) { |
| 103 | + return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); |
| 104 | + }, |
| 105 | + findStream() { throw new Error('not implemented'); }, |
| 106 | + async findOne(object: string, ast: any) { |
| 107 | + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; |
| 108 | + return null; |
| 109 | + }, |
| 110 | + async create(object: string, data: Record<string, unknown>) { |
| 111 | + nextId += 1; |
| 112 | + const id = (data.id as string) ?? `r_${nextId}`; |
| 113 | + const row = { ...data, id }; |
| 114 | + storeFor(object).set(id, row); |
| 115 | + return row; |
| 116 | + }, |
| 117 | + async update(object: string, id: string, data: Record<string, unknown>) { |
| 118 | + const s = storeFor(object); |
| 119 | + const cur = s.get(id); |
| 120 | + if (!cur) throw new Error(`not found: ${object}/${id}`); |
| 121 | + const updated = { ...cur, ...data, id }; |
| 122 | + s.set(id, updated); |
| 123 | + return updated; |
| 124 | + }, |
| 125 | + async upsert(object: string, data: Record<string, unknown>) { |
| 126 | + const id = data.id as string | undefined; |
| 127 | + if (id && storeFor(object).has(id)) return this.update(object, id, data); |
| 128 | + return this.create(object, data); |
| 129 | + }, |
| 130 | + async delete(object: string, id: string) { |
| 131 | + return storeFor(object).delete(id); |
| 132 | + }, |
| 133 | + async count(object: string, ast: any) { |
| 134 | + return (await this.find(object, ast)).length; |
| 135 | + }, |
| 136 | + async bulkCreate(object: string, rows: Record<string, unknown>[]) { |
| 137 | + return Promise.all(rows.map((r) => this.create(object, r))); |
| 138 | + }, |
| 139 | + async bulkUpdate() { return []; }, |
| 140 | + async bulkDelete() {}, |
| 141 | + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, |
| 142 | + async commit() {}, |
| 143 | + async rollback() {}, |
| 144 | + }; |
| 145 | + return { driver, stores }; |
| 146 | +} |
| 147 | + |
| 148 | +/** Artifact app shipped by a code package with a hard lock. */ |
| 149 | +function artifactApp() { |
| 150 | + return { |
| 151 | + name: 'setup', |
| 152 | + label: 'Setup', |
| 153 | + navigation: [], |
| 154 | + _packageId: PKG, |
| 155 | + _packageVersion: '1.0.0', |
| 156 | + _provenance: 'package', |
| 157 | + _lock: 'full', |
| 158 | + _lockReason: 'Core admin UI shipped by the platform package.', |
| 159 | + }; |
| 160 | +} |
| 161 | + |
| 162 | +const overlayBody = { name: 'setup', label: 'Setup HACKED', navigation: [] }; |
| 163 | + |
| 164 | +function findByName(items: any[], name: string): any { |
| 165 | + return (items as any[]).find((it) => it?.name === name); |
| 166 | +} |
| 167 | + |
| 168 | +describe('registry shadow — control-plane PUT → GET → DELETE keeps the artifact envelope', () => { |
| 169 | + let engine: ObjectQL; |
| 170 | + let protocol: ObjectStackProtocolImplementation; |
| 171 | + |
| 172 | + beforeEach(async () => { |
| 173 | + engine = new ObjectQL(); |
| 174 | + const { driver } = makeMemoryDriver(); |
| 175 | + engine.registerDriver(driver, true); |
| 176 | + await engine.init(); |
| 177 | + engine.registry.registerObject(sysMetadataObject as any); |
| 178 | + engine.registry.registerObject(sysMetadataHistoryObject as any); |
| 179 | + engine.registry.registerItem('app', artifactApp(), 'name', PKG); |
| 180 | + // No environmentId — single-kernel / control-plane mode, where the |
| 181 | + // L3 lock gate is bypassed and the GET list hydrates overlay rows |
| 182 | + // into the process-wide registry. |
| 183 | + protocol = new ObjectStackProtocolImplementation(engine); |
| 184 | + }); |
| 185 | + |
| 186 | + it('GET list while the overlay row exists: overlay content wins, artifact envelope wins', async () => { |
| 187 | + await protocol.saveMetaItem({ type: 'app', name: 'setup', item: { ...overlayBody } }); |
| 188 | + |
| 189 | + const res = await protocol.getMetaItems({ type: 'app' }); |
| 190 | + const setup = findByName((res as any).items, 'setup'); |
| 191 | + expect(setup).toBeDefined(); |
| 192 | + expect(setup.label).toBe('Setup HACKED'); // overlay content |
| 193 | + expect(setup._lock).toBe('full'); // artifact envelope (ADR-0010 §3.3) |
| 194 | + expect(setup._packageId).toBe(PKG); |
| 195 | + expect(setup._provenance).toBe('package'); |
| 196 | + }); |
| 197 | + |
| 198 | + it('the hydrated plain-key shadow itself carries the artifact envelope', async () => { |
| 199 | + await protocol.saveMetaItem({ type: 'app', name: 'setup', item: { ...overlayBody } }); |
| 200 | + await protocol.getMetaItems({ type: 'app' }); // triggers hydration |
| 201 | + |
| 202 | + // Registry-direct read (what nav/UI code does) must not see a |
| 203 | + // stripped envelope even though the overlay body shadows the |
| 204 | + // artifact on the plain key. |
| 205 | + const direct: any = engine.registry.getItem('app', 'setup'); |
| 206 | + expect(direct.label).toBe('Setup HACKED'); |
| 207 | + expect(direct._lock).toBe('full'); |
| 208 | + expect(direct._packageId).toBe(PKG); |
| 209 | + }); |
| 210 | + |
| 211 | + it('DELETE (reset) heals the registry: artifact value and lock are back without a restart', async () => { |
| 212 | + await protocol.saveMetaItem({ type: 'app', name: 'setup', item: { ...overlayBody } }); |
| 213 | + await protocol.getMetaItems({ type: 'app' }); // pollute via hydration |
| 214 | + |
| 215 | + const del = await protocol.deleteMetaItem({ type: 'app', name: 'setup' }); |
| 216 | + expect(del.success).toBe(true); |
| 217 | + expect(del.reset).toBe(true); |
| 218 | + |
| 219 | + // Registry-direct read resolves the packaged artifact again. |
| 220 | + const direct: any = engine.registry.getItem('app', 'setup'); |
| 221 | + expect(direct.label).toBe('Setup'); |
| 222 | + expect(direct._lock).toBe('full'); |
| 223 | + expect(direct._packageId).toBe(PKG); |
| 224 | + |
| 225 | + // And the protocol list surface agrees. |
| 226 | + const res = await protocol.getMetaItems({ type: 'app' }); |
| 227 | + const setup = findByName((res as any).items, 'setup'); |
| 228 | + expect(setup.label).toBe('Setup'); |
| 229 | + expect(setup._lock).toBe('full'); |
| 230 | + expect(setup._packageId).toBe(PKG); |
| 231 | + }); |
| 232 | + |
| 233 | + it('a second DELETE self-heals pre-existing pollution even with no overlay row', async () => { |
| 234 | + // Simulate the pre-fix world: overlay body sits on the plain key |
| 235 | + // with a stripped envelope, and the sys_metadata row is gone. |
| 236 | + engine.registry.registerItem('app', { ...overlayBody }, 'name'); |
| 237 | + |
| 238 | + const del = await protocol.deleteMetaItem({ type: 'app', name: 'setup' }); |
| 239 | + expect(del.success).toBe(true); |
| 240 | + expect(del.reset).toBe(false); // no row to delete… |
| 241 | + |
| 242 | + // …but the registry shadow is healed anyway. |
| 243 | + const direct: any = engine.registry.getItem('app', 'setup'); |
| 244 | + expect(direct.label).toBe('Setup'); |
| 245 | + expect(direct._lock).toBe('full'); |
| 246 | + }); |
| 247 | +}); |
| 248 | + |
| 249 | +describe('registry shadow — scoped-kernel lock enforcement is shadow-immune', () => { |
| 250 | + it('saveMetaItem still 403s on a full-locked artifact when a stripped shadow exists', async () => { |
| 251 | + const registry = new SchemaRegistry({ multiTenant: false }); |
| 252 | + registry.registerItem('app', artifactApp(), 'name', PKG); |
| 253 | + // Pre-fix pollution: plain-key shadow without the lock envelope. |
| 254 | + registry.registerItem('app', { ...overlayBody }, 'name'); |
| 255 | + |
| 256 | + const mockEngine: any = { |
| 257 | + registry, |
| 258 | + find: async () => [], |
| 259 | + findOne: async () => null, |
| 260 | + insert: async () => ({ id: 'x' }), |
| 261 | + update: async () => ({ id: 'x' }), |
| 262 | + delete: async () => ({ deleted: 1 }), |
| 263 | + }; |
| 264 | + const protocol = new ObjectStackProtocolImplementation( |
| 265 | + mockEngine, undefined, undefined, 'env_prod', |
| 266 | + ); |
| 267 | + |
| 268 | + await expect(protocol.saveMetaItem({ |
| 269 | + type: 'app', name: 'setup', organizationId: 'org_a', |
| 270 | + item: { ...overlayBody }, |
| 271 | + })).rejects.toMatchObject({ code: 'item_locked', status: 403 }); |
| 272 | + |
| 273 | + await expect(protocol.deleteMetaItem({ |
| 274 | + type: 'app', name: 'setup', organizationId: 'org_a', |
| 275 | + })).rejects.toMatchObject({ code: 'item_locked', status: 403 }); |
| 276 | + }); |
| 277 | +}); |
| 278 | + |
| 279 | +describe('SchemaRegistry.getArtifactItem / removeRuntimeShadow', () => { |
| 280 | + it('getArtifactItem prefers the composite-key artifact over a plain-key shadow', () => { |
| 281 | + const registry = new SchemaRegistry({ multiTenant: false }); |
| 282 | + registry.registerItem('app', artifactApp(), 'name', PKG); |
| 283 | + registry.registerItem('app', { ...overlayBody }, 'name'); |
| 284 | + |
| 285 | + expect((registry.getItem('app', 'setup') as any).label).toBe('Setup HACKED'); |
| 286 | + const artifact: any = registry.getArtifactItem('app', 'setup'); |
| 287 | + expect(artifact.label).toBe('Setup'); |
| 288 | + expect(artifact._lock).toBe('full'); |
| 289 | + }); |
| 290 | + |
| 291 | + it('getArtifactItem returns undefined for runtime-only and sys_metadata-sentinel items', () => { |
| 292 | + const registry = new SchemaRegistry({ multiTenant: false }); |
| 293 | + registry.registerItem('app', { name: 'mine', label: 'Mine' }, 'name'); |
| 294 | + registry.registerItem( |
| 295 | + 'app', |
| 296 | + { name: 'hydrated', label: 'Hydrated', _packageId: 'sys_metadata' }, |
| 297 | + 'name', |
| 298 | + ); |
| 299 | + expect(registry.getArtifactItem('app', 'mine')).toBeUndefined(); |
| 300 | + expect(registry.getArtifactItem('app', 'hydrated')).toBeUndefined(); |
| 301 | + }); |
| 302 | + |
| 303 | + it('removeRuntimeShadow deletes the plain key only when a packaged artifact remains', () => { |
| 304 | + const registry = new SchemaRegistry({ multiTenant: false }); |
| 305 | + registry.registerItem('app', artifactApp(), 'name', PKG); |
| 306 | + registry.registerItem('app', { ...overlayBody }, 'name'); |
| 307 | + |
| 308 | + expect(registry.removeRuntimeShadow('app', 'setup')).toBe(true); |
| 309 | + expect((registry.getItem('app', 'setup') as any).label).toBe('Setup'); |
| 310 | + |
| 311 | + // Runtime-only item: never removed. |
| 312 | + registry.registerItem('app', { name: 'mine', label: 'Mine' }, 'name'); |
| 313 | + expect(registry.removeRuntimeShadow('app', 'mine')).toBe(false); |
| 314 | + expect((registry.getItem('app', 'mine') as any)?.label).toBe('Mine'); |
| 315 | + }); |
| 316 | +}); |
0 commit comments