|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21911, ADR-0096] The metadata protocol's platform-store calls carry the |
| 5 | + * EXPLICIT system opt-in, never a principal-less context. |
| 6 | + * |
| 7 | + * plugin-security hands an engine operation whose context carries no user, no |
| 8 | + * position, no permission set and no `isSystem` straight to `next()` (the |
| 9 | + * ADR-0096 E1 hand-off). The protocol's own reads and writes of the |
| 10 | + * `sys_metadata` family reached the engine exactly that way — a `{ where }` |
| 11 | + * with no `context`, or a repository transaction context with only its |
| 12 | + * handle — so they worked only because the hand-off let them through. They |
| 13 | + * now pass `context: { isSystem: true }` (inside a repository transaction, |
| 14 | + * `{ ...ctx, isSystem: true }`, so the handle rides along), the opt-in that |
| 15 | + * already exists; nothing about what any door authorizes moves. |
| 16 | + * |
| 17 | + * The observation channel is the engine double's own record of the context |
| 18 | + * each call arrived with. Every step asserts it recorded at least one call |
| 19 | + * (so a step that stops reaching the store reds instead of passing over |
| 20 | + * nothing) and that every recorded call carried `isSystem: true`. |
| 21 | + * |
| 22 | + * The double is the pinned shape of `protocol-publish-drafts-org-scope.test.ts` |
| 23 | + * (its write verbs and `findOne` open with the producer's own dispatch |
| 24 | + * predicates), plus that recorder and a transaction that hands its callback a |
| 25 | + * real handle object, as `ObjectQL.transaction` does. |
| 26 | + */ |
| 27 | + |
| 28 | +import { describe, expect, it } from 'vitest'; |
| 29 | +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; |
| 30 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 31 | + |
| 32 | +interface Row { |
| 33 | + id: string; |
| 34 | + type: string; |
| 35 | + name: string; |
| 36 | + organization_id: string | null; |
| 37 | + package_id: string | null; |
| 38 | + state: string; |
| 39 | + metadata: string; |
| 40 | + [k: string]: unknown; |
| 41 | +} |
| 42 | + |
| 43 | +interface HistoryRow { |
| 44 | + id: string; |
| 45 | + organization_id: string | null; |
| 46 | + type: string; |
| 47 | + name: string; |
| 48 | + version: number; |
| 49 | + [k: string]: unknown; |
| 50 | +} |
| 51 | + |
| 52 | +interface EngineCall { |
| 53 | + verb: string; |
| 54 | + table: string; |
| 55 | + context: Record<string, unknown> | undefined; |
| 56 | +} |
| 57 | + |
| 58 | +const TRX = { trx: 'handle' }; |
| 59 | + |
| 60 | +function keyOf(w: Record<string, unknown>) { |
| 61 | + return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; |
| 62 | +} |
| 63 | + |
| 64 | +function matchesMetadataWhere(r: Row, where: Record<string, unknown>): boolean { |
| 65 | + for (const [k, v] of Object.entries(where)) { |
| 66 | + if (k === '$or') { |
| 67 | + const clauses = v as Array<Record<string, unknown>>; |
| 68 | + if (!clauses.some((c) => matchesMetadataWhere(r, c))) return false; |
| 69 | + continue; |
| 70 | + } |
| 71 | + if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); |
| 72 | + if (v === undefined) continue; |
| 73 | + if (r[k] !== v) return false; |
| 74 | + } |
| 75 | + return true; |
| 76 | +} |
| 77 | + |
| 78 | +function makeStubEngine() { |
| 79 | + const rows = new Map<string, Row>(); |
| 80 | + const historyRows: HistoryRow[] = []; |
| 81 | + const calls: EngineCall[] = []; |
| 82 | + let nextId = 0; |
| 83 | + |
| 84 | + const record = (verb: string, table: string, context: unknown) => { |
| 85 | + calls.push({ verb, table, context: context as Record<string, unknown> | undefined }); |
| 86 | + }; |
| 87 | + |
| 88 | + const findRow = (w: Record<string, unknown>): { key: string; row: Row } | null => { |
| 89 | + if (w.id !== undefined) { |
| 90 | + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; |
| 91 | + return null; |
| 92 | + } |
| 93 | + for (const [k, r] of rows) if (matchesMetadataWhere(r, w)) return { key: k, row: r }; |
| 94 | + return null; |
| 95 | + }; |
| 96 | + |
| 97 | + const matchesHistory = (h: HistoryRow, w: Record<string, unknown>): boolean => { |
| 98 | + for (const k of ['organization_id', 'type', 'name', 'version', 'checksum'] as const) { |
| 99 | + if (w[k] !== undefined && h[k] !== w[k]) return false; |
| 100 | + } |
| 101 | + return true; |
| 102 | + }; |
| 103 | + |
| 104 | + const engine: any = { |
| 105 | + async findOne(table: string, opts: { where: Record<string, unknown>; context?: unknown }) { |
| 106 | + assertEngineFindOnePredicate(table, opts); |
| 107 | + record('findOne', table, opts.context); |
| 108 | + if (table === 'sys_metadata_history') { |
| 109 | + return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null; |
| 110 | + } |
| 111 | + return findRow(opts.where)?.row ?? null; |
| 112 | + }, |
| 113 | + async find(table: string, opts: { where: Record<string, unknown>; limit?: number; context?: unknown }) { |
| 114 | + record('find', table, opts?.context); |
| 115 | + const hits: unknown[] = table === 'sys_metadata_history' |
| 116 | + ? historyRows.filter((h) => matchesHistory(h, opts.where ?? {})) |
| 117 | + : table === 'sys_metadata' |
| 118 | + ? Array.from(rows.values()).filter((r) => matchesMetadataWhere(r, opts.where ?? {})) |
| 119 | + : []; |
| 120 | + // The caller's bound, applied after the filter, by presence. |
| 121 | + return typeof opts?.limit === 'number' ? hits.slice(0, opts.limit) : hits; |
| 122 | + }, |
| 123 | + async insert(table: string, data: Record<string, unknown>, options?: { context?: unknown }) { |
| 124 | + record('insert', table, options?.context); |
| 125 | + nextId += 1; |
| 126 | + if (table === 'sys_metadata_history') { |
| 127 | + historyRows.push({ id: `h_${nextId}`, ...(data as any) }); |
| 128 | + return { id: `h_${nextId}` }; |
| 129 | + } |
| 130 | + if (table !== 'sys_metadata') return { id: `${table}_${nextId}` }; |
| 131 | + const row = { id: `r_${nextId}`, ...(data as any) } as Row; |
| 132 | + rows.set(keyOf(data), row); |
| 133 | + return { id: row.id }; |
| 134 | + }, |
| 135 | + async update(table: string, data: Record<string, unknown>, opts: { where: Record<string, unknown>; context?: unknown }) { |
| 136 | + assertEngineUpdateDispatch(data, opts); |
| 137 | + record('update', table, opts.context); |
| 138 | + const found = findRow(opts.where); |
| 139 | + if (!found) return { id: null }; |
| 140 | + const merged = { ...found.row, ...(data as any) }; |
| 141 | + rows.delete(found.key); |
| 142 | + rows.set(keyOf(merged), merged); |
| 143 | + return { id: found.row.id }; |
| 144 | + }, |
| 145 | + async delete(table: string, opts: { where: Record<string, unknown>; context?: unknown }) { |
| 146 | + assertEngineDeleteDispatch(opts); |
| 147 | + record('delete', table, opts.context); |
| 148 | + const found = findRow(opts.where); |
| 149 | + if (!found) return { deleted: 0 }; |
| 150 | + rows.delete(found.key); |
| 151 | + return { deleted: 1 }; |
| 152 | + }, |
| 153 | + async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> { |
| 154 | + return cb({ transaction: TRX }, { owned: true }); |
| 155 | + }, |
| 156 | + registry: { |
| 157 | + registerItem: () => undefined, |
| 158 | + registerObject: () => undefined, |
| 159 | + unregisterItem: () => undefined, |
| 160 | + listItems: () => [], |
| 161 | + getItem: () => undefined, |
| 162 | + getObject: () => undefined, |
| 163 | + getPackage: () => undefined, |
| 164 | + getArtifactItem: () => undefined, |
| 165 | + isPackageDisabled: () => false, |
| 166 | + applyNavContributions: (app: unknown) => app, |
| 167 | + }, |
| 168 | + }; |
| 169 | + return { engine, rows, historyRows, calls }; |
| 170 | +} |
| 171 | + |
| 172 | +const viewBody = (name: string) => ({ |
| 173 | + name, |
| 174 | + label: 'Project Tasks', |
| 175 | + object: 'proj_task', |
| 176 | + viewKind: 'list', |
| 177 | + columns: [{ field: 'title', label: 'Title' }], |
| 178 | +}); |
| 179 | + |
| 180 | +/** |
| 181 | + * Runs `step`, then asserts it reached the store and that every engine call |
| 182 | + * it issued carried the explicit system opt-in. |
| 183 | + */ |
| 184 | +async function expectSystemOptIn( |
| 185 | + calls: EngineCall[], |
| 186 | + label: string, |
| 187 | + step: () => Promise<unknown>, |
| 188 | +): Promise<EngineCall[]> { |
| 189 | + const from = calls.length; |
| 190 | + await step(); |
| 191 | + const issued = calls.slice(from); |
| 192 | + expect(issued.length, `${label}: reached the store`).toBeGreaterThan(0); |
| 193 | + const principalLess = issued.filter((c) => c.context?.isSystem !== true); |
| 194 | + expect(principalLess, `${label}: every engine call carries isSystem: true`).toEqual([]); |
| 195 | + return issued; |
| 196 | +} |
| 197 | + |
| 198 | +describe('platform-store calls carry the explicit system opt-in (#21911)', () => { |
| 199 | + it('the repository write path: draft save, publish, active save, rollback, delete', async () => { |
| 200 | + const { engine, calls } = makeStubEngine(); |
| 201 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 202 | + |
| 203 | + // SysMetadataRepository.put (create, draft) + the two lineage counters. |
| 204 | + const draftCalls = await expectSystemOptIn(calls, 'draft save', () => protocol.saveMetaItem({ |
| 205 | + type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), |
| 206 | + packageId: 'app.pin', mode: 'draft', |
| 207 | + })); |
| 208 | + // Inside the repository transaction the handle rides along with the opt-in. |
| 209 | + const inTxn = draftCalls.filter((c) => c.context?.transaction !== undefined); |
| 210 | + expect(inTxn.length, 'the put transaction reached the store').toBeGreaterThan(0); |
| 211 | + for (const c of inTxn) expect(c.context).toEqual({ transaction: TRX, isSystem: true }); |
| 212 | + |
| 213 | + // publishPackageDrafts: listDrafts, its pre-publish active-row read, |
| 214 | + // promoteDraft (+ put), recordMetadataAudit and persistPackageCommitRow. |
| 215 | + const publishCalls = await expectSystemOptIn(calls, 'publish', async () => { |
| 216 | + const res = await protocol.publishPackageDrafts({ packageId: 'app.pin' }); |
| 217 | + expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); |
| 218 | + }); |
| 219 | + expect(publishCalls.some((c) => c.table === 'sys_metadata_audit' && c.verb === 'insert')).toBe(true); |
| 220 | + expect(publishCalls.some((c) => c.table === 'sys_metadata_commit' && c.verb === 'insert')).toBe(true); |
| 221 | + |
| 222 | + // put (update) + the runtime authoring gate's stored-collection fold. |
| 223 | + await expectSystemOptIn(calls, 'active save', () => protocol.saveMetaItem({ |
| 224 | + type: 'view', name: 'proj_task_grid', |
| 225 | + item: { ...viewBody('proj_task_grid'), label: 'Project Tasks v2' }, |
| 226 | + packageId: 'app.pin', mode: 'publish', force: true, |
| 227 | + })); |
| 228 | + |
| 229 | + // restoreVersion (history read + active read, then put). |
| 230 | + await expectSystemOptIn(calls, 'rollback', async () => { |
| 231 | + await protocol.rollbackMetaItem({ type: 'view', name: 'proj_task_grid', toVersion: 1 } as any); |
| 232 | + }); |
| 233 | + |
| 234 | + // SysMetadataRepository.delete (findOne, delete, tombstone insert). |
| 235 | + const deleteCalls = await expectSystemOptIn(calls, 'delete', () => protocol.deleteMetaItem({ |
| 236 | + type: 'view', name: 'proj_task_grid', force: true, |
| 237 | + } as any)); |
| 238 | + expect(deleteCalls.some((c) => c.verb === 'delete' && c.table === 'sys_metadata')).toBe(true); |
| 239 | + }); |
| 240 | + |
| 241 | + it('the overlay reads: served row, lock layer, list read, draft preview', async () => { |
| 242 | + const { engine, calls } = makeStubEngine(); |
| 243 | + const protocol = new ObjectStackProtocolImplementation(engine) as any; |
| 244 | + |
| 245 | + await protocol.saveMetaItem({ |
| 246 | + type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), |
| 247 | + packageId: 'app.pin', mode: 'draft', |
| 248 | + }); |
| 249 | + |
| 250 | + // findServedOverlayRow + overlayLockLayerAt (the by-name read). |
| 251 | + await expectSystemOptIn(calls, 'by-name read', () => protocol.getMetaItem({ type: 'view', name: 'proj_task_grid' })); |
| 252 | + // readFlattenedMetaItems: readActiveOverlayRows' queryByOrg + the draft preview. |
| 253 | + await expectSystemOptIn(calls, 'list read with draft preview', () => protocol.getMetaItems({ |
| 254 | + type: 'view', previewDrafts: true, |
| 255 | + })); |
| 256 | + // Each private reader on its own, so no caller can mask one of them. |
| 257 | + await expectSystemOptIn(calls, 'findServedOverlayRow', () => protocol.findServedOverlayRow({ |
| 258 | + type: 'view', name: 'proj_task_grid', orgId: 'org_a', state: 'draft', packageId: 'app.pin', |
| 259 | + })); |
| 260 | + await expectSystemOptIn(calls, 'overlayLockLayerAt', () => protocol.overlayLockLayerAt( |
| 261 | + { type: 'view', name: 'proj_task_grid', organizationId: 'org_a' }, { otherSpelling: true }, |
| 262 | + )); |
| 263 | + await expectSystemOptIn(calls, 'readActiveOverlayRows', () => protocol.readActiveOverlayRows({ type: 'view' }, 'org_a')); |
| 264 | + await expectSystemOptIn(calls, 'foldStoredCollection', () => protocol.foldStoredCollection([], 'object', 'objects', 'org_a')); |
| 265 | + await expectSystemOptIn(calls, 'resolveOverlayPackageBinding', () => protocol.resolveOverlayPackageBinding('view', 'proj_task_grid', 'org_a')); |
| 266 | + await expectSystemOptIn(calls, 'storedFlowBindingAgrees', () => protocol.storedFlowBindingAgrees('flow_a', 'app.pin')); |
| 267 | + await expectSystemOptIn(calls, 'recordMetadataAudit', () => protocol.recordMetadataAudit({ |
| 268 | + type: 'view', name: 'proj_task_grid', operation: 'update', outcome: 'success', code: 'OK', |
| 269 | + })); |
| 270 | + await expectSystemOptIn(calls, 'persistPackageCommitRow', () => protocol.persistPackageCommitRow({ id: 'c_1' })); |
| 271 | + }); |
| 272 | + |
| 273 | + it('the package verbs: reassign orphans, duplicate, uninstall', async () => { |
| 274 | + const { engine, calls } = makeStubEngine(); |
| 275 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 276 | + |
| 277 | + await protocol.saveMetaItem({ |
| 278 | + type: 'view', name: 'orphan_grid', item: viewBody('orphan_grid'), mode: 'publish', |
| 279 | + }); |
| 280 | + |
| 281 | + const reassignCalls = await expectSystemOptIn(calls, 'reassignOrphanedMetadata', async () => { |
| 282 | + const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.pin' }); |
| 283 | + expect(res.reassignedCount).toBe(1); |
| 284 | + }); |
| 285 | + expect(reassignCalls.map((c) => c.verb)).toEqual(['find', 'update']); |
| 286 | + |
| 287 | + await expectSystemOptIn(calls, 'duplicatePackage', async () => { |
| 288 | + await protocol.duplicatePackage({ |
| 289 | + sourcePackageId: 'app.pin', targetPackageId: 'com.example.pincopy', targetNamespace: 'pincopy', |
| 290 | + } as any).catch(() => undefined); |
| 291 | + }); |
| 292 | + await expectSystemOptIn(calls, 'deletePackage', async () => { |
| 293 | + await protocol.deletePackage({ packageId: 'com.example.pincopy', allTenants: true } as any).catch(() => undefined); |
| 294 | + }); |
| 295 | + }); |
| 296 | +}); |
0 commit comments