|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#26885 — the permission-set editor shows every axis the set |
| 5 | + * carries, Export included, and the Setup page's Assigned Users panel no |
| 6 | + * longer takes the grid's height. |
| 7 | + * |
| 8 | + * - Export (`allowExport`) is a column on both renderings — the routed page's |
| 9 | + * table and the Studio Access pillar's Objects tab — found by its header |
| 10 | + * and toggled like Transfer; the saved payload carries the bit. |
| 11 | + * - Export is an opt-in, high-privilege grant (`@objectstack/spec` |
| 12 | + * `security/high-privilege.ts`), so the granting bulk buttons leave it as |
| 13 | + * it is: "All" does not grant it, and none of them clears it. "None" still |
| 14 | + * empties the whole row (pinned in `PermissionMatrixEditor.bulkMergeKeys`). |
| 15 | + * - Assigned Users renders in the matrix's own scroll, after the grid, not |
| 16 | + * as a strip pinned under it. |
| 17 | + * |
| 18 | + * Every grant assertion reads the SAVED payload, the only thing either save |
| 19 | + * door persists. |
| 20 | + */ |
| 21 | + |
| 22 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 23 | +import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; |
| 24 | +import { MemoryRouter } from 'react-router-dom'; |
| 25 | + |
| 26 | +interface FakeServer { |
| 27 | + set: Record<string, any>; |
| 28 | + saved: Record<string, any> | null; |
| 29 | +} |
| 30 | + |
| 31 | +function makeClient(server: FakeServer) { |
| 32 | + return { |
| 33 | + layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }), |
| 34 | + getDraft: async () => null, |
| 35 | + list: async (type: string) => |
| 36 | + type === 'object' ? Object.keys(server.set.objects).map((name) => ({ item: { name } })) : [], |
| 37 | + get: async (type: string) => (type === 'object' ? { fields: [] } : null), |
| 38 | + save: async (_t: string, _n: string, payload: Record<string, any>) => { |
| 39 | + server.saved = payload; |
| 40 | + return payload; |
| 41 | + }, |
| 42 | + } as any; |
| 43 | +} |
| 44 | + |
| 45 | +let clientImpl: any; |
| 46 | + |
| 47 | +vi.mock('./useMetadata', () => ({ |
| 48 | + useMetadataClient: () => clientImpl, |
| 49 | + useMetadataTypes: () => ({ |
| 50 | + loading: false, |
| 51 | + error: null, |
| 52 | + entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }], |
| 53 | + }), |
| 54 | +})); |
| 55 | +vi.mock('./AssignedUsersSection', () => ({ |
| 56 | + AssignedUsersSection: () => <div data-testid="assigned-users-stub" />, |
| 57 | +})); |
| 58 | +vi.mock('@object-ui/fields', async (importOriginal) => ({ |
| 59 | + ...(await importOriginal<typeof import('@object-ui/fields')>()), |
| 60 | + CapabilityMultiSelectField: () => <div data-testid="cap-picker" />, |
| 61 | + parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []), |
| 62 | +})); |
| 63 | + |
| 64 | +import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; |
| 65 | + |
| 66 | +afterEach(cleanup); |
| 67 | + |
| 68 | +async function renderSet( |
| 69 | + objects: Record<string, unknown>, |
| 70 | + opts: { search?: string; studio?: boolean } = {}, |
| 71 | +): Promise<FakeServer> { |
| 72 | + const server: FakeServer = { set: { name: 'sales_rep', label: 'Sales Rep', objects, fields: {} }, saved: null }; |
| 73 | + clientImpl = makeClient(server); |
| 74 | + render( |
| 75 | + <MemoryRouter initialEntries={[`/apps/setup/metadata/permission/sales_rep${opts.search ?? ''}`]}> |
| 76 | + <PermissionMatrixEditPage |
| 77 | + type="permission" |
| 78 | + name="sales_rep" |
| 79 | + studio={opts.studio ? { tab: 'objects', onTabChange: () => {} } : undefined} |
| 80 | + /> |
| 81 | + </MemoryRouter>, |
| 82 | + ); |
| 83 | + await screen.findByText('crm_case'); |
| 84 | + return server; |
| 85 | +} |
| 86 | + |
| 87 | +/** Click Save and return the payload the client was handed. */ |
| 88 | +async function save(server: FakeServer) { |
| 89 | + fireEvent.click(screen.getByRole('button', { name: /^Save$/ })); |
| 90 | + await waitFor(() => expect(server.saved).not.toBeNull()); |
| 91 | + return server.saved!; |
| 92 | +} |
| 93 | + |
| 94 | +function row(objectName: string) { |
| 95 | + const found = screen.getAllByRole('row').find((r) => within(r).queryByText(objectName) != null); |
| 96 | + expect(found, `row for ${objectName}`).toBeTruthy(); |
| 97 | + return found!; |
| 98 | +} |
| 99 | + |
| 100 | +describe('the Export column (objectstack#26885)', () => { |
| 101 | + it('has a header, between Delete and Transfer, as the position summary orders it', async () => { |
| 102 | + await renderSet({ crm_case: { allowRead: true } }); |
| 103 | + const headers = screen.getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? ''); |
| 104 | + const e = headers.indexOf('E'); |
| 105 | + expect(e).toBeGreaterThan(0); |
| 106 | + expect(headers[e - 1]).toBe('D'); |
| 107 | + expect(headers[e + 1]).toBe('Tr'); |
| 108 | + expect(screen.getAllByRole('columnheader')[e].getAttribute('title')).toBe('Export records'); |
| 109 | + }); |
| 110 | + |
| 111 | + it('shows a stored grant, and unticking it saves allowExport: false', async () => { |
| 112 | + const server = await renderSet({ crm_case: { allowRead: true, allowExport: true } }); |
| 113 | + const box = screen.getByLabelText('crm_case Export records'); |
| 114 | + expect(box.getAttribute('data-state')).toBe('checked'); |
| 115 | + |
| 116 | + fireEvent.click(box); |
| 117 | + const payload = await save(server); |
| 118 | + expect(payload.objects.crm_case).toEqual({ allowRead: true, allowExport: false }); |
| 119 | + }); |
| 120 | + |
| 121 | + it('ticking it saves allowExport: true and nothing else', async () => { |
| 122 | + const server = await renderSet({ crm_case: { allowRead: true } }); |
| 123 | + const box = screen.getByLabelText('crm_case Export records'); |
| 124 | + expect(box.getAttribute('data-state')).toBe('unchecked'); |
| 125 | + |
| 126 | + fireEvent.click(box); |
| 127 | + const payload = await save(server); |
| 128 | + expect(payload.objects.crm_case).toEqual({ allowRead: true, allowExport: true }); |
| 129 | + }); |
| 130 | + |
| 131 | + it('the Studio Objects tab has the column too, with its legend entry', async () => { |
| 132 | + const server = await renderSet({ crm_case: { allowRead: true } }, { studio: true }); |
| 133 | + const matrix = screen.getByTestId('perm-studio-matrix'); |
| 134 | + const headers = within(matrix).getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? ''); |
| 135 | + expect(headers).toContain('E'); |
| 136 | + expect(within(screen.getByTestId('perm-objects-toolbar')).getByText('Export')).toBeTruthy(); |
| 137 | + |
| 138 | + fireEvent.click(within(matrix).getByLabelText('crm_case Export records')); |
| 139 | + const payload = await save(server); |
| 140 | + expect(payload.objects.crm_case.allowExport).toBe(true); |
| 141 | + }); |
| 142 | +}); |
| 143 | + |
| 144 | +describe('the bulk buttons leave Export as it is (objectstack#26885)', () => { |
| 145 | + const bulk = (label: string) => within(row('crm_case')).getByRole('button', { name: new RegExp(`^${label}$`) }); |
| 146 | + |
| 147 | + it('"All" grants every other column but not Export, and says so on the button', async () => { |
| 148 | + const server = await renderSet({ crm_case: { allowRead: true } }); |
| 149 | + expect(bulk('All').getAttribute('title')).toMatch(/except Export/); |
| 150 | + |
| 151 | + fireEvent.click(bulk('All')); |
| 152 | + const payload = await save(server); |
| 153 | + expect(payload.objects.crm_case).toEqual({ |
| 154 | + allowCreate: true, |
| 155 | + allowRead: true, |
| 156 | + allowEdit: true, |
| 157 | + allowDelete: true, |
| 158 | + allowTransfer: true, |
| 159 | + viewAllRecords: true, |
| 160 | + modifyAllRecords: true, |
| 161 | + }); |
| 162 | + }); |
| 163 | + |
| 164 | + for (const label of ['R', 'CRUD', 'All']) { |
| 165 | + it(`"${label}" keeps a granted Export ticked`, async () => { |
| 166 | + const server = await renderSet({ crm_case: { allowRead: true, allowExport: true } }); |
| 167 | + fireEvent.click(bulk(label)); |
| 168 | + expect(screen.getByLabelText('crm_case Export records').getAttribute('data-state')).toBe('checked'); |
| 169 | + const payload = await save(server); |
| 170 | + expect(payload.objects.crm_case.allowExport).toBe(true); |
| 171 | + }); |
| 172 | + } |
| 173 | +}); |
| 174 | + |
| 175 | +describe('Assigned Users does not take the grid’s height (objectstack#26885)', () => { |
| 176 | + it('renders in the matrix’s own scroll, after the grid', async () => { |
| 177 | + await renderSet({ crm_case: { allowRead: true } }, { search: '?scope=environment' }); |
| 178 | + const panel = screen.getByTestId('perm-assignments'); |
| 179 | + const table = screen.getByRole('table'); |
| 180 | + // The table's parent is the matrix's scroll box: the panel scrolls with |
| 181 | + // the grid instead of standing beside it as a fixed-height strip. |
| 182 | + const scroller = table.parentElement!; |
| 183 | + expect(scroller.className).toMatch(/\boverflow-auto\b/); |
| 184 | + expect(scroller.contains(panel)).toBe(true); |
| 185 | + expect(table.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); |
| 186 | + // Control: the panel is the Setup scope's — the stub really mounted in it. |
| 187 | + expect(within(panel).getByTestId('assigned-users-stub')).toBeTruthy(); |
| 188 | + }); |
| 189 | +}); |
0 commit comments