From 1a0ff5b749fe76ff9a17e605a17cf57307c502b3 Mon Sep 17 00:00:00 2001 From: Askold Astakhov Date: Sat, 16 May 2026 16:04:25 +0200 Subject: [PATCH] feat: MockAccessProvider, shouldAddCallbackUrl --- .changeset/wide-candles-cover.md | 7 + README.md | 36 ++- docs/en/api/core.md | 18 +- docs/en/api/react-router.md | 36 ++- docs/ru/api/core.md | 18 +- docs/ru/api/react-router.md | 36 ++- packages/react-router/src/AccessRoute.tsx | 5 +- .../react-router/src/createAccessRouter.tsx | 18 +- .../tests/create-guarded-router.test.tsx | 47 ++++ .../tests/guard-provider.test.tsx | 69 ++++++ packages/react/package.json | 5 + packages/react/src/AccessProvider.tsx | 4 +- packages/react/src/testing.tsx | 43 ++++ packages/react/src/types.ts | 2 + packages/react/tests/access-provider.test.tsx | 4 + packages/react/tests/build.test.ts | 3 + .../react/tests/mock-access-provider.test.tsx | 220 ++++++++++++++++++ packages/react/vite.config.ts | 7 +- 18 files changed, 549 insertions(+), 29 deletions(-) create mode 100644 .changeset/wide-candles-cover.md create mode 100644 packages/react/src/testing.tsx create mode 100644 packages/react/tests/mock-access-provider.test.tsx diff --git a/.changeset/wide-candles-cover.md b/.changeset/wide-candles-cover.md new file mode 100644 index 0000000..a245f65 --- /dev/null +++ b/.changeset/wide-candles-cover.md @@ -0,0 +1,7 @@ +--- +'@react-protected/react-router': minor +'@react-protected/react': minor +'@react-protected/core': minor +--- + +Update roadmap and docs diff --git a/README.md b/README.md index 83e6205..a49eb04 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ - Intermediate React package with context, hooks, and UI guard component - React Router adapter for data routers (`createAccessRouter`) and JSX guards (`AccessRoute`) - RBAC via `hasRole`, ABAC via `hasPermission`, `guest-only` routes at the adapter level -- Optional `callbackUrl` flow for returning users after login +- Optional `callbackUrlParam` for redirecting users back to the page they tried to visit after login ## Packages @@ -42,12 +42,42 @@ | -------------------------------- | ------------------------------------------------------------------------ | | `@react-protected/core` | Pure access-control logic — no React, no router, no redirects | | `@react-protected/react` | React context (`AccessProvider`), hooks, and `HasAccess` component | -| `@react-protected/react-router` | Adapter for React Router: `createAccessRouter` and `AccessRoute` | +| `@react-protected/react-router` | Adapter for React Router: `createAccessRouter` and `AccessRoute`. Includes everything from `@react-protected/react` | ## Roadmap - Add a TanStack Router adapter - Add a Wouter adapter +- Add a `guard` field to route config — a custom function called after all standard checks (auth, roles, permissions), for business logic that cannot be expressed as a role or permission set alone: + + ```ts + // Redirect to profile setup if email is missing + { + path: '/dashboard', + guard: ({ session }) => { + if (!session?.user.email) return { redirect: '/profile/setup' } + }, + } + + // Combine with standard permission check + { + path: '/reports', + permissions: ['reports:read'], + guard: ({ session }) => { + if (session?.user.subscriptionExpired) return { redirect: '/subscription/expired' } + }, + } + + // Route param ownership check + { + path: '/users/:userId/edit', + guard: ({ session, params }) => { + if (session?.user.role !== 'admin' && params.userId !== session?.user.id) return false + }, + } + ``` + + Standard checks run first; if they produce a redirect, `guard` is not called. When all standard checks pass, `guard` runs and its result is the final decision (`true` / `false` / `undefined` to pass through / `{ redirect: string }`). ## Installation @@ -94,6 +124,7 @@ export const router = createAccessRouter( loginPath: '/login', forbiddenPath: '/403', defaultPath: '/dashboard', + callbackUrlParam: 'next', } ) @@ -115,6 +146,7 @@ const App = () => ( loginPath="/login" forbiddenPath="/403" defaultPath="/dashboard" + callbackUrlParam="next" > } /> diff --git a/docs/en/api/core.md b/docs/en/api/core.md index 5fefcda..aed69aa 100644 --- a/docs/en/api/core.md +++ b/docs/en/api/core.md @@ -26,7 +26,23 @@ const guard = createGuard({ | `hasRole` | `(user, roles) => boolean` | `() => false` | Role check for RBAC | | `hasPermission` | `(user, permissions) => boolean` | `() => false` | Permission check for ABAC-style access | -Navigation paths (`loginPath`, `forbiddenPath`, `defaultPath`) and `callbackUrlParam` are not part of core — they live in the adapter layer (`AccessProvider` / `createAccessRouter`). +### Recommended semantics + +The library does not enforce a specific matching strategy — the semantics are entirely determined by your `hasRole` and `hasPermission` implementations. The convention used across all examples: + +| Callback | Strategy | Rationale | +| ----------------- | -------- | ------------------------------------------------------------------------- | +| `hasRole` | OR | Roles grant alternative paths — `admin` **or** `manager` may access | +| `hasPermission` | AND | Permissions accumulate — the user must hold **every** required one | + +```ts +hasRole: (user, roles) => roles.some((r) => user.roles.includes(r)) +hasPermission: (user, perms) => perms.every((p) => user.permissions.includes(p)) +``` + +You can use different semantics if your domain requires it — the callbacks are yours to define. + +Navigation paths (`loginPath`, `forbiddenPath`, `defaultPath`) and `callbackUrlParam` are not part of core — they live in `@react-protected/react` (via `AccessProvider`) and `@react-protected/react-router` (via `createAccessRouter`). ## guard.check(config) diff --git a/docs/en/api/react-router.md b/docs/en/api/react-router.md index a52e20d..9e8f5a4 100644 --- a/docs/en/api/react-router.md +++ b/docs/en/api/react-router.md @@ -35,12 +35,13 @@ import { AccessProvider } from '@react-protected/react-router' **Navigation config** (used by adapters for redirects): -| Prop | Type | Default | Description | -| ------------------ | -------- | ------------ | ------------------------------------------------------ | -| `loginPath` | `string` | `'/login'` | Where unauthenticated users are redirected | -| `forbiddenPath` | `string` | `'/403'` | Where users without the required role/permission go | -| `defaultPath` | `string` | `'/'` | Where authenticated users go from `guest-only` routes | -| `callbackUrlParam` | `string` | — | If set, appends the current path as a query param on login redirect | +| Prop | Type | Default | Description | +| ----------------------- | --------------- | ------------ | ----------------------------------------------------------------------------------- | +| `loginPath` | `string` | `'/login'` | Where unauthenticated users are redirected | +| `forbiddenPath` | `string` | `'/403'` | Where users without the required role/permission go | +| `defaultPath` | `string` | `'/'` | Where authenticated users go from `guest-only` routes | +| `callbackUrlParam` | `string` | — | If set, appends the current path as a query param on login redirect | +| `shouldAddCallbackUrl` | `() => boolean` | `() => true` | Called on each unauthenticated redirect to decide whether to append the callback URL | `AccessProvider` is declarative: when its props change, descendants receive a fresh guard with updated options. @@ -155,7 +156,7 @@ Returns the full context value including the guard and navigation config. ```tsx import { useAccess } from '@react-protected/react-router' -const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam } = useAccess() +const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam, shouldAddCallbackUrl } = useAccess() const result = guard.check({ roles: ['admin'] }) ``` @@ -194,7 +195,7 @@ import { HasAccess } from '@react-protected/react-router' ``` -## callbackUrl flow +## Callback URL flow When `callbackUrlParam` is set, unauthenticated redirects include the current path: @@ -209,3 +210,22 @@ const [params] = useSearchParams() const callbackUrl = params.get('next') navigate(callbackUrl ?? '/dashboard', { replace: true }) ``` + +### Conditional callback URL + +`shouldAddCallbackUrl` lets you suppress the callback URL at runtime without removing `callbackUrlParam`. It is called on every unauthenticated redirect: + +```tsx + !authStore.getState().loggedOut} + ... +> +``` + +| Scenario | Result | +| --------------------------------- | -------------------------------------- | +| Session expired (normal timeout) | `/login?next=%2Fdashboard` — user returns to where they were | +| User explicitly logged out | `/login` — no callback URL, clean start | + +When `shouldAddCallbackUrl` is not provided, the callback URL is always appended (existing behavior). diff --git a/docs/ru/api/core.md b/docs/ru/api/core.md index 96c8753..96635aa 100644 --- a/docs/ru/api/core.md +++ b/docs/ru/api/core.md @@ -26,7 +26,23 @@ const guard = createGuard({ | `hasRole` | `(user, roles) => boolean` | `() => false` | Проверка ролей (RBAC) | | `hasPermission` | `(user, permissions) => boolean` | `() => false` | Проверка прав доступа (ABAC) | -Пути для редиректов (`loginPath`, `forbiddenPath`, `defaultPath`) и `callbackUrlParam` не входят в ядро — они живут на уровне адаптера (`AccessProvider` / `createAccessRouter`). +### Рекомендуемая семантика + +Библиотека не навязывает конкретную стратегию сопоставления — семантика полностью определяется твоими реализациями `hasRole` и `hasPermission`. Конвенция, которой следуют все примеры: + +| Колбэк | Стратегия | Обоснование | +| ----------------- | --------- | -------------------------------------------------------------------------------- | +| `hasRole` | OR | Роли дают альтернативный доступ — `admin` **или** `manager` могут зайти | +| `hasPermission` | AND | Права накапливаются — пользователь должен иметь **каждое** из требуемых | + +```ts +hasRole: (user, roles) => roles.some((r) => user.roles.includes(r)) +hasPermission: (user, perms) => perms.every((p) => user.permissions.includes(p)) +``` + +При необходимости можно использовать другую семантику — колбэки полностью под твоим контролем. + +Пути для редиректов (`loginPath`, `forbiddenPath`, `defaultPath`) и `callbackUrlParam` не входят в ядро — они живут в `@react-protected/react` (через `AccessProvider`) и `@react-protected/react-router` (через `createAccessRouter`). ## guard.check(config) diff --git a/docs/ru/api/react-router.md b/docs/ru/api/react-router.md index 566cba6..afeff5c 100644 --- a/docs/ru/api/react-router.md +++ b/docs/ru/api/react-router.md @@ -35,12 +35,13 @@ import { AccessProvider } from '@react-protected/react-router' **Конфигурация навигации** (используется адаптером для редиректов): -| Prop | Тип | Default | Описание | -| ------------------ | -------- | ------------ | --------------------------------------------------------------------- | -| `loginPath` | `string` | `'/login'` | Куда перенаправлять незалогиненных пользователей | -| `forbiddenPath` | `string` | `'/403'` | Куда перенаправлять при нехватке прав | -| `defaultPath` | `string` | `'/'` | Куда перенаправлять залогиненных с `guest-only` маршрутов | -| `callbackUrlParam` | `string` | — | Если указан, добавляет текущий путь как query-параметр при редиректе на логин | +| Prop | Тип | Default | Описание | +| ----------------------- | --------------- | ------------ | --------------------------------------------------------------------------------------------- | +| `loginPath` | `string` | `'/login'` | Куда перенаправлять незалогиненных пользователей | +| `forbiddenPath` | `string` | `'/403'` | Куда перенаправлять при нехватке прав | +| `defaultPath` | `string` | `'/'` | Куда перенаправлять залогиненных с `guest-only` маршрутов | +| `callbackUrlParam` | `string` | — | Если указан, добавляет текущий путь как query-параметр при редиректе на логин | +| `shouldAddCallbackUrl` | `() => boolean` | `() => true` | Вызывается при каждом редиректе незалогиненного — решает, добавлять ли callback URL | `AccessProvider` декларативный: при изменении props потомки получают новый guard с актуальными опциями. @@ -155,7 +156,7 @@ type ProtectedRouteObject = RouteObject & { ```tsx import { useAccess } from '@react-protected/react-router' -const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam } = useAccess() +const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam, shouldAddCallbackUrl } = useAccess() const result = guard.check({ roles: ['admin'] }) ``` @@ -194,7 +195,7 @@ import { HasAccess } from '@react-protected/react-router' ``` -## Поток callbackUrl +## Callback URL flow Если указан `callbackUrlParam`, редирект незалогиненного включает текущий путь: @@ -209,3 +210,22 @@ const [params] = useSearchParams() const callbackUrl = params.get('next') navigate(callbackUrl ?? '/dashboard', { replace: true }) ``` + +### Условный callback URL + +`shouldAddCallbackUrl` позволяет отключить добавление callback URL в рантайме, не убирая `callbackUrlParam`. Вызывается при каждом редиректе незалогиненного: + +```tsx + !authStore.getState().loggedOut} + ... +> +``` + +| Сценарий | Результат | +| -------------------------------- | -------------------------------------------------------------- | +| Сессия истекла (обычный таймаут) | `/login?next=%2Fdashboard` — пользователь вернётся куда шёл | +| Явный выход из системы | `/login` — без callback URL, чистый старт | + +Если `shouldAddCallbackUrl` не передан, callback URL добавляется всегда (поведение не меняется). diff --git a/packages/react-router/src/AccessRoute.tsx b/packages/react-router/src/AccessRoute.tsx index d32caf5..de13f95 100644 --- a/packages/react-router/src/AccessRoute.tsx +++ b/packages/react-router/src/AccessRoute.tsx @@ -18,7 +18,7 @@ export const AccessRoute = memo(({ meta, children, }: AccessRouteProps) => { - const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam } = useAccess() + const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam, shouldAddCallbackUrl } = useAccess() const location = useLocation() if (access === 'guest-only') { @@ -33,7 +33,8 @@ export const AccessRoute = memo(({ if (!result.allowed) { if (result.reason === 'unauthenticated') { const currentPath = `${location.pathname}${location.search}${location.hash}` - const redirectTo = callbackUrlParam + const addCallback = callbackUrlParam && (shouldAddCallbackUrl?.() ?? true) + const redirectTo = addCallback ? `${loginPath}?${callbackUrlParam}=${encodeURIComponent(currentPath)}` : loginPath return diff --git a/packages/react-router/src/createAccessRouter.tsx b/packages/react-router/src/createAccessRouter.tsx index 354defd..9f16cf8 100644 --- a/packages/react-router/src/createAccessRouter.tsx +++ b/packages/react-router/src/createAccessRouter.tsx @@ -25,6 +25,7 @@ type RouterGuardContext = { forbiddenPath: string defaultPath: string callbackUrlParam?: string + shouldAddCallbackUrl?: () => boolean } type GuardedElementProps = RouterRouteConfig & { @@ -35,6 +36,7 @@ type GuardedElementProps = RouterRouteConfig & { forbiddenPath: string defaultPath: string callbackUrlParam?: string + shouldAddCallbackUrl?: () => boolean } type LazyRouteLoader = Record Promise) | undefined> @@ -45,10 +47,12 @@ function buildRedirect( loginPath: string, forbiddenPath: string, defaultPath: string, - callbackUrlParam?: string + callbackUrlParam?: string, + shouldAddCallbackUrl?: () => boolean ): string { if (reason === 'unauthenticated') { - return callbackUrlParam + const addCallback = callbackUrlParam && (shouldAddCallbackUrl?.() ?? true) + return addCallback ? `${loginPath}?${callbackUrlParam}=${encodeURIComponent(currentPath)}` : loginPath } @@ -68,6 +72,7 @@ function GuardedElement({ forbiddenPath, defaultPath, callbackUrlParam, + shouldAddCallbackUrl, }: GuardedElementProps) { const location = useLocation() const currentPath = `${location.pathname}${location.search}${location.hash}` @@ -90,7 +95,8 @@ function GuardedElement({ loginPath, forbiddenPath, defaultPath, - callbackUrlParam + callbackUrlParam, + shouldAddCallbackUrl ) return } @@ -114,6 +120,7 @@ function wrapGuardedElement(ctx: RouterGuardContext, element?: Rea forbiddenPath={ctx.forbiddenPath} defaultPath={ctx.defaultPath} callbackUrlParam={ctx.callbackUrlParam} + shouldAddCallbackUrl={ctx.shouldAddCallbackUrl} /> ) } @@ -144,7 +151,8 @@ function wrapDataFunction( ctx.loginPath, ctx.forbiddenPath, ctx.defaultPath, - ctx.callbackUrlParam + ctx.callbackUrlParam, + ctx.shouldAddCallbackUrl ) return redirect(redirectTo) as TResult } @@ -209,6 +217,7 @@ export function createAccessRouter( forbiddenPath = '/403', defaultPath = '/', callbackUrlParam, + shouldAddCallbackUrl, ...guardOptions } = options @@ -244,6 +253,7 @@ export function createAccessRouter( forbiddenPath, defaultPath, callbackUrlParam, + shouldAddCallbackUrl, } const guardedElement = diff --git a/packages/react-router/tests/create-guarded-router.test.tsx b/packages/react-router/tests/create-guarded-router.test.tsx index 5b8d752..96022b6 100644 --- a/packages/react-router/tests/create-guarded-router.test.tsx +++ b/packages/react-router/tests/create-guarded-router.test.tsx @@ -170,6 +170,53 @@ describe('createAccessRouter', () => { expect(result.headers.get('Location')).toBe('/login') }) + it('omits callbackUrl in action redirect when shouldAddCallbackUrl returns false', async () => { + let capturedRoutes: Array | undefined + + vi.resetModules() + globalThis.Request = NativeRequest + + vi.doMock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + createBrowserRouter: (guardedRoutes: Array) => { + capturedRoutes = guardedRoutes + return { mocked: true } + }, + } + }) + + const { createAccessRouter } = await import('../src/createAccessRouter') + + createAccessRouter( + [ + { + path: '/private', + access: 'authenticated', + action: async () => null, + element:
private
, + }, + ], + { getUser: () => null, callbackUrlParam: 'next', shouldAddCallbackUrl: () => false } + ) + + vi.doUnmock('react-router-dom') + + const action = capturedRoutes?.[0]?.action + if (typeof action !== 'function') throw new Error('Expected action to be function') + + const result = await action({ + request: new Request('https://example.test/private', { method: 'POST' }), + params: {}, + context: undefined, + } as ActionFunctionArgs) + + expect(result instanceof Response).toBe(true) + if (!(result instanceof Response)) throw new Error('Expected Response') + expect(result.headers.get('Location')).toBe('/login') + }) + it('appends callbackUrl in action redirect when callbackUrlParam is set', async () => { let capturedRoutes: Array | undefined diff --git a/packages/react-router/tests/guard-provider.test.tsx b/packages/react-router/tests/guard-provider.test.tsx index 8bfd36b..c0bda73 100644 --- a/packages/react-router/tests/guard-provider.test.tsx +++ b/packages/react-router/tests/guard-provider.test.tsx @@ -108,6 +108,75 @@ describe('AccessRoute', () => { ) }) + it('omits callbackUrl when shouldAddCallbackUrl returns false', () => { + function LoginPage() { + const location = useLocation() + return ( +
+ {location.pathname} + {location.search} +
+ ) + } + + render( + + null} + callbackUrlParam="next" + shouldAddCallbackUrl={() => false} + > + + +
private
+ + } + /> + } /> +
+
+
+ ) + expect(screen.getByTestId('login').textContent).toBe('/login') + }) + + it('appends callbackUrl when shouldAddCallbackUrl returns true', () => { + function LoginPage() { + const location = useLocation() + return ( +
+ {new URLSearchParams(location.search).get('next')} +
+ ) + } + + render( + + null} + callbackUrlParam="next" + shouldAddCallbackUrl={() => true} + > + + +
private
+ + } + /> + } /> +
+
+
+ ) + expect(screen.getByTestId('callback').textContent).toBe('/private') + }) + it('redirects authenticated user away from guest-only route', () => { render( diff --git a/packages/react/package.json b/packages/react/package.json index a7bd3d0..00daa19 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -19,6 +19,11 @@ "import": "./dist/index.js", "require": "./dist/index.cjs", "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "require": "./dist/testing.cjs", + "types": "./dist/testing.d.ts" } }, "files": [ diff --git a/packages/react/src/AccessProvider.tsx b/packages/react/src/AccessProvider.tsx index 9249d50..e2c0ca9 100644 --- a/packages/react/src/AccessProvider.tsx +++ b/packages/react/src/AccessProvider.tsx @@ -11,6 +11,7 @@ export function AccessProvider({ forbiddenPath = '/403', defaultPath = '/', callbackUrlParam, + shouldAddCallbackUrl, getUser, isAuthenticated, hasRole, @@ -28,8 +29,9 @@ export function AccessProvider({ forbiddenPath, defaultPath, callbackUrlParam, + shouldAddCallbackUrl, }), - [guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam] + [guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam, shouldAddCallbackUrl] ) return ( diff --git a/packages/react/src/testing.tsx b/packages/react/src/testing.tsx new file mode 100644 index 0000000..c30b341 --- /dev/null +++ b/packages/react/src/testing.tsx @@ -0,0 +1,43 @@ +import type { GuardOptions } from '@react-protected/core' +import type { ReactNode } from 'react' + +import { AccessProvider } from './AccessProvider' +import type { NavigationConfig } from './types' + +export type MockAccessProviderProps = Partial> & + NavigationConfig & { + user?: TUser | null + allowed?: boolean + children?: ReactNode + } + +export function MockAccessProvider({ + user = null, + allowed = true, + children, + getUser, + isAuthenticated, + hasRole, + hasPermission, + loginPath, + forbiddenPath, + defaultPath, + callbackUrlParam, + shouldAddCallbackUrl, +}: MockAccessProviderProps) { + return ( + user)} + isAuthenticated={isAuthenticated ?? (() => allowed)} + hasRole={hasRole ?? (() => allowed)} + hasPermission={hasPermission ?? (() => allowed)} + loginPath={loginPath} + forbiddenPath={forbiddenPath} + defaultPath={defaultPath} + callbackUrlParam={callbackUrlParam} + shouldAddCallbackUrl={shouldAddCallbackUrl} + > + {children} + + ) +} diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 9ceeb1b..1709f52 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -8,6 +8,7 @@ export type NavigationConfig = { forbiddenPath?: string defaultPath?: string callbackUrlParam?: string + shouldAddCallbackUrl?: () => boolean } export type AccessContextValue = { @@ -16,6 +17,7 @@ export type AccessContextValue = { forbiddenPath: string defaultPath: string callbackUrlParam?: string + shouldAddCallbackUrl?: () => boolean } export type AccessProviderProps = GuardOptions & diff --git a/packages/react/tests/access-provider.test.tsx b/packages/react/tests/access-provider.test.tsx index 32f4d38..21cca27 100644 --- a/packages/react/tests/access-provider.test.tsx +++ b/packages/react/tests/access-provider.test.tsx @@ -27,6 +27,8 @@ describe('AccessProvider', () => { return null } + const shouldAddCallbackUrl = () => true + renderToString( ({ role: 'admin' })} @@ -35,6 +37,7 @@ describe('AccessProvider', () => { forbiddenPath="/no-access" defaultPath="/home" callbackUrlParam="next" + shouldAddCallbackUrl={shouldAddCallbackUrl} > @@ -44,6 +47,7 @@ describe('AccessProvider', () => { expect(ctx?.forbiddenPath).toBe('/no-access') expect(ctx?.defaultPath).toBe('/home') expect(ctx?.callbackUrlParam).toBe('next') + expect(ctx?.shouldAddCallbackUrl).toBe(shouldAddCallbackUrl) expect(ctx?.guard.check({ roles: ['admin'] })).toEqual({ allowed: true }) }) diff --git a/packages/react/tests/build.test.ts b/packages/react/tests/build.test.ts index b426eb5..c112aa2 100644 --- a/packages/react/tests/build.test.ts +++ b/packages/react/tests/build.test.ts @@ -21,6 +21,9 @@ describe('package build', () => { access(join(distDir, 'index.js')), access(join(distDir, 'index.cjs')), access(join(distDir, 'index.d.ts')), + access(join(distDir, 'testing.js')), + access(join(distDir, 'testing.cjs')), + access(join(distDir, 'testing.d.ts')), ]) }) }) diff --git a/packages/react/tests/mock-access-provider.test.tsx b/packages/react/tests/mock-access-provider.test.tsx new file mode 100644 index 0000000..ebe72f2 --- /dev/null +++ b/packages/react/tests/mock-access-provider.test.tsx @@ -0,0 +1,220 @@ +/* @vitest-environment jsdom */ + +import { cleanup, render, renderHook, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { useAccess } from '../src/AccessProvider' +import { HasAccess, useHasAccess } from '../src/HasAccess' +import { MockAccessProvider } from '../src/testing' + +type TestUser = { id: number; roles: Array; authorities: Array } + +afterEach(cleanup) + +describe('MockAccessProvider — defaults', () => { + it('allows all checks when allowed=true (default)', () => { + const { result } = renderHook( + () => ({ + auth: useHasAccess({ access: 'authenticated' }), + role: useHasAccess({ roles: ['admin'] }), + perm: useHasAccess({ permissions: ['reports:write'] }), + }), + { wrapper: ({ children }) => {children} } + ) + + expect(result.current.auth).toBe(true) + expect(result.current.role).toBe(true) + expect(result.current.perm).toBe(true) + }) + + it('blocks all checks when allowed=false', () => { + const { result } = renderHook( + () => ({ + auth: useHasAccess({ access: 'authenticated' }), + role: useHasAccess({ roles: ['admin'] }), + perm: useHasAccess({ permissions: ['reports:write'] }), + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + expect(result.current.auth).toBe(false) + expect(result.current.role).toBe(false) + expect(result.current.perm).toBe(false) + }) + + it('uses default navigation paths', () => { + const { result } = renderHook(() => useAccess(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current.loginPath).toBe('/login') + expect(result.current.forbiddenPath).toBe('/403') + expect(result.current.defaultPath).toBe('/') + }) +}) + +describe('MockAccessProvider — navigation config', () => { + it('forwards custom navigation paths', () => { + const { result } = renderHook(() => useAccess(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(result.current.loginPath).toBe('/auth') + expect(result.current.forbiddenPath).toBe('/no-access') + expect(result.current.defaultPath).toBe('/home') + }) +}) + +describe('MockAccessProvider — user prop', () => { + it('exposes user via getUser when user prop is set', () => { + const mockUser: TestUser = { id: 1, roles: ['admin'], authorities: [] } + + const { result } = renderHook(() => useAccess(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.guard.options.getUser()).toEqual(mockUser) + }) + + it('returns null by default when no user prop is provided', () => { + const { result } = renderHook(() => useAccess(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current.guard.options.getUser()).toBeNull() + }) +}) + +describe('MockAccessProvider — custom guard overrides', () => { + it('uses custom hasRole when provided', () => { + const mockUser: TestUser = { id: 1, roles: ['editor'], authorities: [] } + + const { result } = renderHook( + () => ({ + hasAdmin: useHasAccess({ roles: ['admin'] }), + hasEditor: useHasAccess({ roles: ['editor'] }), + }), + { + wrapper: ({ children }) => ( + roles.some((r) => user.roles.includes(r))} + > + {children} + + ), + } + ) + + expect(result.current.hasAdmin).toBe(false) + expect(result.current.hasEditor).toBe(true) + }) + + it('uses custom hasPermission when provided', () => { + const mockUser: TestUser = { id: 1, roles: [], authorities: ['reports:read'] } + + const { result } = renderHook( + () => ({ + canRead: useHasAccess({ permissions: ['reports:read'] }), + canWrite: useHasAccess({ permissions: ['reports:write'] }), + }), + { + wrapper: ({ children }) => ( + perms.every((p) => user.authorities.includes(p))} + > + {children} + + ), + } + ) + + expect(result.current.canRead).toBe(true) + expect(result.current.canWrite).toBe(false) + }) + + it('uses custom isAuthenticated when provided', () => { + const { result } = renderHook(() => useHasAccess({ access: 'authenticated' }), { + wrapper: ({ children }) => ( + false} + > + {children} + + ), + }) + + expect(result.current).toBe(false) + }) + + it('uses custom getUser when provided', () => { + const customUser = { id: 42, roles: [], authorities: [] } + + const { result } = renderHook(() => useAccess(), { + wrapper: ({ children }) => ( + customUser}>{children} + ), + }) + + expect(result.current.guard.options.getUser()).toEqual(customUser) + }) +}) + +describe('MockAccessProvider — integration with HasAccess', () => { + it('renders children when allowed=true (default)', () => { + render( + + + + + + ) + + expect(screen.getByText('Delete')).toBeTruthy() + }) + + it('hides children when allowed=false', () => { + render( + + + + + + ) + + expect(screen.queryByText('Delete')).toBeNull() + }) + + it('renders children with real role logic matching the user', () => { + const mockUser: TestUser = { id: 1, roles: ['viewer'], authorities: [] } + + render( + roles.some((r) => user.roles.includes(r))} + > + + admin-only + + + viewer-content + + + ) + + expect(screen.queryByText('admin-only')).toBeNull() + expect(screen.getByText('viewer-content')).toBeTruthy() + }) +}) diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 5008f62..a2b1a44 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -19,9 +19,12 @@ export default defineConfig({ ], build: { lib: { - entry: resolve(packageRoot, 'src/index.ts'), + entry: { + index: resolve(packageRoot, 'src/index.ts'), + testing: resolve(packageRoot, 'src/testing.tsx'), + }, formats: ['es', 'cjs'], - fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'), + fileName: (format, entryName) => `${entryName}.${format === 'es' ? 'js' : 'cjs'}`, }, rollupOptions: { external: ['react', 'react-dom', '@react-protected/core'],