|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0130 D4 / option B — this package's readers over a multi-package app |
| 5 | + * (#15229, reader program 5/4 of the ruling on #14512). |
| 6 | + * |
| 7 | + * The acceptance pin for the program lives in `@objectstack/cli` |
| 8 | + * (`option-b-reader-acceptance.pin.test.ts`, #15004) and measures these readers |
| 9 | + * through a booted two-package fixture. What is here instead is the CONTRACT of |
| 10 | + * the resolution itself, which that pin cannot see: that the flattened top level |
| 11 | + * still answers FIRST — including when it is an empty array — and that a |
| 12 | + * malformed `packages` refuses rather than reading as "no collections". |
| 13 | + * |
| 14 | + * Every test drives a SHIPPED reader (`deriveCrudCases`, `declaredPositionNames`, |
| 15 | + * `rlsProbePermissionSet`), never `declaredCollection` directly: a test shaped |
| 16 | + * like the helper would pass over a reader that never calls it. |
| 17 | + */ |
| 18 | + |
| 19 | +import { describe, expect, it } from 'vitest'; |
| 20 | + |
| 21 | +import { deriveCrudCases } from './derive.js'; |
| 22 | +import { declaredPositionNames, rlsProbePermissionSet } from './rls.js'; |
| 23 | + |
| 24 | +/** |
| 25 | + * One package body, as `packages[i].manifest` carries it: an |
| 26 | + * `AssembledPackageBodySchema` — `ManifestSchema` fields at the TOP of the body |
| 27 | + * with the collections beside them, never a nested `manifest` key. |
| 28 | + * `resolveArtifactPackageOrder` parses each entry whole, so these bodies are |
| 29 | + * real definitions and not sketches. |
| 30 | + */ |
| 31 | +const corePackage = { |
| 32 | + id: 'com.example.readers.core', |
| 33 | + name: 'Readers Core', |
| 34 | + version: '1.0.0', |
| 35 | + type: 'app', |
| 36 | + objects: [ |
| 37 | + { |
| 38 | + name: 'reader_account', |
| 39 | + label: 'Reader Account', |
| 40 | + fields: { name: { name: 'name', type: 'text', label: 'Name', required: true } }, |
| 41 | + }, |
| 42 | + ], |
| 43 | + datasources: [ |
| 44 | + { |
| 45 | + name: 'reader_warehouse', |
| 46 | + label: 'Reader Warehouse', |
| 47 | + driver: 'sqlite', |
| 48 | + config: { filename: '.objectstack/data/reader-warehouse.db' }, |
| 49 | + schemaMode: 'external', |
| 50 | + external: { allowWrites: true }, |
| 51 | + }, |
| 52 | + ], |
| 53 | + positions: [{ name: 'reader_position', label: 'Reader Position' }], |
| 54 | +}; |
| 55 | + |
| 56 | +const ordersPackage = { |
| 57 | + id: 'com.example.readers.orders', |
| 58 | + name: 'Readers Orders', |
| 59 | + version: '1.0.0', |
| 60 | + type: 'module', |
| 61 | + dependencies: { 'com.example.readers.core': '^1.0.0' }, |
| 62 | + objects: [ |
| 63 | + { |
| 64 | + name: 'reader_wh_order', |
| 65 | + label: 'Warehouse Order', |
| 66 | + datasource: 'reader_warehouse', |
| 67 | + external: { remoteName: 'orders', writable: true }, |
| 68 | + fields: { name: { name: 'name', type: 'text', label: 'Number', required: true } }, |
| 69 | + }, |
| 70 | + ], |
| 71 | + positions: [{ name: 'reader_second_position', label: 'Second Position' }], |
| 72 | +}; |
| 73 | + |
| 74 | +/** The option-B shape: `packages[]` carries everything, nothing is flattened. */ |
| 75 | +const optionB = () => ({ |
| 76 | + manifest: { id: 'com.example.readers.app', name: 'Readers App', version: '1.0.0', type: 'app' }, |
| 77 | + packages: [{ manifest: corePackage }, { manifest: ordersPackage }], |
| 78 | +}); |
| 79 | + |
| 80 | +describe('#15229 — `@objectstack/verify` reads its collections from `packages[]` too', () => { |
| 81 | + it('deriveCrudCases derives a case per package-owned object', () => { |
| 82 | + const cases = deriveCrudCases(optionB()); |
| 83 | + expect(cases.map((c) => c.object).sort()).toEqual(['reader_account', 'reader_wh_order']); |
| 84 | + }); |
| 85 | + |
| 86 | + it('the ADR-0015 write gate resolves the datasource from the OTHER package', () => { |
| 87 | + // The object is in `orders`, the datasource that opens the write gate is in |
| 88 | + // `core`. A reader that resolved `objects` but not `datasources` reports the |
| 89 | + // app's write-opted-in external object as read-only and skips it — a |
| 90 | + // verifier quietly proving less, which is the failure mode of this card. |
| 91 | + const federated = deriveCrudCases(optionB()).find((c) => c.object === 'reader_wh_order'); |
| 92 | + expect(federated?.blocked).toBeUndefined(); |
| 93 | + }); |
| 94 | + |
| 95 | + it('declaredPositionNames covers every package, in package order', () => { |
| 96 | + expect(declaredPositionNames(optionB())).toEqual(['reader_position', 'reader_second_position']); |
| 97 | + }); |
| 98 | + |
| 99 | + it('rlsProbePermissionSet grants AND narrows every package-owned object', () => { |
| 100 | + const set = rlsProbePermissionSet(optionB()) as unknown as { |
| 101 | + objects: Record<string, unknown>; |
| 102 | + rowLevelSecurity: Array<{ object: string; operation: string }>; |
| 103 | + }; |
| 104 | + expect(Object.keys(set.objects).sort()).toEqual(['reader_account', 'reader_wh_order']); |
| 105 | + // Both halves are load-bearing: the grants stop the OBJECT gate answering |
| 106 | + // 403 first, the owner-scoped select is what puts the persona outside the |
| 107 | + // record scope. A set with grants and no narrowing is not a probe. |
| 108 | + expect(set.rowLevelSecurity.map((r) => r.object).sort()) |
| 109 | + .toEqual(['reader_account', 'reader_wh_order']); |
| 110 | + expect(new Set(set.rowLevelSecurity.map((r) => r.operation))).toEqual(new Set(['select'])); |
| 111 | + }); |
| 112 | + |
| 113 | + describe('the flattened top level answers FIRST — `packages[]` supplies only what it lacks', () => { |
| 114 | + it("today's additive artifact answers bit-identically, and does not merge the second copy", () => { |
| 115 | + // The additive shape carries every definition TWICE. `packages[]` here |
| 116 | + // deliberately carries an object the top level does NOT — if the reader |
| 117 | + // merged instead of preferring, this would come back with three cases and |
| 118 | + // every app on the additive artifact would be verified against a stack |
| 119 | + // that is not the one it composed. |
| 120 | + const additive = { |
| 121 | + objects: [ |
| 122 | + { name: 'reader_account', label: 'Account', fields: { name: { name: 'name', type: 'text' } } }, |
| 123 | + { name: 'reader_order', label: 'Order', fields: { name: { name: 'name', type: 'text' } } }, |
| 124 | + ], |
| 125 | + packages: [{ manifest: corePackage }, { manifest: ordersPackage }], |
| 126 | + }; |
| 127 | + expect(deriveCrudCases(additive).map((c) => c.object)) |
| 128 | + .toEqual(['reader_account', 'reader_order']); |
| 129 | + }); |
| 130 | + |
| 131 | + it('a DECLARED-EMPTY collection stays empty (`objects: []` is truthy)', () => { |
| 132 | + // Measured on the sibling card #15006: re-expressing one of these reads as |
| 133 | + // "resolve, then take what came back" silently changes the answer for a |
| 134 | + // stack that declares an empty collection. Falsy — absent or null — is the |
| 135 | + // only thing that reaches `packages[]`. |
| 136 | + const declaredEmpty = { objects: [], positions: [], packages: [{ manifest: corePackage }] }; |
| 137 | + expect(deriveCrudCases(declaredEmpty)).toEqual([]); |
| 138 | + expect(declaredPositionNames(declaredEmpty)).toEqual([]); |
| 139 | + expect(Object.keys( |
| 140 | + (rlsProbePermissionSet(declaredEmpty) as unknown as { objects: Record<string, unknown> }).objects, |
| 141 | + )).toEqual([]); |
| 142 | + }); |
| 143 | + |
| 144 | + it('a single-package app with no `packages` key is unchanged', () => { |
| 145 | + const flat = { objects: [{ name: 'reader_solo', fields: { name: { name: 'name', type: 'text' } } }] }; |
| 146 | + expect(deriveCrudCases(flat).map((c) => c.object)).toEqual(['reader_solo']); |
| 147 | + expect(declaredPositionNames({ positions: [{ name: 'solo_position' }] })) |
| 148 | + .toEqual(['solo_position']); |
| 149 | + expect(deriveCrudCases(undefined)).toEqual([]); |
| 150 | + expect(declaredPositionNames(null)).toEqual([]); |
| 151 | + }); |
| 152 | + }); |
| 153 | + |
| 154 | + it('a malformed `packages` REFUSES with the ADR-0112 envelope, never as "no collections"', () => { |
| 155 | + // `resolveArtifactPackageOrder` owns this verdict (`@objectstack/core`, |
| 156 | + // ADR-0130 D4) — asserted here as the envelope (`code` + `status`) rather |
| 157 | + // than as a bare throw, so a driver that throws a plain Error cannot pass. |
| 158 | + let raised: (Error & { code?: string; status?: number }) | undefined; |
| 159 | + try { |
| 160 | + deriveCrudCases({ packages: [{ notAManifest: true }] }); |
| 161 | + } catch (e) { |
| 162 | + raised = e as Error & { code?: string; status?: number }; |
| 163 | + } |
| 164 | + expect(raised?.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY'); |
| 165 | + expect(raised?.status).toBe(422); |
| 166 | + }); |
| 167 | +}); |
0 commit comments