@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.
Files changed (106) hide show
  1. package/dist/styles/core.css +3 -0
  2. package/docs/api/classes/ErrorBoundary.md +1 -1
  3. package/docs/api/classes/InvalidScopeError.md +1 -1
  4. package/docs/api/classes/MissingUserContextError.md +1 -1
  5. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  6. package/docs/api/classes/PermissionDeniedError.md +1 -1
  7. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  8. package/docs/api/classes/RBACAuditManager.md +1 -1
  9. package/docs/api/classes/RBACCache.md +1 -1
  10. package/docs/api/classes/RBACEngine.md +1 -1
  11. package/docs/api/classes/RBACError.md +1 -1
  12. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  13. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  14. package/docs/api/interfaces/AggregateConfig.md +1 -1
  15. package/docs/api/interfaces/ButtonProps.md +1 -1
  16. package/docs/api/interfaces/CardProps.md +1 -1
  17. package/docs/api/interfaces/ColorPalette.md +1 -1
  18. package/docs/api/interfaces/ColorShade.md +1 -1
  19. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  20. package/docs/api/interfaces/DataTableAction.md +1 -1
  21. package/docs/api/interfaces/DataTableColumn.md +1 -1
  22. package/docs/api/interfaces/DataTableProps.md +1 -1
  23. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  24. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  25. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  26. package/docs/api/interfaces/EventContextType.md +1 -1
  27. package/docs/api/interfaces/EventLogoProps.md +1 -1
  28. package/docs/api/interfaces/EventProviderProps.md +1 -1
  29. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  30. package/docs/api/interfaces/FileUploadProps.md +1 -1
  31. package/docs/api/interfaces/FooterProps.md +1 -1
  32. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  33. package/docs/api/interfaces/InputProps.md +1 -1
  34. package/docs/api/interfaces/LabelProps.md +1 -1
  35. package/docs/api/interfaces/LoginFormProps.md +1 -1
  36. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  37. package/docs/api/interfaces/NavigationContextType.md +1 -1
  38. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  39. package/docs/api/interfaces/NavigationItem.md +1 -1
  40. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  41. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  42. package/docs/api/interfaces/Organisation.md +1 -1
  43. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  44. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  45. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  46. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  47. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  48. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  49. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  50. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  51. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  52. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  53. package/docs/api/interfaces/PaletteData.md +1 -1
  54. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  55. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  56. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  57. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  58. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  59. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  60. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  61. package/docs/api/interfaces/RBACConfig.md +1 -1
  62. package/docs/api/interfaces/RBACContextType.md +1 -1
  63. package/docs/api/interfaces/RBACLogger.md +1 -1
  64. package/docs/api/interfaces/RBACProviderProps.md +1 -1
  65. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  66. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  67. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  68. package/docs/api/interfaces/RouteConfig.md +1 -1
  69. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  70. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  71. package/docs/api/interfaces/StorageConfig.md +1 -1
  72. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  73. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  74. package/docs/api/interfaces/StorageListOptions.md +1 -1
  75. package/docs/api/interfaces/StorageListResult.md +1 -1
  76. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  77. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  78. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  79. package/docs/api/interfaces/StyleImport.md +1 -1
  80. package/docs/api/interfaces/ToastActionElement.md +1 -1
  81. package/docs/api/interfaces/ToastProps.md +1 -1
  82. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  83. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  84. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  85. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  86. package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
  87. package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
  88. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  89. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  90. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  91. package/docs/api/interfaces/UserEventAccess.md +1 -1
  92. package/docs/api/interfaces/UserMenuProps.md +1 -1
  93. package/docs/api/interfaces/UserProfile.md +1 -1
  94. package/docs/api/modules.md +2 -2
  95. package/package.json +1 -1
  96. package/src/components/Header/Header.test.tsx +1 -1
  97. package/src/components/OrganisationSelector/OrganisationSelector.test.tsx +1 -1
  98. package/src/rbac/api.test.ts +511 -0
  99. package/src/rbac/components/__tests__/NavigationGuard.test.tsx +843 -0
  100. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +1007 -0
  101. package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +806 -0
  102. package/src/rbac/components/__tests__/RoleBasedRouter.test.tsx +741 -0
  103. package/src/rbac/hooks/useCan.test.ts +1 -1
  104. package/src/rbac/hooks/usePermissions.test.ts +10 -5
  105. package/src/rbac/hooks/useRBAC.test.ts +141 -93
  106. 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
+ });