|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectui#13338 — the Setup catalog's Active switch on the Permission Sets |
| 5 | + * and Positions lists. |
| 6 | + * |
| 7 | + * What is pinned, on both lists: |
| 8 | + * |
| 9 | + * - switching OFF asks first and sends nothing until the admin confirms; the |
| 10 | + * confirm names how many users lose the access, counted from the server's |
| 11 | + * assignment rows when it opens (`sys_user_permission_set` and the holders |
| 12 | + * of every position that distributes the set; `sys_user_position` for a |
| 13 | + * position), one person once; |
| 14 | + * - Cancel leaves the item on and writes nothing; |
| 15 | + * - switching ON asks nothing and writes at once; |
| 16 | + * - a toast confirms the change either way; |
| 17 | + * - a refused count read is said as such, never as "0 users", and the admin |
| 18 | + * may still go ahead (the confirm informs, it does not gate). |
| 19 | + */ |
| 20 | +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; |
| 21 | +import { render, cleanup, screen, waitFor, fireEvent } from '@testing-library/react'; |
| 22 | +import '@testing-library/jest-dom'; |
| 23 | +import * as React from 'react'; |
| 24 | +import { MemoryRouter } from 'react-router-dom'; |
| 25 | +// The switch column waits on `audienceAnchorsOf`'s dynamic import of this exact |
| 26 | +// specifier; loaded here so its cost lands in the import phase. |
| 27 | +import '@objectstack/spec/identity'; |
| 28 | + |
| 29 | +const PROJECT_PKG = 'com.example.showcase'; |
| 30 | + |
| 31 | +const PERMISSION_SETS: Record<string, unknown>[] = [ |
| 32 | + { name: 'sales_rep', label: 'Sales Rep', _packageId: PROJECT_PKG, _provenance: 'package' }, |
| 33 | + { name: 'qa_fls_rep', label: 'QA FLS Rep', _packageId: PROJECT_PKG, _provenance: 'package' }, |
| 34 | + { name: 'member_default', label: 'Member Default', _packageId: PROJECT_PKG, _provenance: 'package' }, |
| 35 | +]; |
| 36 | + |
| 37 | +/** The registry's position definitions: the binding to a set is part of the definition. */ |
| 38 | +const POSITIONS: Record<string, unknown>[] = [ |
| 39 | + { name: 'everyone', label: 'Everyone', permissionSets: ['member_default'], _packageId: PROJECT_PKG, _provenance: 'package' }, |
| 40 | + { name: 'sales_manager', label: 'Sales Manager', permissionSets: ['sales_rep'], _packageId: PROJECT_PKG, _provenance: 'package' }, |
| 41 | + { name: 'auditor', label: 'Auditor', permissionSets: [], _packageId: PROJECT_PKG, _provenance: 'package' }, |
| 42 | +]; |
| 43 | + |
| 44 | +/** Stable singleton — the page's load effect keys on the client identity. */ |
| 45 | +const CLIENT = { |
| 46 | + list: async (type: string) => |
| 47 | + type === 'package' |
| 48 | + ? [{ manifest: { id: PROJECT_PKG, scope: 'project', name: 'Showcase' } }] |
| 49 | + : type === 'permission' |
| 50 | + ? PERMISSION_SETS |
| 51 | + : type === 'position' |
| 52 | + ? POSITIONS |
| 53 | + : [], |
| 54 | +}; |
| 55 | + |
| 56 | +const ENTRIES = [ |
| 57 | + { type: 'permission', label: 'Permission Set', allowEnvironmentOverlay: false, allowRuntimeCreate: true }, |
| 58 | + { type: 'position', label: 'Position', allowEnvironmentOverlay: false, allowRuntimeCreate: true }, |
| 59 | +]; |
| 60 | + |
| 61 | +vi.mock('./useMetadata', async (importOriginal) => ({ |
| 62 | + ...(await importOriginal<Record<string, unknown>>()), |
| 63 | + useMetadataClient: () => CLIENT, |
| 64 | + useMetadataTypes: () => ({ loading: false, error: null, entries: ENTRIES }), |
| 65 | +})); |
| 66 | + |
| 67 | +vi.mock('./i18n', async (importOriginal) => ({ |
| 68 | + ...(await importOriginal<Record<string, unknown>>()), |
| 69 | + useMetadataLocale: () => 'en-US', |
| 70 | +})); |
| 71 | + |
| 72 | +vi.mock('../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => true })); |
| 73 | +vi.mock('../../hooks/useTenancyPosture', async (importOriginal) => ({ |
| 74 | + ...(await importOriginal<Record<string, unknown>>()), |
| 75 | + useTenancyPosture: () => 'single', |
| 76 | +})); |
| 77 | + |
| 78 | +const toasts = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); |
| 79 | +vi.mock('sonner', () => ({ toast: toasts })); |
| 80 | + |
| 81 | +/** The assignment rows the server holds, by object. */ |
| 82 | +let data: Record<string, Record<string, unknown>[]> = {}; |
| 83 | +/** When set, every holder read is refused with this sentence. */ |
| 84 | +let holderRefusal: string | null = null; |
| 85 | +const adapter = { |
| 86 | + find: vi.fn(async (object: string, query: { $filter?: Record<string, unknown> } = {}) => { |
| 87 | + if (holderRefusal) throw new Error(holderRefusal); |
| 88 | + const filter = query.$filter ?? {}; |
| 89 | + return { |
| 90 | + data: (data[object] ?? []).filter((r) => |
| 91 | + Object.entries(filter).every(([k, v]) => |
| 92 | + v && typeof v === 'object' && Array.isArray((v as { $in?: unknown[] }).$in) |
| 93 | + ? (v as { $in: unknown[] }).$in.includes(r[k]) |
| 94 | + : r[k] === v, |
| 95 | + ), |
| 96 | + ), |
| 97 | + }; |
| 98 | + }), |
| 99 | +}; |
| 100 | +const metadataStore = { ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : [])) }; |
| 101 | +vi.mock('@object-ui/react', async (importOriginal) => ({ |
| 102 | + ...(await importOriginal<Record<string, unknown>>()), |
| 103 | + useAdapter: () => adapter, |
| 104 | + useMetadata: () => metadataStore, |
| 105 | +})); |
| 106 | + |
| 107 | +let ledgerRows: Record<string, unknown>[] = []; |
| 108 | +const posts: Array<{ url: string; body?: string }> = []; |
| 109 | + |
| 110 | +// Imported AFTER the mocks so the page picks them up. |
| 111 | +import { MetadataResourceListPage } from './ResourceListPage'; |
| 112 | +import { t, tFormat } from './i18n'; |
| 113 | + |
| 114 | +beforeEach(() => { |
| 115 | + holderRefusal = null; |
| 116 | + data = { |
| 117 | + sys_user_permission_set: [ |
| 118 | + { id: 'g1', permission_set: 'sales_rep', user_id: 'u1' }, |
| 119 | + { id: 'g2', permission_set: 'sales_rep', user_id: 'u2' }, |
| 120 | + { id: 'g3', permission_set: 'qa_fls_rep', user_id: 'u9' }, |
| 121 | + ], |
| 122 | + sys_user_position: [ |
| 123 | + // u2 also holds the set directly: counted once. |
| 124 | + { id: 'p1', position: 'sales_manager', user_id: 'u2' }, |
| 125 | + { id: 'p2', position: 'sales_manager', user_id: 'u3' }, |
| 126 | + { id: 'p3', position: 'auditor', user_id: 'u7' }, |
| 127 | + ], |
| 128 | + }; |
| 129 | + ledgerRows = [{ metadata_type: 'position', name: 'auditor', active: false }]; |
| 130 | + posts.length = 0; |
| 131 | + adapter.find.mockClear(); |
| 132 | + Object.values(toasts).forEach((f) => f.mockClear()); |
| 133 | + vi.stubGlobal( |
| 134 | + 'fetch', |
| 135 | + vi.fn(async (url: string, init?: RequestInit) => { |
| 136 | + const method = init?.method ?? 'GET'; |
| 137 | + const json = (status: number, body: unknown) => |
| 138 | + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); |
| 139 | + if (method === 'GET' && String(url).startsWith('/api/v1/data/sys_metadata_activation')) { |
| 140 | + return json(200, { object: 'sys_metadata_activation', records: ledgerRows, hasMore: false }); |
| 141 | + } |
| 142 | + if (method === 'POST' && String(url).startsWith('/api/v1/security/_activation/')) { |
| 143 | + posts.push({ url: String(url), body: init?.body as string | undefined }); |
| 144 | + const enabled = JSON.parse(String(init?.body ?? '{}')).enabled; |
| 145 | + return json(200, { success: true, data: { enabled } }); |
| 146 | + } |
| 147 | + return json(404, { success: false, error: { message: `unexpected ${method} ${url}` } }); |
| 148 | + }), |
| 149 | + ); |
| 150 | +}); |
| 151 | +afterEach(() => { |
| 152 | + cleanup(); |
| 153 | + vi.unstubAllGlobals(); |
| 154 | +}); |
| 155 | + |
| 156 | +function mount(type: 'permission' | 'position') { |
| 157 | + return render( |
| 158 | + <MemoryRouter initialEntries={[`/apps/setup/metadata/${type}?scope=environment`]}> |
| 159 | + <MetadataResourceListPage type={type} /> |
| 160 | + </MemoryRouter>, |
| 161 | + ); |
| 162 | +} |
| 163 | + |
| 164 | +/** Click a switch that is on, and wait for the confirm's count to arrive. */ |
| 165 | +async function askToSwitchOff(name: string) { |
| 166 | + const sw = await screen.findByTestId(`catalog-active-${name}`); |
| 167 | + await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true')); |
| 168 | + fireEvent.click(sw); |
| 169 | + const confirm = await screen.findByTestId('catalog-deactivate-confirm'); |
| 170 | + await waitFor(() => expect(confirm).not.toBeDisabled()); |
| 171 | + return { sw, confirm }; |
| 172 | +} |
| 173 | + |
| 174 | +describe.each([ |
| 175 | + { |
| 176 | + list: 'Permission Sets', |
| 177 | + type: 'permission' as const, |
| 178 | + name: 'sales_rep', |
| 179 | + label: 'Sales Rep', |
| 180 | + // u1, u2 directly; u2, u3 through sales_manager — three people. |
| 181 | + holders: 3, |
| 182 | + }, |
| 183 | + { |
| 184 | + list: 'Positions', |
| 185 | + type: 'position' as const, |
| 186 | + name: 'sales_manager', |
| 187 | + label: 'Sales Manager', |
| 188 | + holders: 2, |
| 189 | + }, |
| 190 | +])('the $list list: switching off asks first (objectui#13338)', ({ type, name, label, holders }) => { |
| 191 | + it('names how many users lose the access, and writes nothing until confirmed', async () => { |
| 192 | + mount(type); |
| 193 | + await askToSwitchOff(name); |
| 194 | + expect(screen.getByTestId('catalog-deactivate-dialog')).toHaveTextContent( |
| 195 | + tFormat('engine.catalog.deactivate.title', 'en-US', { name: label }), |
| 196 | + ); |
| 197 | + expect(screen.getByTestId('catalog-deactivate-impact')).toHaveTextContent( |
| 198 | + tFormat('engine.catalog.deactivate.holders', 'en-US', { count: holders }), |
| 199 | + ); |
| 200 | + expect(posts).toEqual([]); |
| 201 | + |
| 202 | + fireEvent.click(screen.getByTestId('catalog-deactivate-confirm')); |
| 203 | + await waitFor(() => |
| 204 | + expect(screen.getByTestId(`catalog-active-${name}`)).toHaveAttribute('aria-checked', 'false'), |
| 205 | + ); |
| 206 | + expect(posts).toEqual([{ url: `/api/v1/security/_activation/${type}/${name}`, body: '{"enabled":false}' }]); |
| 207 | + expect(toasts.success).toHaveBeenCalledWith( |
| 208 | + tFormat('engine.catalog.active.switchedOff', 'en-US', { name: label }), |
| 209 | + ); |
| 210 | + }); |
| 211 | + |
| 212 | + it('Cancel leaves it on and writes nothing', async () => { |
| 213 | + mount(type); |
| 214 | + const { sw } = await askToSwitchOff(name); |
| 215 | + fireEvent.click(screen.getByTestId('catalog-deactivate-cancel')); |
| 216 | + await waitFor(() => expect(screen.queryByTestId('catalog-deactivate-dialog')).toBeNull()); |
| 217 | + expect(sw).toHaveAttribute('aria-checked', 'true'); |
| 218 | + expect(posts).toEqual([]); |
| 219 | + expect(toasts.success).not.toHaveBeenCalled(); |
| 220 | + }); |
| 221 | + |
| 222 | + it('a refused count is said as such, never as "0 users", and the admin may still go ahead', async () => { |
| 223 | + holderRefusal = 'Reading the assignments was refused.'; |
| 224 | + mount(type); |
| 225 | + const { confirm } = await askToSwitchOff(name); |
| 226 | + const impact = screen.getByTestId('catalog-deactivate-impact'); |
| 227 | + expect(impact).toHaveTextContent( |
| 228 | + tFormat('engine.catalog.deactivate.countFailed', 'en-US', { message: holderRefusal }), |
| 229 | + ); |
| 230 | + expect(impact).not.toHaveTextContent(tFormat('engine.catalog.deactivate.holders', 'en-US', { count: 0 })); |
| 231 | + fireEvent.click(confirm); |
| 232 | + await waitFor(() => expect(posts).toHaveLength(1)); |
| 233 | + }); |
| 234 | +}); |
| 235 | + |
| 236 | +describe('switching on asks nothing (objectui#13338)', () => { |
| 237 | + it('on the Positions list, writes at once and toasts; CONTROL: no confirm opened and no holder read', async () => { |
| 238 | + mount('position'); |
| 239 | + const sw = await screen.findByTestId('catalog-active-auditor'); |
| 240 | + await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'false')); |
| 241 | + fireEvent.click(sw); |
| 242 | + await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true')); |
| 243 | + expect(screen.queryByTestId('catalog-deactivate-dialog')).toBeNull(); |
| 244 | + expect(posts).toEqual([{ url: '/api/v1/security/_activation/position/auditor', body: '{"enabled":true}' }]); |
| 245 | + expect(adapter.find).not.toHaveBeenCalled(); |
| 246 | + expect(toasts.success).toHaveBeenCalledWith( |
| 247 | + tFormat('engine.catalog.active.switchedOn', 'en-US', { name: 'Auditor' }), |
| 248 | + ); |
| 249 | + }); |
| 250 | + |
| 251 | + it('on the Permission Sets list, writes at once and toasts', async () => { |
| 252 | + ledgerRows = [{ metadata_type: 'permission', name: 'qa_fls_rep', active: false }]; |
| 253 | + mount('permission'); |
| 254 | + const sw = await screen.findByTestId('catalog-active-qa_fls_rep'); |
| 255 | + await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'false')); |
| 256 | + fireEvent.click(sw); |
| 257 | + await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true')); |
| 258 | + expect(screen.queryByTestId('catalog-deactivate-dialog')).toBeNull(); |
| 259 | + expect(posts).toEqual([{ url: '/api/v1/security/_activation/permission/qa_fls_rep', body: '{"enabled":true}' }]); |
| 260 | + expect(toasts.success).toHaveBeenCalledWith( |
| 261 | + tFormat('engine.catalog.active.switchedOn', 'en-US', { name: 'QA FLS Rep' }), |
| 262 | + ); |
| 263 | + }); |
| 264 | +}); |
| 265 | + |
| 266 | +describe('the count, read the way the holders sections read it (objectui#13338)', () => { |
| 267 | + it('one holder is said in the singular', async () => { |
| 268 | + mount('permission'); |
| 269 | + await askToSwitchOff('qa_fls_rep'); |
| 270 | + expect(screen.getByTestId('catalog-deactivate-impact')).toHaveTextContent( |
| 271 | + t('engine.catalog.deactivate.holdersOne', 'en-US'), |
| 272 | + ); |
| 273 | + }); |
| 274 | + |
| 275 | + it('a set bound to the everyone anchor is said to reach every signed-in member', async () => { |
| 276 | + mount('permission'); |
| 277 | + await askToSwitchOff('member_default'); |
| 278 | + expect(screen.getByTestId('catalog-deactivate-impact')).toHaveTextContent( |
| 279 | + t('engine.catalog.deactivate.everyone', 'en-US'), |
| 280 | + ); |
| 281 | + }); |
| 282 | + |
| 283 | + it('reads the grants by the set name and the position holders by position name', async () => { |
| 284 | + mount('permission'); |
| 285 | + await askToSwitchOff('sales_rep'); |
| 286 | + expect(adapter.find).toHaveBeenCalledWith('sys_user_permission_set', { |
| 287 | + $filter: { permission_set: 'sales_rep' }, |
| 288 | + $top: 1000, |
| 289 | + }); |
| 290 | + expect(adapter.find).toHaveBeenCalledWith('sys_user_position', { |
| 291 | + $filter: { position: { $in: ['sales_manager'] } }, |
| 292 | + $top: 1000, |
| 293 | + }); |
| 294 | + }); |
| 295 | +}); |
0 commit comments