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