Makes the application say what it is doing and when it fails

U4. Console logging plus Sentry, a same-origin tunnel so ad blockers cannot
silence browser errors, Umami on the admin SPA, and six security events that
alert rules can actually be built on.

The correlation id is the W3C trace id from the ambient Activity, enabled by
one line of ActivityTrackingOptions so every entry from every category carries
it without touching a call site. It propagates across the master/slave
boundary via traceparent, which TraceIdentifier cannot do at all, and it is
the same value ProblemDetails already returns to the browser.

The security events use source-generated LoggerMessage with constant
templates. Sentry groups log events by message, so interpolating an email
address would give every address its own issue and "more than 20 failed
logins in five minutes" could never fire — the events would arrive, be
visible, be tagged, and the alerting would silently be impossible. A test
asserts the rendered message is identical across argument values.

Scrubbing happens in-process, before transmission, and covers Set-Cookie as
well as Cookie: the login response issues the refreshToken there, so
scrubbing only the request side would protect nothing. Transactions are
scrubbed too, because they carry request data and are the channel nobody
thinks of.

The tunnel derives its destination from the DSN once at startup and reads
nothing from the request, which is what separates a tunnel from a
server-side request forgery primitive. Size is capped by a bounded read
rather than by trusting Content-Length, and the endpoint is rate limited.

Two things found along the way. Zod 4's url() hands the value to the URL
constructor, which accepts any scheme — so the existing frontend validation
would have accepted the exact "htp://" typo BR-U4-24 names, and the SPA
would have called a nonexistent origin. Now constrained to http(s). And the
new appsettings comments are verified against the real configuration
provider, because the failure mode if it rejected them is both hosts
refusing to start after a release switch.

One deviation. IAdminTokenValidator was meant to gain a reason-reporting
overload; implemented that way, a substitute returning false by default
silently inverted the access decision while both methods compiled. Two
methods whose difference is invisible at the call site is the defect, so it
is now a single Validate returning AdminTokenResult.

Touches two files from already-committed units: DatabaseMigrationExtensions
(U2) gains a flush before the rethrow, or the one Critical event in the
system dies with the process; AdminTokenValidator (U1) classifies why a
bypass was refused.

Build 0 errors; 366 backend tests pass, up from 315, and 237 frontend tests,
up from 213. tsc clean, eslint clean on every changed file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
2026-07-28 11:25:54 +02:00
co-authored by Claude Opus 5
parent a122548454
commit 8e79a72340
54 changed files with 2598 additions and 66 deletions
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SentryErrorBoundary } from '@/components/SentryErrorBoundary';
function Boom(): never {
throw new Error('Internal detail: connection string Server=db;Password=hunter2');
}
describe('SentryErrorBoundary', () => {
beforeEach(() => {
// React logs caught render errors to the console; silence the expected noise.
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('shows a fallback instead of blanking the screen', () => {
render(
<SentryErrorBoundary>
<Boom />
</SentryErrorBoundary>,
);
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
expect(screen.getByRole('alert')).toBeInTheDocument();
});
/**
* Exception text frequently contains internal detail, so showing it to an operator is both
* unhelpful and a small information leak. This asserts the leak cannot reappear.
*/
it('shows no exception message and no stack trace', () => {
render(
<SentryErrorBoundary>
<Boom />
</SentryErrorBoundary>,
);
const fallback = screen.getByTestId('error-boundary-fallback');
expect(fallback.textContent).not.toContain('Internal detail');
expect(fallback.textContent).not.toContain('hunter2');
expect(fallback.textContent).not.toContain('Server=');
});
it('offers a retry that remounts the subtree', async () => {
let shouldThrow = true;
function Flaky() {
if (shouldThrow) {
throw new Error('transient');
}
return <p data-testid="recovered">recovered</p>;
}
render(
<SentryErrorBoundary>
<Flaky />
</SentryErrorBoundary>,
);
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
shouldThrow = false;
await userEvent.click(screen.getByTestId('error-boundary-retry-button'));
expect(screen.getByTestId('recovered')).toBeInTheDocument();
});
it('renders its children untouched when nothing throws', () => {
render(
<SentryErrorBoundary>
<p data-testid="child">fine</p>
</SentryErrorBoundary>,
);
expect(screen.getByTestId('child')).toBeInTheDocument();
expect(screen.queryByTestId('error-boundary-fallback')).not.toBeInTheDocument();
});
/**
* No DSN is configured in the test environment, so this run also covers the without-Sentry
* case: the boundary still catches and still shows the fallback, it simply reports nothing.
*/
it('works without a Sentry DSN configured', () => {
expect(import.meta.env.VITE_SENTRY_DSN).toBeUndefined();
});
});
@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import * as Sentry from '@sentry/react';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
interface SentryErrorBoundaryProps {
children: ReactNode;
}
interface FallbackProps {
resetError: () => void;
}
/**
* Shown when a render error was caught.
*
* Deliberately shows no exception message and no stack trace: exception text frequently contains
* internal detail, and showing it to an operator is both unhelpful and a small information leak.
*/
function ErrorFallback({ resetError }: FallbackProps) {
const { t } = useTranslation();
return (
<div
className="flex flex-col items-center justify-center py-16 text-center space-y-4"
role="alert"
data-testid="error-boundary-fallback"
>
<AlertTriangle className="size-12 text-destructive" />
<h1 className="text-2xl font-semibold">{t('error.unexpected.title')}</h1>
<p className="text-muted-foreground max-w-sm">{t('error.unexpected.message')}</p>
<Button onClick={resetError} data-testid="error-boundary-retry-button">
{t('common.retry')}
</Button>
</div>
);
}
/**
* Catches render-time React errors that would otherwise blank the screen, reports them, and shows
* a recoverable fallback.
*
* Placed inside AuthProvider rather than outermost: the fallback has to be reachable for a
* logged-in user, and an error inside a page must not tear down the session context — otherwise
* recovering from a render error would also log the user out.
*
* Works without a DSN too: it still catches and still shows the fallback, it simply reports
* nothing.
*/
export function SentryErrorBoundary({ children }: SentryErrorBoundaryProps) {
return (
<Sentry.ErrorBoundary
fallback={({ resetError }) => <ErrorFallback resetError={resetError} />}
>
{children}
</Sentry.ErrorBoundary>
);
}
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
import { UmamiAnalytics } from '@/components/UmamiAnalytics';
import { resetAppConfigCache } from '@/lib/config';
const SCRIPT_SELECTOR = '#umami-analytics-script';
function scripts(): NodeListOf<HTMLScriptElement> {
return document.head.querySelectorAll<HTMLScriptElement>(SCRIPT_SELECTOR);
}
describe('UmamiAnalytics', () => {
beforeEach(() => {
resetAppConfigCache();
document.head.querySelectorAll(SCRIPT_SELECTOR).forEach((node) => node.remove());
});
afterEach(() => {
vi.unstubAllEnvs();
resetAppConfigCache();
});
function configure(): void {
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://analytics.example.com/script.js');
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'site-id');
}
it('injects nothing in development, even when fully configured', () => {
vi.stubEnv('DEV', true);
configure();
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(0);
});
it('injects nothing when the website ID is missing', () => {
vi.stubEnv('DEV', false);
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://analytics.example.com/script.js');
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', undefined as unknown as string);
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(0);
});
it('injects nothing when the script URL is missing', () => {
vi.stubEnv('DEV', false);
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', undefined as unknown as string);
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'site-id');
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(0);
});
it('injects the script once when configured outside development', () => {
vi.stubEnv('DEV', false);
configure();
render(<UmamiAnalytics />);
const injected = scripts();
expect(injected).toHaveLength(1);
expect(injected[0].src).toBe('https://analytics.example.com/script.js');
expect(injected[0].getAttribute('data-website-id')).toBe('site-id');
expect(injected[0].defer).toBe(true);
});
/**
* Guards against reinstating a cleanup that removes the script: under StrictMode the
* double-invocation would become inject -> remove -> inject, and removing the element does not
* unregister the listeners Umami already installed, so the first page view can be counted
* twice.
*/
it('injects the script only once across re-renders', () => {
vi.stubEnv('DEV', false);
configure();
const { rerender } = render(<UmamiAnalytics />);
rerender(<UmamiAnalytics />);
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(1);
});
it('renders nothing visible', () => {
vi.stubEnv('DEV', false);
configure();
const { container } = render(<UmamiAnalytics />);
expect(container.firstChild).toBeNull();
});
});
@@ -0,0 +1,47 @@
import { useEffect } from 'react';
import { getAppConfig } from '@/lib/config';
const SCRIPT_ELEMENT_ID = 'umami-analytics-script';
/**
* Injects the self-hosted Umami tracking script when both the script URL and the website ID are
* configured at build time. Renders nothing.
*
* A component rather than a tag in index.html, because the website ID is a build-time variable
* and index.html cannot read import.meta.env. It also makes the "never in development" and "once
* only" rules testable.
*
* `Do Not Track` is deliberately not consulted: Umami sets no cookies and collects no personal
* data, and this SPA's audience is a known set of operators, so honouring DNT would reduce data
* without protecting anyone. A conscious choice rather than an omission.
*/
export function UmamiAnalytics() {
useEffect(() => {
const { umamiScriptUrl, umamiWebsiteId } = getAppConfig();
// Never in local development, regardless of configuration, so local testing does not
// pollute the real visitor analytics.
if (import.meta.env.DEV || !umamiScriptUrl || !umamiWebsiteId) {
return;
}
if (document.getElementById(SCRIPT_ELEMENT_ID) !== null) {
return;
}
const script = document.createElement('script');
script.id = SCRIPT_ELEMENT_ID;
script.src = umamiScriptUrl;
script.defer = true;
script.setAttribute('data-website-id', umamiWebsiteId);
document.head.appendChild(script);
// Deliberately no cleanup removing the script. Under StrictMode the double-invocation
// would become inject -> remove -> inject, and removing the element does not unregister
// the listeners Umami already installed — so the first page view can be counted twice.
// This component lives for the application's lifetime and has nothing to clean up; the
// duplicate guard above handles re-invocation on its own.
}, []);
return null;
}