Skip to content

Commit 6da4b39

Browse files
committed
feat(extension): implement popup route error boundaries with settings recovery and safe logging
1 parent 5341188 commit 6da4b39

4 files changed

Lines changed: 357 additions & 38 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import * as React from 'react';
2+
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
3+
import { ErrorFallback } from './ErrorFallback';
4+
5+
export interface ErrorBoundaryProps {
6+
children: React.ReactNode;
7+
onReset?: () => void;
8+
onGoHome?: () => void;
9+
onGoToSettings?: () => void;
10+
onReport?: (errorId: string, sanitizedMessage: string) => void;
11+
}
12+
13+
const SENSITIVE_PATTERNS = [/secret/i, /private.?key/i, /mnemonic/i, /seed/i, /passphrase/i];
14+
15+
export function sanitizeMessage(message: string): string {
16+
// If message contains any Stellar secret key (56 characters starting with S)
17+
if (/\bS[A-Z2-7]{55}\b/i.test(message)) {
18+
return '[redacted — potentially sensitive content]';
19+
}
20+
for (const pattern of SENSITIVE_PATTERNS) {
21+
if (pattern.test(message)) {
22+
return '[redacted — potentially sensitive content]';
23+
}
24+
}
25+
return message;
26+
}
27+
28+
function generateErrorId(): string {
29+
const ts = Date.now().toString(36).toUpperCase();
30+
const rand = Math.random().toString(36).slice(2, 6).toUpperCase();
31+
return `ERR-${ts}-${rand}`;
32+
}
33+
34+
export function ErrorBoundary({
35+
children,
36+
onReset,
37+
onGoHome,
38+
onGoToSettings,
39+
onReport,
40+
}: ErrorBoundaryProps) {
41+
return (
42+
<ReactErrorBoundary
43+
onReset={onReset}
44+
fallbackRender={({ error, resetErrorBoundary }) => {
45+
const errorId = generateErrorId();
46+
const rawMessage = error instanceof Error ? error.message : String(error);
47+
const sanitizedMessage = sanitizeMessage(rawMessage);
48+
49+
// Safe logging - log sanitized message only, no sensitive key material
50+
console.error('[ErrorBoundary]', { errorId, message: sanitizedMessage });
51+
52+
return (
53+
<ErrorFallback
54+
error={new Error(sanitizedMessage)}
55+
errorId={errorId}
56+
onReset={resetErrorBoundary}
57+
onGoHome={
58+
onGoHome
59+
? () => {
60+
resetErrorBoundary();
61+
onGoHome();
62+
}
63+
: undefined
64+
}
65+
onGoToSettings={
66+
onGoToSettings
67+
? () => {
68+
resetErrorBoundary();
69+
onGoToSettings();
70+
}
71+
: undefined
72+
}
73+
onReport={onReport}
74+
/>
75+
);
76+
}}
77+
>
78+
{children}
79+
</ReactErrorBoundary>
80+
);
81+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import * as React from 'react';
2+
import { AlertOctagon, RefreshCw, Home, Settings } from 'lucide-react';
3+
4+
export interface ErrorFallbackProps {
5+
error: Error;
6+
errorId: string;
7+
onReset: () => void;
8+
onGoHome?: () => void;
9+
onGoToSettings?: () => void;
10+
onReport?: (errorId: string, sanitizedMessage: string) => void;
11+
}
12+
13+
export function ErrorFallback({
14+
error,
15+
errorId,
16+
onReset,
17+
onGoHome,
18+
onGoToSettings,
19+
onReport,
20+
}: ErrorFallbackProps) {
21+
// Trigger optional report hook inside useEffect to run exactly once when fallback displays
22+
React.useEffect(() => {
23+
onReport?.(errorId, error.message);
24+
}, [errorId, error.message, onReport]);
25+
26+
return (
27+
<div className="flex min-h-screen flex-col bg-background px-6 py-8 text-foreground">
28+
<div className="flex-1 flex flex-col items-center justify-center text-center">
29+
{/* Animated outer circle for error icon */}
30+
<div className="mb-6 rounded-full bg-red-500/10 p-4 ring-8 ring-red-500/5 animate-pulse">
31+
<AlertOctagon className="h-10 w-10 text-red-500" />
32+
</div>
33+
34+
<h1 className="text-xl font-bold tracking-tight text-foreground">Something went wrong</h1>
35+
<p className="mt-2 text-sm text-muted-foreground max-w-xs leading-relaxed">
36+
An unexpected rendering error occurred in the extension popup.
37+
</p>
38+
39+
{/* Display sanitized error message in a nice muted box */}
40+
<div className="mt-4 w-full rounded-xl border border-border bg-card p-4 text-left shadow-sm">
41+
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
42+
Error Details
43+
</span>
44+
<p className="text-sm font-medium text-foreground break-words leading-normal">
45+
{error.message}
46+
</p>
47+
</div>
48+
49+
{/* Error ID for support correlation */}
50+
<div className="mt-4 flex items-center justify-between w-full rounded-lg bg-accent/50 px-3 py-2 border border-border/50">
51+
<span className="text-xs font-medium text-muted-foreground">Support Reference:</span>
52+
<span className="font-mono text-xs font-bold text-foreground tracking-wider select-all">
53+
{errorId}
54+
</span>
55+
</div>
56+
</div>
57+
58+
{/* Action buttons */}
59+
<div className="mt-auto space-y-3 pt-6 border-t border-border/40">
60+
<button
61+
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground shadow-sm transition-all hover:bg-primary/90 hover:scale-[1.01] active:scale-[0.99]"
62+
onClick={onReset}
63+
type="button"
64+
>
65+
<RefreshCw className="h-4 w-4" />
66+
Try again
67+
</button>
68+
69+
{onGoHome && (
70+
<button
71+
className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 py-3 text-sm font-semibold text-foreground transition-all hover:bg-accent hover:scale-[1.01] active:scale-[0.99]"
72+
onClick={onGoHome}
73+
type="button"
74+
>
75+
<Home className="h-4 w-4" />
76+
Go to home
77+
</button>
78+
)}
79+
80+
{onGoToSettings && (
81+
<button
82+
className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 py-3 text-sm font-semibold text-foreground transition-all hover:bg-accent hover:scale-[1.01] active:scale-[0.99]"
83+
onClick={onGoToSettings}
84+
type="button"
85+
>
86+
<Settings className="h-4 w-4" />
87+
Go to settings
88+
</button>
89+
)}
90+
</div>
91+
</div>
92+
);
93+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import * as React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { MemoryRouter } from 'react-router-dom';
5+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6+
import { ErrorBoundary } from '../ErrorBoundary';
7+
8+
// Silence React's own error-boundary console output in test runs
9+
beforeEach(() => {
10+
vi.spyOn(console, 'error').mockImplementation(() => {});
11+
});
12+
13+
afterEach(() => {
14+
vi.restoreAllMocks();
15+
});
16+
17+
// A component that always throws during render
18+
function Bomb({ message }: { message: string }) {
19+
throw new Error(message);
20+
}
21+
22+
// Wraps the boundary in a MemoryRouter
23+
function harness(
24+
children: React.ReactNode,
25+
onGoHome = vi.fn(),
26+
onGoToSettings = vi.fn(),
27+
onReport?: (id: string, msg: string) => void
28+
) {
29+
return render(
30+
<MemoryRouter>
31+
<ErrorBoundary onGoHome={onGoHome} onGoToSettings={onGoToSettings} onReport={onReport}>
32+
{children}
33+
</ErrorBoundary>
34+
</MemoryRouter>
35+
);
36+
}
37+
38+
describe('ErrorBoundary & ErrorFallback', () => {
39+
it('renders children normally when nothing throws', () => {
40+
harness(<div>All good</div>);
41+
expect(screen.getByText('All good')).toBeInTheDocument();
42+
});
43+
44+
it('catches a render error and shows the fallback — no white-screen', () => {
45+
harness(<Bomb message="boom" />);
46+
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
47+
expect(screen.getByText('boom')).toBeInTheDocument();
48+
});
49+
50+
it('shows an Error ID for support correlation', () => {
51+
harness(<Bomb message="id-check" />);
52+
const el = screen.getByText(/Support Reference:/i);
53+
expect(el).toBeInTheDocument();
54+
55+
const idEl = screen.getByText(/ERR-[A-Z0-9]+-[A-Z0-9]+/);
56+
expect(idEl).toBeInTheDocument();
57+
});
58+
59+
it('"Try again" resets the boundary and re-renders children', async () => {
60+
const user = userEvent.setup();
61+
let shouldThrow = true;
62+
63+
// A component whose throw behaviour we can flip between renders
64+
function Flaky() {
65+
if (shouldThrow) throw new Error('flaky');
66+
return <div>Recovered</div>;
67+
}
68+
69+
const { rerender } = render(
70+
<MemoryRouter>
71+
<ErrorBoundary>
72+
<Flaky />
73+
</ErrorBoundary>
74+
</MemoryRouter>
75+
);
76+
77+
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
78+
79+
// Stop throwing before the reset re-render
80+
shouldThrow = false;
81+
await user.click(screen.getByRole('button', { name: /try again/i }));
82+
83+
expect(await screen.findByText('Recovered')).toBeInTheDocument();
84+
});
85+
86+
it('"Go to home" calls onGoHome and clears the boundary', async () => {
87+
const user = userEvent.setup();
88+
const onGoHome = vi.fn();
89+
90+
harness(<Bomb message="go-home" />, onGoHome);
91+
await user.click(screen.getByRole('button', { name: /go to home/i }));
92+
93+
expect(onGoHome).toHaveBeenCalledOnce();
94+
});
95+
96+
it('"Go to settings" calls onGoToSettings and clears the boundary', async () => {
97+
const user = userEvent.setup();
98+
const onGoToSettings = vi.fn();
99+
100+
harness(<Bomb message="go-settings" />, vi.fn(), onGoToSettings);
101+
await user.click(screen.getByRole('button', { name: /go to settings/i }));
102+
103+
expect(onGoToSettings).toHaveBeenCalledOnce();
104+
});
105+
106+
it('redacts sensitive key material from displayed error text and console logs', () => {
107+
harness(<Bomb message="private_key: SABCD1234SENSITIVE" />);
108+
expect(screen.queryByText(/SABCD1234/)).not.toBeInTheDocument();
109+
expect(screen.getByText(/redacted/i)).toBeInTheDocument();
110+
111+
// Verify console error was called with sanitized message
112+
const errorSpy = console.error as jest.Mock;
113+
expect(errorSpy).toHaveBeenCalled();
114+
const lastCallArgs = errorSpy.mock.calls.find((call) => call[0] === '[ErrorBoundary]');
115+
expect(lastCallArgs).toBeDefined();
116+
expect(lastCallArgs[1].message).toMatch(/redacted/i);
117+
expect(lastCallArgs[1].message).not.toContain('SABCD1234SENSITIVE');
118+
});
119+
120+
it('redacts Stellar secret key (56 chars starting with S)', () => {
121+
// 56 characters starting with S (S + 55 valid base32 characters)
122+
const stellarSecretKey = 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA──────────────────────';
123+
harness(<Bomb message={`Secret is ${stellarSecretKey}`} />);
124+
expect(screen.queryByText(stellarSecretKey)).not.toBeInTheDocument();
125+
expect(screen.getByText(/redacted/i)).toBeInTheDocument();
126+
});
127+
128+
it('calls onReport with the error ID and sanitized message', () => {
129+
const onReport = vi.fn();
130+
harness(<Bomb message="report me" />, vi.fn(), vi.fn(), onReport);
131+
132+
expect(onReport).toHaveBeenCalledOnce();
133+
const [errorId, sanitized] = onReport.mock.calls[0] as [string, string];
134+
expect(errorId).toMatch(/ERR-[A-Z0-9]+-[A-Z0-9]+/);
135+
expect(sanitized).toBe('report me');
136+
});
137+
});

0 commit comments

Comments
 (0)