import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRequire } from "node:module"; import os from "node:os"; import http from "node:http"; import express from "express"; import request from "supertest"; import type { NextFunction, Request, Response } from "express"; import { HttpError } from "../errors.js"; import { errorHandler } from "../middleware/error-handler.js"; import { finalizeServerShutdown } from "../shutdown.js"; import { authRoutes } from "../routes/auth.js"; import * as sentryModule from "../sentry.js"; /** * Tests for the opt-in Sentry error-monitoring gate. `@sentry/node` is an * optional runtime dependency and is NOT installed in CI, which is itself * part of the contract under test: with `SENTRY_DSN` set and the package * absent, the module must warn and settle instead of crashing the server. * * The module reads `SENTRY_DSN` at import time, so each test resets the * module registry and imports a fresh copy. */ const DSN_ENV = "SENTRY_DSN"; const FRONTEND_DSN_ENV = "SENTRY_DSN_FRONTEND"; const BACKEND_DSN_ENV = "SENTRY_DSN_BACKEND"; const originalDsn = process.env[DSN_ENV]; const originalFrontendDsn = process.env[FRONTEND_DSN_ENV]; const originalBackendDsn = process.env[BACKEND_DSN_ENV]; async function importFreshSentry() { vi.resetModules(); return await import("../sentry.js"); } /** * Register a fake `@sentry/node` module for the next dynamic import. Each * mock function is returned so a test can assert on the call it received. * The mock stays in place until `vi.doUnmock` runs, so `afterEach` clears it. * * `@sentry/node` is not installed on disk in this test environment, so the * exact-version gate would report it missing before the dynamic import ever * runs. This helper also mocks the gate module itself to report success, so * a test can exercise the "package present and at the right version" path * without installing the real package. */ function mockSentryPackage() { const init = vi.fn(); const captureException = vi.fn(() => "event-id"); const close = vi.fn(async () => true); const httpIntegration = vi.fn((options: unknown) => ({ name: "Http", ...(options as object) })); const onUnhandledRejectionIntegration = vi.fn((options: unknown) => ({ name: "OnUnhandledRejection", ...(options as object), })); vi.doMock("@sentry/node", () => ({ init, captureException, close, httpIntegration, onUnhandledRejectionIntegration, })); vi.doMock("../peer-version-check.js", () => ({ checkExactPeerVersions: () => ({ ok: true }), })); return { init, captureException, close, httpIntegration, onUnhandledRejectionIntegration }; } // A representative default-integration list, shaped like the array // `@sentry/node@10.71.0`'s `getDefaultIntegrations()` returns for a Node // server. Recorded against the published package with: // node -e "const S=require('@sentry/node'); \ // console.log(S.getDefaultIntegrations({}).map(i=>i.name))" const DEFAULT_INTEGRATION_NAMES = [ "InboundFilters", "FunctionToString", "LinkedErrors", "RequestData", "NodeSystemError", "ConversationId", "Console", "OnUncaughtException", "OnUnhandledRejection", "ContextLines", "LocalVariablesAsync", "Context", "ChildProcess", "ProcessSession", "Modules", "Http", "NodeFetch", ]; beforeEach(() => { delete process.env[DSN_ENV]; delete process.env[FRONTEND_DSN_ENV]; delete process.env[BACKEND_DSN_ENV]; }); afterEach(() => { if (originalDsn === undefined) delete process.env[DSN_ENV]; else process.env[DSN_ENV] = originalDsn; if (originalFrontendDsn === undefined) delete process.env[FRONTEND_DSN_ENV]; else process.env[FRONTEND_DSN_ENV] = originalFrontendDsn; if (originalBackendDsn === undefined) delete process.env[BACKEND_DSN_ENV]; else process.env[BACKEND_DSN_ENV] = originalBackendDsn; vi.restoreAllMocks(); vi.doUnmock("@sentry/node"); vi.doUnmock("../peer-version-check.js"); }); describe("sentryReady", () => { it("resolves and imports no SDK when SENTRY_DSN is unset", async () => { // A spy that fails the test if the module attempts a dynamic import of // an SDK it should never touch on the closed-gate path. `@sentry/node` // is not installed, so an attempted import would settle this promise // with a warning instead of resolving clean. const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const { sentryReady } = await importFreshSentry(); await expect(sentryReady).resolves.toBeUndefined(); expect(warn).not.toHaveBeenCalled(); }); }); describe("captureException", () => { it("sends only private normalized portfolio diagnostics on the matching event", async () => { process.env[BACKEND_DSN_ENV] = "https://fixture@example.com/1"; const sdk = mockSentryPackage(); const { captureException, sentryReady } = await importFreshSentry(); const { CloudPortfolioError } = await import("../services/cloud-portfolio-error.js"); await sentryReady; const error = new CloudPortfolioError("upstream", { phase: "fetch", elapsedMs: 270, upstreamStatus: null }, { code: "ECONNRESET" }); Object.assign(error, { cause: new Error("private cause"), headers: { authorization: "private token" } }); Object.defineProperty(error, "diagnostics", { value: { token: "private replacement" } }); captureException(error); const unrelated = new Error("unrelated"); captureException(unrelated); expect(sdk.captureException.mock.calls[0]).toEqual([ expect.objectContaining({ message: error.message, stack: error.stack }), { tags: { error_code: "cloud_portfolio_failure" }, fingerprint: ["{{ default }}"], contexts: { cloud_portfolio: { phase: "fetch", elapsedMs: 270, upstreamStatus: null, networkCode: "ECONNRESET" }, } }, ]); expect(JSON.stringify(sdk.captureException.mock.calls[0])).not.toContain("private"); expect(sdk.captureException.mock.calls[0]![0]).not.toHaveProperty("cause"); expect(sdk.captureException.mock.calls[1]).toEqual([unrelated]); }); it("adds bounded Stop timeout context only to that event", async () => { process.env[BACKEND_DSN_ENV] = "https://fixture@example.com/1"; const sdk = mockSentryPackage(); const { captureException, sentryReady } = await importFreshSentry(); const { AdapterStopTimeoutError } = await import("../services/adapter-stop-timeout.js"); await sentryReady; const error = new AdapterStopTimeoutError(60_000, { runId: "11111111-1111-4111-8111-111111111111", adapterType: "cursor", runtimeMode: "legacy", abortRequested: true, phase: "instruction_collection", phaseElapsedMs: 60_321, }); Object.assign(error, { providerResponse: "private fixture payload" }); captureException(error); const unrelated = new Error("unrelated"); captureException(unrelated); expect(sdk.captureException.mock.calls[0]).toEqual([ expect.objectContaining({ message: error.message, stack: error.stack }), { tags: { error_code: "adapter_stop_unconfirmed" }, fingerprint: ["{{ default }}"], contexts: { adapter_stop: { runId: "11111111-1111-4111-8111-111111111111", adapterType: "cursor", runtimeMode: "legacy", abortRequested: true, timeoutMs: 60_000, phase: "instruction_collection", phaseElapsedMs: 60_321 }, } }, ]); expect(JSON.stringify(sdk.captureException.mock.calls[0])).not.toContain("private fixture payload"); expect(sdk.captureException.mock.calls[1]).toEqual([unrelated]); }); it("does not send arbitrary Stop diagnostic values", async () => { process.env[BACKEND_DSN_ENV] = "https://fixture@example.com/1"; const sdk = mockSentryPackage(); const { captureException, sentryReady } = await importFreshSentry(); const { AdapterStopTimeoutError } = await import("../services/adapter-stop-timeout.js"); await sentryReady; captureException(new AdapterStopTimeoutError(NaN, { runId: "private fixture payload", adapterType: "private fixture payload", runtimeMode: "private fixture payload", phase: "private fixture payload", phaseElapsedMs: Infinity, })); expect(JSON.stringify(sdk.captureException.mock.calls)).not.toContain("private fixture payload"); expect(sdk.captureException.mock.calls[0]).toEqual([expect.any(Error), expect.objectContaining({ contexts: { adapter_stop: { runId: null, adapterType: "unknown", runtimeMode: "unknown", abortRequested: null, timeoutMs: null, phase: "unknown", phaseElapsedMs: null }, } })]); }); it("is a no-op and does not throw when the gate is closed", async () => { const { captureException, sentryReady } = await importFreshSentry(); await sentryReady; expect(() => captureException(new Error("boom"))).not.toThrow(); }); }); describe("shutdownSentry", () => { it("resolves once for concurrent callers", async () => { const { shutdownSentry } = await importFreshSentry(); const first = shutdownSentry(); const second = shutdownSentry(); // Memoized: concurrent callers share one shutdown promise. expect(first).toBe(second); await expect(first).resolves.toBeUndefined(); }); }); /** * Seam tests for the two `errorHandler` call sites that report to Sentry. * These tests spy on the exported `captureException` binding rather than * import a real client, so they assert the call-site shape (one call, the * Error object only) without a live Sentry SDK. */ describe("errorHandler Sentry capture", () => { function makeReq(): Request { return { method: "GET", originalUrl: "/api/test", body: { a: 1 }, params: { id: "123" }, query: { q: "x" }, } as unknown as Request; } function makeRes(): Response { const res = { status: vi.fn(), json: vi.fn(), } as unknown as Response; (res.status as unknown as ReturnType).mockReturnValue(res); return res; } it("captures one event for a 500-level HttpError", () => { const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {}); const req = makeReq(); const res = makeRes(); const next = vi.fn() as unknown as NextFunction; const err = new HttpError(500, "db exploded"); errorHandler(err, req, res, next); expect(capture).toHaveBeenCalledTimes(1); }); it("captures one event for an unknown error", () => { const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {}); const req = makeReq(); const res = makeRes(); const next = vi.fn() as unknown as NextFunction; const err = new Error("boom"); errorHandler(err, req, res, next); expect(capture).toHaveBeenCalledTimes(1); }); it("captures no event for a Zod validation 400 response", () => { const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {}); const req = makeReq(); const res = makeRes(); const next = vi.fn() as unknown as NextFunction; const issue = { code: "invalid_type", expected: "string", received: "undefined", path: ["provider"], message: "Required", }; const err = Object.assign(new Error("Validation failed"), { name: "ZodError", issues: [issue], }); errorHandler(err, req, res, next); expect(res.status).toHaveBeenCalledWith(400); expect(capture).not.toHaveBeenCalled(); }); it("passes the Error object only to captureException, never the request-bearing ErrorContext", () => { const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {}); const req = makeReq(); const res = makeRes(); const next = vi.fn() as unknown as NextFunction; const err = new Error("boom"); errorHandler(err, req, res, next); expect(capture).toHaveBeenCalledWith(err); const [received] = capture.mock.calls[0]!; expect(received).toBeInstanceOf(Error); // The `ErrorContext` shape carries the request body, params, and query. // The received value must not carry any of them. expect(received).not.toHaveProperty("reqBody"); expect(received).not.toHaveProperty("reqParams"); expect(received).not.toHaveProperty("reqQuery"); }); }); describe("finalizeServerShutdown Sentry teardown", () => { it("calls shutdownSentry after shutdownInstrumentation", async () => { const order: string[] = []; const shutdownInstrumentation = vi.fn(async () => { order.push("instrumentation"); }); const shutdownSentry = vi.fn(async () => { order.push("sentry"); }); await finalizeServerShutdown({ signal: "SIGTERM", shutdownAppServices: undefined, stopEmbeddedPostgres: null, shutdownInstrumentation, shutdownSentry, log: { info: vi.fn(), error: vi.fn() }, }); expect(order).toEqual(["instrumentation", "sentry"]); }); }); describe("missing @sentry/node package", () => { it("logs one warning and resolves", async () => { process.env[BACKEND_DSN_ENV] = "https://public@o0.ingest.sentry.io/1"; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); // Keep this failure-mode test valid when the optional real-SDK tests run. vi.doMock("../peer-version-check.js", () => ({ checkExactPeerVersions: () => ({ ok: false, detail: { missing: ["@sentry/node"], mismatched: [] }, }), })); const { sentryReady } = await importFreshSentry(); // Bootstrap must absorb the failed dynamic import — the server keeps // booting without error monitoring rather than crashing on an opt-in // feature. await expect(sentryReady).resolves.toBeUndefined(); expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith( expect.stringContaining("@sentry/node package is not installed"), expect.anything(), ); }); }); describe("@sentry/node installed at an unsupported version", () => { it("logs one diagnostic and resolves without importing the package", async () => { process.env[BACKEND_DSN_ENV] = "https://public@o0.ingest.sentry.io/1"; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.doMock("../peer-version-check.js", () => ({ checkExactPeerVersions: () => ({ ok: false, diagnostic: "unused by the Sentry gate; see server/src/sentry.ts", detail: { missing: [], mismatched: [{ name: "@sentry/node", installed: "9.0.0", expected: "10.71.0" }], }, }), })); const { sentryReady } = await importFreshSentry(); // Bootstrap must absorb the reported mismatch — the server keeps // booting without error monitoring rather than crashing on an opt-in // feature. await expect(sentryReady).resolves.toBeUndefined(); expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith( expect.stringContaining("@sentry/node package is not installed"), expect.anything(), ); vi.doUnmock("../peer-version-check.js"); }); }); describe("split DSN gating", () => { it("opens the gate and passes the backend DSN to Sentry.init when SENTRY_DSN_BACKEND alone is set", async () => { process.env[BACKEND_DSN_ENV] = "https://public-backend@o0.ingest.sentry.io/2"; const mocks = mockSentryPackage(); const { sentryReady } = await importFreshSentry(); await sentryReady; expect(mocks.init).toHaveBeenCalledTimes(1); const initOptions = mocks.init.mock.calls[0][0] as { dsn: string }; expect(initOptions.dsn).toBe("https://public-backend@o0.ingest.sentry.io/2"); }); it("leaves the gate closed and loads no SDK when SENTRY_DSN_FRONTEND alone is set", async () => { process.env[FRONTEND_DSN_ENV] = "https://public-frontend@o0.ingest.sentry.io/1"; const mocks = mockSentryPackage(); const { sentryReady } = await importFreshSentry(); await sentryReady; expect(mocks.init).not.toHaveBeenCalled(); }); it("logs one warning that names the three variables and holds no DSN value when only SENTRY_DSN is set", async () => { process.env[DSN_ENV] = "https://public-legacy@o0.ingest.sentry.io/3"; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); mockSentryPackage(); const { sentryReady } = await importFreshSentry(); await sentryReady; expect(warn).toHaveBeenCalledTimes(1); const [message] = warn.mock.calls[0]!; expect(message).toEqual(expect.stringContaining("SENTRY_DSN_FRONTEND")); expect(message).toEqual(expect.stringContaining("SENTRY_DSN_BACKEND")); expect(message).toEqual(expect.stringContaining("SENTRY_DSN")); for (const call of warn.mock.calls) { for (const arg of call) { expect(String(arg)).not.toContain("https://public-legacy@o0.ingest.sentry.io/3"); } } }); it("logs no warning when both specific variables are set", async () => { process.env[FRONTEND_DSN_ENV] = "https://public-frontend@o0.ingest.sentry.io/1"; process.env[BACKEND_DSN_ENV] = "https://public-backend@o0.ingest.sentry.io/2"; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); mockSentryPackage(); const { sentryReady } = await importFreshSentry(); await sentryReady; expect(warn).not.toHaveBeenCalled(); }); }); describe("buildSentryInitOptions", () => { it("sets sendDefaultPii false, tracesSampleRate 0, and skipOpenTelemetrySetup true", async () => { const { buildSentryInitOptions } = await importFreshSentry(); const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { httpIntegration: () => ({ name: "Http" }), onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), }); expect(options.sendDefaultPii).toBe(false); expect(options.tracesSampleRate).toBe(0); expect(options.skipOpenTelemetrySetup).toBe(true); }); it("passes onUnhandledRejection with mode strict", async () => { const { buildSentryInitOptions } = await importFreshSentry(); const onUnhandledRejectionIntegration = vi.fn((options: unknown) => ({ name: "OnUnhandledRejection", ...(options as object), })); const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { httpIntegration: () => ({ name: "Http" }), onUnhandledRejectionIntegration, }); const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name }))); expect(onUnhandledRejectionIntegration).toHaveBeenCalledWith({ mode: "strict" }); const rejectionIntegration = resolved.find((i) => i.name === "OnUnhandledRejection"); expect(rejectionIntegration).toMatchObject({ mode: "strict" }); // The default OnUnhandledRejection entry must not survive alongside it. expect(resolved.filter((i) => i.name === "OnUnhandledRejection")).toHaveLength(1); }); it("the resolved server integration list holds no Console integration and no ContextLines integration", async () => { const { buildSentryInitOptions } = await importFreshSentry(); const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { httpIntegration: () => ({ name: "Http" }), onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), }); const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name }))); const names = resolved.map((i) => i.name); expect(names).not.toContain("Console"); expect(names).not.toContain("ContextLines"); }); it("the resolved server integration list keeps OnUncaughtException, OnUnhandledRejection, LinkedErrors, and RequestData", async () => { const { buildSentryInitOptions } = await importFreshSentry(); const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { httpIntegration: () => ({ name: "Http" }), onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), }); const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name }))); const names = resolved.map((i) => i.name); // The full error-capture set the plan requires, not just the four named // in this test's title. expect(names).toEqual( expect.arrayContaining([ "OnUncaughtException", "OnUnhandledRejection", "ChildProcess", "LinkedErrors", "RequestData", "Modules", "Context", "ProcessSession", ]), ); }); it("turns the outbound HTTP breadcrumb off and keeps the rest of the Http integration", async () => { const { buildSentryInitOptions } = await importFreshSentry(); const httpIntegration = vi.fn((options: unknown) => ({ name: "Http", ...(options as object) })); const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { httpIntegration, onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), }); const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name }))); expect(httpIntegration).toHaveBeenCalledWith({ breadcrumbs: false }); expect(resolved.filter((i) => i.name === "Http")).toHaveLength(1); }); }); describe("buildSentryInitOptions serverName", () => { it("sets serverName to the host name", async () => { const { buildSentryInitOptions } = await importFreshSentry(); const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { httpIntegration: () => ({ name: "Http" }), onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), }); expect(options.serverName).toBe(os.hostname()); }); it("reads the host name from node:os at call time, not at module load time", async () => { vi.doMock("node:os", () => ({ default: { hostname: () => "fixed-test-host" }, hostname: () => "fixed-test-host", })); const { buildSentryInitOptions } = await importFreshSentry(); const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { httpIntegration: () => ({ name: "Http" }), onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), }); expect(options.serverName).toBe("fixed-test-host"); vi.doUnmock("node:os"); }); const SENTRY_NAME_ENV = "SENTRY_NAME"; let originalSentryName: string | undefined; beforeEach(() => { originalSentryName = process.env[SENTRY_NAME_ENV]; }); afterEach(() => { if (originalSentryName === undefined) delete process.env[SENTRY_NAME_ENV]; else process.env[SENTRY_NAME_ENV] = originalSentryName; }); it("uses SENTRY_NAME as serverName when the variable holds a non-empty string", async () => { process.env[SENTRY_NAME_ENV] = "opaque-operator-id"; const { buildSentryInitOptions } = await importFreshSentry(); const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { httpIntegration: () => ({ name: "Http" }), onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), }); expect(options.serverName).toBe("opaque-operator-id"); }); it("uses the host name as serverName when SENTRY_NAME is absent", async () => { delete process.env[SENTRY_NAME_ENV]; const { buildSentryInitOptions } = await importFreshSentry(); const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { httpIntegration: () => ({ name: "Http" }), onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), }); expect(options.serverName).toBe(os.hostname()); }); it("uses the host name as serverName when SENTRY_NAME is an empty string", async () => { process.env[SENTRY_NAME_ENV] = ""; const { buildSentryInitOptions } = await importFreshSentry(); const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { httpIntegration: () => ({ name: "Http" }), onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), }); expect(options.serverName).toBe(os.hostname()); }); }); describe("buildSentryInitOptions release", () => { const commit = "0123456789abcdef0123456789abcdef01234567"; const readBuildCommit = vi.fn<() => string | null>(); const integrations = { httpIntegration: () => ({ name: "Http" }), onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), }; beforeEach(() => { vi.stubEnv("SENTRY_RELEASE", ""); readBuildCommit.mockReturnValue(commit); vi.doMock("../build-commit.js", () => ({ readBuildCommit })); }); afterEach(() => { vi.unstubAllEnvs(); vi.doUnmock("../build-commit.js"); readBuildCommit.mockReset(); }); it("uses the server build commit", async () => { const { buildSentryInitOptions } = await importFreshSentry(); expect(buildSentryInitOptions("test-dsn", integrations).release).toBe(commit); }); it("preserves an operator's explicit release", async () => { vi.stubEnv("SENTRY_RELEASE", " custom-release "); const { buildSentryInitOptions } = await importFreshSentry(); expect(buildSentryInitOptions("test-dsn", integrations).release).toBe("custom-release"); }); it("leaves an unknown build unattributed", async () => { // Keep one module factory and change its return value explicitly for this // case, rather than depending on a second factory replacing the first. readBuildCommit.mockReturnValue(null); const { buildSentryInitOptions } = await importFreshSentry(); expect(buildSentryInitOptions("test-dsn", integrations).release).toBeUndefined(); expect(readBuildCommit).toHaveBeenCalled(); }); }); describe("with @sentry/node mocked", () => { it("initializes the client and shares captureException / shutdownSentry with it", async () => { process.env[DSN_ENV] = "https://public@o0.ingest.sentry.io/1"; const mocks = mockSentryPackage(); const { sentryReady, captureException, shutdownSentry } = await importFreshSentry(); await sentryReady; expect(mocks.init).toHaveBeenCalledTimes(1); const initOptions = mocks.init.mock.calls[0][0] as { dsn: string }; expect(initOptions.dsn).toBe("https://public@o0.ingest.sentry.io/1"); captureException(new Error("boom")); expect(mocks.captureException).toHaveBeenCalledWith(expect.any(Error)); await shutdownSentry(); expect(mocks.close).toHaveBeenCalledWith(5_000); }); }); /** * Proves the one-project result: the server and the browser both resolve * their Sentry client from the same `SENTRY_DSN` value, so both send events * to the same Sentry project. The browser reads its DSN from the * `GET /api/auth/get-session` response body (see `ui/src/lib/sentry.ts` and * `ui/src/components/SentryGate.tsx`), never from a `` tag. */ describe("one-project resolution", () => { function makeSessionApp() { const app = express(); app.use((req, _res, next) => { req.actor = { type: "board", userId: "user-1", source: "session" }; next(); }); const db = { select: () => ({ from: () => ({ where: () => Promise.resolve([ { id: "user-1", name: "Jane Example", email: "jane@example.com", image: null }, ]), }), }), }; app.use("/api/auth", authRoutes(db as unknown as Parameters[0])); app.use(errorHandler); return app; } it("the server initializer and GET /api/auth/get-session resolve the same Sentry project", async () => { process.env[DSN_ENV] = "https://public@o0.ingest.sentry.io/1"; const mocks = mockSentryPackage(); const { sentryReady } = await importFreshSentry(); await sentryReady; const initOptions = mocks.init.mock.calls[0]![0] as { dsn: string }; const app = makeSessionApp(); const res = await request(app).get("/api/auth/get-session"); expect(res.status).toBe(200); expect(res.body.sentryDsn).toBe(initOptions.dsn); }); }); // `@sentry/node` is an optional runtime dependency (see the module comment // in sentry.ts). When it is absent, the three tests below cannot run against // the true SDK, so they are skipped — the same pattern instrumentation.test.ts // uses for its OpenTelemetry-SDK-dependent test. const sentryPackage = (() => { try { const require = createRequire(import.meta.url); return require("@sentry/node") as { init(options: Record): unknown; captureException(error: unknown): string; httpIntegration(options: { breadcrumbs: boolean }): { name: string }; onUnhandledRejectionIntegration(options: { mode: string }): { name: string }; flush(timeout?: number): Promise; close(timeout?: number): Promise; }; } catch { return null; } })(); describe.skipIf(!sentryPackage)("captured event shape against the real @sentry/node SDK", () => { /** * Initialize the real SDK with this module's exact options, plus a * transport stub so no event leaves the test process, plus `beforeSend` * so the test can inspect the resolved event before it would have been * sent. `beforeSend` is test-only introspection — the module under test * adds no `beforeSend` of its own (constraint: built-in options only). */ async function initRealSentryForTest(onEvent: (event: Record) => void) { const Sentry = sentryPackage!; const { buildSentryInitOptions } = await importFreshSentry(); const options = { ...buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", Sentry), transport: () => ({ send: async () => ({}), flush: async () => true }), beforeSend: (event: Record) => { onEvent(event); return event; }, }; Sentry.init(options); } it("attaches the actual build commit to an emitted event", async () => { const commit = "0123456789abcdef0123456789abcdef01234567"; vi.stubEnv("PAPERCLIP_BUILD_COMMIT", commit); vi.stubEnv("SENTRY_RELEASE", ""); try { let captured: Record | null = null; await initRealSentryForTest((event) => { captured = event; }); sentryPackage!.captureException(new Error("build attribution check")); await sentryPackage!.flush(2000); expect(captured).toMatchObject({ release: commit }); expect(captured).not.toHaveProperty("request"); } finally { vi.unstubAllEnvs(); } }); it("a server event captured after a console.error call carries no console breadcrumb", async () => { const Sentry = sentryPackage!; let captured: Record | null = null; await initRealSentryForTest((event) => { captured = event; }); // eslint-disable-next-line no-console console.error("child process stderr: simulated failure"); Sentry.captureException(new Error("after console.error")); await Sentry.flush(2000); expect(captured).not.toBeNull(); expect((captured as Record).breadcrumbs).toBeUndefined(); }); it("a server event carries no stack-frame source context", async () => { const Sentry = sentryPackage!; let captured: Record | null = null; await initRealSentryForTest((event) => { captured = event; }); Sentry.captureException(new Error("stack frame check")); await Sentry.flush(2000); const event = captured as unknown as { exception: { values: Array<{ stacktrace: { frames: Array> } }> }; }; const frames = event.exception.values[0].stacktrace.frames; expect(frames.length).toBeGreaterThan(0); for (const frame of frames) { expect(frame.context_line).toBeUndefined(); expect(frame.pre_context).toBeUndefined(); expect(frame.post_context).toBeUndefined(); } }); it("a server event carries no outbound HTTP breadcrumb", async () => { const Sentry = sentryPackage!; let captured: Record | null = null; await initRealSentryForTest((event) => { captured = event; }); const server = http.createServer((_req, res) => res.end("ok")); await new Promise((resolve) => server.listen(0, resolve)); const port = (server.address() as { port: number }).port; await new Promise((resolve) => { http.get(`http://127.0.0.1:${port}/probe?token=secret`, (res) => { res.resume(); res.on("end", resolve); }); }); server.close(); Sentry.captureException(new Error("after outbound http call")); await Sentry.flush(2000); expect((captured as unknown as Record).breadcrumbs).toBeUndefined(); }); /** * `skipOpenTelemetrySetup: true` (set above) keeps this module out of * Paperclip's separate, independently opt-in OpenTelemetry feature. It * also turns off Sentry's own per-request async-context tracking. The * `RequestData` integration reads the inbound URL, method, headers, * cookies, and query string from that per-request context. With the * context off, a captured event carries no request field — not the * SDK's documented default. This test proves the gap, so the operator * documentation states the true capture set. */ it("a server event captured inside a real HTTP request handler carries no request field", async () => { const Sentry = sentryPackage!; let captured: Record | null = null; await initRealSentryForTest((event) => { captured = event; }); const server = http.createServer((req, res) => { req.on("data", () => {}); req.on("end", () => { Sentry.captureException(new Error("boom from a real request handler")); res.end("ok"); }); }); await new Promise((resolve) => server.listen(0, resolve)); const port = (server.address() as { port: number }).port; await new Promise((resolve) => { http.get(`http://127.0.0.1:${port}/api/test?foo=bar`, (res) => { res.resume(); res.on("end", resolve); }); }); server.close(); await Sentry.flush(2000); expect(captured).not.toBeNull(); expect((captured as unknown as Record).request).toBeUndefined(); }); });