Private
Public Access
1
0
Files
linux_patch_manager/frontend/src/pages/__tests__/SsoCallbackPage.test.tsx
Draco-Lunaris-Echo f58d7a6f17 fix(security): stop embedding JWT tokens in SSO callback redirect URL (#4) (#14)
Replaces URL-embedded JWT tokens with a single-use, 60-second handoff code that the SPA exchanges via server-to-server POST. The URL now contains only `?handoff=<code>` — no tokens are placed in the browser history, proxy access logs, or Referer header.

Backend: new SsoHandoff store (DashMap, 60s TTL, atomic DashMap::remove for single-use), POST /api/v1/auth/sso/handoff endpoint, 7 new tests.

Frontend: SsoCallbackPage rewritten to use useSearchParams + POST exchange, with history.replaceState to clear the handoff code from the address bar. Switched from window.location.search to useSearchParams() for test compatibility. New Vitest infrastructure (vitest, @testing-library/react, jsdom) and 6 new tests.

CI fix in ccba9e3: cargo fmt --all and added searchParams to useEffect dep array to satisfy CI's Rust Format and Frontend Lint checks.

Refs: closes #4
2026-06-03 06:28:08 -05:00

206 lines
7.1 KiB
TypeScript

/// Tests for SsoCallbackPage (issue #4 — SSO token handoff).
///
/// Per `tasks/sso-token-handoff-spec.md` §6.3:
/// 9. renders_processing_state_initially
/// 10. calls_handoff_endpoint_on_mount
/// 11. stores_tokens_and_user_on_success
/// 12. shows_error_on_handoff_failure
/// 13. shows_error_when_handoff_code_missing
/// 14. clears_handoff_code_from_url_after_success
///
/// We mock `fetch`, the auth store, and `window.history.replaceState`
/// so the test focuses on the page's effect-driven logic (URL parsing
/// → POST exchange → store update → navigation → URL cleanup). We do
/// NOT mock `react-router-dom` — instead, we use a real
/// `MemoryRouter` and assert on side effects (the auth store mocks +
/// `replaceState` spy + visible error text).
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, act } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import SsoCallbackPage from '../SsoCallbackPage'
// Mock the auth store — we don't want real zustand state leaking
// between tests, and we want to assert on setTokens/setUser calls.
const setTokensMock = vi.fn()
const setUserMock = vi.fn()
vi.mock('../../store/authStore', () => ({
useAuthStore: () => ({
setTokens: setTokensMock,
setUser: setUserMock,
}),
}))
// Helper: render the page with a controlled URL and let the test
// inspect the rendered output + the auth store mocks.
function renderAt(url: string) {
return render(
<MemoryRouter initialEntries={[url]}>
<SsoCallbackPage />
</MemoryRouter>,
)
}
beforeEach(() => {
setTokensMock.mockReset()
setUserMock.mockReset()
// Default fetch: never-resolving promise (keeps the page in
// "processing" state). Individual tests override this.
globalThis.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('SsoCallbackPage', () => {
// 9. renders_processing_state_initially — on mount with a handoff
// code, shows the spinner and "Completing sign-in…".
it('renders the processing state initially', async () => {
// Wrap in act() to flush the useEffect that calls fetch.
await act(async () => {
renderAt('/auth/sso/callback?handoff=test-code')
})
expect(screen.getByText(/completing sign-in/i)).toBeInTheDocument()
// The MUI CircularProgress renders a role="progressbar"
expect(screen.getByRole('progressbar')).toBeInTheDocument()
})
// 10. calls_handoff_endpoint_on_mount — mocks fetch and asserts
// the POST goes to /api/v1/auth/sso/handoff with
// { handoff_code: <code> }.
it('POSTs the handoff code to the backend on mount', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'a',
refresh_token: 'r',
token_type: 'Bearer',
expires_in: 900,
user: { id: 'u1', username: 'tester' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback?handoff=abc123')
})
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/auth/sso/handoff')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body)).toEqual({ handoff_code: 'abc123' })
})
// 11. stores_tokens_and_user_on_success — mocks a successful
// response, asserts setTokens and setUser are called, and
// setTokens receives the correct token values.
it('stores tokens + user on a successful exchange', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'access-jwt',
refresh_token: 'refresh-raw',
token_type: 'Bearer',
expires_in: 900,
user: { id: 'user-42', username: 'alice' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback?handoff=ok')
})
await waitFor(() => {
expect(setTokensMock).toHaveBeenCalledWith('access-jwt', 'refresh-raw')
})
expect(setUserMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-42', username: 'alice' }),
)
})
// 12. shows_error_on_handoff_failure — mocks a 400 response,
// asserts the error message is rendered and the spinner
// stops.
it('shows an error when the backend returns 400', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
error: { code: 'invalid_handoff', message: 'Handoff code has expired' },
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback?handoff=expired')
})
expect(await screen.findByText(/handoff code has expired/i)).toBeInTheDocument()
expect(screen.queryByText(/completing sign-in/i)).not.toBeInTheDocument()
// No token storage on error
expect(setTokensMock).not.toHaveBeenCalled()
expect(setUserMock).not.toHaveBeenCalled()
})
// 13. shows_error_when_handoff_code_missing — invokes the effect
// with no handoff code, asserts the "Missing handoff code"
// error is shown.
it('shows a missing-code error when ?handoff= is absent', async () => {
const fetchMock = vi.fn()
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback')
})
expect(await screen.findByText(/missing handoff code/i)).toBeInTheDocument()
// No fetch call should have been made
expect(fetchMock).not.toHaveBeenCalled()
})
// 14. clears_handoff_code_from_url_after_success — asserts
// window.history.replaceState is called to remove the
// ?handoff= param from the URL after a successful exchange.
it('clears the handoff code from the URL after a successful exchange', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'a',
refresh_token: 'r',
token_type: 'Bearer',
expires_in: 900,
user: { id: 'u', username: 'u' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
await act(async () => {
renderAt('/auth/sso/callback?handoff=secret-code')
})
await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalled()
})
// Verify the replaceState call cleared the query string — the
// third argument is the new URL ('/auth/sso/callback' with no
// query).
const args = replaceStateSpy.mock.calls[0]
expect(args[2]).toBe('/auth/sso/callback')
})
})