diff --git a/Dockerfile b/Dockerfile index 0c9a71e06..cff0791c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ ARG XNET_VERSION=0.56.0 # renovate: datasource=go depName=golang.org/x/crypto ARG XCRYPTO_VERSION=0.53.0 # renovate: datasource=npm depName=npm -ARG NPM_VERSION=11.17.0 +ARG NPM_VERSION=11.18.0 # Allow pinning Caddy version - Renovate will update this # 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} && \ # renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.13 && \ # renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs - go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.78.0 && \ + go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.78.1 && \ go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.7 && \ go get github.com/aws/aws-sdk-go-v2/service/s3@v1.102.1 && \ # CVE-2026-32952: go-ntlmssp DoS via malicious NTLM challenge response diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 160c23057..a1b621610 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef, type ReactNode, type FC } from 'react'; +import { toast } from 'react-hot-toast'; import { AuthContext, type User } from './AuthContextValue'; import client, { setAuthToken, setAuthErrorHandler } from '../api/client'; @@ -19,12 +20,15 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { // Handle session expiry by clearing auth state and redirecting to login const handleAuthError = useCallback(() => { - console.warn('Session expired, clearing auth state'); invalidateAuthRequests(); localStorage.removeItem('charon_auth_token'); setAuthToken(null); setUser(null); setIsLoading(false); + toast.error('Session expired. Please log in again.', { + id: 'auth-session-expired', + duration: 10000, + }); }, [invalidateAuthRequests]); // Register auth error handler on mount; unregister on unmount so the axios @@ -76,6 +80,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { const requestVersion = authRequestVersionRef.current + 1; authRequestVersionRef.current = requestVersion; setIsLoading(true); + toast.dismiss('auth-session-expired'); if (token) { localStorage.setItem('charon_auth_token', token); diff --git a/frontend/src/context/__tests__/AuthContext.test.tsx b/frontend/src/context/__tests__/AuthContext.test.tsx index 482a5bbee..c6154fa07 100644 --- a/frontend/src/context/__tests__/AuthContext.test.tsx +++ b/frontend/src/context/__tests__/AuthContext.test.tsx @@ -1,4 +1,5 @@ import { act, render, screen, waitFor } from '@testing-library/react' +import { toast } from 'react-hot-toast' import { describe, it, expect, beforeEach, vi } from 'vitest' import client, { setAuthToken, setAuthErrorHandler } from '../../api/client' @@ -7,6 +8,13 @@ import { useAuth } from '../../hooks/useAuth' const TOKEN_KEY = 'charon_auth_token' +vi.mock('react-hot-toast', () => ({ + toast: { + error: vi.fn(), + dismiss: vi.fn(), + }, +})) + vi.mock('../../api/client', () => ({ default: { post: vi.fn().mockResolvedValue({}), @@ -19,6 +27,7 @@ vi.mock('../../api/client', () => ({ const mockClient = vi.mocked(client) const mockSetAuthToken = vi.mocked(setAuthToken) const mockSetAuthErrorHandler = vi.mocked(setAuthErrorHandler) +const mockToast = vi.mocked(toast) const AuthStateProbe = () => { const { user, isAuthenticated, isLoading } = useAuth() @@ -83,10 +92,9 @@ describe(' session validation on mount (page reload)', () => { expect(mockSetAuthToken).toHaveBeenCalledWith('valid-token') }) - it('registers an auth-error handler that clears the session, and unregisters it on unmount', async () => { + it('registers an auth-error handler that clears the session, shows a toast, and unregisters on unmount', async () => { localStorage.setItem(TOKEN_KEY, 'valid-token') mockClient.get.mockResolvedValue({ data: sessionUser, status: 200 }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { unmount } = renderProvider() await waitFor(() => expect(screen.getByTestId('authenticated').textContent).toBe('true')) @@ -99,10 +107,39 @@ describe(' session validation on mount (page reload)', () => { await waitFor(() => expect(screen.getByTestId('authenticated').textContent).toBe('false')) expect(localStorage.getItem(TOKEN_KEY)).toBeNull() expect(mockSetAuthToken).toHaveBeenLastCalledWith(null) + expect(mockToast.error).toHaveBeenCalledWith( + 'Session expired. Please log in again.', + expect.objectContaining({ id: 'auth-session-expired' }) + ) unmount() expect(mockSetAuthErrorHandler).toHaveBeenLastCalledWith(null) + }) + + it('dismisses the session-expired toast when login succeeds', async () => { + mockClient.get.mockResolvedValue({ data: sessionUser, status: 200 }) - warnSpy.mockRestore() + let loginFn: ((token?: string) => Promise) | undefined + const LoginProbe = () => { + const { login } = useAuth() + loginFn = login + return null + } + + render( + + + + + ) + + await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false')) + + await act(async () => { + await loginFn?.('new-token') + }) + + expect(mockToast.dismiss).toHaveBeenCalledWith('auth-session-expired') + expect(screen.getByTestId('authenticated').textContent).toBe('true') }) }) diff --git a/tests/core/authentication.spec.ts b/tests/core/authentication.spec.ts index 5cd671edb..61c001baa 100644 --- a/tests/core/authentication.spec.ts +++ b/tests/core/authentication.spec.ts @@ -175,14 +175,18 @@ test.describe('Authentication Flows', () => { test('should show validation error for invalid email format', async ({ page }) => { await page.goto('/login'); - await page.locator('input[type="email"]').fill('not-an-email'); + // Wait for the setup-status check to complete so the login form is visible. + // In slow Firefox CI environments the form is replaced by a loading screen + // while the check is in-flight, causing fill() to time out. + const emailInput = page.locator('input[type="email"]'); + await emailInput.waitFor({ state: 'visible', timeout: 15000 }); + + await emailInput.fill('not-an-email'); await page.locator('input[type="password"]').fill('SomePassword123!'); await test.step('Verify email validation error', async () => { await page.getByRole('button', { name: /sign in/i }).click(); - const emailInput = page.locator('input[type="email"]'); - // Check for HTML5 validation state or aria-invalid or visible error text const isInvalid = (await emailInput.getAttribute('aria-invalid')) === 'true' || @@ -369,7 +373,15 @@ test.describe('Authentication Flows', () => { test('should handle 401 response gracefully', async ({ page, adminUser }) => { await loginUser(page, adminUser); - await test.step('Intercept API calls to return 401', async () => { + await test.step('Simulate expired session (clear token and intercept API with 401)', async () => { + // Clear the auth token from storage so checkAuth immediately sees an + // invalid session without needing a network round-trip. This avoids + // a Firefox CDP timing issue where XHR requests fired during page.goto() + // navigation are not intercepted by Playwright route handlers. + await page.evaluate(() => localStorage.removeItem('charon_auth_token')); + + // Also intercept API calls to return 401 to test the axios error + // interceptor path (defense-in-depth). await page.route('**/api/v1/**', async (route) => { // Let health check through, block others with 401 if (route.request().url().includes('/health')) { @@ -384,7 +396,7 @@ test.describe('Authentication Flows', () => { }); }); - await test.step('Trigger an API call by navigating', async () => { + await test.step('Trigger an auth check by navigating', async () => { await page.goto('/proxy-hosts'); // Wait for the 401 response to be processed and UI to react await waitForDebounce(page);