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
63 changes: 59 additions & 4 deletions app/frontend/__tests__/errorReporter.smoke.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorReporter, redactPII } from "@/lib/errorReporter";
import { errorReporter, redactPII, extractCodeOrigin } from "@/lib/errorReporter";

describe("errorReporter", () => {
beforeEach(() => {
Expand Down Expand Up @@ -36,25 +36,67 @@ describe("errorReporter", () => {
).toContain("[REDACTED_PHONE]");
});

it("redacts Stellar secret keys, Bearer tokens, JWTs, API keys, passwords, and sensitive object keys", () => {
const sensitivePayload = {
secretKey: "SBEXAMPLESECRETKEY12345678901234567890123456789012345678901234",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.doNotLeakSignatureHere123",
authorization: "Bearer secret-token-abc-123",
password: "SuperSecretPassword123!",
apiKey: "api_key_live_998877665544332211",
regularField: "Hello World",
nestedSecrets: {
rawMessage: "Using secret=my_db_secret and password=hidden_pwd with SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
},
};

const redacted = redactPII(sensitivePayload) as Record<string, unknown>;

expect(redacted.secretKey).toBe("[REDACTED]");
expect(redacted.token).toBe("[REDACTED]");
expect(redacted.authorization).toBe("[REDACTED]");
expect(redacted.password).toBe("[REDACTED]");
expect(redacted.apiKey).toBe("[REDACTED]");
expect(redacted.regularField).toBe("Hello World");
expect(
(redacted.nestedSecrets as Record<string, unknown>).rawMessage
).toContain("[REDACTED_SECRET]");
expect(
(redacted.nestedSecrets as Record<string, unknown>).rawMessage
).toContain("[REDACTED_PASSWORD]");
expect(
(redacted.nestedSecrets as Record<string, unknown>).rawMessage
).toContain("[REDACTED_SECRET_KEY]");
});

it("extracts code origin from stack trace", () => {
const fakeStack = `Error: Something failed\n at DashboardView (Dashboard.tsx:42:15)\n at renderWithHooks (react-dom.js:123:45)`;
const origin = extractCodeOrigin(fakeStack);
expect(origin).toBe("at DashboardView (Dashboard.tsx:42:15)");
});

it("does not send when reporting is disabled", async () => {
process.env.NEXT_PUBLIC_ERROR_REPORTING_ENABLED = "false";
await errorReporter.captureError(new Error("test"));
expect(global.fetch).not.toHaveBeenCalled();
});

it("sends payload when reporting is enabled", async () => {
it("sends payload with route context, code origin, and metadata when reporting is enabled", async () => {
process.env.NEXT_PUBLIC_ERROR_REPORTING_ENABLED = "true";
process.env.NEXT_PUBLIC_ERROR_REPORTING_URL = "https://example.com/api/errors";
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
global.fetch = mockFetch as unknown as typeof fetch;

await errorReporter.captureError(new Error("Server failed to load"), {
const error = new Error("Server failed to load");
error.stack = "Error: Server failed to load\n at PaymentProcessor (PaymentProcessor.tsx:88:12)";

await errorReporter.captureError(error, {
requestId: "req-123",
correlationId: "corr-456",
userId: "user-789",
route: "/dashboard",
codeOrigin: "PaymentProcessor.tsx",
componentStack: "at Dashboard (Dashboard.tsx:10)",
extra: { feature: "payment" },
extra: { feature: "payment", password: "should-be-redacted" },
});

expect(mockFetch).toHaveBeenCalledTimes(1);
Expand All @@ -72,7 +114,20 @@ describe("errorReporter", () => {
expect(body.error.message).toBe("Server failed to load");
expect(body.context.requestId).toBe("req-123");
expect(body.context.correlationId).toBe("corr-456");
expect(body.context.route).toBe("/dashboard");
expect(body.context.codeOrigin).toBe("PaymentProcessor.tsx");
expect(body.context.extra.password).toBe("[REDACTED]");
expect(body.appVersion).toBe("test-version");
expect(body.environment).toBe("preview");
});

it("gracefully catches fetch errors without throwing", async () => {
process.env.NEXT_PUBLIC_ERROR_REPORTING_ENABLED = "true";
process.env.NEXT_PUBLIC_ERROR_REPORTING_URL = "https://example.com/api/errors";
global.fetch = vi.fn().mockRejectedValue(new Error("Network connection down"));

await expect(
errorReporter.captureError(new Error("Component crashed"))
).resolves.not.toThrow();
});
});
98 changes: 98 additions & 0 deletions app/frontend/src/__tests__/errorRecovery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
NotificationCenterProvider,
useNotificationCenter,
} from "@/components/NotificationCenterProvider";
import { NOTIFICATION_STORAGE_KEY } from "@/lib/notifications";
import { fetchAnalytics } from "@/hooks/analyticsApi";
import { errorReporter } from "@/lib/errorReporter";

function NotificationViewer() {
const { notifications, unreadCount, markAllAsRead } = useNotificationCenter();
return (
<div>
<p data-testid="count">{unreadCount}</p>
<p data-testid="total">{notifications.length}</p>
<button type="button" onClick={markAllAsRead}>
Mark All
</button>
</div>
);
}

describe("Client Error Recovery & Resilience", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});

describe("NotificationCenterProvider resilience", () => {
it("safely recovers from invalid JSON in localStorage and reports the error", () => {
const captureSpy = vi.spyOn(errorReporter, "captureError");
localStorage.setItem(NOTIFICATION_STORAGE_KEY, "invalid-json-string{{");

render(
<NotificationCenterProvider>
<NotificationViewer />
</NotificationCenterProvider>
);

// Should not throw, should fall back to initial notifications
expect(screen.getByTestId("total")).toBeInTheDocument();
expect(captureSpy).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
codeOrigin: "NotificationCenterProvider.deserialize",
})
);
});

it("safely recovers when stored value is not an array (e.g. string/number/object)", () => {
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify({ notAnArray: true }));

render(
<NotificationCenterProvider>
<NotificationViewer />
</NotificationCenterProvider>
);

expect(screen.getByTestId("total")).toBeInTheDocument();
});

it("allows operations like markAllAsRead even after corrupt recovery", () => {
localStorage.setItem(NOTIFICATION_STORAGE_KEY, "broken-json");

render(
<NotificationCenterProvider>
<NotificationViewer />
</NotificationCenterProvider>
);

const markBtn = screen.getByRole("button", { name: "Mark All" });
expect(() => fireEvent.click(markBtn)).not.toThrow();
});
});

describe("analyticsApi error capture and fallback", () => {
it("captures analytics fetch failure and returns safe fallback data", async () => {
const captureSpy = vi.spyOn(errorReporter, "captureError");
global.fetch = vi.fn().mockRejectedValue(new Error("Analytics endpoint 503"));

const data = await fetchAnalytics("24h");

expect(data).toBeDefined();
expect(data.volume).toEqual([]);
expect(data.summary.totalVolume).toBe(0);
expect(captureSpy).toHaveBeenCalledWith(
expect.objectContaining({ message: "Analytics endpoint 503" }),
expect.objectContaining({
codeOrigin: "analyticsApi.fetchAnalytics",
})
);
});
});
});
62 changes: 62 additions & 0 deletions app/frontend/src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

import { useEffect } from "react";
import Link from "next/link";
import { errorReporter } from "@/lib/errorReporter";

export default function RouteError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
errorReporter.captureError(error, {
route: typeof window !== "undefined" ? window.location.pathname : undefined,
codeOrigin: "app/error.tsx",
extra: {
digest: error.digest,
source: "route-error-boundary",
},
});
}, [error]);

return (
<div className="flex min-h-[70vh] flex-col items-center justify-center px-4 text-center">
<div className="mx-auto max-w-md rounded-3xl border border-white/10 bg-neutral-950/90 p-8 shadow-2xl backdrop-blur-xl">
<div className="mb-4 inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-red-500/10 text-2xl text-red-400">
⚠️
</div>
<h2 className="mb-2 text-2xl font-bold text-white">Something went wrong!</h2>
<p className="mb-6 text-sm text-neutral-400">
An unexpected route error occurred. We have captured the details to help resolve this.
</p>

{error.message && (
<div className="mb-6 overflow-hidden rounded-xl border border-white/10 bg-white/5 p-3 text-left">
<p className="font-mono text-xs text-neutral-400 break-all">
{error.message}
</p>
</div>
)}

<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<button
type="button"
onClick={() => reset()}
className="w-full sm:w-auto px-6 py-3 rounded-xl bg-indigo-500 hover:bg-indigo-600 font-bold text-sm text-white transition active:scale-95"
>
Try again
</button>
<Link
href="/"
className="w-full sm:w-auto px-6 py-3 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 font-bold text-sm text-white transition text-center"
>
Return to Home
</Link>
</div>
</div>
</div>
);
}
59 changes: 59 additions & 0 deletions app/frontend/src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import { useEffect } from "react";
import { errorReporter } from "@/lib/errorReporter";

export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
errorReporter.captureError(error, {
route: typeof window !== "undefined" ? window.location.pathname : undefined,
codeOrigin: "app/global-error.tsx",
extra: {
digest: error.digest,
source: "global-error-boundary",
},
});
}, [error]);

return (
<html lang="en" className="dark">
<body className="bg-black text-white antialiased min-h-screen flex items-center justify-center p-4">
<div className="mx-auto max-w-md text-center rounded-3xl border border-white/10 bg-neutral-950 p-8 shadow-2xl">
<div className="mb-4 inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-red-500/10 text-2xl text-red-400">
⚠️
</div>
<h1 className="mb-2 text-2xl font-bold text-white">Application Error</h1>
<p className="mb-6 text-sm text-neutral-400">
A critical application error occurred. You can retry or reload the application.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<button
type="button"
onClick={() => reset()}
className="w-full sm:w-auto px-6 py-3 rounded-xl bg-indigo-500 hover:bg-indigo-600 font-bold text-sm text-white transition"
>
Try again
</button>
<button
type="button"
onClick={() => {
if (typeof window !== "undefined") {
window.location.href = "/";
}
}}
className="w-full sm:w-auto px-6 py-3 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 font-bold text-sm text-white transition"
>
Go to Home
</button>
</div>
</div>
</body>
</html>
);
}
2 changes: 1 addition & 1 deletion app/frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Header } from "@/components/Header";
import { NotificationCenterProvider } from "@/components/NotificationCenterProvider";
import { ErrorReportingShell } from "@/components/ErrorReportingShell";
import { PWAHandler } from "@/components/PWAHandler";
import { BRANDING, getBrandedTitle } from "@/lib/branding";
import { BRANDING } from "@/lib/branding";
import "./globals.css";

const siteUrl =
Expand Down
Loading
Loading