|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Contract lock for the public explain / access-matrix schemas (ADR-0090 D6). |
| 5 | + * |
| 6 | + * These schemas are the wire contract that downstream consumers depend on — |
| 7 | + * notably the ADR-0091 **L3** enterprise product (cloud `security-enterprise`: |
| 8 | + * recertification review UX, evidence export, break-glass attribution) reads |
| 9 | + * `ExplainDecision.layers[].contributors[]` and `AccessMatrixEntry` directly. |
| 10 | + * |
| 11 | + * `api-surface.json` already locks the export NAMES (a removed export fails the |
| 12 | + * lint gate); this file locks the FIELD SHAPE (a removed/renamed field, a |
| 13 | + * dropped enum member, or a changed default). Together they make explain a |
| 14 | + * stable contract cloud can consume without drift fear. Any break here is a |
| 15 | + * deliberate, reviewable protocol change — bump the protocol major with it. |
| 16 | + */ |
| 17 | + |
| 18 | +import { describe, it, expect } from 'vitest'; |
| 19 | +import { |
| 20 | + ExplainOperationSchema, |
| 21 | + ExplainLayerSchema, |
| 22 | + ExplainRequestSchema, |
| 23 | + ExplainDecisionSchema, |
| 24 | + AccessMatrixEntrySchema, |
| 25 | + AccessMatrixSchema, |
| 26 | +} from './explain.zod'; |
| 27 | + |
| 28 | +describe('ExplainOperationSchema — the operation vocabulary is fixed', () => { |
| 29 | + it('accepts exactly the seven CRUD + lifecycle operations', () => { |
| 30 | + for (const op of ['read', 'create', 'update', 'delete', 'transfer', 'restore', 'purge']) { |
| 31 | + expect(ExplainOperationSchema.parse(op)).toBe(op); |
| 32 | + } |
| 33 | + }); |
| 34 | + it('rejects an unknown operation', () => { |
| 35 | + expect(() => ExplainOperationSchema.parse('list')).toThrow(); |
| 36 | + }); |
| 37 | +}); |
| 38 | + |
| 39 | +describe('ExplainLayerSchema — the nine-layer pipeline + contributor shape', () => { |
| 40 | + const LAYERS = [ |
| 41 | + 'principal', 'required_permissions', 'object_crud', 'fls', |
| 42 | + 'owd_baseline', 'depth', 'sharing', 'vama_bypass', 'rls', |
| 43 | + ]; |
| 44 | + it('locks the nine layer ids, in order', () => { |
| 45 | + for (const layer of LAYERS) { |
| 46 | + expect(ExplainLayerSchema.parse({ layer, verdict: 'neutral', detail: 'x' }).layer).toBe(layer); |
| 47 | + } |
| 48 | + expect(() => ExplainLayerSchema.parse({ layer: 'tenant', verdict: 'neutral', detail: 'x' })).toThrow(); |
| 49 | + }); |
| 50 | + |
| 51 | + it('locks the six verdicts', () => { |
| 52 | + for (const verdict of ['grants', 'denies', 'narrows', 'widens', 'neutral', 'not_applicable']) { |
| 53 | + expect(ExplainLayerSchema.parse({ layer: 'rls', verdict, detail: 'x' }).verdict).toBe(verdict); |
| 54 | + } |
| 55 | + expect(() => ExplainLayerSchema.parse({ layer: 'rls', verdict: 'allows', detail: 'x' })).toThrow(); |
| 56 | + }); |
| 57 | + |
| 58 | + it('contributors default to [] and carry kind / name / via / state', () => { |
| 59 | + const bare = ExplainLayerSchema.parse({ layer: 'principal', verdict: 'neutral', detail: 'x' }); |
| 60 | + expect(bare.contributors).toEqual([]); |
| 61 | + |
| 62 | + const full = ExplainLayerSchema.parse({ |
| 63 | + layer: 'principal', |
| 64 | + verdict: 'neutral', |
| 65 | + detail: 'x', |
| 66 | + contributors: [ |
| 67 | + { kind: 'position', name: 'approver', via: 'delegation from u_boss until 2026-08-01', state: 'active' }, |
| 68 | + { kind: 'permission_set', name: 'approve_set', via: 'position:approver' }, |
| 69 | + { kind: 'position', name: 'payroll_approver', via: 'held until 2026-07-01 — expired', state: 'expired' }, |
| 70 | + { kind: 'system', name: 'platform_admin' }, |
| 71 | + ], |
| 72 | + }); |
| 73 | + expect(full.contributors.map((c) => c.kind)).toEqual(['position', 'permission_set', 'position', 'system']); |
| 74 | + // [ADR-0091 D2] the lifecycle state member L3 reads for the "expired" report |
| 75 | + expect(full.contributors[2].state).toBe('expired'); |
| 76 | + expect(full.contributors[1].state).toBeUndefined(); |
| 77 | + }); |
| 78 | + |
| 79 | + it('rejects an unknown contributor kind or lifecycle state', () => { |
| 80 | + expect(() => ExplainLayerSchema.parse({ |
| 81 | + layer: 'principal', verdict: 'neutral', detail: 'x', |
| 82 | + contributors: [{ kind: 'role', name: 'x' }], |
| 83 | + })).toThrow(); |
| 84 | + expect(() => ExplainLayerSchema.parse({ |
| 85 | + layer: 'principal', verdict: 'neutral', detail: 'x', |
| 86 | + contributors: [{ kind: 'position', name: 'x', state: 'suspended' }], |
| 87 | + })).toThrow(); |
| 88 | + }); |
| 89 | +}); |
| 90 | + |
| 91 | +describe('ExplainRequestSchema — the request contract', () => { |
| 92 | + it('requires object + operation; userId optional', () => { |
| 93 | + expect(ExplainRequestSchema.parse({ object: 'leave_request', operation: 'read' })).toMatchObject({ |
| 94 | + object: 'leave_request', operation: 'read', |
| 95 | + }); |
| 96 | + expect(ExplainRequestSchema.parse({ object: 'x', operation: 'update', userId: 'u2' }).userId).toBe('u2'); |
| 97 | + expect(() => ExplainRequestSchema.parse({ operation: 'read' })).toThrow(); |
| 98 | + }); |
| 99 | +}); |
| 100 | + |
| 101 | +describe('ExplainDecisionSchema — the full decision report L3 consumes', () => { |
| 102 | + it('round-trips a representative decision with every field L3 reads', () => { |
| 103 | + const decision = { |
| 104 | + allowed: true, |
| 105 | + object: 'leave_request', |
| 106 | + operation: 'read', |
| 107 | + principal: { |
| 108 | + userId: 'u2', |
| 109 | + positions: ['approver', 'everyone'], |
| 110 | + permissionSets: ['approve_set', 'member_default'], |
| 111 | + principalKind: 'human', |
| 112 | + onBehalfOf: { userId: 'u9' }, |
| 113 | + }, |
| 114 | + layers: [ |
| 115 | + { |
| 116 | + layer: 'principal', verdict: 'neutral', detail: '…', |
| 117 | + contributors: [{ kind: 'position', name: 'approver', via: 'delegation from u_boss until 2026-08-01', state: 'active' }], |
| 118 | + }, |
| 119 | + { layer: 'rls', verdict: 'narrows', detail: '…', contributors: [] }, |
| 120 | + ], |
| 121 | + readFilter: { owner: 'u2' }, |
| 122 | + }; |
| 123 | + const parsed = ExplainDecisionSchema.parse(decision); |
| 124 | + expect(parsed.allowed).toBe(true); |
| 125 | + expect(parsed.principal.userId).toBe('u2'); |
| 126 | + expect(parsed.principal.positions).toContain('approver'); |
| 127 | + expect(parsed.principal.permissionSets).toContain('approve_set'); |
| 128 | + expect(parsed.principal.principalKind).toBe('human'); |
| 129 | + expect(parsed.principal.onBehalfOf).toEqual({ userId: 'u9' }); |
| 130 | + expect(parsed.layers).toHaveLength(2); |
| 131 | + expect(parsed.readFilter).toEqual({ owner: 'u2' }); |
| 132 | + }); |
| 133 | + |
| 134 | + it('principal.userId is nullable (anonymous), positions/permissionSets default to []', () => { |
| 135 | + const parsed = ExplainDecisionSchema.parse({ |
| 136 | + allowed: false, object: 'x', operation: 'read', |
| 137 | + principal: { userId: null }, |
| 138 | + layers: [], |
| 139 | + }); |
| 140 | + expect(parsed.principal.userId).toBeNull(); |
| 141 | + expect(parsed.principal.positions).toEqual([]); |
| 142 | + expect(parsed.principal.permissionSets).toEqual([]); |
| 143 | + }); |
| 144 | + |
| 145 | + it('rejects an unknown principalKind', () => { |
| 146 | + expect(() => ExplainDecisionSchema.parse({ |
| 147 | + allowed: true, object: 'x', operation: 'read', |
| 148 | + principal: { userId: 'u', principalKind: 'robot' }, layers: [], |
| 149 | + })).toThrow(); |
| 150 | + }); |
| 151 | +}); |
| 152 | + |
| 153 | +describe('AccessMatrix schemas — the authoring-time companion', () => { |
| 154 | + it('AccessMatrixEntry locks the crud bits + super-user bypass + scopes + sharingModel', () => { |
| 155 | + const entry = AccessMatrixEntrySchema.parse({ |
| 156 | + permissionSet: 'crm_admin', object: 'crm_lead', |
| 157 | + create: true, read: true, edit: true, delete: false, |
| 158 | + viewAllRecords: true, modifyAllRecords: false, |
| 159 | + readScope: 'unit_and_below', writeScope: 'own', sharingModel: 'private', |
| 160 | + }); |
| 161 | + expect(entry).toMatchObject({ |
| 162 | + permissionSet: 'crm_admin', object: 'crm_lead', |
| 163 | + create: true, read: true, edit: true, delete: false, |
| 164 | + viewAllRecords: true, modifyAllRecords: false, |
| 165 | + readScope: 'unit_and_below', writeScope: 'own', sharingModel: 'private', |
| 166 | + }); |
| 167 | + }); |
| 168 | + |
| 169 | + it('the crud + bypass bits are REQUIRED (a missing bit is a contract break)', () => { |
| 170 | + expect(() => AccessMatrixEntrySchema.parse({ |
| 171 | + permissionSet: 'x', object: 'y', create: true, read: true, edit: true, delete: true, |
| 172 | + viewAllRecords: true, /* modifyAllRecords missing */ |
| 173 | + })).toThrow(); |
| 174 | + }); |
| 175 | + |
| 176 | + it('AccessMatrix defaults version=1 and entries=[]', () => { |
| 177 | + expect(AccessMatrixSchema.parse({})).toEqual({ version: 1, entries: [] }); |
| 178 | + expect(() => AccessMatrixSchema.parse({ version: 2 })).toThrow(); |
| 179 | + }); |
| 180 | +}); |
0 commit comments