218 lines
7.8 KiB
TypeScript
218 lines
7.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@sentry/node", () => {
|
|
const initMock = vi.fn();
|
|
const captureExceptionMock = vi.fn();
|
|
const captureMessageMock = vi.fn();
|
|
const httpIntegration = vi.fn(() => ({ name: "http" }));
|
|
const requestDataIntegration = vi.fn((opts: Record<string, unknown>) => ({ name: "request-data", options: opts }));
|
|
return {
|
|
__esModule: true,
|
|
init: initMock,
|
|
captureException: captureExceptionMock,
|
|
captureMessage: captureMessageMock,
|
|
httpIntegration,
|
|
requestDataIntegration,
|
|
};
|
|
});
|
|
|
|
import * as Sentry from "@sentry/node";
|
|
|
|
import {
|
|
__resetSentryServerForTests,
|
|
initSentryServer,
|
|
parseServerSampleRate,
|
|
readServerSentryConfig,
|
|
requireServerSentryService,
|
|
webErrorReporter,
|
|
} from "./sentry.server";
|
|
|
|
const mockedInit = Sentry.init as unknown as ReturnType<typeof vi.fn>;
|
|
const mockedRequestData = Sentry.requestDataIntegration as unknown as ReturnType<typeof vi.fn>;
|
|
const mockedCaptureException = Sentry.captureException as unknown as ReturnType<typeof vi.fn>;
|
|
const mockedCaptureMessage = Sentry.captureMessage as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
describe("sentry.server/parseServerSampleRate", () => {
|
|
it("clamps to [0, 1] and falls back on bad input", () => {
|
|
expect(parseServerSampleRate(undefined, 0.4)).toBe(0.4);
|
|
expect(parseServerSampleRate("", 0.5)).toBe(0.5);
|
|
expect(parseServerSampleRate("oops", 0.6)).toBe(0.6);
|
|
expect(parseServerSampleRate("-2", 0.1)).toBe(0);
|
|
expect(parseServerSampleRate("9", 0.1)).toBe(1);
|
|
expect(parseServerSampleRate("0.33", 0.1)).toBe(0.33);
|
|
});
|
|
});
|
|
|
|
describe("sentry.server/readServerSentryConfig", () => {
|
|
it("returns undefined dsn for empty env", () => {
|
|
expect(readServerSentryConfig({} as NodeJS.ProcessEnv)).toMatchObject({ dsn: undefined, service: undefined });
|
|
});
|
|
|
|
it("reads SENTRY_DSN_WEB and friends", () => {
|
|
const config = readServerSentryConfig({
|
|
SENTRY_DSN_WEB: "https://srv@sentry.example/2",
|
|
SENTRY_RELEASE: "v9",
|
|
SENTRY_ENVIRONMENT: "staging",
|
|
SENTRY_TRACES_SAMPLE_RATE: "0.25",
|
|
NODE_ENV: "production",
|
|
WEB_RUNTIME_SURFACE: "core",
|
|
} as unknown as NodeJS.ProcessEnv);
|
|
expect(config.dsn).toBe("https://srv@sentry.example/2");
|
|
expect(config.release).toBe("v9");
|
|
expect(config.environment).toBe("staging");
|
|
expect(config.tracesSampleRate).toBe(0.25);
|
|
expect(config.service).toBe("hivegrid-web");
|
|
});
|
|
|
|
it("falls back to the immutable image revision when SENTRY_RELEASE is empty", () => {
|
|
const config = readServerSentryConfig({
|
|
SENTRY_DSN_WEB: "https://srv@sentry.example/2",
|
|
SENTRY_RELEASE: "",
|
|
APP_REVISION: "sha-web",
|
|
WEB_RUNTIME_SURFACE: "core",
|
|
} as unknown as NodeJS.ProcessEnv);
|
|
expect(config.release).toBe("sha-web");
|
|
});
|
|
|
|
it("uses distinct Admin and Portal services and fails closed for invalid configured surfaces", () => {
|
|
expect(requireServerSentryService({ WEB_RUNTIME_SURFACE: "admin" })).toBe("hivegrid-admin");
|
|
expect(requireServerSentryService({ WEB_RUNTIME_SURFACE: "portal" })).toBe("hivegrid-portal");
|
|
expect(() =>
|
|
readServerSentryConfig({
|
|
SENTRY_DSN_WEB: "https://srv@sentry.example/2",
|
|
WEB_RUNTIME_SURFACE: "customer",
|
|
} as unknown as NodeJS.ProcessEnv)
|
|
).toThrow("WEB_RUNTIME_SURFACE must be explicitly set to core, admin, or portal");
|
|
});
|
|
});
|
|
|
|
describe("sentry.server/initSentryServer", () => {
|
|
beforeEach(() => {
|
|
mockedInit.mockClear();
|
|
mockedRequestData.mockClear();
|
|
__resetSentryServerForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
__resetSentryServerForTests();
|
|
});
|
|
|
|
it("is a no-op when DSN is missing", () => {
|
|
expect(
|
|
initSentryServer({
|
|
dsn: undefined,
|
|
environment: "test",
|
|
release: undefined,
|
|
tracesSampleRate: 0.1,
|
|
service: undefined,
|
|
})
|
|
).toBe(false);
|
|
expect(mockedInit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed when a configured DSN has no surface service identity", () => {
|
|
expect(() =>
|
|
initSentryServer({
|
|
dsn: "https://srv@sentry.example/2",
|
|
environment: "test",
|
|
release: undefined,
|
|
tracesSampleRate: 0.1,
|
|
service: undefined,
|
|
})
|
|
).toThrow("Server Sentry service identity is required");
|
|
expect(mockedInit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("initializes Sentry with PII-safe requestDataIntegration", () => {
|
|
expect(
|
|
initSentryServer({
|
|
dsn: "https://srv@sentry.example/2",
|
|
environment: "prod",
|
|
release: "rel-1",
|
|
tracesSampleRate: 0.2,
|
|
service: "hivegrid-portal",
|
|
})
|
|
).toBe(true);
|
|
expect(mockedInit).toHaveBeenCalledTimes(1);
|
|
const initArgs = mockedInit.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(initArgs.dsn).toBe("https://srv@sentry.example/2");
|
|
expect(initArgs.environment).toBe("prod");
|
|
expect(initArgs.release).toBe("rel-1");
|
|
expect(initArgs.tracesSampleRate).toBe(0.2);
|
|
expect(initArgs.initialScope).toEqual({ tags: { service: "hivegrid-portal" } });
|
|
expect(initArgs.sendDefaultPii).toBe(false);
|
|
expect(typeof initArgs.beforeSend).toBe("function");
|
|
|
|
const portalToken = "p".repeat(43);
|
|
const sanitized = (initArgs.beforeSend as (event: Record<string, unknown>) => Record<string, unknown>)({
|
|
request: { url: `https://portal.example.test/__portal/acme/verify-email/${portalToken}` },
|
|
exception: { values: [{ type: "Error", value: "Bearer raw-secret" }] },
|
|
});
|
|
expect(JSON.stringify(sanitized)).not.toContain(portalToken);
|
|
expect(JSON.stringify(sanitized)).not.toContain("raw-secret");
|
|
|
|
expect(mockedRequestData).toHaveBeenCalledTimes(1);
|
|
const rdOpts = mockedRequestData.mock.calls[0][0] as Record<string, unknown>;
|
|
const include = rdOpts.include as Record<string, unknown>;
|
|
expect(include.ip).toBe(false);
|
|
expect(include.cookies).toBe(false);
|
|
expect(include.data).toBe(false);
|
|
expect(include.query_string).toBe(false);
|
|
});
|
|
|
|
it("is idempotent", () => {
|
|
const config = {
|
|
dsn: "https://srv@sentry.example/2",
|
|
environment: undefined,
|
|
release: undefined,
|
|
tracesSampleRate: 0.1,
|
|
service: "hivegrid-web" as const,
|
|
};
|
|
expect(initSentryServer(config)).toBe(true);
|
|
expect(initSentryServer(config)).toBe(true);
|
|
expect(mockedInit).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("sentry.server/webErrorReporter", () => {
|
|
beforeEach(() => {
|
|
mockedCaptureException.mockClear();
|
|
mockedCaptureMessage.mockClear();
|
|
__resetSentryServerForTests();
|
|
});
|
|
|
|
it("is a no-op before init", async () => {
|
|
await webErrorReporter.report({ component: "test", operation: "noop" });
|
|
expect(mockedCaptureException).not.toHaveBeenCalled();
|
|
expect(mockedCaptureMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("captures exceptions when an error is provided after init", async () => {
|
|
initSentryServer({
|
|
dsn: "https://srv@sentry.example/2",
|
|
environment: undefined,
|
|
release: undefined,
|
|
tracesSampleRate: 0.1,
|
|
service: "hivegrid-portal",
|
|
});
|
|
const err = new Error("boom");
|
|
await webErrorReporter.report({ component: "loader", operation: "x", error: err });
|
|
expect(mockedCaptureException).toHaveBeenCalledTimes(1);
|
|
expect(mockedCaptureException.mock.calls[0][0]).toBe(err);
|
|
expect(mockedCaptureException.mock.calls[0][1]).toMatchObject({ tags: { service: "hivegrid-portal" } });
|
|
});
|
|
|
|
it("captures messages when no error is provided", async () => {
|
|
initSentryServer({
|
|
dsn: "https://srv@sentry.example/2",
|
|
environment: undefined,
|
|
release: undefined,
|
|
tracesSampleRate: 0.1,
|
|
service: "hivegrid-web",
|
|
});
|
|
await webErrorReporter.report({ component: "loader", operation: "x", errorMessage: "explicit-message" });
|
|
expect(mockedCaptureMessage).toHaveBeenCalledTimes(1);
|
|
expect(mockedCaptureMessage.mock.calls[0][0]).toBe("explicit-message");
|
|
});
|
|
});
|