|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The unit tests that travelled with `isSupportedRlsExpression` / |
| 5 | + * `sqlPredicateToCel` when #4983 hoisted them out of |
| 6 | + * `@objectstack/plugin-security` (`security-plugin.test.ts`, describe block |
| 7 | + * "RLSCompiler D4 — uncompilable predicates are surfaced"). The two shape cases |
| 8 | + * are reproduced VERBATIM below: the hoist is a change of address, so a moved |
| 9 | + * test that also changes its assertions would hide the one thing the move has |
| 10 | + * to prove. The consumer-side half — that `RLSCompiler` still warns, still |
| 11 | + * fails closed, and still agrees with this predicate — stayed in |
| 12 | + * plugin-security, where the consumer is. |
| 13 | + */ |
| 14 | + |
| 15 | +import { describe, it, expect } from 'vitest'; |
| 16 | +import { readFileSync } from 'node:fs'; |
| 17 | +import { dirname, join } from 'node:path'; |
| 18 | +import { fileURLToPath } from 'node:url'; |
| 19 | + |
| 20 | +import { isSupportedRlsExpression, sqlPredicateToCel } from './rls-predicate'; |
| 21 | +import { isPushdownableCel } from './cel-to-filter'; |
| 22 | + |
| 23 | +// --------------------------------------------------------------------------- |
| 24 | +// ADR-0056 D4 — RLS predicates that won't compile must not vanish in silence |
| 25 | +// (moved verbatim from plugin-security/src/security-plugin.test.ts, #4983) |
| 26 | +// --------------------------------------------------------------------------- |
| 27 | +describe('isSupportedRlsExpression — the ADR-0056 D4 shape gate', () => { |
| 28 | + it('isSupportedRlsExpression accepts the compilable shapes', () => { |
| 29 | + // Legacy SQL-ish subset (bridged `=`/`IN`). |
| 30 | + expect(isSupportedRlsExpression('owner_id = current_user.id')).toBe(true); |
| 31 | + expect(isSupportedRlsExpression('owner = current_user.email')).toBe(true); |
| 32 | + expect(isSupportedRlsExpression("status = 'published'")).toBe(true); |
| 33 | + expect(isSupportedRlsExpression('id IN (current_user.org_user_ids)')).toBe(true); |
| 34 | + expect(isSupportedRlsExpression('1 = 1')).toBe(true); |
| 35 | + // ADR-0058: the canonical compiler lowers a broader pushdown subset, so the |
| 36 | + // shape gate now (correctly) reports these as enforceable — `==`/`!=`, |
| 37 | + // comparisons, and CEL compound predicates all compile to a FilterCondition. |
| 38 | + expect(isSupportedRlsExpression('owner == current_user.id')).toBe(true); // `==` |
| 39 | + expect(isSupportedRlsExpression('amount > 100')).toBe(true); // comparison |
| 40 | + expect(isSupportedRlsExpression('region != null')).toBe(true); // null check |
| 41 | + expect(isSupportedRlsExpression('a == 1 && b == 2')).toBe(true); // CEL compound |
| 42 | + }); |
| 43 | + |
| 44 | + it('isSupportedRlsExpression rejects genuinely non-pushdownable shapes', () => { |
| 45 | + // These cannot lower to a FilterCondition for ANY input, so the gate must |
| 46 | + // reject them (ADR-0055 / ADR-0056 D4) — they fail closed at runtime. |
| 47 | + expect(isSupportedRlsExpression('a = current_user.id AND b = 1')).toBe(false); // SQL AND ≠ CEL && (unparseable) |
| 48 | + expect(isSupportedRlsExpression('amount + 1 > 2')).toBe(false); // arithmetic |
| 49 | + expect(isSupportedRlsExpression('id IN (SELECT id FROM users)')).toBe(false); // subquery |
| 50 | + expect(isSupportedRlsExpression('record.a.b == 1')).toBe(false); // cross-object traversal |
| 51 | + expect(isSupportedRlsExpression('')).toBe(false); |
| 52 | + }); |
| 53 | +}); |
| 54 | + |
| 55 | +// --------------------------------------------------------------------------- |
| 56 | +// The bridge's boundary conditions — the reason a COPY of it was unacceptable |
| 57 | +// --------------------------------------------------------------------------- |
| 58 | +// |
| 59 | +// `sqlPredicateToCel` is a regex rewrite, and its edge cases are precisely the |
| 60 | +// red/green line of the authoring gate built on it (#4983). A second |
| 61 | +// implementation drifting by one character would make `os validate` reject |
| 62 | +// policies the runtime executes correctly — the false-positive direction, which |
| 63 | +// is worse than the gap. Pinning them here is what makes ONE definition worth |
| 64 | +// insisting on. |
| 65 | + |
| 66 | +describe('sqlPredicateToCel — the legacy bridge, pinned at its boundaries', () => { |
| 67 | + it('rewrites the historically-supported SQL subset', () => { |
| 68 | + expect(sqlPredicateToCel('owner_id = current_user.id')).toBe('owner_id == current_user.id'); |
| 69 | + expect(sqlPredicateToCel('id IN (current_user.org_user_ids)')).toBe('id in (current_user.org_user_ids)'); |
| 70 | + expect(sqlPredicateToCel('1 = 1')).toBe('1 == 1'); |
| 71 | + }); |
| 72 | + |
| 73 | + it('never rewrites inside a quoted string literal', () => { |
| 74 | + expect(sqlPredicateToCel("status = 'a = b'")).toBe("status == 'a = b'"); |
| 75 | + expect(sqlPredicateToCel("note = 'IN transit'")).toBe("note == 'IN transit'"); |
| 76 | + }); |
| 77 | + |
| 78 | + it('is IDEMPOTENT on canonical CEL — an authored predicate passes through unchanged', () => { |
| 79 | + for (const cel of [ |
| 80 | + 'owner_id == current_user.id', |
| 81 | + 'id in current_user.org_user_ids', |
| 82 | + 'amount >= 100', |
| 83 | + 'amount <= 100', |
| 84 | + 'region != null', |
| 85 | + "a == 1 && b == 'x'", |
| 86 | + ]) { |
| 87 | + expect(sqlPredicateToCel(cel)).toBe(cel); |
| 88 | + expect(sqlPredicateToCel(sqlPredicateToCel(cel))).toBe(cel); |
| 89 | + } |
| 90 | + }); |
| 91 | + |
| 92 | + it('leaves comparison operators containing `=` alone', () => { |
| 93 | + // The lookbehind/lookahead exist for these: `>=`, `<=`, `!=`, `==`. |
| 94 | + expect(sqlPredicateToCel('a >= 1')).toBe('a >= 1'); |
| 95 | + expect(sqlPredicateToCel('a <= 1')).toBe('a <= 1'); |
| 96 | + expect(sqlPredicateToCel('a != 1')).toBe('a != 1'); |
| 97 | + }); |
| 98 | +}); |
| 99 | + |
| 100 | +// --------------------------------------------------------------------------- |
| 101 | +// The composition the gate depends on |
| 102 | +// --------------------------------------------------------------------------- |
| 103 | + |
| 104 | +describe('isSupportedRlsExpression — composition and dependency direction', () => { |
| 105 | + it('is exactly `isPushdownableCel(sqlPredicateToCel(x)).ok` for a non-blank predicate', () => { |
| 106 | + const corpus = [ |
| 107 | + 'owner_id = current_user.id', |
| 108 | + "status = 'published'", |
| 109 | + 'id IN (current_user.org_user_ids)', |
| 110 | + 'amount > 100', |
| 111 | + 'a == 1 && b == 2', |
| 112 | + 'amount + 1 > 2', |
| 113 | + 'size(record.tags) > 0', |
| 114 | + "record.account.region == 'EU'", |
| 115 | + 'a = current_user.id AND b = 1', |
| 116 | + ]; |
| 117 | + for (const source of corpus) { |
| 118 | + expect({ source, ok: isSupportedRlsExpression(source) }) |
| 119 | + .toEqual({ source, ok: isPushdownableCel(sqlPredicateToCel(source)).ok }); |
| 120 | + } |
| 121 | + }); |
| 122 | + |
| 123 | + /** |
| 124 | + * #4983's hard constraint: the direction is `plugin-security` → `formula` and |
| 125 | + * `lint` → `formula`, NEVER the reverse. `@objectstack/formula` depends on |
| 126 | + * `@objectstack/spec` alone (see its package.json), and this module may not |
| 127 | + * quietly acquire a runtime import — that would put the hoisted predicate back |
| 128 | + * out of `@objectstack/lint`'s reach ("Depends on @objectstack/spec; never on |
| 129 | + * a runtime") and undo the whole move. Asserted against the source, because |
| 130 | + * a dependency that is only wrong at build time produces no failing assertion. |
| 131 | + */ |
| 132 | + it('never imports a runtime — the hoist direction is pinned, not just intended', () => { |
| 133 | + const here = dirname(fileURLToPath(import.meta.url)); |
| 134 | + const source = readFileSync(join(here, 'rls-predicate.ts'), 'utf8'); |
| 135 | + const specifiers = [...source.matchAll(/from\s+'([^']+)'/g)].map((m) => m[1]); |
| 136 | + expect(specifiers).toEqual(['./cel-to-filter']); |
| 137 | + |
| 138 | + const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { |
| 139 | + dependencies?: Record<string, string>; |
| 140 | + }; |
| 141 | + expect(Object.keys(pkg.dependencies ?? {}).sort()).toEqual(['@marcbachmann/cel-js', '@objectstack/spec']); |
| 142 | + }); |
| 143 | +}); |
0 commit comments