188 lines
6.9 KiB
TypeScript
188 lines
6.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { __resetSentryClientForTests, initSentryClient, parseSampleRate, readClientSentryConfig, scrubSensitiveQueryParams } from "./sentry.client";
|
|
|
|
vi.mock("@sentry/react-router", () => {
|
|
const initMock = vi.fn();
|
|
const reactRouterTracingIntegration = vi.fn(() => ({ name: "react-router-tracing" }));
|
|
const replayIntegration = vi.fn((opts: Record<string, unknown>) => ({ name: "replay", options: opts }));
|
|
return {
|
|
__esModule: true,
|
|
init: initMock,
|
|
reactRouterTracingIntegration,
|
|
replayIntegration,
|
|
};
|
|
});
|
|
|
|
import * as Sentry from "@sentry/react-router";
|
|
|
|
const mockedInit = Sentry.init as unknown as ReturnType<typeof vi.fn>;
|
|
const mockedReplay = Sentry.replayIntegration as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
describe("sentry.client/parseSampleRate", () => {
|
|
it("returns the fallback for missing or empty values", () => {
|
|
expect(parseSampleRate(undefined, 0.5)).toBe(0.5);
|
|
expect(parseSampleRate("", 0.25)).toBe(0.25);
|
|
});
|
|
|
|
it("returns the fallback for non-numeric values", () => {
|
|
expect(parseSampleRate("abc", 0.7)).toBe(0.7);
|
|
});
|
|
|
|
it("clamps values to the [0, 1] range", () => {
|
|
expect(parseSampleRate("-0.5", 0.1)).toBe(0);
|
|
expect(parseSampleRate("1.5", 0.1)).toBe(1);
|
|
});
|
|
|
|
it("parses valid numeric strings", () => {
|
|
expect(parseSampleRate("0.25", 0.1)).toBe(0.25);
|
|
expect(parseSampleRate("1.0", 0.1)).toBe(1);
|
|
expect(parseSampleRate("0", 0.1)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("sentry.client/readClientSentryConfig", () => {
|
|
it("falls back to defaults when env is empty", () => {
|
|
const config = readClientSentryConfig({});
|
|
expect(config.dsn).toBeUndefined();
|
|
expect(config.tracesSampleRate).toBe(0.1);
|
|
expect(config.replaysSessionSampleRate).toBe(0.1);
|
|
expect(config.replaysOnErrorSampleRate).toBe(1.0);
|
|
});
|
|
|
|
it("reads VITE_SENTRY_* env vars", () => {
|
|
const config = readClientSentryConfig({
|
|
VITE_SENTRY_DSN_WEB: "https://public@sentry.example/1",
|
|
VITE_SENTRY_RELEASE: "abc123",
|
|
VITE_SENTRY_TRACES_SAMPLE_RATE: "0.5",
|
|
VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE: "0.2",
|
|
VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE: "1",
|
|
MODE: "production",
|
|
});
|
|
expect(config.dsn).toBe("https://public@sentry.example/1");
|
|
expect(config.release).toBe("abc123");
|
|
expect(config.tracesSampleRate).toBe(0.5);
|
|
expect(config.replaysSessionSampleRate).toBe(0.2);
|
|
expect(config.replaysOnErrorSampleRate).toBe(1);
|
|
expect(config.environment).toBe("production");
|
|
});
|
|
});
|
|
|
|
describe("sentry.client/initSentryClient", () => {
|
|
beforeEach(() => {
|
|
mockedInit.mockClear();
|
|
mockedReplay.mockClear();
|
|
__resetSentryClientForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
__resetSentryClientForTests();
|
|
});
|
|
|
|
it("is a no-op when DSN is missing", () => {
|
|
const initialized = initSentryClient({
|
|
dsn: undefined,
|
|
environment: "test",
|
|
release: undefined,
|
|
tracesSampleRate: 0.1,
|
|
replaysSessionSampleRate: 0.1,
|
|
replaysOnErrorSampleRate: 1,
|
|
});
|
|
expect(initialized).toBe(false);
|
|
expect(mockedInit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls Sentry.init with the provided sample rates when DSN is set", () => {
|
|
const initialized = initSentryClient({
|
|
dsn: "https://public@sentry.example/1",
|
|
environment: "staging",
|
|
release: "release-1",
|
|
tracesSampleRate: 0.42,
|
|
replaysSessionSampleRate: 0.12,
|
|
replaysOnErrorSampleRate: 0.99,
|
|
});
|
|
expect(initialized).toBe(true);
|
|
expect(mockedInit).toHaveBeenCalledTimes(1);
|
|
const initArgs = mockedInit.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(initArgs.dsn).toBe("https://public@sentry.example/1");
|
|
expect(initArgs.environment).toBe("staging");
|
|
expect(initArgs.release).toBe("release-1");
|
|
expect(initArgs.tracesSampleRate).toBe(0.42);
|
|
expect(initArgs.replaysSessionSampleRate).toBe(0.12);
|
|
expect(initArgs.replaysOnErrorSampleRate).toBe(0.99);
|
|
expect(initArgs.sendDefaultPii).toBe(false);
|
|
});
|
|
|
|
it("configures the replay integration with PII-safe defaults", () => {
|
|
initSentryClient({
|
|
dsn: "https://public@sentry.example/1",
|
|
environment: undefined,
|
|
release: undefined,
|
|
tracesSampleRate: 0.1,
|
|
replaysSessionSampleRate: 0.1,
|
|
replaysOnErrorSampleRate: 1,
|
|
});
|
|
expect(mockedReplay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sanitizes errors, transactions, and breadcrumbs before transport", () => {
|
|
initSentryClient({
|
|
dsn: "https://public@sentry.example/1",
|
|
environment: undefined,
|
|
release: undefined,
|
|
tracesSampleRate: 0.1,
|
|
replaysSessionSampleRate: 0.1,
|
|
replaysOnErrorSampleRate: 1,
|
|
});
|
|
const initArgs = mockedInit.mock.calls[0][0] as Record<string, (...args: never[]) => unknown>;
|
|
const portalToken = "p".repeat(43);
|
|
const event = initArgs.beforeSend?.({
|
|
request: { url: `https://portal.example.test/acme/login/magic/${portalToken}` },
|
|
exception: { values: [{ type: "Error", value: "Bearer raw-secret" }] },
|
|
} as never);
|
|
const transaction = initArgs.beforeSendTransaction?.({
|
|
transaction: `pageload /__portal/acme/reset-password/${portalToken}`,
|
|
request: { url: "/search?email=person@example.test" },
|
|
} as never);
|
|
const breadcrumb = initArgs.beforeBreadcrumb?.({ message: `opened /acme/setup/${portalToken}` } as never);
|
|
|
|
expect(JSON.stringify(event)).not.toContain(portalToken);
|
|
expect(JSON.stringify(event)).not.toContain("raw-secret");
|
|
expect(JSON.stringify(transaction)).not.toContain("person@example.test");
|
|
expect(JSON.stringify(transaction)).not.toContain(portalToken);
|
|
expect(JSON.stringify(breadcrumb)).not.toContain(portalToken);
|
|
});
|
|
|
|
it("does not initialize twice", () => {
|
|
const config = {
|
|
dsn: "https://public@sentry.example/1",
|
|
environment: undefined,
|
|
release: undefined,
|
|
tracesSampleRate: 0.1,
|
|
replaysSessionSampleRate: 0.1,
|
|
replaysOnErrorSampleRate: 1,
|
|
};
|
|
expect(initSentryClient(config)).toBe(true);
|
|
expect(initSentryClient(config)).toBe(true);
|
|
expect(mockedInit).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("sentry.client/scrubSensitiveQueryParams", () => {
|
|
it("redacts sensitive query parameters in full URLs", () => {
|
|
expect(scrubSensitiveQueryParams("https://example.com/auth?token=abc&keep=1")).toBe("https://example.com/auth?token=%5BRedacted%5D&keep=1");
|
|
});
|
|
|
|
it("redacts sensitive query parameters in relative URLs", () => {
|
|
expect(scrubSensitiveQueryParams("/reset?password=abc&id=2")).toBe("/reset?password=%5BRedacted%5D&id=2");
|
|
});
|
|
|
|
it("returns the input untouched when no sensitive keys are present", () => {
|
|
expect(scrubSensitiveQueryParams("https://example.com/x?keep=1")).toBe("https://example.com/x?keep=1");
|
|
});
|
|
|
|
it("returns the input untouched for non-URL strings", () => {
|
|
expect(scrubSensitiveQueryParams("not a url")).toBe("not a url");
|
|
});
|
|
});
|