|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#26858 — the permission-set editor hands its RLS clause editors |
| 5 | + * the served verdict on the set on screen. |
| 6 | + * |
| 7 | + * The set's page is its own: it holds the draft read itself and strips the |
| 8 | + * read decorations off the body, so the verdict the draft read serves (since |
| 9 | + * objectstack#26707, what its publish will refuse) never reached a field. The |
| 10 | + * page now keeps that verdict and provides it, and reads it again after each |
| 11 | + * package-door save, so the field follows the draft it describes. |
| 12 | + * |
| 13 | + * The fake door below serves a draft whose `_diagnostics` refuses its USING |
| 14 | + * clause the way `rls-predicate-unenforceable` does, until the clause changes. |
| 15 | + */ |
| 16 | + |
| 17 | +import '@testing-library/jest-dom/vitest'; |
| 18 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 19 | +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; |
| 20 | +import { MemoryRouter } from 'react-router-dom'; |
| 21 | + |
| 22 | +const REFUSED_USING = 'deal_score > 5'; |
| 23 | +const REFUSAL = |
| 24 | + "rowLevelSecurity 'hot_deals' using reads 'deal_score', a formula field the database cannot filter on"; |
| 25 | + |
| 26 | +const SET = { |
| 27 | + name: 'sales_perms', |
| 28 | + label: 'Sales', |
| 29 | + objects: { a_account: { allowRead: true } }, |
| 30 | + fields: {}, |
| 31 | +}; |
| 32 | + |
| 33 | +interface Server { |
| 34 | + draft: Record<string, unknown> | null; |
| 35 | + draftReads: number; |
| 36 | +} |
| 37 | + |
| 38 | +/** The door's publish verdict on a stored draft: it refuses the one USING clause. */ |
| 39 | +function verdictOf(body: Record<string, unknown>) { |
| 40 | + const rows = (body.rowLevelSecurity ?? []) as Array<{ using?: string }>; |
| 41 | + const errors = rows.flatMap((r, i) => |
| 42 | + r.using === REFUSED_USING |
| 43 | + ? [{ path: `rowLevelSecurity.${i}.using`, message: REFUSAL, code: 'rls-predicate-unenforceable' }] |
| 44 | + : [], |
| 45 | + ); |
| 46 | + return errors.length ? { valid: false, errors } : { valid: true }; |
| 47 | +} |
| 48 | + |
| 49 | +type FakeClient = Record<string, (...args: never[]) => Promise<unknown>>; |
| 50 | + |
| 51 | +function makeClient(server: Server): FakeClient { |
| 52 | + return { |
| 53 | + layered: async () => ({ effective: SET, code: null, overlay: null, overlayScope: null }), |
| 54 | + getDraft: async () => { |
| 55 | + server.draftReads += 1; |
| 56 | + return server.draft |
| 57 | + ? { type: 'permission', name: 'sales_perms', item: { ...server.draft, _draft: true, _diagnostics: verdictOf(server.draft) } } |
| 58 | + : null; |
| 59 | + }, |
| 60 | + list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []), |
| 61 | + get: async () => null, |
| 62 | + save: async (_type: string, _name: string, payload: Record<string, unknown>) => { |
| 63 | + server.draft = JSON.parse(JSON.stringify(payload)) as Record<string, unknown>; |
| 64 | + return payload; |
| 65 | + }, |
| 66 | + }; |
| 67 | +} |
| 68 | + |
| 69 | +let clientImpl: FakeClient; |
| 70 | + |
| 71 | +vi.mock('./useMetadata', () => ({ |
| 72 | + useMetadataClient: () => clientImpl, |
| 73 | + useMetadataTypes: () => ({ |
| 74 | + loading: false, |
| 75 | + error: null, |
| 76 | + entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }], |
| 77 | + }), |
| 78 | +})); |
| 79 | + |
| 80 | +vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null })); |
| 81 | + |
| 82 | +import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; |
| 83 | + |
| 84 | +afterEach(cleanup); |
| 85 | + |
| 86 | +const USING_PLACEHOLDER = 'organization_id == current_user.organization_id'; |
| 87 | + |
| 88 | +async function openPackageDoor(server: Server) { |
| 89 | + clientImpl = makeClient(server); |
| 90 | + render( |
| 91 | + <MemoryRouter> |
| 92 | + <PermissionMatrixEditPage type="permission" name="sales_perms" packageId="app.a" /> |
| 93 | + </MemoryRouter>, |
| 94 | + ); |
| 95 | + await screen.findByText('a_account'); |
| 96 | + fireEvent.click(screen.getByRole('button', { name: /Row-Level Security/ })); |
| 97 | +} |
| 98 | + |
| 99 | +const policy = (using: string) => ({ |
| 100 | + ...SET, |
| 101 | + rowLevelSecurity: [{ name: 'hot_deals', object: 'a_account', operation: 'select', using, enabled: true }], |
| 102 | +}); |
| 103 | + |
| 104 | +describe('the permission-set editor provides the served verdict to its RLS clauses (objectstack#26858)', () => { |
| 105 | + it('a pending draft the publish refuses: the USING clause shows the server message, not "Valid CEL"', async () => { |
| 106 | + const server: Server = { draft: policy(REFUSED_USING), draftReads: 0 }; |
| 107 | + await openPackageDoor(server); |
| 108 | + expect(await screen.findByText(REFUSAL)).toBeInTheDocument(); |
| 109 | + // The lint itself answered: the clause parses, and only the server refuses it. |
| 110 | + await waitFor(() => expect(screen.getByPlaceholderText(USING_PLACEHOLDER)).toHaveValue(REFUSED_USING)); |
| 111 | + await new Promise((r) => setTimeout(r, 600)); |
| 112 | + expect(screen.queryByText('Valid CEL')).toBeNull(); |
| 113 | + }); |
| 114 | + |
| 115 | + it('the verdict follows the draft: after an autosave of a changed clause the refusal goes', async () => { |
| 116 | + const server: Server = { draft: policy(REFUSED_USING), draftReads: 0 }; |
| 117 | + await openPackageDoor(server); |
| 118 | + await screen.findByText(REFUSAL); |
| 119 | + const readsBefore = server.draftReads; |
| 120 | + |
| 121 | + fireEvent.change(screen.getByPlaceholderText(USING_PLACEHOLDER), { target: { value: 'owner_id == current_user.id' } }); |
| 122 | + |
| 123 | + await waitFor(() => expect(server.draftReads).toBeGreaterThan(readsBefore), { timeout: 5000 }); |
| 124 | + await waitFor(() => expect(screen.queryByText(REFUSAL)).toBeNull(), { timeout: 5000 }); |
| 125 | + expect(await screen.findByText('Valid CEL', {}, { timeout: 3000 })).toBeInTheDocument(); |
| 126 | + }); |
| 127 | + |
| 128 | + it('control: a draft the publish accepts reads "Valid CEL"', async () => { |
| 129 | + const server: Server = { draft: policy('owner_id == current_user.id'), draftReads: 0 }; |
| 130 | + await openPackageDoor(server); |
| 131 | + expect(await screen.findByText('Valid CEL', {}, { timeout: 3000 })).toBeInTheDocument(); |
| 132 | + expect(screen.queryByText(REFUSAL)).toBeNull(); |
| 133 | + }); |
| 134 | +}); |
0 commit comments