@jmruthers/pace-core 0.5.3 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/styles/core.css +3 -0
- package/docs/api/classes/ErrorBoundary.md +1 -1
- package/docs/api/classes/InvalidScopeError.md +1 -1
- package/docs/api/classes/MissingUserContextError.md +1 -1
- package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
- package/docs/api/classes/PermissionDeniedError.md +1 -1
- package/docs/api/classes/PublicErrorBoundary.md +1 -1
- package/docs/api/classes/RBACAuditManager.md +1 -1
- package/docs/api/classes/RBACCache.md +1 -1
- package/docs/api/classes/RBACEngine.md +1 -1
- package/docs/api/classes/RBACError.md +1 -1
- package/docs/api/classes/RBACNotInitializedError.md +1 -1
- package/docs/api/classes/SecureSupabaseClient.md +1 -1
- package/docs/api/interfaces/AggregateConfig.md +1 -1
- package/docs/api/interfaces/ButtonProps.md +1 -1
- package/docs/api/interfaces/CardProps.md +1 -1
- package/docs/api/interfaces/ColorPalette.md +1 -1
- package/docs/api/interfaces/ColorShade.md +1 -1
- package/docs/api/interfaces/DataAccessRecord.md +1 -1
- package/docs/api/interfaces/DataTableAction.md +1 -1
- package/docs/api/interfaces/DataTableColumn.md +1 -1
- package/docs/api/interfaces/DataTableProps.md +1 -1
- package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
- package/docs/api/interfaces/EmptyStateConfig.md +1 -1
- package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
- package/docs/api/interfaces/EventContextType.md +1 -1
- package/docs/api/interfaces/EventLogoProps.md +1 -1
- package/docs/api/interfaces/EventProviderProps.md +1 -1
- package/docs/api/interfaces/FileSizeLimits.md +1 -1
- package/docs/api/interfaces/FileUploadProps.md +1 -1
- package/docs/api/interfaces/FooterProps.md +1 -1
- package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
- package/docs/api/interfaces/InputProps.md +1 -1
- package/docs/api/interfaces/LabelProps.md +1 -1
- package/docs/api/interfaces/LoginFormProps.md +1 -1
- package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
- package/docs/api/interfaces/NavigationContextType.md +1 -1
- package/docs/api/interfaces/NavigationGuardProps.md +1 -1
- package/docs/api/interfaces/NavigationItem.md +1 -1
- package/docs/api/interfaces/NavigationMenuProps.md +1 -1
- package/docs/api/interfaces/NavigationProviderProps.md +1 -1
- package/docs/api/interfaces/Organisation.md +1 -1
- package/docs/api/interfaces/OrganisationContextType.md +1 -1
- package/docs/api/interfaces/OrganisationMembership.md +1 -1
- package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
- package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
- package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
- package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
- package/docs/api/interfaces/PageAccessRecord.md +1 -1
- package/docs/api/interfaces/PagePermissionContextType.md +1 -1
- package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
- package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
- package/docs/api/interfaces/PaletteData.md +1 -1
- package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
- package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
- package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
- package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
- package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
- package/docs/api/interfaces/RBACConfig.md +1 -1
- package/docs/api/interfaces/RBACContextType.md +1 -1
- package/docs/api/interfaces/RBACLogger.md +1 -1
- package/docs/api/interfaces/RBACProviderProps.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
- package/docs/api/interfaces/RouteAccessRecord.md +1 -1
- package/docs/api/interfaces/RouteConfig.md +1 -1
- package/docs/api/interfaces/SecureDataContextType.md +1 -1
- package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
- package/docs/api/interfaces/StorageConfig.md +1 -1
- package/docs/api/interfaces/StorageFileInfo.md +1 -1
- package/docs/api/interfaces/StorageFileMetadata.md +1 -1
- package/docs/api/interfaces/StorageListOptions.md +1 -1
- package/docs/api/interfaces/StorageListResult.md +1 -1
- package/docs/api/interfaces/StorageUploadOptions.md +1 -1
- package/docs/api/interfaces/StorageUploadResult.md +1 -1
- package/docs/api/interfaces/StorageUrlOptions.md +1 -1
- package/docs/api/interfaces/StyleImport.md +1 -1
- package/docs/api/interfaces/ToastActionElement.md +1 -1
- package/docs/api/interfaces/ToastProps.md +1 -1
- package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
- package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
- package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
- package/docs/api/interfaces/UserEventAccess.md +1 -1
- package/docs/api/interfaces/UserMenuProps.md +1 -1
- package/docs/api/interfaces/UserProfile.md +1 -1
- package/docs/api/modules.md +2 -2
- package/package.json +1 -1
- package/src/components/Header/Header.test.tsx +1 -1
- package/src/components/OrganisationSelector/OrganisationSelector.test.tsx +1 -1
- package/src/rbac/api.test.ts +511 -0
- package/src/rbac/components/__tests__/NavigationGuard.test.tsx +843 -0
- package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +1007 -0
- package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +806 -0
- package/src/rbac/components/__tests__/RoleBasedRouter.test.tsx +741 -0
- package/src/rbac/hooks/useCan.test.ts +1 -1
- package/src/rbac/hooks/usePermissions.test.ts +10 -5
- package/src/rbac/hooks/useRBAC.test.ts +141 -93
- package/src/styles/core.css +3 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file PermissionEnforcer Component Tests
|
|
3
|
+
* @package @jmruthers/pace-core
|
|
4
|
+
* @module RBAC/Components/PermissionEnforcer
|
|
5
|
+
* @since 2.0.0
|
|
6
|
+
*
|
|
7
|
+
* Comprehensive tests for the PermissionEnforcer component covering all critical functionality.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { ReactNode } from 'react';
|
|
13
|
+
import { PermissionEnforcer } from '../PermissionEnforcer';
|
|
14
|
+
import { useCan } from '../../hooks';
|
|
15
|
+
import { useUnifiedAuth } from '../../../providers/UnifiedAuthProvider';
|
|
16
|
+
|
|
17
|
+
// Mock the RBAC hooks
|
|
18
|
+
vi.mock('../../hooks', () => ({
|
|
19
|
+
useCan: vi.fn()
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// Mock the auth provider
|
|
23
|
+
vi.mock('../../../providers/UnifiedAuthProvider', () => ({
|
|
24
|
+
useUnifiedAuth: vi.fn()
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// Mock the event context utility
|
|
28
|
+
vi.mock('../../utils/eventContext', () => ({
|
|
29
|
+
createScopeFromEvent: vi.fn()
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import { createScopeFromEvent } from '../../utils/eventContext';
|
|
33
|
+
|
|
34
|
+
// Mock data
|
|
35
|
+
const mockUser = {
|
|
36
|
+
id: 'user-123',
|
|
37
|
+
email: 'test@example.com'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const mockScope = {
|
|
41
|
+
organisationId: 'org-123',
|
|
42
|
+
eventId: 'event-123',
|
|
43
|
+
appId: 'app-123'
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const mockPermissions = ['read:events', 'manage:events'] as const;
|
|
47
|
+
const mockOperation = 'event-management';
|
|
48
|
+
|
|
49
|
+
// Test component
|
|
50
|
+
const TestComponent = ({ children }: { children: ReactNode }) => (
|
|
51
|
+
<div data-testid="test-component">{children}</div>
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const TestFallback = () => (
|
|
55
|
+
<div data-testid="test-fallback">Access Denied</div>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const TestLoading = () => (
|
|
59
|
+
<div data-testid="test-loading">Loading...</div>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
describe('PermissionEnforcer Component', () => {
|
|
63
|
+
const mockUseCan = vi.mocked(useCan);
|
|
64
|
+
const mockUseUnifiedAuth = vi.mocked(useUnifiedAuth);
|
|
65
|
+
const mockCreateScopeFromEvent = vi.mocked(createScopeFromEvent);
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.clearAllMocks();
|
|
69
|
+
|
|
70
|
+
// Default mock implementations
|
|
71
|
+
mockUseUnifiedAuth.mockReturnValue({
|
|
72
|
+
user: mockUser,
|
|
73
|
+
selectedOrganisationId: 'org-123',
|
|
74
|
+
selectedEventId: 'event-123',
|
|
75
|
+
supabase: {} as any
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
mockUseCan.mockReturnValue({
|
|
79
|
+
can: true,
|
|
80
|
+
isLoading: false,
|
|
81
|
+
error: null
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
vi.restoreAllMocks();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('Rendering', () => {
|
|
90
|
+
it('renders children when permission is granted', async () => {
|
|
91
|
+
mockUseCan.mockReturnValue({
|
|
92
|
+
can: true,
|
|
93
|
+
isLoading: false,
|
|
94
|
+
error: null
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
render(
|
|
98
|
+
<PermissionEnforcer
|
|
99
|
+
permissions={mockPermissions}
|
|
100
|
+
operation={mockOperation}
|
|
101
|
+
>
|
|
102
|
+
<TestComponent>Protected Content</TestComponent>
|
|
103
|
+
</PermissionEnforcer>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
|
108
|
+
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('renders fallback when permission is denied', async () => {
|
|
113
|
+
mockUseCan.mockReturnValue({
|
|
114
|
+
can: false,
|
|
115
|
+
isLoading: false,
|
|
116
|
+
error: null
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
render(
|
|
120
|
+
<PermissionEnforcer
|
|
121
|
+
permissions={mockPermissions}
|
|
122
|
+
operation={mockOperation}
|
|
123
|
+
fallback={<TestFallback />}
|
|
124
|
+
>
|
|
125
|
+
<TestComponent>Protected Content</TestComponent>
|
|
126
|
+
</PermissionEnforcer>
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
await waitFor(() => {
|
|
130
|
+
expect(screen.getByTestId('test-fallback')).toBeInTheDocument();
|
|
131
|
+
expect(screen.queryByTestId('test-component')).not.toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('shows loading state during permission check', () => {
|
|
136
|
+
mockUseCan.mockReturnValue({
|
|
137
|
+
can: false,
|
|
138
|
+
isLoading: true,
|
|
139
|
+
error: null
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
render(
|
|
143
|
+
<PermissionEnforcer
|
|
144
|
+
permissions={mockPermissions}
|
|
145
|
+
operation={mockOperation}
|
|
146
|
+
loading={<TestLoading />}
|
|
147
|
+
>
|
|
148
|
+
<TestComponent>Protected Content</TestComponent>
|
|
149
|
+
</PermissionEnforcer>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(screen.getByTestId('test-loading')).toBeInTheDocument();
|
|
153
|
+
expect(screen.queryByTestId('test-component')).not.toBeInTheDocument();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('uses default fallback when none provided', async () => {
|
|
157
|
+
mockUseCan.mockReturnValue({
|
|
158
|
+
can: false,
|
|
159
|
+
isLoading: false,
|
|
160
|
+
error: null
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
render(
|
|
164
|
+
<PermissionEnforcer
|
|
165
|
+
permissions={mockPermissions}
|
|
166
|
+
operation={mockOperation}
|
|
167
|
+
>
|
|
168
|
+
<TestComponent>Protected Content</TestComponent>
|
|
169
|
+
</PermissionEnforcer>
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await waitFor(() => {
|
|
173
|
+
expect(screen.getByText('Access Denied')).toBeInTheDocument();
|
|
174
|
+
expect(screen.getByText('You don\'t have permission to perform this operation.')).toBeInTheDocument();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('uses default loading when none provided', () => {
|
|
179
|
+
mockUseCan.mockReturnValue({
|
|
180
|
+
can: false,
|
|
181
|
+
isLoading: true,
|
|
182
|
+
error: null
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
render(
|
|
186
|
+
<PermissionEnforcer
|
|
187
|
+
permissions={mockPermissions}
|
|
188
|
+
operation={mockOperation}
|
|
189
|
+
>
|
|
190
|
+
<TestComponent>Protected Content</TestComponent>
|
|
191
|
+
</PermissionEnforcer>
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('Permission Checking', () => {
|
|
199
|
+
it('enforces single permission correctly', async () => {
|
|
200
|
+
const singlePermission = ['read:events'] as const;
|
|
201
|
+
|
|
202
|
+
mockUseCan.mockReturnValue({
|
|
203
|
+
can: true,
|
|
204
|
+
isLoading: false,
|
|
205
|
+
error: null
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
render(
|
|
209
|
+
<PermissionEnforcer
|
|
210
|
+
permissions={singlePermission}
|
|
211
|
+
operation={mockOperation}
|
|
212
|
+
>
|
|
213
|
+
<TestComponent>Protected Content</TestComponent>
|
|
214
|
+
</PermissionEnforcer>
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(mockUseCan).toHaveBeenCalledWith(
|
|
222
|
+
'user-123',
|
|
223
|
+
expect.objectContaining({
|
|
224
|
+
organisationId: 'org-123',
|
|
225
|
+
eventId: 'event-123'
|
|
226
|
+
}),
|
|
227
|
+
'read:events',
|
|
228
|
+
undefined,
|
|
229
|
+
true
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('enforces multiple permissions with AND logic', async () => {
|
|
234
|
+
mockUseCan.mockReturnValue({
|
|
235
|
+
can: true,
|
|
236
|
+
isLoading: false,
|
|
237
|
+
error: null
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
render(
|
|
241
|
+
<PermissionEnforcer
|
|
242
|
+
permissions={mockPermissions}
|
|
243
|
+
operation={mockOperation}
|
|
244
|
+
requireAll={true}
|
|
245
|
+
>
|
|
246
|
+
<TestComponent>Protected Content</TestComponent>
|
|
247
|
+
</PermissionEnforcer>
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
await waitFor(() => {
|
|
251
|
+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Should check the first permission as representative
|
|
255
|
+
expect(mockUseCan).toHaveBeenCalledWith(
|
|
256
|
+
'user-123',
|
|
257
|
+
expect.objectContaining({
|
|
258
|
+
organisationId: 'org-123',
|
|
259
|
+
eventId: 'event-123'
|
|
260
|
+
}),
|
|
261
|
+
'read:events',
|
|
262
|
+
undefined,
|
|
263
|
+
true
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('handles permission checking errors gracefully', async () => {
|
|
268
|
+
const error = new Error('Permission check failed');
|
|
269
|
+
mockUseCan.mockReturnValue({
|
|
270
|
+
can: false,
|
|
271
|
+
isLoading: false,
|
|
272
|
+
error
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
render(
|
|
276
|
+
<PermissionEnforcer
|
|
277
|
+
permissions={mockPermissions}
|
|
278
|
+
operation={mockOperation}
|
|
279
|
+
fallback={<TestFallback />}
|
|
280
|
+
>
|
|
281
|
+
<TestComponent>Protected Content</TestComponent>
|
|
282
|
+
</PermissionEnforcer>
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
await waitFor(() => {
|
|
286
|
+
expect(screen.getByTestId('test-fallback')).toBeInTheDocument();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('handles empty permissions array', async () => {
|
|
291
|
+
mockUseCan.mockReturnValue({
|
|
292
|
+
can: true,
|
|
293
|
+
isLoading: false,
|
|
294
|
+
error: null
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
render(
|
|
298
|
+
<PermissionEnforcer
|
|
299
|
+
permissions={[]}
|
|
300
|
+
operation={mockOperation}
|
|
301
|
+
>
|
|
302
|
+
<TestComponent>Protected Content</TestComponent>
|
|
303
|
+
</PermissionEnforcer>
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
await waitFor(() => {
|
|
307
|
+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('Scope Resolution', () => {
|
|
313
|
+
it('uses provided scope when available', async () => {
|
|
314
|
+
const customScope = {
|
|
315
|
+
organisationId: 'custom-org',
|
|
316
|
+
eventId: 'custom-event',
|
|
317
|
+
appId: 'custom-app'
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
mockUseCan.mockReturnValue({
|
|
321
|
+
can: true,
|
|
322
|
+
isLoading: false,
|
|
323
|
+
error: null
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
render(
|
|
327
|
+
<PermissionEnforcer
|
|
328
|
+
permissions={mockPermissions}
|
|
329
|
+
operation={mockOperation}
|
|
330
|
+
scope={customScope}
|
|
331
|
+
>
|
|
332
|
+
<TestComponent>Protected Content</TestComponent>
|
|
333
|
+
</PermissionEnforcer>
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
await waitFor(() => {
|
|
337
|
+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(mockUseCan).toHaveBeenCalledWith(
|
|
341
|
+
'user-123',
|
|
342
|
+
customScope,
|
|
343
|
+
'read:events',
|
|
344
|
+
undefined,
|
|
345
|
+
true
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('resolves scope from organisation and event context', async () => {
|
|
350
|
+
mockUseCan.mockReturnValue({
|
|
351
|
+
can: true,
|
|
352
|
+
isLoading: false,
|
|
353
|
+
error: null
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
render(
|
|
357
|
+
<PermissionEnforcer
|
|
358
|
+
permissions={mockPermissions}
|
|
359
|
+
operation={mockOperation}
|
|
360
|
+
>
|
|
361
|
+
<TestComponent>Protected Content</TestComponent>
|
|
362
|
+
</PermissionEnforcer>
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
await waitFor(() => {
|
|
366
|
+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
expect(mockUseCan).toHaveBeenCalledWith(
|
|
370
|
+
'user-123',
|
|
371
|
+
expect.objectContaining({
|
|
372
|
+
organisationId: 'org-123',
|
|
373
|
+
eventId: 'event-123'
|
|
374
|
+
}),
|
|
375
|
+
'read:events',
|
|
376
|
+
undefined,
|
|
377
|
+
true
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('resolves scope from organisation only', async () => {
|
|
382
|
+
mockUseUnifiedAuth.mockReturnValue({
|
|
383
|
+
user: mockUser,
|
|
384
|
+
selectedOrganisationId: 'org-123',
|
|
385
|
+
selectedEventId: null,
|
|
386
|
+
supabase: {} as any
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
mockUseCan.mockReturnValue({
|
|
390
|
+
can: true,
|
|
391
|
+
isLoading: false,
|
|
392
|
+
error: null
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
render(
|
|
396
|
+
<PermissionEnforcer
|
|
397
|
+
permissions={mockPermissions}
|
|
398
|
+
operation={mockOperation}
|
|
399
|
+
>
|
|
400
|
+
<TestComponent>Protected Content</TestComponent>
|
|
401
|
+
</PermissionEnforcer>
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
await waitFor(() => {
|
|
405
|
+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
expect(mockUseCan).toHaveBeenCalledWith(
|
|
409
|
+
'user-123',
|
|
410
|
+
expect.objectContaining({
|
|
411
|
+
organisationId: 'org-123',
|
|
412
|
+
eventId: undefined
|
|
413
|
+
}),
|
|
414
|
+
'read:events',
|
|
415
|
+
undefined,
|
|
416
|
+
true
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('resolves scope from event context when organisation not available', async () => {
|
|
421
|
+
mockUseUnifiedAuth.mockReturnValue({
|
|
422
|
+
user: mockUser,
|
|
423
|
+
selectedOrganisationId: null,
|
|
424
|
+
selectedEventId: 'event-123',
|
|
425
|
+
supabase: {} as any
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
mockCreateScopeFromEvent.mockResolvedValue({
|
|
429
|
+
organisationId: 'resolved-org',
|
|
430
|
+
eventId: 'event-123',
|
|
431
|
+
appId: 'resolved-app'
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
mockUseCan.mockReturnValue({
|
|
435
|
+
can: true,
|
|
436
|
+
isLoading: false,
|
|
437
|
+
error: null
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
render(
|
|
441
|
+
<PermissionEnforcer
|
|
442
|
+
permissions={mockPermissions}
|
|
443
|
+
operation={mockOperation}
|
|
444
|
+
>
|
|
445
|
+
<TestComponent>Protected Content</TestComponent>
|
|
446
|
+
</PermissionEnforcer>
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
await waitFor(() => {
|
|
450
|
+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
expect(mockCreateScopeFromEvent).toHaveBeenCalledWith({}, 'event-123');
|
|
454
|
+
expect(mockUseCan).toHaveBeenCalledWith(
|
|
455
|
+
'user-123',
|
|
456
|
+
expect.objectContaining({
|
|
457
|
+
organisationId: 'resolved-org',
|
|
458
|
+
eventId: 'event-123'
|
|
459
|
+
}),
|
|
460
|
+
'read:events',
|
|
461
|
+
undefined,
|
|
462
|
+
true
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('handles scope resolution errors', async () => {
|
|
467
|
+
mockUseUnifiedAuth.mockReturnValue({
|
|
468
|
+
user: mockUser,
|
|
469
|
+
selectedOrganisationId: null,
|
|
470
|
+
selectedEventId: 'event-123',
|
|
471
|
+
supabase: {} as any
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const error = new Error('Could not resolve organisation from event');
|
|
475
|
+
mockCreateScopeFromEvent.mockRejectedValue(error);
|
|
476
|
+
|
|
477
|
+
render(
|
|
478
|
+
<PermissionEnforcer
|
|
479
|
+
permissions={mockPermissions}
|
|
480
|
+
operation={mockOperation}
|
|
481
|
+
fallback={<TestFallback />}
|
|
482
|
+
>
|
|
483
|
+
<TestComponent>Protected Content</TestComponent>
|
|
484
|
+
</PermissionEnforcer>
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
await waitFor(() => {
|
|
488
|
+
expect(screen.getByTestId('test-fallback')).toBeInTheDocument();
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('handles missing context gracefully', async () => {
|
|
493
|
+
mockUseUnifiedAuth.mockReturnValue({
|
|
494
|
+
user: mockUser,
|
|
495
|
+
selectedOrganisationId: null,
|
|
496
|
+
selectedEventId: null,
|
|
497
|
+
supabase: null
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
render(
|
|
501
|
+
<PermissionEnforcer
|
|
502
|
+
permissions={mockPermissions}
|
|
503
|
+
operation={mockOperation}
|
|
504
|
+
fallback={<TestFallback />}
|
|
505
|
+
>
|
|
506
|
+
<TestComponent>Protected Content</TestComponent>
|
|
507
|
+
</PermissionEnforcer>
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
await waitFor(() => {
|
|
511
|
+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
describe('Security Features', () => {
|
|
517
|
+
it('prevents bypassing in strict mode', async () => {
|
|
518
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
519
|
+
|
|
520
|
+
mockUseCan.mockReturnValue({
|
|
521
|
+
can: false,
|
|
522
|
+
isLoading: false,
|
|
523
|
+
error: null
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
render(
|
|
527
|
+
<PermissionEnforcer
|
|
528
|
+
permissions={mockPermissions}
|
|
529
|
+
operation={mockOperation}
|
|
530
|
+
strictMode={true}
|
|
531
|
+
fallback={<TestFallback />}
|
|
532
|
+
>
|
|
533
|
+
<TestComponent>Protected Content</TestComponent>
|
|
534
|
+
</PermissionEnforcer>
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
await waitFor(() => {
|
|
538
|
+
expect(screen.getByTestId('test-fallback')).toBeInTheDocument();
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
542
|
+
expect.stringContaining('STRICT MODE VIOLATION'),
|
|
543
|
+
expect.objectContaining({
|
|
544
|
+
permissions: mockPermissions,
|
|
545
|
+
operation: mockOperation,
|
|
546
|
+
userId: 'user-123'
|
|
547
|
+
})
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
consoleSpy.mockRestore();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('logs security violations for audit', async () => {
|
|
554
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
555
|
+
|
|
556
|
+
mockUseCan.mockReturnValue({
|
|
557
|
+
can: false,
|
|
558
|
+
isLoading: false,
|
|
559
|
+
error: null
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
render(
|
|
563
|
+
<PermissionEnforcer
|
|
564
|
+
permissions={mockPermissions}
|
|
565
|
+
operation={mockOperation}
|
|
566
|
+
auditLog={true}
|
|
567
|
+
fallback={<TestFallback />}
|
|
568
|
+
>
|
|
569
|
+
<TestComponent>Protected Content</TestComponent>
|
|
570
|
+
</PermissionEnforcer>
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
await waitFor(() => {
|
|
574
|
+
expect(screen.getByTestId('test-fallback')).toBeInTheDocument();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
578
|
+
expect.stringContaining('Permission check attempt'),
|
|
579
|
+
expect.objectContaining({
|
|
580
|
+
permissions: mockPermissions,
|
|
581
|
+
operation: mockOperation,
|
|
582
|
+
userId: 'user-123',
|
|
583
|
+
allowed: false
|
|
584
|
+
})
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
consoleSpy.mockRestore();
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('calls onDenied callback when access is denied', async () => {
|
|
591
|
+
const onDeniedSpy = vi.fn();
|
|
592
|
+
|
|
593
|
+
mockUseCan.mockReturnValue({
|
|
594
|
+
can: false,
|
|
595
|
+
isLoading: false,
|
|
596
|
+
error: null
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
render(
|
|
600
|
+
<PermissionEnforcer
|
|
601
|
+
permissions={mockPermissions}
|
|
602
|
+
operation={mockOperation}
|
|
603
|
+
onDenied={onDeniedSpy}
|
|
604
|
+
fallback={<TestFallback />}
|
|
605
|
+
>
|
|
606
|
+
<TestComponent>Protected Content</TestComponent>
|
|
607
|
+
</PermissionEnforcer>
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
await waitFor(() => {
|
|
611
|
+
expect(screen.getByTestId('test-fallback')).toBeInTheDocument();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
expect(onDeniedSpy).toHaveBeenCalledWith(mockPermissions, mockOperation);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('does not call onDenied when access is granted', async () => {
|
|
618
|
+
const onDeniedSpy = vi.fn();
|
|
619
|
+
|
|
620
|
+
mockUseCan.mockReturnValue({
|
|
621
|
+
can: true,
|
|
622
|
+
isLoading: false,
|
|
623
|
+
error: null
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
render(
|
|
627
|
+
<PermissionEnforcer
|
|
628
|
+
permissions={mockPermissions}
|
|
629
|
+
operation={mockOperation}
|
|
630
|
+
onDenied={onDeniedSpy}
|
|
631
|
+
>
|
|
632
|
+
<TestComponent>Protected Content</TestComponent>
|
|
633
|
+
</PermissionEnforcer>
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
await waitFor(() => {
|
|
637
|
+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
expect(onDeniedSpy).not.toHaveBeenCalled();
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
describe('Configuration Options', () => {
|
|
645
|
+
it('respects strictMode setting', async () => {
|
|
646
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
647
|
+
|
|
648
|
+
mockUseCan.mockReturnValue({
|
|
649
|
+
can: false,
|
|
650
|
+
isLoading: false,
|
|
651
|
+
error: null
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
render(
|
|
655
|
+
<PermissionEnforcer
|
|
656
|
+
permissions={mockPermissions}
|
|
657
|
+
operation={mockOperation}
|
|
658
|
+
strictMode={false}
|
|
659
|
+
fallback={<TestFallback />}
|
|
660
|
+
>
|
|
661
|
+
<TestComponent>Protected Content</TestComponent>
|
|
662
|
+
</PermissionEnforcer>
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
await waitFor(() => {
|
|
666
|
+
expect(screen.getByTestId('test-fallback')).toBeInTheDocument();
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
expect(consoleSpy).not.toHaveBeenCalledWith(
|
|
670
|
+
expect.stringContaining('STRICT MODE VIOLATION')
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
consoleSpy.mockRestore();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('respects auditLog setting', async () => {
|
|
677
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
678
|
+
|
|
679
|
+
mockUseCan.mockReturnValue({
|
|
680
|
+
can: false,
|
|
681
|
+
isLoading: false,
|
|
682
|
+
error: null
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
render(
|
|
686
|
+
<PermissionEnforcer
|
|
687
|
+
permissions={mockPermissions}
|
|
688
|
+
operation={mockOperation}
|
|
689
|
+
auditLog={false}
|
|
690
|
+
fallback={<TestFallback />}
|
|
691
|
+
>
|
|
692
|
+
<TestComponent>Protected Content</TestComponent>
|
|
693
|
+
</PermissionEnforcer>
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
await waitFor(() => {
|
|
697
|
+
expect(screen.getByTestId('test-fallback')).toBeInTheDocument();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
expect(consoleSpy).not.toHaveBeenCalledWith(
|
|
701
|
+
expect.stringContaining('Permission check attempt')
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
consoleSpy.mockRestore();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('respects requireAll setting', async () => {
|
|
708
|
+
mockUseCan.mockReturnValue({
|
|
709
|
+
can: true,
|
|
710
|
+
isLoading: false,
|
|
711
|
+
error: null
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
render(
|
|
715
|
+
<PermissionEnforcer
|
|
716
|
+
permissions={mockPermissions}
|
|
717
|
+
operation={mockOperation}
|
|
718
|
+
requireAll={false}
|
|
719
|
+
>
|
|
720
|
+
<TestComponent>Protected Content</TestComponent>
|
|
721
|
+
</PermissionEnforcer>
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
await waitFor(() => {
|
|
725
|
+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Should still check the first permission as representative
|
|
729
|
+
expect(mockUseCan).toHaveBeenCalledWith(
|
|
730
|
+
'user-123',
|
|
731
|
+
expect.objectContaining({
|
|
732
|
+
organisationId: 'org-123',
|
|
733
|
+
eventId: 'event-123'
|
|
734
|
+
}),
|
|
735
|
+
'read:events',
|
|
736
|
+
undefined,
|
|
737
|
+
true
|
|
738
|
+
);
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
describe('Error Handling', () => {
|
|
743
|
+
it('handles missing user gracefully', async () => {
|
|
744
|
+
mockUseUnifiedAuth.mockReturnValue({
|
|
745
|
+
user: null,
|
|
746
|
+
selectedOrganisationId: 'org-123',
|
|
747
|
+
selectedEventId: 'event-123',
|
|
748
|
+
supabase: {} as any
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
mockUseCan.mockReturnValue({
|
|
752
|
+
can: false,
|
|
753
|
+
isLoading: false,
|
|
754
|
+
error: null
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
render(
|
|
758
|
+
<PermissionEnforcer
|
|
759
|
+
permissions={mockPermissions}
|
|
760
|
+
operation={mockOperation}
|
|
761
|
+
fallback={<TestFallback />}
|
|
762
|
+
>
|
|
763
|
+
<TestComponent>Protected Content</TestComponent>
|
|
764
|
+
</PermissionEnforcer>
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
await waitFor(() => {
|
|
768
|
+
expect(screen.getByTestId('test-fallback')).toBeInTheDocument();
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
expect(mockUseCan).toHaveBeenCalledWith(
|
|
772
|
+
'',
|
|
773
|
+
expect.objectContaining({
|
|
774
|
+
organisationId: 'org-123',
|
|
775
|
+
eventId: 'event-123'
|
|
776
|
+
}),
|
|
777
|
+
'read:events',
|
|
778
|
+
undefined,
|
|
779
|
+
true
|
|
780
|
+
);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('handles permission check errors', async () => {
|
|
784
|
+
const error = new Error('Database connection failed');
|
|
785
|
+
mockUseCan.mockReturnValue({
|
|
786
|
+
can: false,
|
|
787
|
+
isLoading: false,
|
|
788
|
+
error
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
render(
|
|
792
|
+
<PermissionEnforcer
|
|
793
|
+
permissions={mockPermissions}
|
|
794
|
+
operation={mockOperation}
|
|
795
|
+
fallback={<TestFallback />}
|
|
796
|
+
>
|
|
797
|
+
<TestComponent>Protected Content</TestComponent>
|
|
798
|
+
</PermissionEnforcer>
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
await waitFor(() => {
|
|
802
|
+
expect(screen.getByTestId('test-fallback')).toBeInTheDocument();
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
});
|