Skip to content
Open
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
10 changes: 6 additions & 4 deletions frontend/e2e/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,9 @@ test.describe("Error: backend 500 on send message", () => {

// Error message should appear in chat
await expect(
page.getByText(/Internal server error/i),
page.getByText(/The server could not complete the request\. Please try again\./i),
).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/Internal server error/i)).toHaveCount(0);

// The failed text should be restored in the input for easy re-send
await expect(input).toHaveValue("This should fail", { timeout: 5000 });
Expand All @@ -282,9 +283,10 @@ test.describe("Error: backend 500 on send message", () => {
await input.fill("First send fails");
await page.getByRole("button", { name: /send/i }).click();

await expect(page.getByText(/Internal server error/i)).toBeVisible({
timeout: 10000,
});
await expect(
page.getByText(/The server could not complete the request\. Please try again\./i),
).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/Internal server error/i)).toHaveCount(0);
await expect(page.getByTestId("loading-state")).toHaveCount(0);
await expect(input).toHaveValue("First send fails", { timeout: 5000 });
});
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/auth/AuthProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,11 @@ describe("AuthProvider", () => {
});
});

// Test 13: fetchAuthConfig rejects with Error → err.message shown
it("shows error when initialization fails with Error", async () => {
mockFetchAuthConfig.mockRejectedValue(new Error("Config fetch failed"));
// Test 13: fetchAuthConfig rejects with Error → generic message shown
it("shows a generic error when initialization fails with Error", async () => {
mockFetchAuthConfig.mockRejectedValue(
new Error("token secret=sk-test failed at C:\\internal\\auth.ts")
);

render(
<AuthProvider>
Expand All @@ -158,7 +160,8 @@ describe("AuthProvider", () => {

await waitFor(() => {
expect(screen.getByText("Authentication Error")).toBeVisible();
expect(screen.getByText("Config fetch failed")).toBeVisible();
expect(screen.getByText("Failed to initialize authentication")).toBeVisible();
expect(screen.queryByText(/sk-test|internal\\auth/i)).not.toBeInTheDocument();
});
});

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
setMsalInstance(instance)
setAuthConfig(config)
}
} catch (err) {
} catch {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to initialize authentication')
setError('Failed to initialize authentication')
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,7 @@ describe("ChatWindow Integration", () => {
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(screen.getByText(/Network error/)).toBeInTheDocument();
expect(screen.getByText(/An unexpected error occurred\./)).toBeInTheDocument();
});
});

Expand Down Expand Up @@ -861,11 +861,11 @@ describe("ChatWindow Integration", () => {
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(screen.getByText(/Request failed with status code 404/)).toBeInTheDocument();
expect(screen.getByText(/An unexpected error occurred\./)).toBeInTheDocument();
});
});

it("should extract detail from axios-style error response", async () => {
it("should sanitize detail from a 500 axios-style error response", async () => {
const user = userEvent.setup();

mockedMapper.buildMessagePieces.mockResolvedValue([
Expand Down Expand Up @@ -895,7 +895,7 @@ describe("ChatWindow Integration", () => {
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(screen.getByText(/Failed to add message/)).toBeInTheDocument();
expect(screen.getByText(/The server could not complete the request\. Please try again\./)).toBeInTheDocument();
});
});

Expand Down Expand Up @@ -929,7 +929,7 @@ describe("ChatWindow Integration", () => {
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(screen.getByText(/Internal Server Error/)).toBeInTheDocument();
expect(screen.getByText(/The server could not complete the request\. Please try again\./)).toBeInTheDocument();
});
});

Expand All @@ -955,7 +955,7 @@ describe("ChatWindow Integration", () => {
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(screen.getByText(/string error/)).toBeInTheDocument();
expect(screen.getByText(/An unexpected error occurred\./)).toBeInTheDocument();
});
});

Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/Chat/ConversationPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ describe("ConversationPanel", () => {
await waitFor(() => {
expect(screen.getByTestId("conversation-error")).toBeInTheDocument();
});
expect(screen.getByText("Network error")).toBeInTheDocument();
expect(screen.getByText("An unexpected error occurred.")).toBeInTheDocument();
});

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -428,7 +428,9 @@ describe("ConversationPanel", () => {
await waitFor(() => {
expect(screen.getByTestId("conversation-error")).toBeInTheDocument();
});
expect(screen.getByText("Server exploded")).toBeInTheDocument();
expect(
screen.getByText("The server could not complete the request. Please try again.")
).toBeInTheDocument();
expect(screen.getByTestId("conversation-retry-btn")).toBeInTheDocument();
});

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/Config/CreateTargetDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ describe("CreateTargetDialog", () => {
await user.click(screen.getByText("Create Target"));

await waitFor(() => {
expect(screen.getByText("Invalid API key")).toBeInTheDocument();
expect(screen.getByText("An unexpected error occurred.")).toBeInTheDocument();
});
});

Expand Down Expand Up @@ -449,7 +449,7 @@ describe("CreateTargetDialog", () => {
});
});

it("should surface string throws verbatim via toApiError", async () => {
it("should sanitize string throws via toApiError", async () => {
const user = userEvent.setup();
mockedTargetsApi.createTarget.mockRejectedValue("string error");

Expand All @@ -469,7 +469,7 @@ describe("CreateTargetDialog", () => {
await user.click(screen.getByText("Create Target"));

await waitFor(() => {
expect(screen.getByText("string error")).toBeInTheDocument();
expect(screen.getByText("An unexpected error occurred.")).toBeInTheDocument();
});
});

Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/Config/TargetConfig.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ describe("TargetConfig", () => {

await waitFor(
() => {
expect(screen.getByText(/Connection refused/)).toBeInTheDocument();
expect(
screen.getByText(/An unexpected error occurred\./)
).toBeInTheDocument();
},
{ timeout: 15000 }
);
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ErrorBoundary } from './ErrorBoundary'
// Component that throws on render
function ThrowingChild({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) {
throw new Error('Test crash')
throw new Error('secret=sk-test at C:\\internal\\component.tsx')
}
return <div data-testid="child-content">OK</div>
}
Expand All @@ -31,15 +31,16 @@ describe('ErrorBoundary', () => {
expect(screen.queryByTestId('error-boundary-fallback')).toBeNull()
})

it('catches render error and shows fallback', () => {
it('catches render error without showing internal details', () => {
render(
<ErrorBoundary>
<ThrowingChild shouldThrow={true} />
</ErrorBoundary>
)

expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument()
expect(screen.getByText(/test crash/i)).toBeInTheDocument()
expect(screen.getByText(/something went wrong\. please try again\./i)).toBeInTheDocument()
expect(screen.queryByText(/sk-test|internal\\component/i)).not.toBeInTheDocument()
expect(screen.getByText('Try again')).toBeInTheDocument()
})

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { FallbackProps } from 'react-error-boundary'
import type { ReactNode } from 'react'
import { Button, MessageBar, MessageBarBody, tokens } from '@fluentui/react-components'

function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
function ErrorFallback({ resetErrorBoundary }: FallbackProps) {
const [crashCount, setCrashCount] = useState(1)

const handleRetry = () => {
Expand All @@ -31,7 +31,7 @@ function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
>
<MessageBar intent="error">
<MessageBarBody>
Something went wrong: {error instanceof Error ? error.message : 'Unknown error'}
Something went wrong. Please try again.
</MessageBarBody>
</MessageBar>

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/History/AttackHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ describe('AttackHistory', () => {
await waitFor(() => {
expect(screen.getByTestId('error-state')).toBeInTheDocument()
})
expect(screen.getByText('Internal server error')).toBeInTheDocument()
expect(screen.getByText('The server could not complete the request. Please try again.')).toBeInTheDocument()
expect(screen.getByTestId('retry-btn')).toBeInTheDocument()
})

Expand Down
34 changes: 24 additions & 10 deletions frontend/src/services/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,18 @@ describe('toApiError', () => {
})

// 2. Axios error with plain-string body (e.g. proxy HTML)
it('uses a plain-string response body as detail', () => {
it('sanitizes a plain-string server error response', () => {
const internalDetail = '<html>C:\\internal\\proxy.conf secret=sk-test</html>'
const err = makeAxiosError({
status: 502,
data: '<html><body>Bad Gateway</body></html>',
data: internalDetail,
})

const result = toApiError(err)

expect(result.status).toBe(502)
expect(result.detail).toBe('<html><body>Bad Gateway</body></html>')
expect(result.detail).toBe('The server could not complete the request. Please try again.')
expect(result.detail).not.toContain(internalDetail)
expect(result.type).toBeUndefined()
expect(result.isNetworkError).toBe(false)
})
Expand All @@ -80,7 +82,7 @@ describe('toApiError', () => {
const result = toApiError(err)

expect(result.status).toBe(500)
expect(result.detail).toBe('Server error (500)')
expect(result.detail).toBe('The server could not complete the request. Please try again.')
expect(result.type).toBeUndefined()
})

Expand Down Expand Up @@ -116,24 +118,26 @@ describe('toApiError', () => {
})

// 6. Non-Axios Error instance
it('uses Error.message for non-Axios Error instances', () => {
const err = new Error('Something broke')
it('sanitizes non-Axios Error messages', () => {
const err = new Error('secret=sk-test at C:\\internal\\runtime.ts')

const result = toApiError(err)

expect(result.status).toBeNull()
expect(result.detail).toBe('Something broke')
expect(result.detail).toBe('An unexpected error occurred.')
expect(result.detail).not.toContain('sk-test')
expect(result.isNetworkError).toBe(false)
expect(result.isTimeout).toBe(false)
expect(result.raw).toBe(err)
})

// 7. String throw
it('uses the string directly for string throws', () => {
const result = toApiError('unexpected failure')
it('sanitizes string throws', () => {
const result = toApiError('provider deployment secret-model failed')

expect(result.status).toBeNull()
expect(result.detail).toBe('unexpected failure')
expect(result.detail).toBe('An unexpected error occurred.')
expect(result.detail).not.toContain('secret-model')
expect(result.isNetworkError).toBe(false)
})

Expand Down Expand Up @@ -163,6 +167,16 @@ describe('toApiError', () => {
expect(result.detail).toBe('Server error (403)')
})

it('does not expose plain-string client error bodies', () => {
const internalDetail = 'provider token secret=sk-test at C:\\internal\\proxy.conf'
const err = makeAxiosError({ status: 400, data: internalDetail })

const result = toApiError(err)

expect(result.detail).toBe('Server error (400)')
expect(result.detail).not.toContain(internalDetail)
})

// 11. Error with empty message
it('falls back to generic message for Error with empty message', () => {
const err = new Error('')
Expand Down
24 changes: 18 additions & 6 deletions frontend/src/services/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ export interface ApiError {
raw: unknown
}

const UNEXPECTED_ERROR_DETAIL = 'An unexpected error occurred.'
const SERVER_ERROR_DETAIL = 'The server could not complete the request. Please try again.'

/**
* Convert any caught value into a normalized {@link ApiError}.
*
* Handles:
* - Axios errors with an RFC 7807 JSON body (`response.data.detail`)
* - Axios errors with a plain-string body (e.g. nginx 502 HTML)
* - Axios errors with a plain-string body (sanitized rather than displayed)
* - Axios errors with no response at all (network / CORS)
* - Axios timeout errors (`code === 'ECONNABORTED'`)
* - Plain `Error` instances
Expand Down Expand Up @@ -61,6 +64,15 @@ export function toApiError(err: unknown): ApiError {

// We have an HTTP response — try to extract RFC 7807 detail
const { status, data } = err.response
if (status >= 500) {
return {
status,
detail: SERVER_ERROR_DETAIL,
isNetworkError: false,
isTimeout: false,
raw: err,
}
}
const { detail, type } = extractDetail(data)

return {
Expand All @@ -77,7 +89,7 @@ export function toApiError(err: unknown): ApiError {
if (err instanceof Error) {
return {
status: null,
detail: err.message || 'An unexpected error occurred.',
detail: UNEXPECTED_ERROR_DETAIL,
isNetworkError: false,
isTimeout: false,
raw: err,
Expand All @@ -88,7 +100,7 @@ export function toApiError(err: unknown): ApiError {
if (typeof err === 'string') {
return {
status: null,
detail: err,
detail: UNEXPECTED_ERROR_DETAIL,
isNetworkError: false,
isTimeout: false,
raw: err,
Expand All @@ -98,7 +110,7 @@ export function toApiError(err: unknown): ApiError {
// Unknown throw (null, undefined, number, object, etc.)
return {
status: null,
detail: 'An unexpected error occurred.',
detail: UNEXPECTED_ERROR_DETAIL,
isNetworkError: false,
isTimeout: false,
raw: err,
Expand All @@ -119,12 +131,12 @@ function isAxiosError(err: unknown): err is AxiosError {
*
* The body may be:
* - An RFC 7807 JSON object with `.detail` and optionally `.type`
* - A plain string (e.g. nginx HTML error page)
* - A plain string (ignored because proxy or server text is not trusted for display)
* - Something else entirely (null, number, etc.)
*/
function extractDetail(data: unknown): { detail: string | undefined; type: string | undefined } {
if (typeof data === 'string') {
return { detail: data, type: undefined }
return { detail: undefined, type: undefined }
}
if (typeof data === 'object' && data !== null) {
const obj = data as Record<string, unknown>
Expand Down
Loading
Loading