diff --git a/apps/admin/src/auth/AuthContext.test.tsx b/apps/admin/src/auth/AuthContext.test.tsx index b783e56..7cb5fd9 100644 --- a/apps/admin/src/auth/AuthContext.test.tsx +++ b/apps/admin/src/auth/AuthContext.test.tsx @@ -111,4 +111,15 @@ describe('AuthContext admin gate', () => { expect(window.location.pathname).toBe('/'); expect(getToken()).toBeNull(); }); + + it('useAuth throws when rendered outside an AuthProvider', () => { + function Orphan() { + useAuth(); + return null; + } + // React logs the render error to console.error; silence it for the intentional throw. + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow('useAuth must be used within an AuthProvider'); + spy.mockRestore(); + }); }); diff --git a/apps/admin/src/screens/Login.test.tsx b/apps/admin/src/screens/Login.test.tsx new file mode 100644 index 0000000..5228d4f --- /dev/null +++ b/apps/admin/src/screens/Login.test.tsx @@ -0,0 +1,190 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { MeResponse } from '@solelog/shared'; +import Login from './Login'; +import App from '../App'; +import { AuthProvider } from '../auth/AuthContext'; +import { ApiError, signIn as apiSignIn } from '../lib/api'; +import { fetchMe } from '../api/me'; +import { clearToken, setToken } from '../lib/auth-storage'; + +// Mock the network layer. signIn stores a token (mirror the real lib/api behaviour); +// apiFetch is stubbed so the authed shell's list queries resolve cleanly in the lifecycle +// test. fetchMe drives the admin-vs-worker gate. useMe stays real (runs on the mocked fetchMe). +vi.mock('../lib/api', async () => { + const actual = await vi.importActual('../lib/api'); + return { + ...actual, + signIn: vi.fn(async () => { + setToken('tok'); + }), + apiFetch: vi.fn().mockResolvedValue([]), + }; +}); +// fetchMe (used by the AuthContext gate) is the controllable mock. useMe (used by the shell's +// Sidebar header) is stubbed to a no-data result — the gate, not the header, is under test here. +vi.mock('../api/me', () => ({ + fetchMe: vi.fn(), + useMe: () => ({ data: undefined }), +})); + +const mockedSignIn = vi.mocked(apiSignIn); +const mockedFetchMe = vi.mocked(fetchMe); + +const admin: MeResponse = { + user: { id: 'a1', email: 'admin@solelog.local', name: 'Admin', role: 'admin' }, +}; +const worker: MeResponse = { + user: { id: 'w1', email: 'worker@solelog.local', name: 'Werker', role: 'worker' }, +}; + +function renderLogin() { + return render( + + + , + ); +} + +async function typeCredentials(user: ReturnType, email: string) { + await user.type(screen.getByLabelText('E-mailadres'), email); + await user.type(screen.getByLabelText('Wachtwoord'), 'pw'); +} + +describe('Login', () => { + beforeEach(() => { + mockedSignIn.mockReset(); + mockedFetchMe.mockReset(); + mockedSignIn.mockImplementation(async () => { + setToken('tok'); + }); + }); + + afterEach(() => { + clearToken(); + vi.clearAllMocks(); + }); + + it('submits the typed credentials to signIn', async () => { + mockedFetchMe.mockResolvedValue(admin); + const user = userEvent.setup(); + renderLogin(); + + await typeCredentials(user, 'admin@solelog.local'); + await user.click(screen.getByRole('button', { name: 'Inloggen' })); + + await waitFor(() => expect(mockedSignIn).toHaveBeenCalledWith('admin@solelog.local', 'pw')); + // A successful admin sign-in surfaces no error. + expect(screen.queryByText('Inloggen mislukt')).not.toBeInTheDocument(); + }); + + it('shows the admin-only message when a worker signs in (NotAdminError)', async () => { + mockedFetchMe.mockResolvedValue(worker); + const user = userEvent.setup(); + renderLogin(); + + await typeCredentials(user, 'worker@solelog.local'); + await user.click(screen.getByRole('button', { name: 'Inloggen' })); + + await waitFor(() => + expect(screen.getByText('Geen toegang — alleen beheerders.')).toBeInTheDocument(), + ); + }); + + it('shows a generic error when the credentials are rejected', async () => { + mockedSignIn.mockRejectedValue(new ApiError(401, 'Inloggen mislukt')); + const user = userEvent.setup(); + renderLogin(); + + await typeCredentials(user, 'admin@solelog.local'); + await user.click(screen.getByRole('button', { name: 'Inloggen' })); + + await waitFor(() => expect(screen.getByText('Inloggen mislukt')).toBeInTheDocument()); + }); + + it('disables the submit button while the sign-in is pending', async () => { + let release!: () => void; + mockedSignIn.mockImplementation( + () => + new Promise((resolve) => { + release = () => { + setToken('tok'); + resolve(); + }; + }), + ); + mockedFetchMe.mockResolvedValue(admin); + const user = userEvent.setup(); + renderLogin(); + + await typeCredentials(user, 'admin@solelog.local'); + const button = screen.getByRole('button', { name: 'Inloggen' }); + await user.click(button); + + expect(button).toBeDisabled(); + release(); + await waitFor(() => expect(button).not.toBeDisabled()); + }); + + it('clears a previous error on the next submit', async () => { + mockedSignIn.mockRejectedValueOnce(new ApiError(401, 'Inloggen mislukt')); + mockedFetchMe.mockResolvedValue(admin); + const user = userEvent.setup(); + renderLogin(); + + await typeCredentials(user, 'admin@solelog.local'); + const button = screen.getByRole('button', { name: 'Inloggen' }); + await user.click(button); + await waitFor(() => expect(screen.getByText('Inloggen mislukt')).toBeInTheDocument()); + + // Second submit succeeds; the stale error is cleared. + await user.click(button); + await waitFor(() => expect(screen.queryByText('Inloggen mislukt')).not.toBeInTheDocument()); + }); +}); + +// Lifecycle (B-D1): worker credentials are rejected with the not-authorized message; +// admin credentials render the authed shell. +describe('admin-only gate lifecycle', () => { + beforeEach(() => { + mockedSignIn.mockReset(); + mockedFetchMe.mockReset(); + mockedSignIn.mockImplementation(async () => { + setToken('tok'); + }); + }); + + afterEach(() => { + clearToken(); + vi.clearAllMocks(); + }); + + it('rejects a worker with the not-authorized message, then admits an admin to the shell', async () => { + // First sign-in resolves a worker (rejected); the retry resolves an admin (admitted). + mockedFetchMe.mockResolvedValueOnce(worker).mockResolvedValue(admin); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const user = userEvent.setup(); + render( + + + , + ); + + // Login is shown; a worker attempt is rejected and the shell never renders. + await typeCredentials(user, 'worker@solelog.local'); + await user.click(screen.getByRole('button', { name: 'Inloggen' })); + await waitFor(() => + expect(screen.getByText('Geen toegang — alleen beheerders.')).toBeInTheDocument(), + ); + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + + // An admin attempt flips the gate and renders the authed shell (Sidebar nav). + await user.clear(screen.getByLabelText('E-mailadres')); + await typeCredentials(user, 'admin@solelog.local'); + await user.click(screen.getByRole('button', { name: 'Inloggen' })); + await waitFor(() => expect(screen.getByRole('navigation')).toBeInTheDocument()); + expect(within(screen.getByRole('navigation')).getByText('Live')).toBeInTheDocument(); + }); +}); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index c59eef5..d411395 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -355,3 +355,28 @@ Guaranteed behaviors: `['sessions']` query cache on success. - Lifecycle (`src/App.test.tsx`): unauthenticated renders `Login` with no navigation; authenticated renders the Stopwatch screen under the tab bar and no login form. + +## Auth (admin-only gate) — admin client + +Src covered (admin `apps/admin`): `src/auth/AuthContext.tsx` and `src/screens/Login.tsx` — both at +100% stmts/branch/funcs/lines. No `c8 ignore`s needed. + +Guaranteed behaviors: + +- `AuthContext` starts **unauthenticated** when no token is stored and **restores** the authenticated + state on mount when a token is already present (`getToken() !== null`). +- `AuthContext.signIn` delegates to the api `signIn(email, password)`, then fetches `/api/me`: + **admin-only gate** — an `admin` role flips the context to authenticated; a non-admin role clears the + stored token and throws `NotAdminError` (`"not-admin"`), leaving the context unauthenticated. +- `AuthContext.signOut` resets the router path to `/` (so the next authed mount lands on Live, not + whatever tab the admin logged out from), clears the stored token, and flips to unauthenticated. +- `useAuth` throws `"useAuth must be used within an AuthProvider"` when called outside the provider. +- `Login` renders the Dutch form (E-mailadres / Wachtwoord / Inloggen) and submits the typed + credentials to `signIn`. +- `Login` distinguishes the two failure modes: a `NotAdminError` surfaces + `"Geen toegang — alleen beheerders."` while any other failure surfaces `"Inloggen mislukt"`; a stale + error is cleared on the next submit. +- `Login` disables the submit button while the sign-in is pending, re-enabling it once it settles. +- Lifecycle (`src/screens/Login.test.tsx`): from the unauthenticated app, worker credentials are + rejected with the `"Geen toegang — alleen beheerders."` message and the shell never renders; admin + credentials then flip the gate so the authed shell (Sidebar nav) renders.