|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #17425, director ruling D. The rule's job is to be the ONE author-facing |
| 4 | +// voice on a value the parse consumes in silence, so almost every assertion |
| 5 | +// here is paired: a LIT control that must fire and a DARK control that must |
| 6 | +// not. A rule of this shape fails in two directions and only one of them is |
| 7 | +// noisy — a rule that never fires looks exactly like a clean codebase. |
| 8 | + |
| 9 | +import { describe, expect, it } from 'vitest'; |
| 10 | +import { ObjectPermissionSchema } from '@objectstack/spec/security'; |
| 11 | +import { normalizeStackInput } from '@objectstack/spec'; |
| 12 | + |
| 13 | +import { |
| 14 | + PERMISSION_RETIRED_LIFECYCLE_RESIDUE, |
| 15 | + retiredKeyPrescription, |
| 16 | + validateRetiredPermissionResidue, |
| 17 | +} from './validate-retired-permission-residue.js'; |
| 18 | +import { AUTHORING_COMMANDS, authoringRulesFor, runAuthoringRules } from './authoring-rules.js'; |
| 19 | + |
| 20 | +type AnyRec = Record<string, unknown>; |
| 21 | + |
| 22 | +/** The two keys and the single value each one's residue stage swallows. */ |
| 23 | +const RETIRED: ReadonlyArray<readonly [string, boolean]> = [ |
| 24 | + ['allowRestore', false], |
| 25 | + ['allowPurge', false], |
| 26 | +]; |
| 27 | + |
| 28 | +/** A raw authored stack, map-shaped, exactly as a non-TypeScript source spells it. */ |
| 29 | +function rawStack(entry: AnyRec): AnyRec { |
| 30 | + return { |
| 31 | + permissions: { |
| 32 | + support_agent: { |
| 33 | + label: 'Support Agent', |
| 34 | + objects: { crm_ticket: { allowRead: true, ...entry } }, |
| 35 | + }, |
| 36 | + }, |
| 37 | + }; |
| 38 | +} |
| 39 | + |
| 40 | +/** The `normalizeStackInput` output every authoring command hands a `normalized` rule. */ |
| 41 | +function normalized(entry: AnyRec): AnyRec { |
| 42 | + return normalizeStackInput(structuredClone(rawStack(entry)) as AnyRec) as AnyRec; |
| 43 | +} |
| 44 | + |
| 45 | +describe('validateRetiredPermissionResidue (#17425)', () => { |
| 46 | + describe('the premise the rule stands on', () => { |
| 47 | + // Everything below is vacuous if the load path strips the key before a rule |
| 48 | + // can see it — which is what the ADR-0087 conversion does when it is asked |
| 49 | + // to. It is `retiredFromLoadPath`, so it is not asked to here. |
| 50 | + it('LIT — the residue survives normalizeStackInput, which is the tier this rule reads', () => { |
| 51 | + const perm = (normalized({ allowRestore: false, allowPurge: false }).permissions as AnyRec[])[0]; |
| 52 | + const ticket = (perm.objects as AnyRec).crm_ticket as AnyRec; |
| 53 | + expect(Object.prototype.hasOwnProperty.call(ticket, 'allowRestore')).toBe(true); |
| 54 | + expect(Object.prototype.hasOwnProperty.call(ticket, 'allowPurge')).toBe(true); |
| 55 | + }); |
| 56 | + |
| 57 | + it('DARK — the same key does NOT survive the parse, which is why a `parsed` rule could not do this', () => { |
| 58 | + const parsed = ObjectPermissionSchema.safeParse({ allowRead: true, allowRestore: false }); |
| 59 | + expect(parsed.success).toBe(true); |
| 60 | + expect(Object.prototype.hasOwnProperty.call(parsed.data!, 'allowRestore')).toBe(false); |
| 61 | + }); |
| 62 | + |
| 63 | + it.each(RETIRED)('the accept set for `%s` is exactly the value this rule fires on', (key, residue) => { |
| 64 | + // The rule's private table has to agree with the schema's captured |
| 65 | + // literal, and the schema does not export it — so the agreement is |
| 66 | + // asserted through the behaviour the capture produces. |
| 67 | + expect(ObjectPermissionSchema.safeParse({ allowRead: true, [key]: residue }).success).toBe(true); |
| 68 | + for (const other of [true, 'false', 0, null, '']) { |
| 69 | + const refused = ObjectPermissionSchema.safeParse({ allowRead: true, [key]: other }); |
| 70 | + expect(refused.success, `${key}: ${JSON.stringify(other)} must stay refused`).toBe(false); |
| 71 | + } |
| 72 | + }); |
| 73 | + }); |
| 74 | + |
| 75 | + describe('the finding', () => { |
| 76 | + it('LIT — fires on the residue, once per key, naming the site', () => { |
| 77 | + const findings = validateRetiredPermissionResidue(normalized({ allowRestore: false, allowPurge: false })); |
| 78 | + expect(findings.map((f) => f.rule)).toEqual([ |
| 79 | + PERMISSION_RETIRED_LIFECYCLE_RESIDUE, |
| 80 | + PERMISSION_RETIRED_LIFECYCLE_RESIDUE, |
| 81 | + ]); |
| 82 | + expect(findings.map((f) => f.path)).toEqual([ |
| 83 | + 'permissions[0].objects.crm_ticket.allowRestore', |
| 84 | + 'permissions[0].objects.crm_ticket.allowPurge', |
| 85 | + ]); |
| 86 | + expect(findings[0].where).toBe("permission set 'support_agent' · object 'crm_ticket'"); |
| 87 | + expect(findings.every((f) => f.severity === 'warning')).toBe(true); |
| 88 | + }); |
| 89 | + |
| 90 | + it('carries the retirement’s OWN prescription, not a second wording', () => { |
| 91 | + const [finding] = validateRetiredPermissionResidue(normalized({ allowRestore: false })); |
| 92 | + const fromSchema = String( |
| 93 | + (ObjectPermissionSchema as unknown as { shape: Record<string, { description?: string }> }) |
| 94 | + .shape.allowRestore.description, |
| 95 | + ).replace(/^\[REMOVED\]\s*/, ''); |
| 96 | + expect(finding.hint).toBe(fromSchema); |
| 97 | + // Anti-vacuity: an empty derivation would make the assertion above true |
| 98 | + // and the rule silent. The prescription's two load-bearing clauses. |
| 99 | + expect(finding.hint).toContain('Delete the key'); |
| 100 | + expect(finding.hint).toContain('os migrate meta --from 17'); |
| 101 | + }); |
| 102 | + |
| 103 | + it('the prescription resolves for every key the rule knows about', () => { |
| 104 | + for (const [key] of RETIRED) { |
| 105 | + expect(retiredKeyPrescription(key), `no prescription resolved for ${key}`).not.toBeNull(); |
| 106 | + } |
| 107 | + // The resolver is not a constant function: a key with no tombstone has none. |
| 108 | + expect(retiredKeyPrescription('allowTransfer')).not.toContain('was removed'); |
| 109 | + expect(retiredKeyPrescription('allowTeleport')).toBeNull(); |
| 110 | + }); |
| 111 | + }); |
| 112 | + |
| 113 | + describe('DARK controls — what must stay silent', () => { |
| 114 | + it('a clean permission set earns nothing', () => { |
| 115 | + expect(validateRetiredPermissionResidue(normalized({}))).toEqual([]); |
| 116 | + }); |
| 117 | + |
| 118 | + it('COST DIRECTION — a live lifecycle bit set falsy is NOT residue', () => { |
| 119 | + // `allowTransfer` is the surviving lifecycle key (#3004, enforced). It is |
| 120 | + // the nearest miss in the shape: same family, same object, same `false`. |
| 121 | + // Flagging it would tell an author to delete an enforced grant. |
| 122 | + expect(validateRetiredPermissionResidue(normalized({ allowTransfer: false }))).toEqual([]); |
| 123 | + expect(validateRetiredPermissionResidue(normalized({ allowCreate: false, allowDelete: false }))).toEqual([]); |
| 124 | + }); |
| 125 | + |
| 126 | + it('a non-residue VALUE is the tombstone’s business, not this rule’s', () => { |
| 127 | + // Each of these is refused at the parse with the prescription attached. |
| 128 | + for (const other of [true, 'false', 0, null]) { |
| 129 | + expect( |
| 130 | + validateRetiredPermissionResidue(normalized({ allowRestore: other })), |
| 131 | + `${JSON.stringify(other)} must not be double-reported`, |
| 132 | + ).toEqual([]); |
| 133 | + } |
| 134 | + }); |
| 135 | + |
| 136 | + it('a fabricated key earns nothing', () => { |
| 137 | + expect(validateRetiredPermissionResidue(normalized({ allowTeleport: false }))).toEqual([]); |
| 138 | + }); |
| 139 | + |
| 140 | + it('never throws on malformed input, and reports nothing about it', () => { |
| 141 | + for (const junk of [{}, { permissions: null }, { permissions: [null, 7] }, { permissions: [{ objects: 3 }] }, |
| 142 | + { permissions: [{ objects: { a: null } }] }]) { |
| 143 | + expect(validateRetiredPermissionResidue(junk as AnyRec)).toEqual([]); |
| 144 | + } |
| 145 | + }); |
| 146 | + }); |
| 147 | + |
| 148 | + describe('wiring — the rule really runs, on every command', () => { |
| 149 | + it.each([...AUTHORING_COMMANDS])('os %s runs it', (command) => { |
| 150 | + expect(authoringRulesFor(command).map((r) => r.name)).toContain('validateRetiredPermissionResidue'); |
| 151 | + }); |
| 152 | + |
| 153 | + it('LIT — reaches an author through the registry runner on all three commands', () => { |
| 154 | + for (const command of AUTHORING_COMMANDS) { |
| 155 | + const findings = runAuthoringRules(command, { |
| 156 | + normalized: normalized({ allowRestore: false }), |
| 157 | + // The parsed tier CANNOT carry the evidence; handing it over proves |
| 158 | + // the entry reads `normalized` rather than falling back. |
| 159 | + parsed: normalized({}), |
| 160 | + }).filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE); |
| 161 | + expect(findings.map((f) => f.path), `os ${command}`).toEqual([ |
| 162 | + 'permissions[0].objects.crm_ticket.allowRestore', |
| 163 | + ]); |
| 164 | + expect(findings[0].severity).toBe('warning'); |
| 165 | + } |
| 166 | + }); |
| 167 | + |
| 168 | + it('DARK — the same runner is silent on a clean stack', () => { |
| 169 | + for (const command of AUTHORING_COMMANDS) { |
| 170 | + expect( |
| 171 | + runAuthoringRules(command, { normalized: normalized({}) }) |
| 172 | + .filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE), |
| 173 | + `os ${command}`, |
| 174 | + ).toEqual([]); |
| 175 | + } |
| 176 | + }); |
| 177 | + }); |
| 178 | +}); |
0 commit comments