@jmruthers/pace-core 0.5.3 → 0.5.5

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 (170) hide show
  1. package/dist/{DataTable-ZQDRE46Q.js → DataTable-3SSI644S.js} +2 -2
  2. package/dist/{chunk-M4RW7PIP.js → chunk-2BJFM2JC.js} +105 -81
  3. package/dist/chunk-2BJFM2JC.js.map +1 -0
  4. package/dist/{chunk-5H3C2SWM.js → chunk-RTCA5ZNK.js} +2 -2
  5. package/dist/components.js +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/styles/core.css +3 -0
  8. package/dist/utils.js +1 -1
  9. package/docs/api/classes/ErrorBoundary.md +1 -1
  10. package/docs/api/classes/InvalidScopeError.md +1 -1
  11. package/docs/api/classes/MissingUserContextError.md +1 -1
  12. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  13. package/docs/api/classes/PermissionDeniedError.md +1 -1
  14. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  15. package/docs/api/classes/RBACAuditManager.md +1 -1
  16. package/docs/api/classes/RBACCache.md +1 -1
  17. package/docs/api/classes/RBACEngine.md +1 -1
  18. package/docs/api/classes/RBACError.md +1 -1
  19. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  20. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  21. package/docs/api/interfaces/AggregateConfig.md +1 -1
  22. package/docs/api/interfaces/ButtonProps.md +1 -1
  23. package/docs/api/interfaces/CardProps.md +1 -1
  24. package/docs/api/interfaces/ColorPalette.md +1 -1
  25. package/docs/api/interfaces/ColorShade.md +1 -1
  26. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  27. package/docs/api/interfaces/DataTableAction.md +1 -1
  28. package/docs/api/interfaces/DataTableColumn.md +1 -1
  29. package/docs/api/interfaces/DataTableProps.md +34 -34
  30. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  31. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  32. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  33. package/docs/api/interfaces/EventContextType.md +1 -1
  34. package/docs/api/interfaces/EventLogoProps.md +1 -1
  35. package/docs/api/interfaces/EventProviderProps.md +1 -1
  36. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  37. package/docs/api/interfaces/FileUploadProps.md +1 -1
  38. package/docs/api/interfaces/FooterProps.md +1 -1
  39. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  40. package/docs/api/interfaces/InputProps.md +1 -1
  41. package/docs/api/interfaces/LabelProps.md +1 -1
  42. package/docs/api/interfaces/LoginFormProps.md +1 -1
  43. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  44. package/docs/api/interfaces/NavigationContextType.md +1 -1
  45. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  46. package/docs/api/interfaces/NavigationItem.md +1 -1
  47. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  48. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  49. package/docs/api/interfaces/Organisation.md +1 -1
  50. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  51. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  52. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  53. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  54. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  55. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  56. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  57. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  58. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  59. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  60. package/docs/api/interfaces/PaletteData.md +1 -1
  61. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  62. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  63. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  64. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  65. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  66. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  67. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  68. package/docs/api/interfaces/RBACConfig.md +1 -1
  69. package/docs/api/interfaces/RBACContextType.md +1 -1
  70. package/docs/api/interfaces/RBACLogger.md +1 -1
  71. package/docs/api/interfaces/RBACProviderProps.md +1 -1
  72. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  73. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  74. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  75. package/docs/api/interfaces/RouteConfig.md +1 -1
  76. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  77. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  78. package/docs/api/interfaces/StorageConfig.md +1 -1
  79. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  80. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  81. package/docs/api/interfaces/StorageListOptions.md +1 -1
  82. package/docs/api/interfaces/StorageListResult.md +1 -1
  83. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  84. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  85. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  86. package/docs/api/interfaces/StyleImport.md +1 -1
  87. package/docs/api/interfaces/ToastActionElement.md +1 -1
  88. package/docs/api/interfaces/ToastProps.md +1 -1
  89. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  90. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  91. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  92. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  93. package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
  94. package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
  95. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  96. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  97. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  98. package/docs/api/interfaces/UserEventAccess.md +1 -1
  99. package/docs/api/interfaces/UserMenuProps.md +1 -1
  100. package/docs/api/interfaces/UserProfile.md +1 -1
  101. package/docs/api/modules.md +3 -3
  102. package/docs/implementation-guides/data-tables.md +20 -0
  103. package/docs/quick-reference.md +9 -0
  104. package/docs/rbac/examples.md +4 -0
  105. package/package.json +1 -1
  106. package/src/__tests__/helpers/test-utils.tsx +147 -1
  107. package/src/components/DataTable/DataTable.tsx +20 -0
  108. package/src/components/DataTable/__tests__/DataTable.hooks.test 2.tsx +191 -0
  109. package/src/components/DataTable/__tests__/DataTable.hooks.test.tsx +191 -0
  110. package/src/components/DataTable/components/DataTableCore.tsx +167 -138
  111. package/src/components/Header/Header.test.tsx +1 -1
  112. package/src/components/OrganisationSelector/OrganisationSelector.test.tsx +1 -1
  113. package/src/hooks/__tests__/hooks.integration.test.tsx +575 -0
  114. package/src/hooks/__tests__/useApiFetch.unit.test.ts +115 -0
  115. package/src/hooks/__tests__/useComponentPerformance.unit.test.tsx +133 -0
  116. package/src/hooks/__tests__/useDebounce.unit.test.ts +82 -0
  117. package/src/hooks/__tests__/useFocusTrap.unit.test.tsx +293 -0
  118. package/src/hooks/__tests__/useInactivityTracker.unit.test.ts +385 -0
  119. package/src/hooks/__tests__/useOrganisationPermissions.unit.test.tsx +286 -0
  120. package/src/hooks/__tests__/useOrganisationSecurity.unit.test.tsx +838 -0
  121. package/src/hooks/__tests__/usePermissionCache.simple.test.ts +104 -0
  122. package/src/hooks/__tests__/usePermissionCache.unit.test.ts +633 -0
  123. package/src/hooks/__tests__/useRBAC.unit.test.ts +856 -0
  124. package/src/hooks/__tests__/useSecureDataAccess.unit.test.tsx +537 -0
  125. package/src/hooks/__tests__/useToast.unit.test.tsx +62 -0
  126. package/src/hooks/__tests__/useZodForm.unit.test.tsx +37 -0
  127. package/src/rbac/api.test.ts +511 -0
  128. package/src/rbac/components/__tests__/NavigationGuard.test.tsx +843 -0
  129. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +1007 -0
  130. package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +806 -0
  131. package/src/rbac/components/__tests__/RoleBasedRouter.test.tsx +741 -0
  132. package/src/rbac/hooks/useCan.test.ts +1 -1
  133. package/src/rbac/hooks/usePermissions.test.ts +10 -5
  134. package/src/rbac/hooks/useRBAC.test.ts +141 -93
  135. package/src/rbac/utils/__tests__/eventContext.test.ts +428 -0
  136. package/src/rbac/utils/__tests__/eventContext.unit.test.ts +428 -0
  137. package/src/styles/core.css +3 -0
  138. package/src/utils/__tests__/appConfig.unit.test.ts +55 -0
  139. package/src/utils/__tests__/audit.unit.test.ts +69 -0
  140. package/src/utils/__tests__/auth-utils.unit.test.ts +70 -0
  141. package/src/utils/__tests__/bundleAnalysis.unit.test.ts +317 -0
  142. package/src/utils/__tests__/cn.unit.test.ts +34 -0
  143. package/src/utils/__tests__/deviceFingerprint.unit.test.ts +503 -0
  144. package/src/utils/__tests__/dynamicUtils.unit.test.ts +322 -0
  145. package/src/utils/__tests__/formatDate.unit.test.ts +109 -0
  146. package/src/utils/__tests__/formatting.unit.test.ts +66 -0
  147. package/src/utils/__tests__/index.unit.test.ts +251 -0
  148. package/src/utils/__tests__/lazyLoad.unit.test.tsx +309 -0
  149. package/src/utils/__tests__/organisationContext.unit.test.ts +192 -0
  150. package/src/utils/__tests__/performanceBudgets.unit.test.ts +259 -0
  151. package/src/utils/__tests__/permissionTypes.unit.test.ts +250 -0
  152. package/src/utils/__tests__/permissionUtils.unit.test.ts +362 -0
  153. package/src/utils/__tests__/sanitization.unit.test.ts +346 -0
  154. package/src/utils/__tests__/schemaUtils.unit.test.ts +441 -0
  155. package/src/utils/__tests__/secureDataAccess.unit.test.ts +334 -0
  156. package/src/utils/__tests__/secureErrors.unit.test.ts +377 -0
  157. package/src/utils/__tests__/secureStorage.unit.test.ts +293 -0
  158. package/src/utils/__tests__/security.unit.test.ts +127 -0
  159. package/src/utils/__tests__/securityMonitor.unit.test.ts +280 -0
  160. package/src/utils/__tests__/sessionTracking.unit.test.ts +356 -0
  161. package/src/utils/__tests__/validation.unit.test.ts +84 -0
  162. package/src/utils/__tests__/validationUtils.unit.test.ts +571 -0
  163. package/src/validation/__tests__/common.unit.test.ts +101 -0
  164. package/src/validation/__tests__/csrf.unit.test.ts +302 -0
  165. package/src/validation/__tests__/passwordSchema.unit.test 2.ts +98 -0
  166. package/src/validation/__tests__/passwordSchema.unit.test.ts +98 -0
  167. package/src/validation/__tests__/sqlInjectionProtection.unit.test.ts +466 -0
  168. package/dist/chunk-M4RW7PIP.js.map +0 -1
  169. /package/dist/{DataTable-ZQDRE46Q.js.map → DataTable-3SSI644S.js.map} +0 -0
  170. /package/dist/{chunk-5H3C2SWM.js.map → chunk-RTCA5ZNK.js.map} +0 -0
@@ -0,0 +1,1007 @@
1
+ /**
2
+ * @file PagePermissionGuard Component Tests
3
+ * @package @jmruthers/pace-core
4
+ * @module RBAC/Components/PagePermissionGuard
5
+ * @since 2.0.0
6
+ *
7
+ * Comprehensive tests for the PagePermissionGuard 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 { PagePermissionGuard } from '../PagePermissionGuard';
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
+ // Mock the app name resolver
33
+ vi.mock('../../../utils/appNameResolver', () => ({
34
+ getCurrentAppName: vi.fn()
35
+ }));
36
+
37
+ import { createScopeFromEvent } from '../../utils/eventContext';
38
+ import { getCurrentAppName } from '../../../utils/appNameResolver';
39
+
40
+ // Mock data
41
+ const mockUser = {
42
+ id: 'user-123',
43
+ email: 'test@example.com'
44
+ };
45
+
46
+ const mockScope = {
47
+ organisationId: 'org-123',
48
+ eventId: 'event-123',
49
+ appId: 'app-123'
50
+ };
51
+
52
+ const mockPageName = 'dashboard';
53
+ const mockOperation = 'read' as const;
54
+
55
+ // Test component
56
+ const TestComponent = ({ children }: { children: ReactNode }) => (
57
+ <div data-testid="test-component">{children}</div>
58
+ );
59
+
60
+ const TestFallback = () => (
61
+ <div data-testid="test-fallback">Access Denied</div>
62
+ );
63
+
64
+ const TestLoading = () => (
65
+ <div data-testid="test-loading">Loading...</div>
66
+ );
67
+
68
+ describe('PagePermissionGuard Component', () => {
69
+ const mockUseCan = vi.mocked(useCan);
70
+ const mockUseUnifiedAuth = vi.mocked(useUnifiedAuth);
71
+ const mockCreateScopeFromEvent = vi.mocked(createScopeFromEvent);
72
+ const mockGetCurrentAppName = vi.mocked(getCurrentAppName);
73
+
74
+ beforeEach(() => {
75
+ vi.clearAllMocks();
76
+
77
+ // Default mock implementations
78
+ mockUseUnifiedAuth.mockReturnValue({
79
+ user: mockUser,
80
+ selectedOrganisationId: 'org-123',
81
+ selectedEventId: 'event-123',
82
+ supabase: {
83
+ from: vi.fn().mockReturnValue({
84
+ select: vi.fn().mockReturnValue({
85
+ eq: vi.fn().mockReturnValue({
86
+ eq: vi.fn().mockReturnValue({
87
+ single: vi.fn().mockResolvedValue({
88
+ data: { id: 'app-123', name: 'test-app', is_active: true },
89
+ error: null
90
+ })
91
+ })
92
+ })
93
+ })
94
+ })
95
+ } as any
96
+ });
97
+
98
+ mockGetCurrentAppName.mockReturnValue('test-app');
99
+
100
+ mockUseCan.mockReturnValue({
101
+ can: true,
102
+ isLoading: false,
103
+ error: null
104
+ });
105
+ });
106
+
107
+ afterEach(() => {
108
+ vi.restoreAllMocks();
109
+ });
110
+
111
+ describe('Rendering', () => {
112
+ it('renders children when permission is granted', async () => {
113
+ mockUseCan.mockReturnValue({
114
+ can: true,
115
+ isLoading: false,
116
+ error: null
117
+ });
118
+
119
+ render(
120
+ <PagePermissionGuard
121
+ pageName={mockPageName}
122
+ operation={mockOperation}
123
+ >
124
+ <TestComponent>Protected Page</TestComponent>
125
+ </PagePermissionGuard>
126
+ );
127
+
128
+ await waitFor(() => {
129
+ expect(screen.getByTestId('test-component')).toBeInTheDocument();
130
+ expect(screen.getByText('Protected Page')).toBeInTheDocument();
131
+ });
132
+ });
133
+
134
+ it('renders fallback when permission is denied', async () => {
135
+ mockUseCan.mockReturnValue({
136
+ can: false,
137
+ isLoading: false,
138
+ error: null
139
+ });
140
+
141
+ render(
142
+ <PagePermissionGuard
143
+ pageName={mockPageName}
144
+ operation={mockOperation}
145
+ fallback={<TestFallback />}
146
+ >
147
+ <TestComponent>Protected Page</TestComponent>
148
+ </PagePermissionGuard>
149
+ );
150
+
151
+ await waitFor(() => {
152
+ expect(screen.getByTestId('test-fallback')).toBeInTheDocument();
153
+ expect(screen.queryByTestId('test-component')).not.toBeInTheDocument();
154
+ });
155
+ });
156
+
157
+ it('shows loading state during permission check', () => {
158
+ mockUseCan.mockReturnValue({
159
+ can: false,
160
+ isLoading: true,
161
+ error: null
162
+ });
163
+
164
+ render(
165
+ <PagePermissionGuard
166
+ pageName={mockPageName}
167
+ operation={mockOperation}
168
+ loading={<TestLoading />}
169
+ >
170
+ <TestComponent>Protected Page</TestComponent>
171
+ </PagePermissionGuard>
172
+ );
173
+
174
+ expect(screen.getByTestId('test-loading')).toBeInTheDocument();
175
+ expect(screen.queryByTestId('test-component')).not.toBeInTheDocument();
176
+ });
177
+
178
+ it('uses default fallback when none provided', async () => {
179
+ mockUseCan.mockReturnValue({
180
+ can: false,
181
+ isLoading: false,
182
+ error: null
183
+ });
184
+
185
+ render(
186
+ <PagePermissionGuard
187
+ pageName={mockPageName}
188
+ operation={mockOperation}
189
+ >
190
+ <TestComponent>Protected Page</TestComponent>
191
+ </PagePermissionGuard>
192
+ );
193
+
194
+ await waitFor(() => {
195
+ expect(screen.getByText('Access Denied')).toBeInTheDocument();
196
+ expect(screen.getByText('You don\'t have permission to access this page.')).toBeInTheDocument();
197
+ });
198
+ });
199
+
200
+ it('uses default loading when none provided', () => {
201
+ mockUseCan.mockReturnValue({
202
+ can: false,
203
+ isLoading: true,
204
+ error: null
205
+ });
206
+
207
+ render(
208
+ <PagePermissionGuard
209
+ pageName={mockPageName}
210
+ operation={mockOperation}
211
+ >
212
+ <TestComponent>Protected Page</TestComponent>
213
+ </PagePermissionGuard>
214
+ );
215
+
216
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
217
+ });
218
+ });
219
+
220
+ describe('Permission Checking', () => {
221
+ it('builds correct permission string', async () => {
222
+ mockUseCan.mockReturnValue({
223
+ can: true,
224
+ isLoading: false,
225
+ error: null
226
+ });
227
+
228
+ render(
229
+ <PagePermissionGuard
230
+ pageName={mockPageName}
231
+ operation={mockOperation}
232
+ >
233
+ <TestComponent>Protected Page</TestComponent>
234
+ </PagePermissionGuard>
235
+ );
236
+
237
+ await waitFor(() => {
238
+ expect(screen.getByTestId('test-component')).toBeInTheDocument();
239
+ });
240
+
241
+ expect(mockUseCan).toHaveBeenCalledWith(
242
+ 'user-123',
243
+ expect.objectContaining({
244
+ organisationId: 'org-123',
245
+ eventId: 'event-123',
246
+ appId: 'app-123'
247
+ }),
248
+ 'read:page.dashboard',
249
+ 'dashboard',
250
+ true
251
+ );
252
+ });
253
+
254
+ it('uses custom pageId when provided', async () => {
255
+ const customPageId = 'custom-dashboard';
256
+
257
+ mockUseCan.mockReturnValue({
258
+ can: true,
259
+ isLoading: false,
260
+ error: null
261
+ });
262
+
263
+ render(
264
+ <PagePermissionGuard
265
+ pageName={mockPageName}
266
+ operation={mockOperation}
267
+ pageId={customPageId}
268
+ >
269
+ <TestComponent>Protected Page</TestComponent>
270
+ </PagePermissionGuard>
271
+ );
272
+
273
+ await waitFor(() => {
274
+ expect(screen.getByTestId('test-component')).toBeInTheDocument();
275
+ });
276
+
277
+ expect(mockUseCan).toHaveBeenCalledWith(
278
+ 'user-123',
279
+ expect.objectContaining({
280
+ organisationId: 'org-123',
281
+ eventId: 'event-123',
282
+ appId: 'app-123'
283
+ }),
284
+ 'read:page.dashboard',
285
+ customPageId,
286
+ true
287
+ );
288
+ });
289
+
290
+ it('handles different operations correctly', async () => {
291
+ const operations = ['create', 'update', 'delete'] as const;
292
+
293
+ for (const operation of operations) {
294
+ vi.clearAllMocks();
295
+
296
+ mockUseCan.mockReturnValue({
297
+ can: true,
298
+ isLoading: false,
299
+ error: null
300
+ });
301
+
302
+ const { unmount } = render(
303
+ <PagePermissionGuard
304
+ pageName={mockPageName}
305
+ operation={operation}
306
+ >
307
+ <TestComponent>Protected Page</TestComponent>
308
+ </PagePermissionGuard>
309
+ );
310
+
311
+ await waitFor(() => {
312
+ expect(screen.getByTestId('test-component')).toBeInTheDocument();
313
+ });
314
+
315
+ expect(mockUseCan).toHaveBeenCalledWith(
316
+ 'user-123',
317
+ expect.objectContaining({
318
+ organisationId: 'org-123',
319
+ eventId: 'event-123',
320
+ appId: 'app-123'
321
+ }),
322
+ `${operation}:page.dashboard`,
323
+ 'dashboard',
324
+ true
325
+ );
326
+
327
+ unmount();
328
+ }
329
+ });
330
+
331
+ it('handles permission checking errors gracefully', async () => {
332
+ const error = new Error('Permission check failed');
333
+ mockUseCan.mockReturnValue({
334
+ can: false,
335
+ isLoading: false,
336
+ error
337
+ });
338
+
339
+ render(
340
+ <PagePermissionGuard
341
+ pageName={mockPageName}
342
+ operation={mockOperation}
343
+ fallback={<TestFallback />}
344
+ >
345
+ <TestComponent>Protected Page</TestComponent>
346
+ </PagePermissionGuard>
347
+ );
348
+
349
+ await waitFor(() => {
350
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
351
+ });
352
+ });
353
+ });
354
+
355
+ describe('App ID Resolution', () => {
356
+ it('resolves app ID from database', async () => {
357
+ mockUseCan.mockReturnValue({
358
+ can: true,
359
+ isLoading: false,
360
+ error: null
361
+ });
362
+
363
+ render(
364
+ <PagePermissionGuard
365
+ pageName={mockPageName}
366
+ operation={mockOperation}
367
+ >
368
+ <TestComponent>Protected Page</TestComponent>
369
+ </PagePermissionGuard>
370
+ );
371
+
372
+ await waitFor(() => {
373
+ expect(screen.getByTestId('test-component')).toBeInTheDocument();
374
+ });
375
+
376
+ expect(mockGetCurrentAppName).toHaveBeenCalled();
377
+ });
378
+
379
+ it('handles app resolution errors in test environment', async () => {
380
+ mockGetCurrentAppName.mockReturnValue(null);
381
+
382
+ mockUseCan.mockReturnValue({
383
+ can: true,
384
+ isLoading: false,
385
+ error: null
386
+ });
387
+
388
+ // Set NODE_ENV to test
389
+ const originalEnv = process.env.NODE_ENV;
390
+ process.env.NODE_ENV = 'test';
391
+
392
+ render(
393
+ <PagePermissionGuard
394
+ pageName={mockPageName}
395
+ operation={mockOperation}
396
+ >
397
+ <TestComponent>Protected Page</TestComponent>
398
+ </PagePermissionGuard>
399
+ );
400
+
401
+ await waitFor(() => {
402
+ expect(screen.getByTestId('test-component')).toBeInTheDocument();
403
+ });
404
+
405
+ // Restore NODE_ENV
406
+ process.env.NODE_ENV = originalEnv;
407
+ });
408
+
409
+ it('handles app resolution errors in production', async () => {
410
+ mockGetCurrentAppName.mockReturnValue(null);
411
+
412
+ // Set NODE_ENV to production
413
+ const originalEnv = process.env.NODE_ENV;
414
+ process.env.NODE_ENV = 'production';
415
+
416
+ render(
417
+ <PagePermissionGuard
418
+ pageName={mockPageName}
419
+ operation={mockOperation}
420
+ fallback={<TestFallback />}
421
+ >
422
+ <TestComponent>Protected Page</TestComponent>
423
+ </PagePermissionGuard>
424
+ );
425
+
426
+ await waitFor(() => {
427
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
428
+ });
429
+
430
+ // Restore NODE_ENV
431
+ process.env.NODE_ENV = originalEnv;
432
+ });
433
+
434
+ it('validates app ID format in production', async () => {
435
+ mockGetCurrentAppName.mockReturnValue('test-app');
436
+
437
+ // Mock database returning invalid app ID
438
+ mockUseUnifiedAuth.mockReturnValue({
439
+ user: mockUser,
440
+ selectedOrganisationId: 'org-123',
441
+ selectedEventId: 'event-123',
442
+ supabase: {
443
+ from: vi.fn().mockReturnValue({
444
+ select: vi.fn().mockReturnValue({
445
+ eq: vi.fn().mockReturnValue({
446
+ eq: vi.fn().mockReturnValue({
447
+ single: vi.fn().mockResolvedValue({
448
+ data: { id: 'invalid-app-id', name: 'test-app', is_active: true },
449
+ error: null
450
+ })
451
+ })
452
+ })
453
+ })
454
+ })
455
+ } as any
456
+ });
457
+
458
+ // Set NODE_ENV to production
459
+ const originalEnv = process.env.NODE_ENV;
460
+ process.env.NODE_ENV = 'production';
461
+
462
+ render(
463
+ <PagePermissionGuard
464
+ pageName={mockPageName}
465
+ operation={mockOperation}
466
+ fallback={<TestFallback />}
467
+ >
468
+ <TestComponent>Protected Page</TestComponent>
469
+ </PagePermissionGuard>
470
+ );
471
+
472
+ await waitFor(() => {
473
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
474
+ });
475
+
476
+ // Restore NODE_ENV
477
+ process.env.NODE_ENV = originalEnv;
478
+ });
479
+ });
480
+
481
+ describe('Scope Resolution', () => {
482
+ it('uses provided scope when available', async () => {
483
+ const customScope = {
484
+ organisationId: 'custom-org',
485
+ eventId: 'custom-event',
486
+ appId: 'custom-app'
487
+ };
488
+
489
+ mockUseCan.mockReturnValue({
490
+ can: true,
491
+ isLoading: false,
492
+ error: null
493
+ });
494
+
495
+ render(
496
+ <PagePermissionGuard
497
+ pageName={mockPageName}
498
+ operation={mockOperation}
499
+ scope={customScope}
500
+ >
501
+ <TestComponent>Protected Page</TestComponent>
502
+ </PagePermissionGuard>
503
+ );
504
+
505
+ await waitFor(() => {
506
+ expect(screen.getByTestId('test-component')).toBeInTheDocument();
507
+ });
508
+
509
+ expect(mockUseCan).toHaveBeenCalledWith(
510
+ 'user-123',
511
+ customScope,
512
+ 'read:page.dashboard',
513
+ 'dashboard',
514
+ true
515
+ );
516
+ });
517
+
518
+ it('resolves scope from organisation and event context', async () => {
519
+ mockUseCan.mockReturnValue({
520
+ can: true,
521
+ isLoading: false,
522
+ error: null
523
+ });
524
+
525
+ render(
526
+ <PagePermissionGuard
527
+ pageName={mockPageName}
528
+ operation={mockOperation}
529
+ >
530
+ <TestComponent>Protected Page</TestComponent>
531
+ </PagePermissionGuard>
532
+ );
533
+
534
+ await waitFor(() => {
535
+ expect(screen.getByTestId('test-component')).toBeInTheDocument();
536
+ });
537
+
538
+ expect(mockUseCan).toHaveBeenCalledWith(
539
+ 'user-123',
540
+ expect.objectContaining({
541
+ organisationId: 'org-123',
542
+ eventId: 'event-123',
543
+ appId: 'app-123'
544
+ }),
545
+ 'read:page.dashboard',
546
+ 'dashboard',
547
+ true
548
+ );
549
+ });
550
+
551
+ it('resolves scope from organisation only', async () => {
552
+ mockUseUnifiedAuth.mockReturnValue({
553
+ user: mockUser,
554
+ selectedOrganisationId: 'org-123',
555
+ selectedEventId: null,
556
+ supabase: {
557
+ from: vi.fn().mockReturnValue({
558
+ select: vi.fn().mockReturnValue({
559
+ eq: vi.fn().mockReturnValue({
560
+ eq: vi.fn().mockReturnValue({
561
+ single: vi.fn().mockResolvedValue({
562
+ data: { id: 'app-123', name: 'test-app', is_active: true },
563
+ error: null
564
+ })
565
+ })
566
+ })
567
+ })
568
+ })
569
+ } as any
570
+ });
571
+
572
+ mockUseCan.mockReturnValue({
573
+ can: true,
574
+ isLoading: false,
575
+ error: null
576
+ });
577
+
578
+ render(
579
+ <PagePermissionGuard
580
+ pageName={mockPageName}
581
+ operation={mockOperation}
582
+ >
583
+ <TestComponent>Protected Page</TestComponent>
584
+ </PagePermissionGuard>
585
+ );
586
+
587
+ await waitFor(() => {
588
+ expect(screen.getByTestId('test-component')).toBeInTheDocument();
589
+ });
590
+
591
+ expect(mockUseCan).toHaveBeenCalledWith(
592
+ 'user-123',
593
+ expect.objectContaining({
594
+ organisationId: 'org-123',
595
+ eventId: undefined,
596
+ appId: 'app-123'
597
+ }),
598
+ 'read:page.dashboard',
599
+ 'dashboard',
600
+ true
601
+ );
602
+ });
603
+
604
+ it('resolves scope from event context when organisation not available', async () => {
605
+ mockUseUnifiedAuth.mockReturnValue({
606
+ user: mockUser,
607
+ selectedOrganisationId: null,
608
+ selectedEventId: 'event-123',
609
+ supabase: {
610
+ from: vi.fn().mockReturnValue({
611
+ select: vi.fn().mockReturnValue({
612
+ eq: vi.fn().mockReturnValue({
613
+ eq: vi.fn().mockReturnValue({
614
+ single: vi.fn().mockResolvedValue({
615
+ data: { id: 'app-123', name: 'test-app', is_active: true },
616
+ error: null
617
+ })
618
+ })
619
+ })
620
+ })
621
+ })
622
+ } as any
623
+ });
624
+
625
+ mockCreateScopeFromEvent.mockResolvedValue({
626
+ organisationId: 'resolved-org',
627
+ eventId: 'event-123',
628
+ appId: 'resolved-app'
629
+ });
630
+
631
+ mockUseCan.mockReturnValue({
632
+ can: true,
633
+ isLoading: false,
634
+ error: null
635
+ });
636
+
637
+ render(
638
+ <PagePermissionGuard
639
+ pageName={mockPageName}
640
+ operation={mockOperation}
641
+ >
642
+ <TestComponent>Protected Page</TestComponent>
643
+ </PagePermissionGuard>
644
+ );
645
+
646
+ await waitFor(() => {
647
+ expect(screen.getByTestId('test-component')).toBeInTheDocument();
648
+ });
649
+
650
+ expect(mockCreateScopeFromEvent).toHaveBeenCalledWith(
651
+ expect.any(Object),
652
+ 'event-123'
653
+ );
654
+ expect(mockUseCan).toHaveBeenCalledWith(
655
+ 'user-123',
656
+ expect.objectContaining({
657
+ organisationId: 'resolved-org',
658
+ eventId: 'event-123',
659
+ appId: 'app-123'
660
+ }),
661
+ 'read:page.dashboard',
662
+ 'dashboard',
663
+ true
664
+ );
665
+ });
666
+
667
+ it('handles scope resolution errors', async () => {
668
+ mockUseUnifiedAuth.mockReturnValue({
669
+ user: mockUser,
670
+ selectedOrganisationId: null,
671
+ selectedEventId: 'event-123',
672
+ supabase: {} as any
673
+ });
674
+
675
+ const error = new Error('Could not resolve organisation from event');
676
+ mockCreateScopeFromEvent.mockRejectedValue(error);
677
+
678
+ render(
679
+ <PagePermissionGuard
680
+ pageName={mockPageName}
681
+ operation={mockOperation}
682
+ fallback={<TestFallback />}
683
+ >
684
+ <TestComponent>Protected Page</TestComponent>
685
+ </PagePermissionGuard>
686
+ );
687
+
688
+ await waitFor(() => {
689
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
690
+ });
691
+ });
692
+
693
+ it('handles missing context gracefully', async () => {
694
+ mockUseUnifiedAuth.mockReturnValue({
695
+ user: mockUser,
696
+ selectedOrganisationId: null,
697
+ selectedEventId: null,
698
+ supabase: null
699
+ });
700
+
701
+ render(
702
+ <PagePermissionGuard
703
+ pageName={mockPageName}
704
+ operation={mockOperation}
705
+ fallback={<TestFallback />}
706
+ >
707
+ <TestComponent>Protected Page</TestComponent>
708
+ </PagePermissionGuard>
709
+ );
710
+
711
+ await waitFor(() => {
712
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
713
+ });
714
+ });
715
+ });
716
+
717
+ describe('Security Features', () => {
718
+ it('prevents bypassing in strict mode', async () => {
719
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
720
+
721
+ mockUseCan.mockReturnValue({
722
+ can: false,
723
+ isLoading: false,
724
+ error: null
725
+ });
726
+
727
+ render(
728
+ <PagePermissionGuard
729
+ pageName={mockPageName}
730
+ operation={mockOperation}
731
+ strictMode={true}
732
+ fallback={<TestFallback />}
733
+ >
734
+ <TestComponent>Protected Page</TestComponent>
735
+ </PagePermissionGuard>
736
+ );
737
+
738
+ await waitFor(() => {
739
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
740
+ });
741
+
742
+ expect(consoleSpy).toHaveBeenCalledWith(
743
+ expect.stringContaining('STRICT MODE VIOLATION'),
744
+ expect.objectContaining({
745
+ pageName: mockPageName,
746
+ operation: mockOperation,
747
+ userId: 'user-123'
748
+ })
749
+ );
750
+
751
+ consoleSpy.mockRestore();
752
+ });
753
+
754
+ it('logs page access attempts for audit', async () => {
755
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
756
+
757
+ mockUseCan.mockReturnValue({
758
+ can: false,
759
+ isLoading: false,
760
+ error: null
761
+ });
762
+
763
+ render(
764
+ <PagePermissionGuard
765
+ pageName={mockPageName}
766
+ operation={mockOperation}
767
+ auditLog={true}
768
+ fallback={<TestFallback />}
769
+ >
770
+ <TestComponent>Protected Page</TestComponent>
771
+ </PagePermissionGuard>
772
+ );
773
+
774
+ await waitFor(() => {
775
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
776
+ });
777
+
778
+ expect(consoleSpy).toHaveBeenCalledWith(
779
+ expect.stringContaining('Page access attempt'),
780
+ expect.objectContaining({
781
+ pageName: mockPageName,
782
+ operation: mockOperation,
783
+ userId: 'user-123',
784
+ allowed: false
785
+ })
786
+ );
787
+
788
+ consoleSpy.mockRestore();
789
+ });
790
+
791
+ it('calls onDenied callback when access is denied', async () => {
792
+ const onDeniedSpy = vi.fn();
793
+
794
+ mockUseCan.mockReturnValue({
795
+ can: false,
796
+ isLoading: false,
797
+ error: null
798
+ });
799
+
800
+ render(
801
+ <PagePermissionGuard
802
+ pageName={mockPageName}
803
+ operation={mockOperation}
804
+ onDenied={onDeniedSpy}
805
+ fallback={<TestFallback />}
806
+ >
807
+ <TestComponent>Protected Page</TestComponent>
808
+ </PagePermissionGuard>
809
+ );
810
+
811
+ await waitFor(() => {
812
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
813
+ });
814
+
815
+ expect(onDeniedSpy).toHaveBeenCalledWith(mockPageName, mockOperation);
816
+ });
817
+
818
+ it('does not call onDenied when access is granted', async () => {
819
+ const onDeniedSpy = vi.fn();
820
+
821
+ mockUseCan.mockReturnValue({
822
+ can: true,
823
+ isLoading: false,
824
+ error: null
825
+ });
826
+
827
+ render(
828
+ <PagePermissionGuard
829
+ pageName={mockPageName}
830
+ operation={mockOperation}
831
+ onDenied={onDeniedSpy}
832
+ >
833
+ <TestComponent>Protected Page</TestComponent>
834
+ </PagePermissionGuard>
835
+ );
836
+
837
+ await waitFor(() => {
838
+ expect(screen.getByTestId('test-component')).toBeInTheDocument();
839
+ });
840
+
841
+ expect(onDeniedSpy).not.toHaveBeenCalled();
842
+ });
843
+ });
844
+
845
+ describe('Configuration Options', () => {
846
+ it('respects strictMode setting', async () => {
847
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
848
+
849
+ mockUseCan.mockReturnValue({
850
+ can: false,
851
+ isLoading: false,
852
+ error: null
853
+ });
854
+
855
+ render(
856
+ <PagePermissionGuard
857
+ pageName={mockPageName}
858
+ operation={mockOperation}
859
+ strictMode={false}
860
+ fallback={<TestFallback />}
861
+ >
862
+ <TestComponent>Protected Page</TestComponent>
863
+ </PagePermissionGuard>
864
+ );
865
+
866
+ await waitFor(() => {
867
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
868
+ });
869
+
870
+ expect(consoleSpy).not.toHaveBeenCalledWith(
871
+ expect.stringContaining('STRICT MODE VIOLATION')
872
+ );
873
+
874
+ consoleSpy.mockRestore();
875
+ });
876
+
877
+ it('respects auditLog setting', async () => {
878
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
879
+
880
+ mockUseCan.mockReturnValue({
881
+ can: false,
882
+ isLoading: false,
883
+ error: null
884
+ });
885
+
886
+ render(
887
+ <PagePermissionGuard
888
+ pageName={mockPageName}
889
+ operation={mockOperation}
890
+ auditLog={false}
891
+ fallback={<TestFallback />}
892
+ >
893
+ <TestComponent>Protected Page</TestComponent>
894
+ </PagePermissionGuard>
895
+ );
896
+
897
+ await waitFor(() => {
898
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
899
+ });
900
+
901
+ expect(consoleSpy).not.toHaveBeenCalledWith(
902
+ expect.stringContaining('Page access attempt')
903
+ );
904
+
905
+ consoleSpy.mockRestore();
906
+ });
907
+ });
908
+
909
+ describe('Error Handling', () => {
910
+ it('handles missing user gracefully', async () => {
911
+ mockUseUnifiedAuth.mockReturnValue({
912
+ user: null,
913
+ selectedOrganisationId: 'org-123',
914
+ selectedEventId: 'event-123',
915
+ supabase: {
916
+ from: vi.fn().mockReturnValue({
917
+ select: vi.fn().mockReturnValue({
918
+ eq: vi.fn().mockReturnValue({
919
+ eq: vi.fn().mockReturnValue({
920
+ single: vi.fn().mockResolvedValue({
921
+ data: { id: 'app-123', name: 'test-app', is_active: true },
922
+ error: null
923
+ })
924
+ })
925
+ })
926
+ })
927
+ })
928
+ } as any
929
+ });
930
+
931
+ mockUseCan.mockReturnValue({
932
+ can: false,
933
+ isLoading: false,
934
+ error: null
935
+ });
936
+
937
+ render(
938
+ <PagePermissionGuard
939
+ pageName={mockPageName}
940
+ operation={mockOperation}
941
+ fallback={<TestFallback />}
942
+ >
943
+ <TestComponent>Protected Page</TestComponent>
944
+ </PagePermissionGuard>
945
+ );
946
+
947
+ await waitFor(() => {
948
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
949
+ });
950
+
951
+ expect(mockUseCan).toHaveBeenCalledWith(
952
+ '',
953
+ expect.objectContaining({
954
+ organisationId: 'org-123',
955
+ eventId: 'event-123',
956
+ appId: 'app-123'
957
+ }),
958
+ 'read:page.dashboard',
959
+ 'dashboard',
960
+ true
961
+ );
962
+ });
963
+
964
+ it('handles database errors during app resolution', async () => {
965
+ mockUseUnifiedAuth.mockReturnValue({
966
+ user: mockUser,
967
+ selectedOrganisationId: 'org-123',
968
+ selectedEventId: 'event-123',
969
+ supabase: {
970
+ from: vi.fn().mockReturnValue({
971
+ select: vi.fn().mockReturnValue({
972
+ eq: vi.fn().mockReturnValue({
973
+ eq: vi.fn().mockReturnValue({
974
+ single: vi.fn().mockResolvedValue({
975
+ data: null,
976
+ error: { message: 'Database error' }
977
+ })
978
+ })
979
+ })
980
+ })
981
+ })
982
+ } as any
983
+ });
984
+
985
+ // Set NODE_ENV to production
986
+ const originalEnv = process.env.NODE_ENV;
987
+ process.env.NODE_ENV = 'production';
988
+
989
+ render(
990
+ <PagePermissionGuard
991
+ pageName={mockPageName}
992
+ operation={mockOperation}
993
+ fallback={<TestFallback />}
994
+ >
995
+ <TestComponent>Protected Page</TestComponent>
996
+ </PagePermissionGuard>
997
+ );
998
+
999
+ await waitFor(() => {
1000
+ expect(screen.getByText('Checking permissions...')).toBeInTheDocument();
1001
+ });
1002
+
1003
+ // Restore NODE_ENV
1004
+ process.env.NODE_ENV = originalEnv;
1005
+ });
1006
+ });
1007
+ });