|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The row builder's `user.*` subjects are members of the bag the browser |
| 5 | + * really evaluates against (objectstack#28833). |
| 6 | + * |
| 7 | + * `CONTEXT_SUBJECTS` offered `user.isAdmin`, which `buildExpressionUser` never |
| 8 | + * put on the bound `user` object, and `user.role`, the legacy better-auth |
| 9 | + * scalar. The engine's LINT cannot see either problem: it accepts any member of |
| 10 | + * an accepted root, so `ConditionBuilder.contextSubjects.test.tsx` stayed green |
| 11 | + * on `user.isAdmin`. What can see it is the bag, so these cases read the member |
| 12 | + * list off `buildExpressionUser` on both branches and evaluate every offered |
| 13 | + * subject against `buildExpressionScope`, the one scope the app-shell binds. |
| 14 | + * |
| 15 | + * Derived, not retyped (ui AGENTS.md #9): a member renamed or dropped in |
| 16 | + * `expressionUser.ts` reddens here, and so does a subject added to the list |
| 17 | + * that the bag does not carry. |
| 18 | + */ |
| 19 | + |
| 20 | +import { describe, it, expect } from 'vitest'; |
| 21 | + |
| 22 | +import { CEL_EXAMPLE_CLAUSES, CONTEXT_SUBJECTS, celRootsMentioned } from './ConditionBuilder'; |
| 23 | +import { buildExpressionUser } from '../../../providers/expressionUser'; |
| 24 | +import { createExpressionEvaluator } from '../../../providers/ExpressionProvider'; |
| 25 | + |
| 26 | +const ADMIN = { id: 'u-admin', name: 'Ada', email: 'ada@example.com', isPlatformAdmin: true }; |
| 27 | +const MEMBER = { id: 'u-member', name: 'Bo', email: 'bo@example.com' }; |
| 28 | + |
| 29 | +/** The three identities a client gate meets: admin, ordinary member, signed out. */ |
| 30 | +const BRANCHES = [ |
| 31 | + ['platform admin', buildExpressionUser(ADMIN)], |
| 32 | + ['member', buildExpressionUser(MEMBER)], |
| 33 | + ['anonymous', buildExpressionUser(null)], |
| 34 | +] as const; |
| 35 | + |
| 36 | +const USER_SUBJECTS = CONTEXT_SUBJECTS.map((s) => s.value).filter((v) => v.startsWith('user.')); |
| 37 | + |
| 38 | +/** Evaluate on the evaluator's fail-closed seam: a fault throws instead of answering `true`. */ |
| 39 | +function evaluate(expr: string, user: Record<string, unknown>): boolean { |
| 40 | + return createExpressionEvaluator({ user }).evaluateCondition(expr, { throwOnError: true }); |
| 41 | +} |
| 42 | + |
| 43 | +/* ── control — the instrument can FAIL ─────────────────────────────────── */ |
| 44 | + |
| 45 | +describe('the bag refuses the members this card removed (objectstack#28833)', () => { |
| 46 | + it('`user.isAdmin` faults on every branch: it is not a member of the bag', () => { |
| 47 | + // Run first: every case below is worthless if a missing member does not |
| 48 | + // fault. If `buildExpressionUser` ever grows an `isAdmin`, this reddens and |
| 49 | + // the removal gets re-read instead of outliving its reason. |
| 50 | + for (const [branch, user] of BRANCHES) { |
| 51 | + expect(Object.keys(user), branch).not.toContain('isAdmin'); |
| 52 | + expect(() => evaluate('user.isAdmin', user), branch).toThrow(); |
| 53 | + } |
| 54 | + }); |
| 55 | + |
| 56 | + it('`user.role` is in the bag but answers no admin question: a signed-in admin reads `user`', () => { |
| 57 | + // Why it was dropped rather than kept: the member resolves, so the bag pin |
| 58 | + // alone would admit it. It is the legacy scalar, defaulted to `user` for |
| 59 | + // every session, platform admins included. |
| 60 | + expect(evaluate("user.role == 'user'", buildExpressionUser(ADMIN))).toBe(true); |
| 61 | + expect(USER_SUBJECTS).not.toContain('user.role'); |
| 62 | + }); |
| 63 | +}); |
| 64 | + |
| 65 | +/* ── subject — every offered `user.*` member is in the bag ─────────────── */ |
| 66 | + |
| 67 | +describe('every `user.*` subject the dropdown offers is a member of the bag (objectstack#28833)', () => { |
| 68 | + it('offers some `user.*` subject at all — an empty list would satisfy the cases below', () => { |
| 69 | + expect(USER_SUBJECTS).toContain('user.isPlatformAdmin'); |
| 70 | + }); |
| 71 | + |
| 72 | + it.each(BRANCHES)('each subject is an own key of the %s identity and evaluates without a fault', (_branch, user) => { |
| 73 | + for (const subject of USER_SUBJECTS) { |
| 74 | + const member = subject.slice('user.'.length); |
| 75 | + expect(Object.keys(user), subject).toContain(member); |
| 76 | + expect(() => evaluate(`${subject} != null`, user), subject).not.toThrow(); |
| 77 | + } |
| 78 | + }); |
| 79 | + |
| 80 | + it('a row "user.isPlatformAdmin is true" opens for the platform admin and no one else', () => { |
| 81 | + // The row builder compiles a value-less truthy row on a context subject to |
| 82 | + // the bare subject; this is the gate the card's author was trying to build. |
| 83 | + expect(evaluate('user.isPlatformAdmin', buildExpressionUser(ADMIN))).toBe(true); |
| 84 | + expect(evaluate('user.isPlatformAdmin', buildExpressionUser(MEMBER))).toBe(false); |
| 85 | + expect(evaluate('user.isPlatformAdmin', buildExpressionUser(null))).toBe(false); |
| 86 | + }); |
| 87 | +}); |
| 88 | + |
| 89 | +/* ── the raw editor's worked example teaches the same members ──────────── */ |
| 90 | + |
| 91 | +describe('the placeholder example names only members the bag carries (objectstack#28833)', () => { |
| 92 | + it('teaches a `user` clause, and that clause evaluates on every branch', () => { |
| 93 | + const userClauses = CEL_EXAMPLE_CLAUSES.filter((c) => celRootsMentioned(c).includes('user')); |
| 94 | + expect(userClauses.length, 'the example teaches no `user` clause: dead instrument').toBeGreaterThan(0); |
| 95 | + for (const clause of userClauses) { |
| 96 | + for (const [branch, user] of BRANCHES) { |
| 97 | + expect(() => evaluate(clause, user), `${clause} (${branch})`).not.toThrow(); |
| 98 | + } |
| 99 | + } |
| 100 | + }); |
| 101 | +}); |
0 commit comments