Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions frontend/src/__tests__/LoginPage.submit-resilience.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* LoginPage.submit-resilience.test.tsx
*
* Prod bug: a slow `/session` request (intermittent 16-30s auth-chain hop)
* left the credentials submit button stuck on "Signing in…" with no
* feedback, sometimes 401-ing after ~19s with the user never told why.
*
* These tests cover the resilience fix in LoginPage.handleCredentialsSubmit:
* 1. A timed-out / aborted login() rejection surfaces a distinct, clear
* "taking longer than expected" message (not the generic network string)
* and un-sticks the submit button (pending resets, Sign In re-enabled).
* 2. A 401 rejection surfaces a message that doesn't wrongly accuse the
* user of a typo (401 here is ambiguous: bad creds OR a slow-auth
* abort) and also un-sticks the button.
* 3. `pending` always resets via the `finally` — verified by both cases
* above returning to the enabled, unstuck state.
*
* The dedicated shorter timeout applied to the login() call itself
* (LOGIN_TIMEOUT_MS in services/api.ts) is exercised implicitly: this test
* mocks authAPI.login directly, so it asserts on the UI's handling of the
* rejection shape axios produces for a timeout/abort, not the timeout wiring
* itself.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'
import type { AuthMethods } from '@fuzefront/security-client'

vi.mock('../assets/FuzeFrontLogo.svg', () => ({ default: 'mock-logo.png' }))

vi.mock('../contexts/LanguageContext', () => ({
useLanguage: () => ({
t: (key: string) => key,
language: 'en',
setLanguage: vi.fn(),
}),
}))

vi.mock('../lib/shared', () => ({
useCurrentUser: vi.fn(),
}))

import LoginPage from '../pages/LoginPage'
import * as sharedMock from '../lib/shared'
import { authAPI } from '../services/api'

function makeUserCtx(overrides: Partial<ReturnType<typeof sharedMock.useCurrentUser>> = {}) {
return {
user: null,
currentUser: null,
isAuthenticated: false,
setUser: vi.fn(),
setCurrentUser: vi.fn(),
...overrides,
}
}

const PASSWORD_ONLY_METHODS: AuthMethods = {
password: true,
social: [],
mfa: { enabled: false, types: [] },
verification: { email: false, sms: false },
}

describe('LoginPage — credentials submit resilience (fail-fast + clear errors)', () => {
let locationStub: { search: string; pathname: string; href: string; origin: string }

beforeEach(() => {
vi.clearAllMocks()

locationStub = { search: '', pathname: '/', href: 'http://localhost/', origin: 'http://localhost' }
Object.defineProperty(global, 'location', {
value: locationStub,
writable: true,
configurable: true,
})

;(sharedMock.useCurrentUser as ReturnType<typeof vi.fn>).mockReturnValue(makeUserCtx())

vi.spyOn(authAPI, 'handleAuthCallback').mockResolvedValue({})
vi.spyOn(authAPI, 'getAuthMethods').mockResolvedValue(PASSWORD_ONLY_METHODS)
vi.spyOn(authAPI, 'login')
vi.spyOn(authAPI, 'getCurrentUser')

vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'group').mockImplementation(() => {})
vi.spyOn(console, 'groupEnd').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
})

afterEach(() => {
vi.restoreAllMocks()
})

async function fillAndSubmit() {
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
})
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'someone@example.com' } })
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'hunter22' } })
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /^sign in$/i }))
})
}

it('shows a distinct "taking longer than expected" message on a timed-out login and un-sticks the button', async () => {
const timeoutErr: any = new Error('timeout of 15000ms exceeded')
timeoutErr.code = 'ECONNABORTED'
vi.mocked(authAPI.login).mockRejectedValue(timeoutErr)

render(<LoginPage />)
await fillAndSubmit()

await waitFor(() => {
expect(screen.getByText(/taking longer than expected/i)).toBeInTheDocument()
})
// Never the generic network-failure string for a timeout.
expect(screen.queryByText(/network connection failed/i)).not.toBeInTheDocument()

// Button un-stuck: back to "Sign In", enabled.
const signIn = screen.getByRole('button', { name: /^sign in$/i })
expect(signIn).not.toBeDisabled()
expect(screen.queryByText(/^signing in…$/i)).not.toBeInTheDocument()
})

it('shows an ambiguous-401 message (not a flat "wrong password" accusation) and un-sticks the button', async () => {
const unauthorizedErr: any = new Error('Request failed with status code 401')
unauthorizedErr.response = { status: 401, data: { error: 'Unauthorized' } }
vi.mocked(authAPI.login).mockRejectedValue(unauthorizedErr)

render(<LoginPage />)
await fillAndSubmit()

await waitFor(() => {
expect(
screen.getByText(/incorrect email or password, or the sign-in service is temporarily unavailable/i)
).toBeInTheDocument()
})

const signIn = screen.getByRole('button', { name: /^sign in$/i })
expect(signIn).not.toBeDisabled()
expect(screen.queryByText(/^signing in…$/i)).not.toBeInTheDocument()
})
})
68 changes: 62 additions & 6 deletions frontend/src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ function LoginPage() {
const loading = pending !== null
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
// Subtle "still working…" hint shown once a credentials submit has been in
// flight for a while, so a slow-but-succeeding sign-in (occasionally well
// past the ~5.5s fast path) doesn't read as frozen before the bounded
// timeout in api.ts either resolves or fails it.
const [showStillWorking, setShowStillWorking] = useState(false)
const stillWorkingTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Start from the password-only fallback so the sign-in form is ALWAYS
// rendered immediately — never gated on the auth-methods fetch. `getAuthMethods`
// is progressive enhancement: when it resolves it upgrades this to advertise
Expand Down Expand Up @@ -178,6 +184,14 @@ function LoginPage() {
setPending('credentials')
setError('')
setNotice('')
setShowStillWorking(false)

// Show a quiet "still working…" hint if the request is still in flight
// after 8s — the fast path (~5.5s) never sees this; a slow-but-succeeding
// attempt gets reassurance instead of looking frozen before the bounded
// LOGIN_TIMEOUT_MS in api.ts resolves it either way.
if (stillWorkingTimer.current) clearTimeout(stillWorkingTimer.current)
stillWorkingTimer.current = setTimeout(() => setShowStillWorking(true), 8000)

try {
if (mode === 'signup') {
Expand All @@ -200,15 +214,43 @@ function LoginPage() {
await completeSession(result)
} catch (err: any) {
console.error('Authentication error:', err)
let errorMessage =
err.response?.data?.error || err.message || 'Authentication failed'
if (err.code === 'NETWORK_ERROR' || !err.response) {
errorMessage += ' (Network connection failed — check if the service is running)'
} else if (err.response?.status === 500) {
errorMessage += ' (Server error — please try again shortly)'
const isTimeout = err.code === 'ECONNABORTED' || err.name === 'CanceledError'
const isNetworkError = !isTimeout && (err.code === 'NETWORK_ERROR' || !err.response)
const status = err.response?.status

let errorMessage: string
if (isTimeout) {
// The sign-in request was bounded and did not answer in time — this is
// NOT "you typed the wrong password"; word it as a service condition.
errorMessage =
'Sign-in is taking longer than expected — the service may be busy. Please try again.'
} else if (status === 401) {
// A 401 here is genuinely ambiguous: real bad credentials OR a slow
// auth hop that got cut short server-side. Don't wrongly accuse the
// user of a typo when it may be a transient service blip.
errorMessage =
'Incorrect email or password, or the sign-in service is temporarily unavailable. Please try again.'
} else if (isNetworkError) {
errorMessage =
(err.message || 'Authentication failed') +
' (Network connection failed — check if the service is running)'
} else if (status === 500) {
errorMessage =
(err.response?.data?.error || err.message || 'Authentication failed') +
' (Server error — please try again shortly)'
} else {
errorMessage =
err.response?.data?.error || err.message || 'Authentication failed'
}
setError(errorMessage)
} finally {
if (stillWorkingTimer.current) {
clearTimeout(stillWorkingTimer.current)
stillWorkingTimer.current = null
}
setShowStillWorking(false)
// Always un-stick the button — this runs on every exit path (success
// return, thrown error, timeout) so "Signing in…" never persists.
setPending(null)
}
}
Expand All @@ -220,6 +262,7 @@ function LoginPage() {
useEffect(() => {
return () => {
if (redirectWatchdog.current) clearTimeout(redirectWatchdog.current)
if (stillWorkingTimer.current) clearTimeout(stillWorkingTimer.current)
}
}, [])

Expand Down Expand Up @@ -412,6 +455,19 @@ function LoginPage() {
? 'Create account'
: 'Sign In'}
</Button>
{pending === 'credentials' && showStillWorking && (
<p
role="status"
style={{
marginBlockStart: 'var(--space-2, 8px)',
color: 'var(--text-tertiary)',
fontSize: 'var(--text-sm, 14px)',
textAlign: 'center',
}}
>
Still working — the sign-in service is taking a little longer than usual…
</p>
)}
</form>
)}

Expand Down
20 changes: 18 additions & 2 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ const api = axios.create({
timeout: 30000, // 30 second timeout
})

// Dedicated, SHORTER timeout for the login/signup submit path. Prod reality:
// the auth chain normally answers in ~5.5s, but an intermittent slow hop can
// stretch it to 16-30s before the shared 30s client timeout ever trips — that
// entire window the submit button sat on "Signing in…" with zero feedback.
// Bounding just this call lets a slow attempt fail fast with a clear message
// instead of hanging to the full 30s. Env-overridable for tuning without a
// redeploy of the timeout value itself.
const LOGIN_TIMEOUT_MS = Number(import.meta.env.VITE_LOGIN_TIMEOUT_MS) || 15000

// Add request timing and enhanced logging
api.interceptors.request.use(
config => {
Expand Down Expand Up @@ -274,9 +283,13 @@ export const authAPI = {
// `mfa_required` challenge the caller completes via `mfaAPI`. On the
// authenticated branch the session is persisted here.
async login(credentials: LoginCredentials): Promise<SessionResult> {
// Bounded with LOGIN_TIMEOUT_MS (not the shared 30s default) so a slow
// auth hop fails fast with a clear error instead of leaving the submit
// button stuck on "Signing in…" for up to 30s.
const response = await api.post<SessionResult>(
`${SECURITY_BASE}/session`,
credentials
credentials,
{ timeout: LOGIN_TIMEOUT_MS }
)
const result = response.data
if (result.status === 'authenticated') {
Expand All @@ -289,9 +302,12 @@ export const authAPI = {
// UI — never a provider's raw enrollment page. On success a session is
// established directly and persisted.
async signup(credentials: SignupCredentials): Promise<LoginResponse> {
// Same fail-fast bound as login() — account creation drives the same
// auth-chain hop and can hit the same intermittent slow path.
const response = await api.post<LoginResponse>(
`${SECURITY_BASE}/signup`,
credentials
credentials,
{ timeout: LOGIN_TIMEOUT_MS }
)
persistSession(response.data?.token, response.data?.sessionId)
return response.data
Expand Down
Loading