diff --git a/bun.lock b/bun.lock index 0d041fe0..a55dd9fa 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "sentry", + "dependencies": { + "ignore": "^7.0.5", + "p-limit": "^7.2.0", + }, "devDependencies": { "@biomejs/biome": "2.3.8", "@sentry/bun": "10.38.0", @@ -347,6 +351,8 @@ "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], @@ -391,7 +397,7 @@ "nypm": ["nypm@0.6.4", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw=="], - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "p-limit": ["p-limit@7.2.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -471,7 +477,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -491,6 +497,8 @@ "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "ultracite/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], @@ -505,6 +513,8 @@ "@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], diff --git a/package.json b/package.json index c9549394..995a7a61 100644 --- a/package.json +++ b/package.json @@ -57,5 +57,9 @@ "patchedDependencies": { "@stricli/core@1.2.5": "patches/@stricli%2Fcore@1.2.5.patch", "@sentry/core@10.38.0": "patches/@sentry%2Fcore@10.38.0.patch" + }, + "dependencies": { + "ignore": "^7.0.5", + "p-limit": "^7.2.0" } } diff --git a/src/lib/dsn/code-scanner.ts b/src/lib/dsn/code-scanner.ts new file mode 100644 index 00000000..0e20f8b8 --- /dev/null +++ b/src/lib/dsn/code-scanner.ts @@ -0,0 +1,626 @@ +/** + * Language-Agnostic Code Scanner + * + * Scans source code for Sentry DSNs using a simple grep-based approach. + * This replaces the language-specific detectors with a unified scanner that: + * + * 1. Greps for DSN URL pattern directly: https://KEY@HOST/PROJECT_ID + * 2. Filters out DSNs appearing in commented lines + * 3. Respects .gitignore using the `ignore` package + * 4. Validates DSN hosts (SaaS when no SENTRY_URL, or self-hosted host when set) + * 5. Scans concurrently with p-limit for performance + * 6. Skips large files and known non-source directories + */ + +import { readdir } from "node:fs/promises"; +import path from "node:path"; +// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import +import * as Sentry from "@sentry/bun"; +import ignore, { type Ignore } from "ignore"; +import pLimit from "p-limit"; +import { DEFAULT_SENTRY_HOST } from "../constants.js"; +import { ConfigError } from "../errors.js"; +import { createDetectedDsn, inferPackagePath, parseDsn } from "./parser.js"; +import type { DetectedDsn } from "./types.js"; + +/** + * Maximum file size to scan (256KB). + * Files larger than this are skipped as they're unlikely to be source files + * with DSN configuration. + * + * Note: This check happens during file processing rather than collection to + * avoid extra stat() calls. Bun.file().size is a cheap operation once we + * have the file handle. + */ +const MAX_FILE_SIZE = 256 * 1024; + +/** + * Concurrency limit for file reads. + * Balances performance with file descriptor limits. + */ +const CONCURRENCY_LIMIT = 50; + +/** + * Maximum depth to scan from project root. + * Depth 0 = files in root directory + * Depth 2 = files in second-level subdirectories (e.g., src/lib/file.ts) + */ +const MAX_SCAN_DEPTH = 2; + +/** + * Directories that are always skipped regardless of .gitignore. + * These are common dependency/build/cache directories that should never contain DSNs. + * Added to the gitignore instance as built-in patterns. + */ +const ALWAYS_SKIP_DIRS = [ + // Version control + ".git", + ".hg", + ".svn", + // IDE/Editor + ".idea", + ".vscode", + ".cursor", + // Node.js + "node_modules", + // Python + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "venv", + ".venv", + // Java/Kotlin/Gradle + "build", + "target", + ".gradle", + // Go + "vendor", + // Ruby + ".bundle", + // General build outputs + "dist", + "out", + ".next", + ".nuxt", + ".output", + "coverage", +]; + +/** + * File extensions to scan for DSNs. + * Covers source code, config files, and data formats that might contain DSNs. + */ +const TEXT_EXTENSIONS = new Set([ + // JavaScript/TypeScript ecosystem + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".astro", + ".vue", + ".svelte", + // Python + ".py", + // Go + ".go", + // Ruby + ".rb", + ".erb", + // PHP + ".php", + // JVM languages + ".java", + ".kt", + ".kts", + ".scala", + ".groovy", + // .NET languages + ".cs", + ".fs", + ".vb", + // Rust + ".rs", + // Swift/Objective-C + ".swift", + ".m", + ".mm", + // Dart/Flutter + ".dart", + // Elixir/Erlang + ".ex", + ".exs", + ".erl", + // Lua + ".lua", + // Config/data formats + ".json", + ".yaml", + ".yml", + ".toml", + ".xml", + ".properties", + ".config", +]); + +/** + * Common comment prefixes to detect commented-out DSNs. + * Lines starting with these (after trimming whitespace) are ignored. + */ +const COMMENT_PREFIXES = ["//", "#", "--", " + + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores C-style block comment lines starting with /*", () => { + const content = ` + /* const DSN = "https://abc123@o123.ingest.sentry.io/456"; */ + const REAL_DSN = "https://real@o456.ingest.sentry.io/789"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores JSDoc/multi-line comment continuation lines starting with *", () => { + const content = ` + /** + * DSN: "https://abc123@o123.ingest.sentry.io/456" + */ + const REAL_DSN = "https://real@o456.ingest.sentry.io/789"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores SQL comments with --", () => { + const content = ` + -- INSERT INTO config VALUES ('dsn', 'https://abc123@o123.ingest.sentry.io/456'); + INSERT INTO config VALUES ('dsn', 'https://real@o456.ingest.sentry.io/789'); + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores Python triple-quote docstrings", () => { + const content = ` + '''https://abc123@o123.ingest.sentry.io/456''' + DSN = "https://real@o456.ingest.sentry.io/789" + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("returns empty array for content without DSNs", () => { + const content = ` + const config = { debug: true }; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual([]); + }); + + test("only accepts *.sentry.io hosts for SaaS", () => { + const content = ` + const REAL = "https://abc@o123.ingest.sentry.io/456"; + const FAKE = "https://abc@fake.example.com/456"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://abc@o123.ingest.sentry.io/456"]); + }); + + test("accepts self-hosted DSNs when SENTRY_URL is set", () => { + process.env.SENTRY_URL = "https://sentry.mycompany.com:9000"; + const content = ` + const DSN = "https://abc@sentry.mycompany.com:9000/123"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://abc@sentry.mycompany.com:9000/123"]); + }); + + test("rejects SaaS DSNs when SENTRY_URL is set (self-hosted mode)", () => { + process.env.SENTRY_URL = "https://sentry.mycompany.com:9000"; + const content = ` + const SAAS_DSN = "https://abc@o123.ingest.sentry.io/456"; + const SELF_HOSTED_DSN = "https://def@sentry.mycompany.com:9000/789"; + `; + const dsns = extractDsnsFromContent(content); + // Only the self-hosted DSN should be accepted + expect(dsns).toEqual(["https://def@sentry.mycompany.com:9000/789"]); + }); + + test("throws ConfigError when SENTRY_URL is invalid", () => { + process.env.SENTRY_URL = "not-a-valid-url"; + const content = ` + const SAAS_DSN = "https://abc@o123.ingest.sentry.io/456"; + `; + + // Invalid SENTRY_URL should throw immediately since nothing will work + expect(() => extractDsnsFromContent(content)).toThrow( + /SENTRY_URL.*not a valid URL/ + ); + }); + }); + + describe("extractFirstDsnFromContent", () => { + test("returns first DSN", () => { + const content = ` + const DSN1 = "https://first@o123.ingest.sentry.io/111"; + const DSN2 = "https://second@o456.ingest.sentry.io/222"; + `; + const dsn = extractFirstDsnFromContent(content); + expect(dsn).toBe("https://first@o123.ingest.sentry.io/111"); + }); + + test("returns null when no DSN found", () => { + const dsn = extractFirstDsnFromContent("no dsn here"); + expect(dsn).toBeNull(); + }); + }); + + describe("scanCodeForFirstDsn", () => { + test("finds DSN in root file", async () => { + writeFileSync( + join(testDir, "config.ts"), + 'const DSN = "https://abc@o123.ingest.sentry.io/456";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://abc@o123.ingest.sentry.io/456"); + expect(result?.source).toBe("code"); + expect(result?.sourcePath).toBe("config.ts"); + }); + + test("finds DSN in subdirectory", async () => { + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/sentry.ts"), + 'Sentry.init({ dsn: "https://abc@o123.ingest.sentry.io/456" });' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://abc@o123.ingest.sentry.io/456"); + expect(result?.sourcePath).toBe("src/sentry.ts"); + }); + + test("returns null when no DSN found", async () => { + writeFileSync(join(testDir, "index.ts"), "console.log('hello');"); + + const result = await scanCodeForFirstDsn(testDir); + expect(result).toBeNull(); + }); + + test("skips node_modules directory", async () => { + mkdirSync(join(testDir, "node_modules/some-package"), { + recursive: true, + }); + writeFileSync( + join(testDir, "node_modules/some-package/index.js"), + 'const DSN = "https://abc@o123.ingest.sentry.io/456";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result).toBeNull(); + }); + + test("respects gitignore", async () => { + writeFileSync(join(testDir, ".gitignore"), "ignored/"); + mkdirSync(join(testDir, "ignored"), { recursive: true }); + writeFileSync( + join(testDir, "ignored/config.ts"), + 'const DSN = "https://ignored@o123.ingest.sentry.io/456";' + ); + writeFileSync( + join(testDir, "real.ts"), + 'const DSN = "https://real@o456.ingest.sentry.io/789";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://real@o456.ingest.sentry.io/789"); + }); + + test("infers packagePath for monorepo structure", async () => { + // Use depth 2 (packages/frontend/sentry.ts) to stay within MAX_SCAN_DEPTH + mkdirSync(join(testDir, "packages/frontend"), { recursive: true }); + writeFileSync( + join(testDir, "packages/frontend/sentry.ts"), + 'const DSN = "https://abc@o123.ingest.sentry.io/456";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.packagePath).toBe("packages/frontend"); + }); + + test("scans various file types", async () => { + // Test Python + writeFileSync( + join(testDir, "app.py"), + 'sentry_sdk.init(dsn="https://py@o123.ingest.sentry.io/1")' + ); + + let result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://py@o123.ingest.sentry.io/1"); + + // Clean and test Go + rmSync(join(testDir, "app.py")); + writeFileSync( + join(testDir, "main.go"), + 'sentry.Init(sentry.ClientOptions{Dsn: "https://go@o123.ingest.sentry.io/2"})' + ); + + result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://go@o123.ingest.sentry.io/2"); + + // Clean and test Ruby + rmSync(join(testDir, "main.go")); + writeFileSync( + join(testDir, "config.rb"), + 'Sentry.init do |config|\n config.dsn = "https://rb@o123.ingest.sentry.io/3"\nend' + ); + + result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://rb@o123.ingest.sentry.io/3"); + }); + }); + + describe("scanCodeForDsns", () => { + test("finds all DSNs across multiple files", async () => { + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/frontend.ts"), + 'const DSN = "https://frontend@o123.ingest.sentry.io/111";' + ); + writeFileSync( + join(testDir, "src/backend.ts"), + 'const DSN = "https://backend@o456.ingest.sentry.io/222";' + ); + + const results = await scanCodeForDsns(testDir); + expect(results).toHaveLength(2); + + const dsns = results.map((r) => r.raw); + expect(dsns).toContain("https://frontend@o123.ingest.sentry.io/111"); + expect(dsns).toContain("https://backend@o456.ingest.sentry.io/222"); + }); + + test("deduplicates same DSN from multiple files", async () => { + writeFileSync( + join(testDir, "a.ts"), + 'const DSN = "https://same@o123.ingest.sentry.io/456";' + ); + writeFileSync( + join(testDir, "b.ts"), + 'const DSN = "https://same@o123.ingest.sentry.io/456";' + ); + + const results = await scanCodeForDsns(testDir); + expect(results).toHaveLength(1); + }); + + test("returns empty array when no DSNs found", async () => { + writeFileSync(join(testDir, "index.ts"), "console.log('hello');"); + + const results = await scanCodeForDsns(testDir); + expect(results).toEqual([]); + }); + }); +}); diff --git a/test/lib/dsn/detector.test.ts b/test/lib/dsn/detector.test.ts index a0b83a02..a1c9b818 100644 --- a/test/lib/dsn/detector.test.ts +++ b/test/lib/dsn/detector.test.ts @@ -111,7 +111,8 @@ describe("DSN Detector (New Module)", () => { `Sentry.init({ dsn: "${codeDsn}" })` ); - // Should return code DSN (highest priority) + // Code DSN takes priority over .env file DSN + // Priority order: code > env_file > env_var const result = await detectDsn(testDir); expect(result?.raw).toBe(codeDsn); expect(result?.source).toBe("code"); @@ -165,6 +166,72 @@ describe("DSN Detector (New Module)", () => { expect(result?.source).toBe("env"); }); + test("env var DSN is cached and verified without full scan", async () => { + const envVarDsn = "https://var@o111.ingest.sentry.io/111"; + const changedDsn = "https://changed@o222.ingest.sentry.io/222"; + + // Only set env var + process.env.SENTRY_DSN = envVarDsn; + + // First detection - should detect and cache + const result1 = await detectDsn(testDir); + expect(result1?.raw).toBe(envVarDsn); + expect(result1?.source).toBe("env"); + + // Verify it's cached + const cached = await getCachedDsn(testDir); + expect(cached?.dsn).toBe(envVarDsn); + expect(cached?.source).toBe("env"); + + // Second detection - should use cache verification (not full scan) + const result2 = await detectDsn(testDir); + expect(result2?.raw).toBe(envVarDsn); + expect(result2?.source).toBe("env"); + + // Change env var - should detect the change + process.env.SENTRY_DSN = changedDsn; + const result3 = await detectDsn(testDir); + expect(result3?.raw).toBe(changedDsn); + expect(result3?.source).toBe("env"); + + // Cache should be updated + const updatedCache = await getCachedDsn(testDir); + expect(updatedCache?.dsn).toBe(changedDsn); + }); + + test("cache verification respects priority when code DSN is added after env var", async () => { + const envVarDsn = "https://var@o111.ingest.sentry.io/111"; + const codeDsn = "https://code@o222.ingest.sentry.io/222"; + + // First, detect with only env var + process.env.SENTRY_DSN = envVarDsn; + const result1 = await detectDsn(testDir); + expect(result1?.raw).toBe(envVarDsn); + expect(result1?.source).toBe("env"); + + // Verify it's cached + const cached = await getCachedDsn(testDir); + expect(cached?.source).toBe("env"); + + // Now add a code DSN (higher priority) + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/config.ts"), + `Sentry.init({ dsn: "${codeDsn}" })` + ); + + // Next detection should find the code DSN (higher priority) + // even though env var is still cached + const result2 = await detectDsn(testDir); + expect(result2?.raw).toBe(codeDsn); + expect(result2?.source).toBe("code"); + + // Cache should be updated to code DSN + const updatedCache = await getCachedDsn(testDir); + expect(updatedCache?.dsn).toBe(codeDsn); + expect(updatedCache?.source).toBe("code"); + }); + test("skips node_modules and dist directories", async () => { const nodeModulesDsn = "https://nm@o111.ingest.sentry.io/111"; const distDsn = "https://dist@o222.ingest.sentry.io/222"; @@ -245,7 +312,7 @@ describe("DSN Detector (New Module)", () => { const result = await detectAllDsns(testDir); expect(result.hasMultiple).toBe(true); - // Code DSN has higher priority, so it's first + // Code DSNs have highest priority (code > env_file > env_var) expect(result.primary?.raw).toBe(codeDsn); expect(result.all).toHaveLength(2); }); diff --git a/test/lib/dsn/languages/go.test.ts b/test/lib/dsn/languages/go.test.ts deleted file mode 100644 index 22b8ac06..00000000 --- a/test/lib/dsn/languages/go.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Go DSN Detector Tests - * - * Consolidated tests for extracting DSN from Go source code. - * Tests cover: ClientOptions struct, variable assignment, raw string, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromGo, - goDetector, -} from "../../../../src/lib/dsn/languages/go.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("Go DSN Detector", () => { - test("extracts DSN from sentry.Init with ClientOptions", () => { - const code = ` -package main - -import "github.com/getsentry/sentry-go" - -func main() { - err := sentry.Init(sentry.ClientOptions{ - Dsn: "${TEST_DSN}", - Environment: "production", - TracesSampleRate: 1.0, - }) -} -`; - expect(extractDsnFromGo(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from variable assignment", () => { - const code = ` -dsn := "${TEST_DSN}" -sentry.Init(sentry.ClientOptions{Dsn: dsn}) -`; - expect(extractDsnFromGo(code)).toBe(TEST_DSN); - }); - - test("extracts DSN with backtick raw string literal", () => { - const code = ` -dsn := \`${TEST_DSN}\` -`; - expect(extractDsnFromGo(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from os.Getenv", () => { - const code = ` -sentry.Init(sentry.ClientOptions{ - Dsn: os.Getenv("SENTRY_DSN"), -}) -`; - expect(extractDsnFromGo(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(goDetector.name).toBe("Go"); - expect(goDetector.extensions).toContain(".go"); - expect(goDetector.skipDirs).toContain("vendor"); - expect(goDetector.extractDsn).toBe(extractDsnFromGo); - }); -}); diff --git a/test/lib/dsn/languages/java.test.ts b/test/lib/dsn/languages/java.test.ts deleted file mode 100644 index 5d7812a8..00000000 --- a/test/lib/dsn/languages/java.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Java/Kotlin DSN Detector Tests - * - * Consolidated tests for extracting DSN from Java/Kotlin source code and properties files. - * Tests cover: Sentry.init, properties file, Kotlin pattern, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromJava, - javaDetector, -} from "../../../../src/lib/dsn/languages/java.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("Java DSN Detector", () => { - test("extracts DSN from Sentry.init with setDsn", () => { - const code = ` -import io.sentry.Sentry; - -public class SentryConfig { - public static void init() { - Sentry.init(options -> { - options.setDsn("${TEST_DSN}"); - options.setEnvironment("production"); - }); - } -} -`; - expect(extractDsnFromJava(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from sentry.properties file", () => { - const content = ` -# Sentry configuration -dsn=${TEST_DSN} -environment=production -`; - expect(extractDsnFromJava(content)).toBe(TEST_DSN); - }); - - test("extracts DSN from Kotlin companion object", () => { - const code = ` -companion object { - const val dsn = "${TEST_DSN}" -} -`; - expect(extractDsnFromJava(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from System.getenv", () => { - const code = ` -Sentry.init(options -> { - options.setDsn(System.getenv("SENTRY_DSN")); -}); -`; - expect(extractDsnFromJava(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(javaDetector.name).toBe("Java"); - expect(javaDetector.extensions).toContain(".java"); - expect(javaDetector.extensions).toContain(".kt"); - expect(javaDetector.extensions).toContain(".properties"); - expect(javaDetector.skipDirs).toContain("target"); - expect(javaDetector.skipDirs).toContain("build"); - expect(javaDetector.extractDsn).toBe(extractDsnFromJava); - }); -}); diff --git a/test/lib/dsn/languages/javascript.test.ts b/test/lib/dsn/languages/javascript.test.ts deleted file mode 100644 index d4175315..00000000 --- a/test/lib/dsn/languages/javascript.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * JavaScript DSN Detector Tests - * - * Consolidated tests for extracting DSN from JavaScript/TypeScript source code. - * Tests cover: basic init, config object, multiple DSNs, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromCode, - javascriptDetector, -} from "../../../../src/lib/dsn/languages/javascript.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("JavaScript DSN Detector", () => { - test("extracts DSN from basic Sentry.init", () => { - const code = ` - import * as Sentry from "@sentry/react"; - - Sentry.init({ - dsn: "${TEST_DSN}", - tracesSampleRate: 1.0, - }); - `; - expect(extractDsnFromCode(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from config object", () => { - const code = ` - export const sentryConfig = { - dsn: "${TEST_DSN}", - enabled: true, - }; - `; - expect(extractDsnFromCode(code)).toBe(TEST_DSN); - }); - - test("extracts first DSN when multiple exist", () => { - const dsn2 = "https://xyz@o999.ingest.sentry.io/111"; - const code = ` - Sentry.init({ dsn: "${TEST_DSN}" }); - const backup = { dsn: "${dsn2}" }; - `; - expect(extractDsnFromCode(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from env variable", () => { - const code = ` - Sentry.init({ - dsn: process.env.SENTRY_DSN, - }); - `; - expect(extractDsnFromCode(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(javascriptDetector.name).toBe("JavaScript"); - expect(javascriptDetector.extensions).toContain(".ts"); - expect(javascriptDetector.extensions).toContain(".js"); - expect(javascriptDetector.skipDirs).toContain("node_modules"); - expect(javascriptDetector.extractDsn).toBe(extractDsnFromCode); - }); -}); diff --git a/test/lib/dsn/languages/php.test.ts b/test/lib/dsn/languages/php.test.ts deleted file mode 100644 index d2126fa5..00000000 --- a/test/lib/dsn/languages/php.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * PHP DSN Detector Tests - * - * Consolidated tests for extracting DSN from PHP source code. - * Tests cover: Sentry\init, Laravel config, multiline init, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromPhp, - phpDetector, -} from "../../../../src/lib/dsn/languages/php.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("PHP DSN Detector", () => { - test("extracts DSN from Sentry\\init", () => { - const code = ` - '${TEST_DSN}', - 'environment' => 'production', -]); -`; - expect(extractDsnFromPhp(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from Laravel config style", () => { - const code = ` - [ - 'dsn' => '${TEST_DSN}', - ], -]; -`; - expect(extractDsnFromPhp(code)).toBe(TEST_DSN); - }); - - test("extracts DSN with double quotes", () => { - const code = ` - "${TEST_DSN}"]); -`; - expect(extractDsnFromPhp(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from env function", () => { - const code = ` - env('SENTRY_DSN')]); -`; - expect(extractDsnFromPhp(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(phpDetector.name).toBe("PHP"); - expect(phpDetector.extensions).toContain(".php"); - expect(phpDetector.skipDirs).toContain("vendor"); - expect(phpDetector.extractDsn).toBe(extractDsnFromPhp); - }); -}); diff --git a/test/lib/dsn/languages/python.test.ts b/test/lib/dsn/languages/python.test.ts deleted file mode 100644 index 1a27128d..00000000 --- a/test/lib/dsn/languages/python.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Python DSN Detector Tests - * - * Consolidated tests for extracting DSN from Python source code. - * Tests cover: basic init, dict config, Django settings, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromPython, - pythonDetector, -} from "../../../../src/lib/dsn/languages/python.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("Python DSN Detector", () => { - test("extracts DSN from basic sentry_sdk.init", () => { - const code = ` -import sentry_sdk - -sentry_sdk.init( - dsn="${TEST_DSN}", - traces_sample_rate=1.0, -) -`; - expect(extractDsnFromPython(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from dict config", () => { - const code = ` -SENTRY_CONFIG = { - "dsn": "${TEST_DSN}", - "environment": "production", -} -`; - expect(extractDsnFromPython(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from Django settings style", () => { - const code = ` -SENTRY_DSN = "${TEST_DSN}" - -LOGGING = { - "handlers": { - "sentry": { - "dsn": "${TEST_DSN}", - } - } -} -`; - expect(extractDsnFromPython(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from env variable", () => { - const code = ` -import os -sentry_sdk.init(dsn=os.environ.get("SENTRY_DSN")) -`; - expect(extractDsnFromPython(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(pythonDetector.name).toBe("Python"); - expect(pythonDetector.extensions).toContain(".py"); - expect(pythonDetector.skipDirs).toContain("venv"); - expect(pythonDetector.skipDirs).toContain("__pycache__"); - expect(pythonDetector.extractDsn).toBe(extractDsnFromPython); - }); -}); diff --git a/test/lib/dsn/languages/ruby.test.ts b/test/lib/dsn/languages/ruby.test.ts deleted file mode 100644 index 643c4d31..00000000 --- a/test/lib/dsn/languages/ruby.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Ruby DSN Detector Tests - * - * Consolidated tests for extracting DSN from Ruby source code. - * Tests cover: Sentry.init block, Rails initializer, hash patterns, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromRuby, - rubyDetector, -} from "../../../../src/lib/dsn/languages/ruby.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("Ruby DSN Detector", () => { - test("extracts DSN from Sentry.init block", () => { - const code = ` -Sentry.init do |config| - config.dsn = '${TEST_DSN}' - config.traces_sample_rate = 1.0 -end -`; - expect(extractDsnFromRuby(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from Rails initializer style", () => { - const code = ` -Sentry.init do |config| - config.dsn = '${TEST_DSN}' - config.breadcrumbs_logger = [:active_support_logger] - config.traces_sample_rate = 0.5 -end -`; - expect(extractDsnFromRuby(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from symbol key hash", () => { - const code = ` -sentry_config = { - dsn: '${TEST_DSN}', - environment: 'production' -} -`; - expect(extractDsnFromRuby(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from ENV", () => { - const code = ` -Sentry.init do |config| - config.dsn = ENV['SENTRY_DSN'] -end -`; - expect(extractDsnFromRuby(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(rubyDetector.name).toBe("Ruby"); - expect(rubyDetector.extensions).toContain(".rb"); - expect(rubyDetector.skipDirs).toContain("vendor/bundle"); - expect(rubyDetector.extractDsn).toBe(extractDsnFromRuby); - }); -}); diff --git a/test/lib/dsn/project-root.test.ts b/test/lib/dsn/project-root.test.ts new file mode 100644 index 00000000..c336a143 --- /dev/null +++ b/test/lib/dsn/project-root.test.ts @@ -0,0 +1,320 @@ +/** + * Project Root Detection Tests + * + * Tests for finding project root by walking up from a starting directory. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { + findProjectRoot, + getStopBoundary, + hasBuildSystemMarker, + hasLanguageMarker, + hasRepoRootMarker, +} from "../../../src/lib/dsn/project-root.js"; + +// Test directory structure helper +function createDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +function createFile(path: string, content = ""): void { + writeFileSync(path, content); +} + +describe("project-root", () => { + let testDir: string; + + beforeEach(() => { + // Create a unique temp directory for each test + testDir = join( + tmpdir(), + `sentry-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + createDir(testDir); + }); + + afterEach(() => { + // Clean up test directory + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("getStopBoundary", () => { + test("returns home directory", () => { + const boundary = getStopBoundary(); + expect(boundary).toBe(homedir()); + }); + }); + + describe("hasRepoRootMarker", () => { + test("detects .git directory", async () => { + createDir(join(testDir, ".git")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("vcs"); + }); + + test("detects .hg directory", async () => { + createDir(join(testDir, ".hg")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("vcs"); + }); + + test("detects .github directory", async () => { + createDir(join(testDir, ".github")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("ci"); + }); + + test("detects .gitlab-ci.yml file", async () => { + createFile(join(testDir, ".gitlab-ci.yml")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("ci"); + }); + + test("detects .editorconfig with root=true", async () => { + createFile( + join(testDir, ".editorconfig"), + "root = true\n[*]\nindent_style = space" + ); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("editorconfig"); + }); + + test("ignores .editorconfig without root=true", async () => { + createFile(join(testDir, ".editorconfig"), "[*]\nindent_style = space"); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(false); + }); + + test("returns found=false when no markers", async () => { + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(false); + }); + }); + + describe("hasLanguageMarker", () => { + test("detects package.json", async () => { + createFile(join(testDir, "package.json"), "{}"); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects pyproject.toml", async () => { + createFile(join(testDir, "pyproject.toml"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects go.mod", async () => { + createFile(join(testDir, "go.mod"), "module example.com/test"); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects Cargo.toml", async () => { + createFile(join(testDir, "Cargo.toml"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects .sln file (glob pattern)", async () => { + createFile(join(testDir, "MyProject.sln"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects .csproj file (glob pattern)", async () => { + createFile(join(testDir, "MyProject.csproj"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("returns false when no markers", async () => { + expect(await hasLanguageMarker(testDir)).toBe(false); + }); + }); + + describe("hasBuildSystemMarker", () => { + test("detects Makefile", async () => { + createFile(join(testDir, "Makefile"), ""); + expect(await hasBuildSystemMarker(testDir)).toBe(true); + }); + + test("detects CMakeLists.txt", async () => { + createFile(join(testDir, "CMakeLists.txt"), ""); + expect(await hasBuildSystemMarker(testDir)).toBe(true); + }); + + test("detects BUILD.bazel", async () => { + createFile(join(testDir, "BUILD.bazel"), ""); + expect(await hasBuildSystemMarker(testDir)).toBe(true); + }); + + test("returns false when no markers", async () => { + expect(await hasBuildSystemMarker(testDir)).toBe(false); + }); + }); + + describe("findProjectRoot", () => { + describe("DSN detection in .env files", () => { + test("finds DSN in .env file and returns immediately", async () => { + const dsn = "https://abc123@o123.ingest.sentry.io/456"; + createFile(join(testDir, ".env"), `SENTRY_DSN=${dsn}`); + createDir(join(testDir, "src", "lib")); + + const result = await findProjectRoot(join(testDir, "src", "lib")); + + expect(result.foundDsn).toBeDefined(); + expect(result.foundDsn?.raw).toBe(dsn); + expect(result.reason).toBe("env_dsn"); + expect(result.projectRoot).toBe(testDir); + }); + + test("finds DSN in .env.local (higher priority)", async () => { + const dsnLocal = "https://local@o123.ingest.sentry.io/456"; + const dsnBase = "https://base@o123.ingest.sentry.io/789"; + createFile(join(testDir, ".env.local"), `SENTRY_DSN=${dsnLocal}`); + createFile(join(testDir, ".env"), `SENTRY_DSN=${dsnBase}`); + + const result = await findProjectRoot(testDir); + + expect(result.foundDsn?.raw).toBe(dsnLocal); + }); + + test("finds DSN at intermediate level during walk-up", async () => { + const dsn = "https://abc123@o123.ingest.sentry.io/456"; + // Create nested structure: testDir/packages/app/src + const packagesDir = join(testDir, "packages"); + const appDir = join(packagesDir, "app"); + const srcDir = join(appDir, "src"); + createDir(srcDir); + + // Put DSN in app directory + createFile(join(appDir, ".env"), `SENTRY_DSN=${dsn}`); + + // Put .git at root + createDir(join(testDir, ".git")); + + const result = await findProjectRoot(srcDir); + + // Should find DSN at app level (immediate return) + expect(result.foundDsn).toBeDefined(); + expect(result.foundDsn?.raw).toBe(dsn); + expect(result.reason).toBe("env_dsn"); + }); + }); + + describe("VCS marker detection", () => { + test("stops at .git directory", async () => { + createDir(join(testDir, ".git")); + createDir(join(testDir, "src", "lib", "utils")); + + const result = await findProjectRoot( + join(testDir, "src", "lib", "utils") + ); + + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("vcs"); + // Levels: utils(1) -> lib(2) -> src(3) -> testDir(4, found .git) + expect(result.levelsTraversed).toBe(4); + }); + + test("stops at .github directory", async () => { + createDir(join(testDir, ".github")); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("ci"); + }); + }); + + describe("language marker detection", () => { + test("uses closest language marker to cwd", async () => { + // Root has package.json + createFile(join(testDir, "package.json"), "{}"); + + // Nested package also has package.json + const nestedDir = join(testDir, "packages", "frontend"); + createDir(nestedDir); + createFile(join(nestedDir, "package.json"), "{}"); + + // Start from nested/src + const srcDir = join(nestedDir, "src"); + createDir(srcDir); + + const result = await findProjectRoot(srcDir); + + // Should use the closest package.json (in packages/frontend) + expect(result.projectRoot).toBe(nestedDir); + expect(result.reason).toBe("language"); + }); + + test("VCS marker takes precedence over language marker", async () => { + createDir(join(testDir, ".git")); + createFile(join(testDir, "package.json"), "{}"); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + // Should stop at .git even though package.json was also found + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("vcs"); + }); + }); + + describe("build system marker detection", () => { + test("uses build system marker as last resort", async () => { + createFile(join(testDir, "Makefile"), ""); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("build_system"); + }); + + test("language marker takes precedence over build system", async () => { + createFile(join(testDir, "Makefile"), ""); + createFile(join(testDir, "package.json"), "{}"); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + expect(result.reason).toBe("language"); + }); + }); + + describe("fallback behavior", () => { + test("returns cwd when no markers found", async () => { + const deepDir = join(testDir, "a", "b", "c"); + createDir(deepDir); + + const result = await findProjectRoot(deepDir); + + // Should fall back to the starting directory + expect(result.projectRoot).toBe(deepDir); + expect(result.reason).toBe("fallback"); + }); + }); + + describe("levels traversed tracking", () => { + test("tracks correct number of levels", async () => { + createDir(join(testDir, ".git")); + createDir(join(testDir, "a", "b", "c", "d")); + + const result = await findProjectRoot(join(testDir, "a", "b", "c", "d")); + + // Levels: d(1) -> c(2) -> b(3) -> a(4) -> testDir(5, found .git) + expect(result.levelsTraversed).toBe(5); + }); + }); + }); +});