Skip to content

Commit 613937f

Browse files
Wikid82renovate[bot]actions-user
authored
chore(deps): update dockerfile-non-major (#1118)
* chore(deps): update dockerfile-non-major * fix(auth): show toast on session expiry, dismiss on re-login - Replace console.warn with react-hot-toast error notification - Dismiss session-expired toast when user logs in again - Fix Firefox CI flakiness in email validation test - Improve 401 test to clear token before intercepting routes * test(auth): update AuthContext tests for toast-based session expiry --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: GitHub Actions <actions@github.com>
2 parents 48bb4dd + ba9999e commit 613937f

4 files changed

Lines changed: 65 additions & 11 deletions

File tree

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ ARG XNET_VERSION=0.56.0
2929
# renovate: datasource=go depName=golang.org/x/crypto
3030
ARG XCRYPTO_VERSION=0.53.0
3131
# renovate: datasource=npm depName=npm
32-
ARG NPM_VERSION=11.17.0
32+
ARG NPM_VERSION=11.18.0
3333

3434
# Allow pinning Caddy version - Renovate will update this
3535
# Build the most recent Caddy 2.x release (keeps major pinned under v3).
@@ -485,7 +485,7 @@ RUN go get github.com/expr-lang/expr@v${EXPR_LANG_VERSION} && \
485485
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
486486
go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.13 && \
487487
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs
488-
go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.78.0 && \
488+
go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.78.1 && \
489489
go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.7 && \
490490
go get github.com/aws/aws-sdk-go-v2/service/s3@v1.102.1 && \
491491
# CVE-2026-32952: go-ntlmssp DoS via malicious NTLM challenge response

frontend/src/context/AuthContext.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useEffect, useCallback, useRef, type ReactNode, type FC } from 'react';
2+
import { toast } from 'react-hot-toast';
23

34
import { AuthContext, type User } from './AuthContextValue';
45
import client, { setAuthToken, setAuthErrorHandler } from '../api/client';
@@ -19,12 +20,15 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
1920

2021
// Handle session expiry by clearing auth state and redirecting to login
2122
const handleAuthError = useCallback(() => {
22-
console.warn('Session expired, clearing auth state');
2323
invalidateAuthRequests();
2424
localStorage.removeItem('charon_auth_token');
2525
setAuthToken(null);
2626
setUser(null);
2727
setIsLoading(false);
28+
toast.error('Session expired. Please log in again.', {
29+
id: 'auth-session-expired',
30+
duration: 10000,
31+
});
2832
}, [invalidateAuthRequests]);
2933

3034
// Register auth error handler on mount; unregister on unmount so the axios
@@ -76,6 +80,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
7680
const requestVersion = authRequestVersionRef.current + 1;
7781
authRequestVersionRef.current = requestVersion;
7882
setIsLoading(true);
83+
toast.dismiss('auth-session-expired');
7984

8085
if (token) {
8186
localStorage.setItem('charon_auth_token', token);

frontend/src/context/__tests__/AuthContext.test.tsx

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { act, render, screen, waitFor } from '@testing-library/react'
2+
import { toast } from 'react-hot-toast'
23
import { describe, it, expect, beforeEach, vi } from 'vitest'
34

45
import client, { setAuthToken, setAuthErrorHandler } from '../../api/client'
@@ -7,6 +8,13 @@ import { useAuth } from '../../hooks/useAuth'
78

89
const TOKEN_KEY = 'charon_auth_token'
910

11+
vi.mock('react-hot-toast', () => ({
12+
toast: {
13+
error: vi.fn(),
14+
dismiss: vi.fn(),
15+
},
16+
}))
17+
1018
vi.mock('../../api/client', () => ({
1119
default: {
1220
post: vi.fn().mockResolvedValue({}),
@@ -19,6 +27,7 @@ vi.mock('../../api/client', () => ({
1927
const mockClient = vi.mocked(client)
2028
const mockSetAuthToken = vi.mocked(setAuthToken)
2129
const mockSetAuthErrorHandler = vi.mocked(setAuthErrorHandler)
30+
const mockToast = vi.mocked(toast)
2231

2332
const AuthStateProbe = () => {
2433
const { user, isAuthenticated, isLoading } = useAuth()
@@ -83,10 +92,9 @@ describe('<AuthProvider /> session validation on mount (page reload)', () => {
8392
expect(mockSetAuthToken).toHaveBeenCalledWith('valid-token')
8493
})
8594

86-
it('registers an auth-error handler that clears the session, and unregisters it on unmount', async () => {
95+
it('registers an auth-error handler that clears the session, shows a toast, and unregisters on unmount', async () => {
8796
localStorage.setItem(TOKEN_KEY, 'valid-token')
8897
mockClient.get.mockResolvedValue({ data: sessionUser, status: 200 })
89-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
9098

9199
const { unmount } = renderProvider()
92100
await waitFor(() => expect(screen.getByTestId('authenticated').textContent).toBe('true'))
@@ -99,10 +107,39 @@ describe('<AuthProvider /> session validation on mount (page reload)', () => {
99107
await waitFor(() => expect(screen.getByTestId('authenticated').textContent).toBe('false'))
100108
expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
101109
expect(mockSetAuthToken).toHaveBeenLastCalledWith(null)
110+
expect(mockToast.error).toHaveBeenCalledWith(
111+
'Session expired. Please log in again.',
112+
expect.objectContaining({ id: 'auth-session-expired' })
113+
)
102114

103115
unmount()
104116
expect(mockSetAuthErrorHandler).toHaveBeenLastCalledWith(null)
117+
})
118+
119+
it('dismisses the session-expired toast when login succeeds', async () => {
120+
mockClient.get.mockResolvedValue({ data: sessionUser, status: 200 })
105121

106-
warnSpy.mockRestore()
122+
let loginFn: ((token?: string) => Promise<void>) | undefined
123+
const LoginProbe = () => {
124+
const { login } = useAuth()
125+
loginFn = login
126+
return null
127+
}
128+
129+
render(
130+
<AuthProvider>
131+
<LoginProbe />
132+
<AuthStateProbe />
133+
</AuthProvider>
134+
)
135+
136+
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
137+
138+
await act(async () => {
139+
await loginFn?.('new-token')
140+
})
141+
142+
expect(mockToast.dismiss).toHaveBeenCalledWith('auth-session-expired')
143+
expect(screen.getByTestId('authenticated').textContent).toBe('true')
107144
})
108145
})

tests/core/authentication.spec.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,14 +175,18 @@ test.describe('Authentication Flows', () => {
175175
test('should show validation error for invalid email format', async ({ page }) => {
176176
await page.goto('/login');
177177

178-
await page.locator('input[type="email"]').fill('not-an-email');
178+
// Wait for the setup-status check to complete so the login form is visible.
179+
// In slow Firefox CI environments the form is replaced by a loading screen
180+
// while the check is in-flight, causing fill() to time out.
181+
const emailInput = page.locator('input[type="email"]');
182+
await emailInput.waitFor({ state: 'visible', timeout: 15000 });
183+
184+
await emailInput.fill('not-an-email');
179185
await page.locator('input[type="password"]').fill('SomePassword123!');
180186

181187
await test.step('Verify email validation error', async () => {
182188
await page.getByRole('button', { name: /sign in/i }).click();
183189

184-
const emailInput = page.locator('input[type="email"]');
185-
186190
// Check for HTML5 validation state or aria-invalid or visible error text
187191
const isInvalid =
188192
(await emailInput.getAttribute('aria-invalid')) === 'true' ||
@@ -369,7 +373,15 @@ test.describe('Authentication Flows', () => {
369373
test('should handle 401 response gracefully', async ({ page, adminUser }) => {
370374
await loginUser(page, adminUser);
371375

372-
await test.step('Intercept API calls to return 401', async () => {
376+
await test.step('Simulate expired session (clear token and intercept API with 401)', async () => {
377+
// Clear the auth token from storage so checkAuth immediately sees an
378+
// invalid session without needing a network round-trip. This avoids
379+
// a Firefox CDP timing issue where XHR requests fired during page.goto()
380+
// navigation are not intercepted by Playwright route handlers.
381+
await page.evaluate(() => localStorage.removeItem('charon_auth_token'));
382+
383+
// Also intercept API calls to return 401 to test the axios error
384+
// interceptor path (defense-in-depth).
373385
await page.route('**/api/v1/**', async (route) => {
374386
// Let health check through, block others with 401
375387
if (route.request().url().includes('/health')) {
@@ -384,7 +396,7 @@ test.describe('Authentication Flows', () => {
384396
});
385397
});
386398

387-
await test.step('Trigger an API call by navigating', async () => {
399+
await test.step('Trigger an auth check by navigating', async () => {
388400
await page.goto('/proxy-hosts');
389401
// Wait for the 401 response to be processed and UI to react
390402
await waitForDebounce(page);

0 commit comments

Comments
 (0)