Merge pull request #974 from nowcommunity/codex/plan-error-handling-for-unauthorized-access

Align person authorization responses with consistent errors
This commit is contained in:
Kari Lintulaakso
2025-11-13 15:44:11 +02:00
committed by GitHub
26 changed files with 526 additions and 48 deletions
+4 -4
View File
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
import { PersonDetailsType } from '../../../../frontend/src/shared/types'
import { pool } from '../../utils/db'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send, unauthenticatedError } from '../utils'
import { editedPerson, existingPerson } from './data'
let updatedPerson: PersonDetailsType | null = null
@@ -80,7 +80,7 @@ describe('Updating person works', () => {
const { body: resultBodyNoPerm, status: resultStatusNoPerm } = await send('person/', 'PUT', {
person: { initials: 'AD', first_name: 'Test first name 2' },
})
expect(resultBodyNoPerm).toEqual(noPermError)
expect(resultBodyNoPerm).toEqual(unauthenticatedError)
expect(resultStatusNoPerm).toEqual(401)
await login('testEr')
@@ -88,14 +88,14 @@ describe('Updating person works', () => {
person: { initials: 'AD', first_name: 'Test first name 2' },
})
expect(resultBodyEr).toEqual(noPermError)
expect(resultStatusEr).toEqual(401)
expect(resultStatusEr).toEqual(403)
await login('testEu')
const { body: resultBodyEu, status: resultStatusEu } = await send('person/', 'PUT', {
person: { initials: 'AD', first_name: 'Test first name 2' },
})
expect(resultBodyEu).toEqual(noPermError)
expect(resultStatusEu).toEqual(401)
expect(resultStatusEu).toEqual(403)
})
it('Non-admin user updating their own information succeeds', async () => {
+1
View File
@@ -100,3 +100,4 @@ export const resetDatabase = async () => {
export const resetDatabaseTimeout: number = 20000
export const noPermError = { message: 'User not authorized for the requested resource or action' }
export const unauthenticatedError = { message: 'User is not logged in' }
+15 -16
View File
@@ -12,6 +12,9 @@ import { requireOneOf } from '../middlewares/authorizer'
import { writePerson } from '../services/write/person'
import { writeUserGroup } from '../services/write/user'
const UNAUTHENTICATED_ERROR = { message: 'User is not logged in' }
const FORBIDDEN_ERROR = { message: 'User not authorized for the requested resource or action' }
const router = Router()
router.get('/all', requireOneOf([Role.Admin]), async (_req, res) => {
@@ -23,14 +26,12 @@ router.get('/:id', async (req, res) => {
const id = req.params.id
/* Access checking happens differently for this route, since we want to allow the route to return the user's own data for any user */
if (!req.user)
return res.status(401).send({
message: 'User is not logged in',
})
if (req.user.role !== Role.Admin && req.user.initials !== id)
return res.status(401).send({
message: 'User not authorized for the requested resource or action',
})
if (!req.user) {
return res.status(401).send(UNAUTHENTICATED_ERROR)
}
if (req.user.role !== Role.Admin && req.user.initials !== id) {
return res.status(403).send(FORBIDDEN_ERROR)
}
const person = await getPersonDetails(id)
if (!person) return res.status(404).send()
@@ -45,14 +46,12 @@ router.put(
return res.status(403).send({ error: 'Missing initials, creating new persons is not yet implemented' })
}
/* Access checking happens differently for this route, since we want to allow users to modify their own data */
if (!req.user)
return res.status(401).send({
message: 'User not authorized for the requested resource or action',
})
if (req.user.role !== Role.Admin && req.user.initials !== editedPerson.initials)
return res.status(401).send({
message: 'User not authorized for the requested resource or action',
})
if (!req.user) {
return res.status(401).send(UNAUTHENTICATED_ERROR)
}
if (req.user.role !== Role.Admin && req.user.initials !== editedPerson.initials) {
return res.status(403).send(FORBIDDEN_ERROR)
}
const validationErrors = validateEntirePerson({ ...editedPerson })
if (validationErrors.length > 0) {
+6 -1
View File
@@ -1,6 +1,11 @@
export default {
transform: {
'^.+\\.tsx?$': 'ts-jest',
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.json',
},
],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
@@ -8,7 +8,12 @@ import { LocalitiesMap } from '../Map/LocalitiesMap'
export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: CrossSearch) => void }) => {
const { sqlLimit, sqlOffset, sqlColumnFilters, sqlOrderBy } = usePageContext()
const { data: crossSearchQueryData, isFetching } = useGetAllCrossSearchQuery({
const {
data: crossSearchQueryData,
isFetching,
isError,
error,
} = useGetAllCrossSearchQuery({
limit: sqlLimit,
offset: sqlOffset,
columnFilters: sqlColumnFilters,
@@ -821,6 +826,8 @@ export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: Cros
enableColumnFilterModes={true}
serverSidePagination={true}
isCrossSearchTable={true}
isError={isError}
error={error}
/>
</>
)
@@ -23,7 +23,12 @@ const decimalCount = (num: number) => {
export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Locality) => void }) => {
const [selectedLocality, setSelectedLocality] = useState<string | undefined>()
const [modalOpen, setModalOpen] = useState<boolean>(false)
const { data: localitiesQueryData, isFetching: localitiesQueryIsFetching } = useGetAllLocalitiesQuery()
const {
data: localitiesQueryData,
isFetching: localitiesQueryIsFetching,
isError: localitiesQueryIsError,
error: localitiesQueryError,
} = useGetAllLocalitiesQuery()
const [getLocalitySpeciesList, { isLoading }] = useGetLocalitySpeciesListMutation()
const [filteredLocalities, setFilteredLocalities] = useState<SimplifiedLocality[]>()
const columnFilters = usePageContext()
@@ -466,6 +471,8 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
idFieldName="lid"
columns={columns}
isFetching={localitiesQueryIsFetching}
isError={localitiesQueryIsError}
error={localitiesQueryError}
visibleColumns={visibleColumns}
data={localitiesQueryData}
url="locality"
@@ -5,7 +5,7 @@ import { TableView } from '../TableView/TableView'
import { Museum } from '@/shared/types'
export const MuseumTable = ({ selectorFn }: { selectorFn?: (museum: Museum) => void }) => {
const { data: museumQueryData, isFetching } = useGetAllMuseumsQuery()
const { data: museumQueryData, isFetching, isError, error } = useGetAllMuseumsQuery()
const columns = useMemo<MRT_ColumnDef<Museum>[]>(
() => [
@@ -69,6 +69,8 @@ export const MuseumTable = ({ selectorFn }: { selectorFn?: (museum: Museum) => v
idFieldName="museum"
columns={columns}
isFetching={isFetching}
isError={isError}
error={error}
visibleColumns={visibleColumns}
data={museums}
url="museum"
@@ -0,0 +1,61 @@
import LockOutlinedIcon from '@mui/icons-material/LockOutlined'
import { Box, Button, Paper, Stack, Typography } from '@mui/material'
import { Link as RouterLink } from 'react-router-dom'
export type PermissionDeniedProps = {
title?: string
message?: string
actionHref?: string
actionLabel?: string
}
const defaultTitle = 'Permission denied'
const defaultMessage = 'You do not have permission to view this content.'
const defaultActionLabel = 'Go back home'
export const PermissionDenied = ({
title = defaultTitle,
message = defaultMessage,
actionHref = '/',
actionLabel = defaultActionLabel,
}: PermissionDeniedProps) => {
return (
<Paper elevation={4} sx={{ maxWidth: 480, margin: 'auto', mt: 6, p: 5 }} role="alert">
<Stack spacing={3} alignItems="center">
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'warning.light',
color: 'warning.dark',
borderRadius: '50%',
height: 72,
width: 72,
}}
>
<LockOutlinedIcon sx={{ fontSize: 36 }} />
</Box>
<Stack spacing={1} alignItems="center" textAlign="center">
<Typography component="h2" variant="h5">
{title}
</Typography>
<Typography color="text.secondary">{message}</Typography>
</Stack>
{actionHref && (
<Button
component={RouterLink}
to={actionHref}
variant="contained"
color="warning"
aria-label="Navigate back to the home page"
>
{actionLabel}
</Button>
)}
</Stack>
</Paper>
)
}
export default PermissionDenied
@@ -4,7 +4,7 @@ import { TableView } from '../TableView/TableView'
import { personTableColumns } from '@/common'
export const PersonTable = ({ selectorFn }: { selectorFn?: (id: PersonDetailsType) => void }) => {
const { data: personQueryData, isFetching } = useGetAllPersonsQuery()
const { data: personQueryData, isFetching, isError, error } = useGetAllPersonsQuery()
const visibleColumns = {
iniitals: false,
@@ -17,6 +17,8 @@ export const PersonTable = ({ selectorFn }: { selectorFn?: (id: PersonDetailsTyp
idFieldName="initials"
columns={personTableColumns}
isFetching={isFetching}
isError={isError}
error={error}
visibleColumns={visibleColumns}
data={personQueryData}
url="person"
@@ -5,7 +5,7 @@ import { Project } from '@/shared/types'
import { TableView } from '../TableView/TableView'
export const ProjectTable = ({ selectorFn }: { selectorFn?: (id: Project) => void }) => {
const { data: projectQueryData, isFetching } = useGetAllProjectsQuery()
const { data: projectQueryData, isFetching, isError, error } = useGetAllProjectsQuery()
const columns = useMemo<MRT_ColumnDef<Project>[]>(
() => [
{
@@ -47,6 +47,8 @@ export const ProjectTable = ({ selectorFn }: { selectorFn?: (id: Project) => voi
idFieldName="pid"
columns={columns}
isFetching={isFetching}
isError={isError}
error={error}
visibleColumns={visibleColumns}
data={projectQueryData}
url="project"
@@ -4,7 +4,7 @@ import { TableView } from '../TableView/TableView'
import { referenceTableColumns } from '@/common'
export const ReferenceTable = ({ selectorFn }: { selectorFn?: (id: Reference) => void }) => {
const { data: referenceQueryData, isFetching } = useGetAllReferencesQuery()
const { data: referenceQueryData, isFetching, isError, error } = useGetAllReferencesQuery()
let fixedNullDateData = undefined
if (referenceQueryData) {
// remove references with null date_primary values to not break the year filter,
@@ -26,6 +26,8 @@ export const ReferenceTable = ({ selectorFn }: { selectorFn?: (id: Reference) =>
data={fixedNullDateData}
url="reference"
isFetching={isFetching}
isError={isError}
error={error}
/>
)
}
@@ -5,7 +5,7 @@ import { Region } from '@/shared/types'
import { TableView } from '../TableView/TableView'
export const RegionTable = ({ selectorFn }: { selectorFn?: (id: Region) => void }) => {
const { data: reqionQueryData, isFetching } = useGetAllRegionsQuery()
const { data: reqionQueryData, isFetching, isError, error } = useGetAllRegionsQuery()
const columns = useMemo<MRT_ColumnDef<Region>[]>(
() => [
{
@@ -33,6 +33,8 @@ export const RegionTable = ({ selectorFn }: { selectorFn?: (id: Region) => void
idFieldName="reg_coord_id"
columns={columns}
isFetching={isFetching}
isError={isError}
error={error}
visibleColumns={visibleColumns}
data={reqionQueryData}
url="region"
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { type MRT_ColumnDef } from 'material-react-table'
import { type SequenceQueryArgs, useGetSequencesQuery } from '@/redux/timeUnitReducer'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
import type { SerializedError } from '@reduxjs/toolkit'
import { Sequence } from '@/shared/types'
import { TableView } from '../TableView/TableView'
import { Table } from '@/components/Table/Table'
@@ -25,12 +27,17 @@ export const SequenceTable = ({ selectorFn }: { selectorFn?: (id: Sequence) => v
data: sequenceQueryData,
isFetching,
pagination,
} = usePaginatedQuery<SequenceQueryArgs, Sequence>(useGetSequencesQuery, {
tableId: TABLE_ID,
queryArg,
manualPageIndex: pageIndex,
manualPageSize: pageSize,
})
isError,
error,
} = usePaginatedQuery<SequenceQueryArgs, Sequence, Sequence, FetchBaseQueryError | SerializedError>(
useGetSequencesQuery,
{
tableId: TABLE_ID,
queryArg,
manualPageIndex: pageIndex,
manualPageSize: pageSize,
}
)
useEffect(() => {
if (!pagination) {
@@ -83,6 +90,8 @@ export const SequenceTable = ({ selectorFn }: { selectorFn?: (id: Sequence) => v
clickableRows={false}
data={sequenceQueryData}
url="sequence"
isError={Boolean(isError)}
error={error}
/>
</Table>
)
@@ -56,7 +56,7 @@ export const SpeciesTable = ({ selectorFn }: { selectorFn?: (id: Species) => voi
const [modalOpen, setModalOpen] = useState<boolean>(false)
const [commentModalOpen, setCommentModalOpen] = useState<boolean>(false)
const [commentModalData, setCommentModalData] = useState<CommentModalData | null>(null)
const { data: speciesQueryData, isFetching } = useGetAllSpeciesQuery()
const { data: speciesQueryData, isFetching, isError, error } = useGetAllSpeciesQuery()
const synonymFilterFns = useMemo<Record<string, MRT_FilterFn<Species>>>(
() => ({
@@ -496,6 +496,8 @@ export const SpeciesTable = ({ selectorFn }: { selectorFn?: (id: Species) => voi
idFieldName="species_id"
columns={columns}
isFetching={isFetching}
isError={isError}
error={error}
visibleColumns={visibleColumns}
data={speciesQueryData}
url="species"
@@ -1,4 +1,4 @@
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import {
type MRT_ColumnFiltersState,
type MRT_ColumnDef,
@@ -12,7 +12,9 @@ import {
MRT_Row,
type MRT_FilterFn,
} from 'material-react-table'
import { Box, CircularProgress, Paper, Tooltip } from '@mui/material'
import { Alert, Box, CircularProgress, Paper, Tooltip } from '@mui/material'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
import type { SerializedError } from '@reduxjs/toolkit'
import type { FilterFn } from '@tanstack/table-core'
import { useLocation, useNavigate } from 'react-router-dom'
import { ActionComponent } from './ActionComponent'
@@ -23,6 +25,7 @@ import '../../styles/TableView.css'
import { TableToolBar } from './TableToolBar'
import NotListedLocationIcon from '@mui/icons-material/NotListedLocation'
import '../../styles/tableview/TableView.css'
import { resolveErrorMessage, resolveErrorStatus } from './errorUtils'
type TableStateInUrl = 'sorting' | 'columnfilters' | 'pagination'
@@ -54,6 +57,8 @@ export const TableView = <T extends MRT_RowData>({
isFetching,
filterFns,
renderRowActionExtras,
isError,
error,
}: {
data: T[] | undefined
columns: MRT_ColumnDef<T>[]
@@ -73,6 +78,8 @@ export const TableView = <T extends MRT_RowData>({
enableColumnFilterModes?: boolean
serverSidePagination?: boolean
isFetching: boolean
isError?: boolean
error?: FetchBaseQueryError | SerializedError
filterFns?: Record<string, MRT_FilterFn<T>>
renderRowActionExtras?: ({ row }: { row: MRT_Row<T> }) => ReactNode
}) => {
@@ -325,6 +332,10 @@ export const TableView = <T extends MRT_RowData>({
// renderToolbarInternalActions: selectorFn ? renderCustomToolbarModalVersion : renderCustomToolbar,
})
const errorStatus = useMemo(() => resolveErrorStatus(error), [error])
const errorMessage = useMemo(() => resolveErrorMessage(errorStatus, error, isError), [errorStatus, error, isError])
// Load state from url only on first render
useEffect(() => {
if (selectorFn) return
@@ -356,7 +367,16 @@ export const TableView = <T extends MRT_RowData>({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [table, columnFilters, sorting])
if (!data) {
if (isError) {
const severity = errorStatus === 401 || errorStatus === 403 ? 'warning' : 'error'
return (
<Alert severity={severity} role="alert">
{errorMessage}
</Alert>
)
}
if (!data && isFetching) {
return (
<>
<CircularProgress />
@@ -366,6 +386,10 @@ export const TableView = <T extends MRT_RowData>({
)
}
if (!data) {
return null
}
return (
<Paper elevation={5}>
{user && (
@@ -0,0 +1,64 @@
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
import type { SerializedError } from '@reduxjs/toolkit'
const isFetchBaseQueryError = (error: unknown): error is FetchBaseQueryError => {
return typeof error === 'object' && error !== null && 'status' in error
}
const extractStatus = (error: FetchBaseQueryError): number | null => {
if (typeof error.status === 'number') {
return error.status
}
return null
}
const extractMessageFromData = (data: unknown): string | null => {
if (typeof data === 'string') {
return data
}
if (data && typeof data === 'object' && 'message' in data) {
const candidate = (data as { message?: unknown }).message
if (typeof candidate === 'string') {
return candidate
}
}
return null
}
export const resolveErrorStatus = (error: FetchBaseQueryError | SerializedError | undefined): number | null => {
if (!error || !isFetchBaseQueryError(error)) {
return null
}
return extractStatus(error)
}
export const resolveErrorMessage = (
status: number | null,
error: FetchBaseQueryError | SerializedError | undefined,
isError: boolean | undefined
): string | null => {
if (!isError) {
return null
}
if (error && isFetchBaseQueryError(error)) {
const message = extractMessageFromData(error.data)
if (message) {
return message
}
}
if (status === 401) {
return 'You need to sign in to view this data.'
}
if (status === 403) {
return 'You do not have permission to view this data.'
}
return 'We were unable to load this data. Please try again later.'
}
@@ -11,7 +11,7 @@ export const TimeBoundTable = ({
selectorFn?: (newTimeBound: TimeBound) => void
showBid?: boolean
}) => {
const { data: timeBoundQueryData, isFetching } = useGetAllTimeBoundsQuery()
const { data: timeBoundQueryData, isFetching, isError, error } = useGetAllTimeBoundsQuery()
const columns = useMemo<MRT_ColumnDef<TimeBound>[]>(
() => [
{
@@ -58,6 +58,8 @@ export const TimeBoundTable = ({
data={timeBoundQueryData}
url="time-bound"
enableColumnFilterModes={true}
isError={isError}
error={error}
/>
)
}
@@ -11,7 +11,7 @@ export const TimeUnitTable = ({
selectorFn?: (newTimeUnit: TimeUnit) => void
clickableRows?: boolean
}) => {
const { data: timeUnitQueryData, isFetching } = useGetAllTimeUnitsQuery()
const { data: timeUnitQueryData, isFetching, isError, error } = useGetAllTimeUnitsQuery()
const columns = useMemo<MRT_ColumnDef<TimeUnit>[]>(
() => [
{
@@ -65,6 +65,8 @@ export const TimeUnitTable = ({
idFieldName="tu_name"
columns={columns}
isFetching={isFetching}
isError={isError}
error={error}
visibleColumns={visibleColumns}
clickableRows={clickableRows}
data={timeUnitQueryData}
+4 -4
View File
@@ -21,8 +21,8 @@ import { TimeBoundTable } from './TimeBound/TimeBoundTable'
import { TimeUnitTable } from './TimeUnit/TimeUnitTable'
import { TimeUnitDetails } from './TimeUnit/TimeUnitDetails'
import { ReferenceDetails } from './Reference/ReferenceDetails'
import { PersonTable } from './Person/PersonTable'
import { PersonDetails } from './Person/PersonDetails'
import { PersonListPage } from '@/pages/PersonListPage'
import { PersonDetailPage } from '@/pages/PersonDetailPage'
import { ProjectTable } from './Project/ProjectTable'
import { ProjectDetails } from './Project/ProjectDetails'
import { RegionTable } from './Region/RegionTable'
@@ -168,8 +168,8 @@ export const personPage = (
// allowedRoles, as the component has to be rendered for the user's own user-page.
// This is ok because the requests should fail on backend anyways.
<Page
tableView={<PersonTable />}
detailView={<PersonDetails />}
tableView={<PersonListPage />}
detailView={<PersonDetailPage />}
viewName="person"
idFieldName="initials"
createTitle={(person: PersonDetailsType) => `${person.surname}`}
+9 -4
View File
@@ -34,12 +34,12 @@ type UsePaginatedQueryOptions<TQueryArg, TRaw, TMapped> = {
manualPageSize?: number
}
type UnknownQueryResult<T> = {
type UnknownQueryResult<T, TError = unknown> = {
data?: T
isFetching?: boolean
isLoading?: boolean
isError?: boolean
error?: unknown
error?: TError
refetch?: () => unknown
}
@@ -128,8 +128,10 @@ export const usePaginatedQuery = <
TQueryArg,
TRaw,
TMapped = TRaw,
TResult extends UnknownQueryResult<ArrayWithFullCount<TRaw> | PaginatedEnvelope<TRaw>> = UnknownQueryResult<
ArrayWithFullCount<TRaw> | PaginatedEnvelope<TRaw>
TError = unknown,
TResult extends UnknownQueryResult<ArrayWithFullCount<TRaw> | PaginatedEnvelope<TRaw>, TError> = UnknownQueryResult<
ArrayWithFullCount<TRaw> | PaginatedEnvelope<TRaw>,
TError
>,
>(
queryHook: QueryHook<TQueryArg, TResult>,
@@ -236,8 +238,11 @@ export const usePaginatedQuery = <
[transformedRows, metadata?.totalItems]
)
const combinedIsError = Boolean(queryResult.isError) || Boolean(queryResult.error)
return {
...queryResult,
isError: combinedIsError,
data: decoratedRows,
pagination: metadata,
}
+52
View File
@@ -0,0 +1,52 @@
import { useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { skipToken } from '@reduxjs/toolkit/query'
import { PermissionDenied } from '@/components/PermissionDenied'
import { PersonDetails } from '@/components/Person/PersonDetails'
import { resolveErrorStatus } from '@/components/TableView/errorUtils'
import { useUser } from '@/hooks/user'
import { useGetPersonDetailsQuery } from '@/redux/personReducer'
export const PersonDetailPage = () => {
const { id: idFromUrl } = useParams()
const user = useUser()
const isUserPage = idFromUrl === 'user-page'
const isNew = idFromUrl === 'new'
const detailId = isUserPage ? user.initials : idFromUrl
const queryArg = !detailId || isNew ? skipToken : detailId
const isAuthenticated = Boolean(user.token)
const { isError, error } = useGetPersonDetailsQuery(isAuthenticated ? queryArg : skipToken)
const isForbidden = useMemo(() => {
if (!isError) return false
const status = resolveErrorStatus(error)
return status === 403
}, [error, isError])
if (!isAuthenticated) {
return (
<PermissionDenied
title="Sign in to view this person"
message="You must be signed in to access person records."
actionHref="/login"
actionLabel="Go to sign in"
/>
)
}
if (isForbidden) {
return (
<PermissionDenied
title="You do not have access to this person"
message="Your account cannot view the requested person record."
/>
)
}
return <PersonDetails />
}
export default PersonDetailPage
+28
View File
@@ -0,0 +1,28 @@
import { useMemo } from 'react'
import { PermissionDenied } from '@/components/PermissionDenied'
import { PersonTable } from '@/components/Person/PersonTable'
import { resolveErrorStatus } from '@/components/TableView/errorUtils'
import { useGetAllPersonsQuery } from '@/redux/personReducer'
export const PersonListPage = () => {
const { isError, error } = useGetAllPersonsQuery()
const isForbidden = useMemo(() => {
if (!isError) return false
const status = resolveErrorStatus(error)
return status === 403
}, [error, isError])
if (isForbidden) {
return (
<PermissionDenied
title="You do not have access to the people table"
message="Your account is not allowed to browse the people directory. Please contact an administrator if you believe this is a mistake."
/>
)
}
return <PersonTable />
}
export default PersonListPage
@@ -0,0 +1,30 @@
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
import type { SerializedError } from '@reduxjs/toolkit'
import { resolveErrorMessage, resolveErrorStatus } from '@/components/TableView/errorUtils'
describe('table view error utilities', () => {
it('derives numeric status codes from fetch errors', () => {
const fetchError: FetchBaseQueryError = { status: 403, data: {} }
const serializedError: SerializedError = { name: 'Error', message: 'network failure' }
expect(resolveErrorStatus(fetchError)).toBe(403)
expect(resolveErrorStatus(serializedError)).toBeNull()
})
it('prefers backend messages when present', () => {
const error = { status: 403, data: { message: 'Forbidden' } }
const status = resolveErrorStatus(error)
expect(resolveErrorMessage(status, error, true)).toBe('Forbidden')
})
it('falls back to permission copy when backend provides none', () => {
const error = { status: 403, data: {} }
const status = resolveErrorStatus(error)
expect(resolveErrorMessage(status, error, true)).toBe('You do not have permission to view this data.')
})
it('returns null when not in error state', () => {
const error = { status: 403, data: {} }
const status = resolveErrorStatus(error)
expect(resolveErrorMessage(status, error, false)).toBeNull()
})
})
@@ -59,4 +59,38 @@ describe('usePaginatedQuery', () => {
pageSize: 2,
})
})
it('marks forbidden responses as errors without caching pagination metadata', () => {
let latestIsError: boolean | undefined
let latestError: unknown
let latestPagination: TablesState[string] | null | undefined
const useForbiddenQuery = () => ({
error: { status: 403, data: { message: 'Forbidden' } },
isError: true,
isFetching: false,
})
const TestComponent = () => {
const result = usePaginatedQuery<void, SequenceRow>(useForbiddenQuery, {
tableId: 'sequences',
queryArg: undefined,
})
latestIsError = result.isError
latestError = result.error
latestPagination = result.pagination
return null
}
render(
<Provider store={store}>
<TestComponent />
</Provider>
)
expect(latestIsError).toBe(true)
expect(latestError).toEqual({ status: 403, data: { message: 'Forbidden' } })
expect(latestPagination).toBeNull()
expect(store.getState().tables?.sequences).toBeUndefined()
})
})
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
import { PersonDetailPage } from '@/pages/PersonDetailPage'
jest.mock('@/components/Person/PersonDetails', () => ({
PersonDetails: () => <div data-testid="person-details">details</div>,
}))
const mockUseGetPersonDetailsQuery = jest.fn()
const mockUseUser = jest.fn()
jest.mock('@/redux/personReducer', () => ({
useGetPersonDetailsQuery: (arg?: unknown) => mockUseGetPersonDetailsQuery(arg),
}))
jest.mock('@/hooks/user', () => ({
useUser: () => mockUseUser(),
}))
/**
* Manual verification: log in as a non-admin user, navigate directly to `/person/<existing-id>`,
* and confirm the permission message appears instead of the spinner.
*/
describe('PersonDetailPage', () => {
beforeEach(() => {
mockUseGetPersonDetailsQuery.mockReset()
mockUseUser.mockReturnValue({ initials: 'JD', token: 'token-123' })
})
it('shows the permission denied view when details query returns 403', () => {
mockUseGetPersonDetailsQuery.mockReturnValue({
isError: true,
error: { status: 403, data: {} } as FetchBaseQueryError,
})
render(
<MemoryRouter initialEntries={['/person/JD']}>
<Routes>
<Route path="/person/:id" element={<PersonDetailPage />} />
</Routes>
</MemoryRouter>
)
expect(screen.getByText('You do not have access to this person')).toBeTruthy()
expect(screen.queryByTestId('person-details')).toBeNull()
})
it('renders the person details when authorized', () => {
mockUseGetPersonDetailsQuery.mockReturnValue({ isError: false })
render(
<MemoryRouter initialEntries={['/person/JD']}>
<Routes>
<Route path="/person/:id" element={<PersonDetailPage />} />
</Routes>
</MemoryRouter>
)
expect(screen.getByTestId('person-details')).toBeTruthy()
})
it('asks the visitor to sign in when no user token is present', () => {
mockUseUser.mockReturnValue({ initials: null, token: null })
mockUseGetPersonDetailsQuery.mockReturnValue({ isError: false })
render(
<MemoryRouter initialEntries={['/person/JD']}>
<Routes>
<Route path="/person/:id" element={<PersonDetailPage />} />
</Routes>
</MemoryRouter>
)
expect(screen.getByText('Sign in to view this person')).toBeTruthy()
expect(screen.queryByTestId('person-details')).toBeNull()
})
})
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
import { PersonListPage } from '@/pages/PersonListPage'
jest.mock('@/components/Person/PersonTable', () => ({
PersonTable: () => <div data-testid="person-table">table</div>,
}))
const mockUseGetAllPersonsQuery = jest.fn()
jest.mock('@/redux/personReducer', () => ({
useGetAllPersonsQuery: (arg?: unknown) => mockUseGetAllPersonsQuery(arg),
}))
/**
* Manual verification: log in as a non-admin user and browse directly to `/person`.
* Confirm the permission denied message renders instead of the loading indicator.
*/
describe('PersonListPage', () => {
beforeEach(() => {
mockUseGetAllPersonsQuery.mockReset()
})
it('shows the permission denied view when the list query returns 403', () => {
mockUseGetAllPersonsQuery.mockReturnValue({
isError: true,
error: { status: 403, data: {} } as FetchBaseQueryError,
})
render(
<MemoryRouter>
<PersonListPage />
</MemoryRouter>
)
expect(
screen.getByText(
'Your account is not allowed to browse the people directory. Please contact an administrator if you believe this is a mistake.'
)
).toBeTruthy()
expect(screen.queryByTestId('person-table')).toBeNull()
})
it('renders the table when no authorization error occurs', () => {
mockUseGetAllPersonsQuery.mockReturnValue({ isError: false })
render(
<MemoryRouter>
<PersonListPage />
</MemoryRouter>
)
expect(screen.getByTestId('person-table')).toBeTruthy()
})
})