Skip to content

Commit c1d3cf3

Browse files
Merge branch 'main' into add-kiota-to-csproj
2 parents 98a7d14 + 98df6b1 commit c1d3cf3

13 files changed

Lines changed: 641 additions & 293 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,6 @@ nuget-store
465465

466466
# Idea specific configuration files
467467
.idea/.idea.SEBT.Portal/Docker/compose.generated.override.yml
468+
469+
# AI
470+
.claude/settings.local.json
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/**
2+
* OIDC Callback Page Unit Tests
3+
*
4+
* Tests the OIDC callback flow including:
5+
* - Successful token exchange and redirect to dashboard
6+
* - Missing code/state parameters
7+
* - PKCE session expired (no stored PKCE)
8+
* - PKCE state mismatch
9+
* - Token exchange failure
10+
* - Complete-login failure
11+
*/
12+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
13+
import { render, screen, waitFor } from '@testing-library/react'
14+
import { http, HttpResponse } from 'msw'
15+
import { beforeEach, describe, expect, it, vi } from 'vitest'
16+
17+
import { server } from '@/mocks/server'
18+
19+
// Mock router
20+
const mockReplace = vi.fn()
21+
vi.mock('next/navigation', () => ({
22+
useRouter: () => ({
23+
replace: mockReplace,
24+
push: vi.fn(),
25+
back: vi.fn(),
26+
forward: vi.fn(),
27+
refresh: vi.fn(),
28+
prefetch: vi.fn()
29+
})
30+
}))
31+
32+
// Mock auth context
33+
const mockLogin = vi.fn()
34+
vi.mock('@/features/auth', async (importOriginal) => {
35+
const actual = (await importOriginal()) as Record<string, unknown>
36+
return {
37+
...actual,
38+
useAuth: () => ({
39+
login: mockLogin,
40+
isAuthenticated: false,
41+
token: null
42+
}),
43+
setAuthToken: vi.fn()
44+
}
45+
})
46+
47+
// Mock translations
48+
vi.mock('@/lib/translations', () => ({
49+
getTranslations: vi.fn().mockImplementation((namespace: string) => {
50+
const namespaces: Record<string, Record<string, string>> = {
51+
login: {
52+
callbackSigningIn: 'Signing you in…',
53+
callbackSignInIssue: 'Sign-in issue',
54+
callbackErrorMissingParams: 'Missing sign-in information.',
55+
callbackErrorSessionExpired: 'Session expired.',
56+
callbackErrorStateMismatch: 'State mismatch.',
57+
callbackErrorGeneric: 'Something went wrong.'
58+
}
59+
}
60+
/* eslint-disable security/detect-object-injection -- test mock */
61+
const translations = namespaces[namespace] ?? {}
62+
return (key: string, defaultValue?: string) => translations[key] ?? defaultValue ?? key
63+
/* eslint-enable security/detect-object-injection */
64+
})
65+
}))
66+
67+
// Mock state
68+
vi.mock('@/lib/state', () => ({
69+
getState: () => 'co'
70+
}))
71+
72+
// Mock PKCE storage
73+
const mockGetPkce = vi.fn()
74+
const mockClearPkce = vi.fn()
75+
vi.mock('@/lib/oidc-pkce', () => ({
76+
getPkceFromStorage: (...args: unknown[]) => mockGetPkce(...args),
77+
clearPkceStorage: (...args: unknown[]) => mockClearPkce(...args)
78+
}))
79+
80+
import CallbackPage from './page'
81+
82+
function renderCallbackPage() {
83+
const queryClient = new QueryClient({
84+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } }
85+
})
86+
return render(
87+
<QueryClientProvider client={queryClient}>
88+
<CallbackPage />
89+
</QueryClientProvider>
90+
)
91+
}
92+
93+
describe('CallbackPage', () => {
94+
beforeEach(() => {
95+
vi.clearAllMocks()
96+
// Default: URL has code and state
97+
Object.defineProperty(window, 'location', {
98+
value: {
99+
search: '?code=test-auth-code&state=test-state-value',
100+
href: 'http://localhost:3000/callback?code=test-auth-code&state=test-state-value'
101+
},
102+
writable: true
103+
})
104+
})
105+
106+
describe('missing URL parameters', () => {
107+
it('shows error when code is missing from URL', async () => {
108+
Object.defineProperty(window, 'location', {
109+
value: {
110+
search: '?state=test-state',
111+
href: 'http://localhost:3000/callback?state=test-state'
112+
},
113+
writable: true
114+
})
115+
116+
renderCallbackPage()
117+
118+
await waitFor(() => {
119+
expect(screen.getByText('Missing sign-in information.')).toBeInTheDocument()
120+
})
121+
})
122+
123+
it('shows error when state is missing from URL', async () => {
124+
Object.defineProperty(window, 'location', {
125+
value: { search: '?code=test-code', href: 'http://localhost:3000/callback?code=test-code' },
126+
writable: true
127+
})
128+
129+
renderCallbackPage()
130+
131+
await waitFor(() => {
132+
expect(screen.getByText('Missing sign-in information.')).toBeInTheDocument()
133+
})
134+
})
135+
})
136+
137+
describe('PKCE validation', () => {
138+
it('shows session expired when no PKCE data is stored', async () => {
139+
mockGetPkce.mockReturnValue(null)
140+
141+
renderCallbackPage()
142+
143+
await waitFor(() => {
144+
expect(screen.getByText('Session expired.')).toBeInTheDocument()
145+
})
146+
expect(mockClearPkce).toHaveBeenCalled()
147+
})
148+
149+
it('shows state mismatch when PKCE state does not match URL state', async () => {
150+
mockGetPkce.mockReturnValue({
151+
state: 'different-state-value',
152+
code_verifier: 'test-verifier',
153+
redirect_uri: 'http://localhost:3000/callback',
154+
token_endpoint: 'https://auth.example.com/token',
155+
client_id: 'test-client'
156+
})
157+
158+
renderCallbackPage()
159+
160+
await waitFor(() => {
161+
expect(screen.getByText('State mismatch.')).toBeInTheDocument()
162+
})
163+
expect(mockClearPkce).toHaveBeenCalled()
164+
})
165+
})
166+
167+
describe('successful flow', () => {
168+
beforeEach(() => {
169+
mockGetPkce.mockReturnValue({
170+
state: 'test-state-value',
171+
code_verifier: 'test-verifier',
172+
redirect_uri: 'http://localhost:3000/callback',
173+
token_endpoint: 'https://auth.example.com/token',
174+
client_id: 'test-client'
175+
})
176+
// Override MSW handlers to accept stateCode 'co' (mock getState returns 'co')
177+
server.use(
178+
http.post('/api/auth/oidc/callback', () => {
179+
return HttpResponse.json({ callbackToken: 'mock-callback-token' })
180+
}),
181+
http.post('/api/auth/oidc/complete-login', () => {
182+
return HttpResponse.json({ token: 'mock-jwt-token-for-testing' })
183+
})
184+
)
185+
})
186+
187+
it('shows signing in message initially', () => {
188+
renderCallbackPage()
189+
expect(screen.getByText('Signing you in…')).toBeInTheDocument()
190+
})
191+
192+
it('redirects to dashboard on successful login', async () => {
193+
renderCallbackPage()
194+
195+
await waitFor(() => {
196+
expect(mockReplace).toHaveBeenCalledWith('/dashboard')
197+
})
198+
expect(mockLogin).toHaveBeenCalledWith('mock-jwt-token-for-testing')
199+
})
200+
})
201+
202+
describe('token exchange failure', () => {
203+
beforeEach(() => {
204+
mockGetPkce.mockReturnValue({
205+
state: 'test-state-value',
206+
code_verifier: 'test-verifier',
207+
redirect_uri: 'http://localhost:3000/callback',
208+
token_endpoint: 'https://auth.example.com/token',
209+
client_id: 'test-client'
210+
})
211+
})
212+
213+
it('shows error when callback endpoint fails', async () => {
214+
server.use(
215+
http.post('/api/auth/oidc/callback', () => {
216+
return HttpResponse.json({ error: 'Token exchange failed' }, { status: 400 })
217+
})
218+
)
219+
220+
renderCallbackPage()
221+
222+
await waitFor(() => {
223+
expect(screen.getByText('Something went wrong.')).toBeInTheDocument()
224+
})
225+
})
226+
227+
it('shows error when complete-login endpoint fails', async () => {
228+
server.use(
229+
http.post('/api/auth/oidc/callback', () => {
230+
return HttpResponse.json({ callbackToken: 'mock-callback-token' })
231+
}),
232+
http.post('/api/auth/oidc/complete-login', () => {
233+
return HttpResponse.json({ error: 'Invalid token' }, { status: 400 })
234+
})
235+
)
236+
237+
renderCallbackPage()
238+
239+
await waitFor(() => {
240+
expect(screen.getByText('Something went wrong.')).toBeInTheDocument()
241+
})
242+
})
243+
})
244+
245+
describe('error redirect', () => {
246+
it('redirects to login after showing error', async () => {
247+
vi.useFakeTimers({ shouldAdvanceTime: true })
248+
249+
Object.defineProperty(window, 'location', {
250+
value: { search: '', href: 'http://localhost:3000/callback' },
251+
writable: true
252+
})
253+
254+
renderCallbackPage()
255+
256+
await waitFor(() => {
257+
expect(screen.getByText('Missing sign-in information.')).toBeInTheDocument()
258+
})
259+
260+
await vi.advanceTimersByTimeAsync(5000)
261+
262+
expect(mockReplace).toHaveBeenCalledWith('/login')
263+
264+
vi.useRealTimers()
265+
})
266+
})
267+
})

0 commit comments

Comments
 (0)