|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { compileCelToFilter } from '@objectstack/formula'; |
| 5 | + |
| 6 | +import { |
| 7 | + validateSharingRuleEnforceability, |
| 8 | + SHARING_RULE_UNLOWERABLE_CONDITION, |
| 9 | + SHARING_RULE_RUNTIME_VARIABLE_CONDITION, |
| 10 | +} from './validate-sharing-rule-enforceability.js'; |
| 11 | +import { AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js'; |
| 12 | + |
| 13 | +const ids = (stack: unknown) => validateSharingRuleEnforceability(stack).map((f) => f.rule); |
| 14 | + |
| 15 | +/** A complete, spec-shaped sharing rule with the condition swapped in. */ |
| 16 | +const ruleWith = (condition: unknown) => ({ |
| 17 | + sharingRules: [ |
| 18 | + { |
| 19 | + name: 'high_value_opps', |
| 20 | + type: 'criteria', |
| 21 | + object: 'opportunity', |
| 22 | + accessLevel: 'read', |
| 23 | + sharedWith: { type: 'team', value: 'deal_desk' }, |
| 24 | + condition, |
| 25 | + }, |
| 26 | + ], |
| 27 | +}); |
| 28 | + |
| 29 | +// ── Red: declared, schema-valid, and never read ────────────────────── |
| 30 | +// |
| 31 | +// Every source below parses as CEL and passes `SharingRuleSchema`. The seeder |
| 32 | +// still drops the rule on the floor, which is the whole defect (#4698). |
| 33 | + |
| 34 | +describe('validateSharingRuleEnforceability — the declared-but-never-read cases go RED', () => { |
| 35 | + it('flags `has(...)` — the measured instance from the issue (hotcrm#621/#633)', () => { |
| 36 | + const findings = validateSharingRuleEnforceability( |
| 37 | + ruleWith("has(record.owner_id) && record.stage == 'closed_won'"), |
| 38 | + ); |
| 39 | + expect(findings).toHaveLength(1); |
| 40 | + expect(findings[0]).toMatchObject({ |
| 41 | + severity: 'error', |
| 42 | + rule: SHARING_RULE_UNLOWERABLE_CONDITION, |
| 43 | + // The declaration site is named, not just the rule. |
| 44 | + path: 'sharingRules[0].condition', |
| 45 | + where: 'sharing rule "high_value_opps" on object "opportunity"', |
| 46 | + }); |
| 47 | + // It must say what actually happens at boot, not merely "unsupported". |
| 48 | + expect(findings[0].message).toMatch(/SKIPS the rule at boot/); |
| 49 | + expect(findings[0].message).toMatch(/never written to `sys_sharing_rule`/); |
| 50 | + // …and prescribe the fix that works on THIS surface. |
| 51 | + expect(findings[0].hint).toMatch(/record\.x != null/); |
| 52 | + expect(findings[0].hint).toMatch(/INTERPRETED/); |
| 53 | + }); |
| 54 | + |
| 55 | + it.each([ |
| 56 | + ['a bare function call', 'size(record.tags) > 0'], |
| 57 | + ['arithmetic', 'record.amount * 2 > 100'], |
| 58 | + ['a cross-object path', "record.account.region == 'EU'"], |
| 59 | + ['a ternary', "record.stage == 'won' ? true : false"], |
| 60 | + ])('flags %s', (_label, source) => { |
| 61 | + expect(ids(ruleWith(source))).toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]); |
| 62 | + }); |
| 63 | + |
| 64 | + it('gives `current_user.*` its own id — the fix is a different mechanism, not a respelling', () => { |
| 65 | + const findings = validateSharingRuleEnforceability(ruleWith('record.owner_id == current_user.id')); |
| 66 | + expect(findings).toHaveLength(1); |
| 67 | + expect(findings[0]).toMatchObject({ |
| 68 | + severity: 'error', |
| 69 | + rule: SHARING_RULE_RUNTIME_VARIABLE_CONDITION, |
| 70 | + path: 'sharingRules[0].condition', |
| 71 | + }); |
| 72 | + expect(findings[0].message).toMatch(/current_user\.id/); |
| 73 | + // Points at the surface where `current_user.*` IS resolved. |
| 74 | + expect(findings[0].hint).toMatch(/rowLevelSecurity\[\]\.using/); |
| 75 | + }); |
| 76 | + |
| 77 | + it('reports the parsed tier identically — the envelope `ExpressionInputSchema` produces', () => { |
| 78 | + expect(ids(ruleWith({ dialect: 'cel', source: 'size(record.tags) > 0' }))) |
| 79 | + .toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]); |
| 80 | + }); |
| 81 | + |
| 82 | + it('names each offending rule separately, with its own index', () => { |
| 83 | + const findings = validateSharingRuleEnforceability({ |
| 84 | + sharingRules: [ |
| 85 | + { name: 'ok', object: 'a', condition: "record.stage == 'won'" }, |
| 86 | + { name: 'fn', object: 'b', condition: 'has(record.x)' }, |
| 87 | + { name: 'var', object: 'c', condition: 'record.owner == current_user.id' }, |
| 88 | + ], |
| 89 | + }); |
| 90 | + expect(findings.map((f) => [f.rule, f.path])).toEqual([ |
| 91 | + [SHARING_RULE_UNLOWERABLE_CONDITION, 'sharingRules[1].condition'], |
| 92 | + [SHARING_RULE_RUNTIME_VARIABLE_CONDITION, 'sharingRules[2].condition'], |
| 93 | + ]); |
| 94 | + }); |
| 95 | +}); |
| 96 | + |
| 97 | +// ── Green: conditions that really are read ─────────────────────────── |
| 98 | +// |
| 99 | +// A false positive here is worse than the gap the rule closes: it rejects |
| 100 | +// security metadata that enforces correctly today and hands the author a |
| 101 | +// "correction" that would break it. |
| 102 | + |
| 103 | +describe('validateSharingRuleEnforceability — conditions the runtime DOES read stay green', () => { |
| 104 | + it.each([ |
| 105 | + ["record.health == 'red'"], |
| 106 | + ["record.health == 'red' && record.budget > 100000"], |
| 107 | + ['record.done == false'], |
| 108 | + ["record.stage in ['closed_won', 'closed_lost']"], |
| 109 | + ['record.closed_at == null'], |
| 110 | + ["record.name.startsWith('ACME')"], |
| 111 | + ["!(record.stage in ['draft']) || record.amount >= 1000"], |
| 112 | + ])('accepts %s', (source) => { |
| 113 | + expect(validateSharingRuleEnforceability(ruleWith(source))).toEqual([]); |
| 114 | + }); |
| 115 | + |
| 116 | + it('accepts every sharing-rule condition the bundled examples declare', () => { |
| 117 | + // Lifted verbatim from examples/app-showcase/src/security/sharing-rules.ts |
| 118 | + // and examples/app-crm. The gate must not turn shipped apps red. |
| 119 | + const shipped = [ |
| 120 | + "record.health == 'red'", |
| 121 | + "record.health == 'red' && record.budget > 100000", |
| 122 | + "record.status == 'new'", |
| 123 | + 'record.done == false', |
| 124 | + ]; |
| 125 | + for (const source of shipped) { |
| 126 | + expect(validateSharingRuleEnforceability(ruleWith(source))).toEqual([]); |
| 127 | + } |
| 128 | + }); |
| 129 | + |
| 130 | + it('leaves CEL SYNTAX errors to validateStackExpressions — no double report', () => { |
| 131 | + // Parses nowhere, but this rule stays silent: `expression-invalid` already |
| 132 | + // gates the same field with a message written about syntax. |
| 133 | + expect(compileCelToFilter('record.stage ==', { variables: {} })).toMatchObject({ reason: 'parse-error' }); |
| 134 | + expect(validateSharingRuleEnforceability(ruleWith('record.stage =='))).toEqual([]); |
| 135 | + }); |
| 136 | + |
| 137 | + it('ignores shapes Zod owns rather than inventing a second complaint', () => { |
| 138 | + expect(ids(ruleWith(undefined))).toEqual([]); |
| 139 | + expect(ids(ruleWith(''))).toEqual([]); |
| 140 | + expect(ids(ruleWith(' '))).toEqual([]); |
| 141 | + expect(ids(ruleWith(42))).toEqual([]); |
| 142 | + expect(ids(ruleWith({ dialect: 'cel' }))).toEqual([]); |
| 143 | + }); |
| 144 | + |
| 145 | + it('is a no-op on a stack that declares no sharing rules', () => { |
| 146 | + expect(validateSharingRuleEnforceability({})).toEqual([]); |
| 147 | + expect(validateSharingRuleEnforceability(undefined)).toEqual([]); |
| 148 | + expect(validateSharingRuleEnforceability({ sharingRules: [] })).toEqual([]); |
| 149 | + }); |
| 150 | + |
| 151 | + it('checks inactive rules too — the seeder compiles the condition regardless of `active`', () => { |
| 152 | + // `bootstrapDeclaredSharingRules` carries `active` through to `defineRule`; |
| 153 | + // it does not skip the compile. A rule that is off today and unlowerable is |
| 154 | + // still a rule that will grant nothing the day someone switches it on. |
| 155 | + expect(ids({ sharingRules: [{ name: 'r', object: 'o', active: false, condition: 'has(record.x)' }] })) |
| 156 | + .toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]); |
| 157 | + }); |
| 158 | +}); |
| 159 | + |
| 160 | +// ── The predicate is the consumer's, not a model of it ─────────────── |
| 161 | + |
| 162 | +describe('validateSharingRuleEnforceability — the verdict IS the seeder\'s verdict', () => { |
| 163 | + const corpus = [ |
| 164 | + "record.health == 'red'", |
| 165 | + "record.health == 'red' && record.budget > 100000", |
| 166 | + 'record.done == false', |
| 167 | + "record.stage in ['a', 'b']", |
| 168 | + 'record.closed_at != null', |
| 169 | + 'has(record.owner_id)', |
| 170 | + 'size(record.tags) > 0', |
| 171 | + 'record.amount * 2 > 100', |
| 172 | + "record.account.region == 'EU'", |
| 173 | + 'record.owner_id == current_user.id', |
| 174 | + ]; |
| 175 | + |
| 176 | + it('agrees with `compileCelToFilter({ variables: {} })` on every source, in both directions', () => { |
| 177 | + for (const source of corpus) { |
| 178 | + // This is exactly the call `bootstrap-declared-sharing-rules.ts` makes. |
| 179 | + const seederWouldSeed = compileCelToFilter(source, { variables: {} }).ok; |
| 180 | + const lintIsClean = validateSharingRuleEnforceability(ruleWith(source)).length === 0; |
| 181 | + expect({ source, lintIsClean }).toEqual({ source, lintIsClean: seederWouldSeed }); |
| 182 | + } |
| 183 | + }); |
| 184 | + |
| 185 | + /** |
| 186 | + * The "before" half of the proof, kept mechanical rather than asserted in |
| 187 | + * prose. #4698's complaint is that the offending stack passes the WHOLE |
| 188 | + * toolchain, so it is not enough to show the new rule goes red — it must also |
| 189 | + * be shown that nothing else ever did. Running the full author-time registry |
| 190 | + * over the fixture and demanding that every OTHER rule stays silent is that |
| 191 | + * statement, and unlike a comment it keeps holding: if some future rule grows |
| 192 | + * to cover this shape, this test fails and someone has to decide which of the |
| 193 | + * two owns it, instead of the stack quietly acquiring a duplicate diagnostic. |
| 194 | + */ |
| 195 | + it('no OTHER author-time rule sees this — which is exactly why the gate was missing', () => { |
| 196 | + const offending = { |
| 197 | + objects: [ |
| 198 | + { |
| 199 | + name: 'opportunity', |
| 200 | + label: 'Opportunity', |
| 201 | + sharingModel: 'private', |
| 202 | + fields: { |
| 203 | + name: { type: 'text', label: 'Name' }, |
| 204 | + owner_id: { type: 'text', label: 'Owner' }, |
| 205 | + stage: { type: 'text', label: 'Stage' }, |
| 206 | + }, |
| 207 | + }, |
| 208 | + ], |
| 209 | + sharingRules: [ |
| 210 | + { |
| 211 | + name: 'closed_won_to_deal_desk', |
| 212 | + type: 'criteria', |
| 213 | + object: 'opportunity', |
| 214 | + accessLevel: 'read', |
| 215 | + sharedWith: { type: 'team', value: 'deal_desk' }, |
| 216 | + condition: "has(record.owner_id) && record.stage == 'closed_won'", |
| 217 | + }, |
| 218 | + ], |
| 219 | + }; |
| 220 | + |
| 221 | + const findings = runAuthoringRules('validate', { normalized: offending, parsed: offending }); |
| 222 | + expect(findings.map((f) => f.rule)).toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]); |
| 223 | + |
| 224 | + // And the fixture really did travel through every rule — a registry that |
| 225 | + // silently stopped running would satisfy the assertion above vacuously. |
| 226 | + expect(AUTHORING_RULES.filter((r) => r.commands.includes('validate')).length).toBeGreaterThan(20); |
| 227 | + }); |
| 228 | + |
| 229 | + it('a match-all filter is unreachable from a lowering condition (so lint need not re-check it)', () => { |
| 230 | + // The seeder's SECOND guard is `isMatchAllCriteria(f)`, which lives in |
| 231 | + // plugin-sharing — a runtime `@objectstack/lint` must not import. This |
| 232 | + // pins the claim in the module docblock that duplicating it would be dead |
| 233 | + // code: every condition the compiler lowers yields a concrete predicate. |
| 234 | + for (const source of corpus) { |
| 235 | + const result = compileCelToFilter(source, { variables: {} }); |
| 236 | + if (!result.ok) continue; |
| 237 | + expect(Object.keys(result.filter as Record<string, unknown>).length).toBeGreaterThan(0); |
| 238 | + } |
| 239 | + }); |
| 240 | +}); |
0 commit comments