diff --git a/packages/cli/src/lib/dsn/detector.ts b/packages/cli/src/lib/dsn/detector.ts index 416107bdb..3bfe7062a 100644 --- a/packages/cli/src/lib/dsn/detector.ts +++ b/packages/cli/src/lib/dsn/detector.ts @@ -12,7 +12,7 @@ * 3. Full scan: .env files, then source code (slow) */ -import { join } from "node:path"; +import { extname, join } from "node:path"; import { getCachedDsn, setCachedDsn } from "./cache.js"; import { detectFromEnv, SENTRY_DSN_ENV } from "./env.js"; import { @@ -23,8 +23,8 @@ import { import { detectAllFromCode, detectFromCode, - extractDsnFromCode, -} from "./languages/javascript.js"; + languageDetectors, +} from "./languages/index.js"; import { createDetectedDsn, parseDsn } from "./parser.js"; import type { CachedDsnEntry, @@ -180,7 +180,11 @@ async function verifyCachedDsn( try { const content = await Bun.file(filePath).text(); - const foundDsn = extractDsnFromContent(content, cached.source); + const foundDsn = extractDsnFromContent( + content, + cached.source, + cached.sourcePath + ); if (foundDsn === cached.dsn) { // Same DSN - cache is valid! @@ -199,17 +203,31 @@ async function verifyCachedDsn( } /** - * Extract DSN from content based on source type + * Extract DSN from content based on source type and file path. + * + * @param content - File content + * @param source - Source type (env_file, code, etc.) + * @param sourcePath - Path to the file (used to determine language for code files) */ function extractDsnFromContent( content: string, - source: DsnSource + source: DsnSource, + sourcePath?: string ): string | null { switch (source) { case "env_file": return extractDsnFromEnvFile(content); - case "code": - return extractDsnFromCode(content); + case "code": { + if (!sourcePath) { + return null; + } + // Find the right language detector based on file extension + const ext = extname(sourcePath); + const detector = languageDetectors.find((d) => + d.extensions.includes(ext) + ); + return detector?.extractDsn(content) ?? null; + } default: return null; } diff --git a/packages/cli/src/lib/dsn/languages/go.ts b/packages/cli/src/lib/dsn/languages/go.ts new file mode 100644 index 000000000..bc3f588a1 --- /dev/null +++ b/packages/cli/src/lib/dsn/languages/go.ts @@ -0,0 +1,52 @@ +/** + * Go DSN Detection + * + * Detects DSN from Go source code patterns. + * Looks for sentry.Init(sentry.ClientOptions{Dsn: "..."}) and similar patterns. + */ + +import type { LanguageDetector } from "./types.js"; + +/** + * Regex pattern for DSN in Go struct initialization. + * Matches: Dsn: "https://...@..." in sentry.ClientOptions{} + */ +const DSN_PATTERN_STRUCT = /Dsn:\s*["'](https?:\/\/[^"']+@[^"']+)["']/s; + +/** + * Generic pattern for dsn assignment + * Matches: dsn := "..." or dsn = "..." + */ +const DSN_PATTERN_ASSIGN = /dsn\s*:?=\s*["`](https?:\/\/[^"`]+@[^"`]+)["`]/is; + +/** + * Extract DSN string from Go code content. + * + * @param content - Source code content + * @returns DSN string or null if not found + */ +export function extractDsnFromGo(content: string): string | null { + // Try struct field pattern first (most common) + const structMatch = content.match(DSN_PATTERN_STRUCT); + if (structMatch?.[1]) { + return structMatch[1]; + } + + // Try assignment pattern + const assignMatch = content.match(DSN_PATTERN_ASSIGN); + if (assignMatch?.[1]) { + return assignMatch[1]; + } + + return null; +} + +/** + * Go language detector. + */ +export const goDetector: LanguageDetector = { + name: "Go", + extensions: [".go"], + skipDirs: ["vendor", ".git", "testdata"], + extractDsn: extractDsnFromGo, +}; diff --git a/packages/cli/src/lib/dsn/languages/index.ts b/packages/cli/src/lib/dsn/languages/index.ts new file mode 100644 index 000000000..872190301 --- /dev/null +++ b/packages/cli/src/lib/dsn/languages/index.ts @@ -0,0 +1,154 @@ +/** + * Language Detection Registry + * + * Unified scanner for detecting DSN from source code across all supported languages. + * Uses a registry of language detectors to scan files by extension. + */ + +import { extname, join } from "node:path"; +import { createDetectedDsn } from "../parser.js"; +import type { DetectedDsn } from "../types.js"; +import { goDetector } from "./go.js"; +import { javaDetector } from "./java.js"; +import { javascriptDetector } from "./javascript.js"; +import { phpDetector } from "./php.js"; +import { pythonDetector } from "./python.js"; +import { rubyDetector } from "./ruby.js"; +import type { LanguageDetector } from "./types.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Registry +// ───────────────────────────────────────────────────────────────────────────── + +/** All supported language detectors */ +export const languageDetectors: LanguageDetector[] = [ + javascriptDetector, + pythonDetector, + phpDetector, + rubyDetector, + goDetector, + javaDetector, +]; + +/** Map of file extension to detector for fast lookup */ +const extensionToDetector = new Map(); +for (const detector of languageDetectors) { + for (const ext of detector.extensions) { + extensionToDetector.set(ext, detector); + } +} + +/** Combined set of all skip directories */ +const allSkipDirs = new Set(); +for (const detector of languageDetectors) { + for (const dir of detector.skipDirs) { + allSkipDirs.add(dir); + } +} + +/** Glob pattern matching all supported file extensions */ +const allExtensions = languageDetectors.flatMap((d) => d.extensions); +const globPattern = `**/*{${allExtensions.join(",")}}`; +const codeGlob = new Bun.Glob(globPattern); + +// ───────────────────────────────────────────────────────────────────────────── +// Scanner +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Check if a path should be skipped during scanning. + * Matches any path segment against the combined skip directories from all detectors. + * + * @param filepath - Relative file path to check + * @returns True if any path segment matches a skip directory + */ +function shouldSkipPath(filepath: string): boolean { + const parts = filepath.split("/"); + return parts.some((part) => allSkipDirs.has(part)); +} + +/** + * Get the appropriate detector for a file based on its extension. + * + * @param filepath - File path to get detector for + * @returns The matching detector, or undefined if no detector handles this extension + */ +function getDetectorForFile(filepath: string): LanguageDetector | undefined { + const ext = extname(filepath); + return extensionToDetector.get(ext); +} + +/** + * Detect DSN from source code files in a directory. + * Scans all supported languages and returns the first DSN found. + * + * @param cwd - Directory to search in + * @returns First detected DSN or null if not found + */ +export async function detectFromCode(cwd: string): Promise { + for await (const relativePath of codeGlob.scan({ cwd, onlyFiles: true })) { + if (shouldSkipPath(relativePath)) { + continue; + } + + const detector = getDetectorForFile(relativePath); + if (!detector) { + continue; + } + + const filepath = join(cwd, relativePath); + + try { + const content = await Bun.file(filepath).text(); + const dsn = detector.extractDsn(content); + + if (dsn) { + return createDetectedDsn(dsn, "code", relativePath); + } + } catch { + // Skip files we can't read + } + } + + return null; +} + +/** + * Detect DSN from ALL source code files (for conflict detection). + * Unlike detectFromCode, this doesn't stop at the first match. + * + * @param cwd - Directory to search in + * @returns Array of all detected DSNs + */ +export async function detectAllFromCode(cwd: string): Promise { + const results: DetectedDsn[] = []; + + for await (const relativePath of codeGlob.scan({ cwd, onlyFiles: true })) { + if (shouldSkipPath(relativePath)) { + continue; + } + + const detector = getDetectorForFile(relativePath); + if (!detector) { + continue; + } + + const filepath = join(cwd, relativePath); + + try { + const content = await Bun.file(filepath).text(); + const dsn = detector.extractDsn(content); + + if (dsn) { + const detected = createDetectedDsn(dsn, "code", relativePath); + if (detected) { + results.push(detected); + } + } + } catch { + // Skip files we can't read + } + } + + return results; +} diff --git a/packages/cli/src/lib/dsn/languages/java.ts b/packages/cli/src/lib/dsn/languages/java.ts new file mode 100644 index 000000000..ef36386bb --- /dev/null +++ b/packages/cli/src/lib/dsn/languages/java.ts @@ -0,0 +1,77 @@ +/** + * Java/Kotlin DSN Detection + * + * Detects DSN from Java and Kotlin source code patterns. + * Also checks sentry.properties files. + * Looks for options.setDsn("...") and dsn=... patterns. + */ + +import type { LanguageDetector } from "./types.js"; + +/** + * Regex for Java/Kotlin code: options.setDsn("...") + */ +const DSN_PATTERN_SET_DSN = /\.setDsn\s*\(\s*["']([^"']+)["']\s*\)/s; + +/** + * Regex for sentry.properties file: dsn=... + */ +const DSN_PATTERN_PROPERTIES = /^dsn\s*=\s*(.+)$/m; + +/** + * Generic pattern for DSN in annotations or config + * Matches: dsn = "..." or "dsn", "..." + */ +const DSN_PATTERN_GENERIC = + /["']?dsn["']?\s*[=,]\s*["'](https?:\/\/[^"']+@[^"']+)["']/s; + +/** + * Extract DSN string from Java/Kotlin code or properties content. + * + * @param content - Source code or properties content + * @returns DSN string or null if not found + */ +export function extractDsnFromJava(content: string): string | null { + // Try setDsn() pattern first (most common in Java) + const setDsnMatch = content.match(DSN_PATTERN_SET_DSN); + if (setDsnMatch?.[1]) { + return setDsnMatch[1]; + } + + // Try properties file pattern (dsn=...) + const propsMatch = content.match(DSN_PATTERN_PROPERTIES); + if (propsMatch?.[1]) { + const dsn = propsMatch[1].trim(); + // Validate it looks like a DSN + if (dsn.startsWith("https://") && dsn.includes("@")) { + return dsn; + } + } + + // Try generic pattern + const genericMatch = content.match(DSN_PATTERN_GENERIC); + if (genericMatch?.[1]) { + return genericMatch[1]; + } + + return null; +} + +/** + * Java/Kotlin language detector. + */ +export const javaDetector: LanguageDetector = { + name: "Java", + extensions: [".java", ".kt", ".properties"], + skipDirs: [ + "target", + "build", + ".gradle", + ".idea", + ".git", + "out", + "bin", + ".mvn", + ], + extractDsn: extractDsnFromJava, +}; diff --git a/packages/cli/src/lib/dsn/languages/javascript.ts b/packages/cli/src/lib/dsn/languages/javascript.ts index 9d4231718..43a90726c 100644 --- a/packages/cli/src/lib/dsn/languages/javascript.ts +++ b/packages/cli/src/lib/dsn/languages/javascript.ts @@ -5,33 +5,7 @@ * Looks for Sentry.init({ dsn: "..." }) and similar patterns. */ -import { join } from "node:path"; -import { createDetectedDsn } from "../parser.js"; -import type { DetectedDsn } from "../types.js"; - -/** - * Glob pattern for JavaScript/TypeScript source files - */ -export const CODE_GLOB = new Bun.Glob("**/*.{ts,tsx,js,jsx,mjs,cjs}"); - -/** - * Directories to skip when searching for source files - * These are typically dependencies or build outputs - */ -export const SKIP_DIRS = new Set([ - "node_modules", - ".git", - "dist", - "build", - ".next", - ".nuxt", - ".output", - "coverage", - ".turbo", - ".cache", - ".vercel", - ".netlify", -]); +import type { LanguageDetector } from "./types.js"; /** * Regex patterns for extracting DSN from code. @@ -43,7 +17,7 @@ const DSN_PATTERN_INIT = const DSN_PATTERN_GENERIC = /dsn\s*:\s*["'`](https?:\/\/[^"'`]+@[^"'`]+)["'`]/s; /** - * Extract DSN string from code content using regex + * Extract DSN string from JavaScript/TypeScript code content. * * @param content - Source code content * @returns DSN string or null if not found @@ -65,81 +39,24 @@ export function extractDsnFromCode(content: string): string | null { } /** - * Check if a path should be skipped during scanning - */ -function shouldSkipPath(filepath: string): boolean { - const parts = filepath.split("/"); - return parts.some((part) => SKIP_DIRS.has(part)); -} - -/** - * Detect DSN from JavaScript/TypeScript source code - * - * Scans all JS/TS files in the directory (excluding node_modules, etc.) - * and returns the first valid DSN found. - * - * @param cwd - Directory to search in - * @returns First detected DSN or null if not found - */ -export async function detectFromCode(cwd: string): Promise { - for await (const relativePath of CODE_GLOB.scan({ cwd, onlyFiles: true })) { - // Skip node_modules and other non-source directories - if (shouldSkipPath(relativePath)) { - continue; - } - - const filepath = join(cwd, relativePath); - const file = Bun.file(filepath); - - try { - const content = await file.text(); - const dsn = extractDsnFromCode(content); - - if (dsn) { - return createDetectedDsn(dsn, "code", relativePath); - } - } catch { - // Skip files we can't read - } - } - - return null; -} - -/** - * Detect DSN from ALL JavaScript/TypeScript source files (for conflict detection) - * - * Unlike detectFromCode, this doesn't stop at the first match. - * Used to find all DSNs when checking for conflicts. - * - * @param cwd - Directory to search in - * @returns Array of all detected DSNs - */ -export async function detectAllFromCode(cwd: string): Promise { - const results: DetectedDsn[] = []; - - for await (const relativePath of CODE_GLOB.scan({ cwd, onlyFiles: true })) { - if (shouldSkipPath(relativePath)) { - continue; - } - - const filepath = join(cwd, relativePath); - const file = Bun.file(filepath); - - try { - const content = await file.text(); - const dsn = extractDsnFromCode(content); - - if (dsn) { - const detected = createDetectedDsn(dsn, "code", relativePath); - if (detected) { - results.push(detected); - } - } - } catch { - // Skip files we can't read - } - } - - return results; -} + * JavaScript/TypeScript language detector. + */ +export const javascriptDetector: LanguageDetector = { + name: "JavaScript", + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], + skipDirs: [ + "node_modules", + ".git", + "dist", + "build", + ".next", + ".nuxt", + ".output", + "coverage", + ".turbo", + ".cache", + ".vercel", + ".netlify", + ], + extractDsn: extractDsnFromCode, +}; diff --git a/packages/cli/src/lib/dsn/languages/php.ts b/packages/cli/src/lib/dsn/languages/php.ts new file mode 100644 index 000000000..23188851d --- /dev/null +++ b/packages/cli/src/lib/dsn/languages/php.ts @@ -0,0 +1,53 @@ +/** + * PHP DSN Detection + * + * Detects DSN from PHP source code patterns. + * Looks for \Sentry\init(['dsn' => '...']) and similar patterns. + */ + +import type { LanguageDetector } from "./types.js"; + +/** + * Regex patterns for extracting DSN from PHP code. + * Matches: \Sentry\init(['dsn' => '...']) or Sentry\init(["dsn" => "..."]) + */ +const DSN_PATTERN_INIT = + /\\?Sentry\\init\s*\(\s*\[[^\]]*['"]dsn['"]\s*=>\s*['"]([^'"]+)['"]/s; + +/** + * Generic pattern for 'dsn' => '...' in PHP arrays + */ +const DSN_PATTERN_GENERIC = + /['"]dsn['"]\s*=>\s*['"](https?:\/\/[^'"]+@[^'"]+)['"]/s; + +/** + * Extract DSN string from PHP code content. + * + * @param content - Source code content + * @returns DSN string or null if not found + */ +export function extractDsnFromPhp(content: string): string | null { + // Try Sentry\init pattern first (more specific) + const initMatch = content.match(DSN_PATTERN_INIT); + if (initMatch?.[1]) { + return initMatch[1]; + } + + // Try generic 'dsn' => '...' pattern + const genericMatch = content.match(DSN_PATTERN_GENERIC); + if (genericMatch?.[1]) { + return genericMatch[1]; + } + + return null; +} + +/** + * PHP language detector. + */ +export const phpDetector: LanguageDetector = { + name: "PHP", + extensions: [".php"], + skipDirs: ["vendor", ".git", "cache", "storage/framework"], + extractDsn: extractDsnFromPhp, +}; diff --git a/packages/cli/src/lib/dsn/languages/python.ts b/packages/cli/src/lib/dsn/languages/python.ts new file mode 100644 index 000000000..e95debc42 --- /dev/null +++ b/packages/cli/src/lib/dsn/languages/python.ts @@ -0,0 +1,69 @@ +/** + * Python DSN Detection + * + * Detects DSN from Python source code patterns. + * Looks for sentry_sdk.init(dsn="...") and similar patterns. + */ + +import type { LanguageDetector } from "./types.js"; + +/** + * Regex patterns for extracting DSN from Python code. + * Matches: sentry_sdk.init(dsn="...") or sentry_sdk.init(dsn='...') + * Also matches keyword argument style: sentry_sdk.init(\n dsn="...",\n) + */ +const DSN_PATTERN_INIT = + /sentry_sdk\.init\s*\([^)]*dsn\s*=\s*["']([^"']+)["']/s; + +/** + * Generic pattern for dsn= in Python (catches dict-style configs) + * Matches: "dsn": "..." or 'dsn': '...' or dsn="..." or dsn='...' + */ +const DSN_PATTERN_GENERIC = + /["']?dsn["']?\s*[:=]\s*["'](https?:\/\/[^"']+@[^"']+)["']/s; + +/** + * Extract DSN string from Python code content. + * + * @param content - Source code content + * @returns DSN string or null if not found + */ +export function extractDsnFromPython(content: string): string | null { + // Try sentry_sdk.init pattern first (more specific) + const initMatch = content.match(DSN_PATTERN_INIT); + if (initMatch?.[1]) { + return initMatch[1]; + } + + // Try generic dsn pattern + const genericMatch = content.match(DSN_PATTERN_GENERIC); + if (genericMatch?.[1]) { + return genericMatch[1]; + } + + return null; +} + +/** + * Python language detector. + */ +export const pythonDetector: LanguageDetector = { + name: "Python", + extensions: [".py"], + skipDirs: [ + "venv", + ".venv", + "env", + ".env", + "__pycache__", + ".tox", + ".nox", + "site-packages", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "dist", + "build", + ], + extractDsn: extractDsnFromPython, +}; diff --git a/packages/cli/src/lib/dsn/languages/ruby.ts b/packages/cli/src/lib/dsn/languages/ruby.ts new file mode 100644 index 000000000..13f2d8597 --- /dev/null +++ b/packages/cli/src/lib/dsn/languages/ruby.ts @@ -0,0 +1,61 @@ +/** + * Ruby DSN Detection + * + * Detects DSN from Ruby source code patterns. + * Looks for Sentry.init { |config| config.dsn = '...' } and similar patterns. + */ + +import type { LanguageDetector } from "./types.js"; + +/** + * Regex patterns for extracting DSN from Ruby code. + * Matches: config.dsn = '...' or config.dsn = "..." + */ +const DSN_PATTERN_CONFIG = /config\.dsn\s*=\s*['"]([^'"]+)['"]/s; + +/** + * Generic pattern for dsn in Ruby hashes + * Matches: dsn: '...' or :dsn => '...' + */ +const DSN_PATTERN_HASH = + /(?:dsn:|:dsn\s*=>)\s*['"](https?:\/\/[^'"]+@[^'"]+)['"]/s; + +/** + * Extract DSN string from Ruby code content. + * + * @param content - Source code content + * @returns DSN string or null if not found + */ +export function extractDsnFromRuby(content: string): string | null { + // Try config.dsn pattern first (most common in Sentry.init block) + const configMatch = content.match(DSN_PATTERN_CONFIG); + if (configMatch?.[1]) { + return configMatch[1]; + } + + // Try hash-style pattern + const hashMatch = content.match(DSN_PATTERN_HASH); + if (hashMatch?.[1]) { + return hashMatch[1]; + } + + return null; +} + +/** + * Ruby language detector. + */ +export const rubyDetector: LanguageDetector = { + name: "Ruby", + extensions: [".rb"], + skipDirs: [ + "vendor/bundle", + ".bundle", + "tmp", + "log", + ".git", + "coverage", + "pkg", + ], + extractDsn: extractDsnFromRuby, +}; diff --git a/packages/cli/src/lib/dsn/languages/types.ts b/packages/cli/src/lib/dsn/languages/types.ts new file mode 100644 index 000000000..6bbd22de5 --- /dev/null +++ b/packages/cli/src/lib/dsn/languages/types.ts @@ -0,0 +1,29 @@ +/** + * Language Detector Interface + * + * Common interface for all language-specific DSN detectors. + * Each language implements this interface to detect DSNs from its source files. + */ + +/** + * Language-specific DSN detector. + * Each supported language provides a detector implementing this type. + */ +export type LanguageDetector = { + /** Display name for the language (e.g., "Python", "JavaScript") */ + name: string; + + /** File extensions to scan (e.g., [".py"], [".ts", ".tsx"]) */ + extensions: string[]; + + /** Directories to skip when scanning (e.g., ["node_modules", "venv"]) */ + skipDirs: string[]; + + /** + * Extract DSN string from file content. + * + * @param content - File content to search + * @returns DSN string if found, null otherwise + */ + extractDsn: (content: string) => string | null; +}; diff --git a/packages/cli/test/lib/dsn/languages/go.test.ts b/packages/cli/test/lib/dsn/languages/go.test.ts new file mode 100644 index 000000000..aa2896cf8 --- /dev/null +++ b/packages/cli/test/lib/dsn/languages/go.test.ts @@ -0,0 +1,149 @@ +/** + * Go DSN Detector Tests + * + * Tests for extracting DSN from Go source code. + */ + +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", () => { + describe("extractDsnFromGo", () => { + describe("struct field pattern", () => { + test("extracts DSN from sentry.Init with ClientOptions", () => { + const code = ` +package main + +import "github.com/getsentry/sentry-go" + +func main() { + sentry.Init(sentry.ClientOptions{ + Dsn: "${TEST_DSN}", + }) +} +`; + expect(extractDsnFromGo(code)).toBe(TEST_DSN); + }); + + test("extracts DSN with single quotes (raw string)", () => { + const code = ` +sentry.Init(sentry.ClientOptions{ + Dsn: '${TEST_DSN}', +}) +`; + expect(extractDsnFromGo(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from multiline struct", () => { + const code = ` +err := sentry.Init(sentry.ClientOptions{ + Dsn: "${TEST_DSN}", + Environment: "production", + TracesSampleRate: 1.0, +}) +`; + expect(extractDsnFromGo(code)).toBe(TEST_DSN); + }); + + test("extracts DSN when not first field", () => { + const code = ` +sentry.Init(sentry.ClientOptions{ + Environment: "production", + Dsn: "${TEST_DSN}", + Debug: true, +}) +`; + expect(extractDsnFromGo(code)).toBe(TEST_DSN); + }); + }); + + describe("assignment pattern", () => { + test("extracts DSN from short variable declaration", () => { + const code = ` +dsn := "${TEST_DSN}" +sentry.Init(sentry.ClientOptions{Dsn: dsn}) +`; + expect(extractDsnFromGo(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from regular assignment", () => { + const code = ` +var dsn string +dsn = "${TEST_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); + }); + }); + + describe("edge cases", () => { + test("returns null when no DSN found", () => { + const code = ` +package main + +import "fmt" + +func main() { + fmt.Println("Hello world") +} +`; + expect(extractDsnFromGo(code)).toBeNull(); + }); + + test("returns null for empty content", () => { + expect(extractDsnFromGo("")).toBeNull(); + }); + + test("returns null for DSN from os.Getenv", () => { + const code = ` +sentry.Init(sentry.ClientOptions{ + Dsn: os.Getenv("SENTRY_DSN"), +}) +`; + expect(extractDsnFromGo(code)).toBeNull(); + }); + + test("returns null for DSN from viper config", () => { + const code = ` +sentry.Init(sentry.ClientOptions{ + Dsn: viper.GetString("sentry.dsn"), +}) +`; + expect(extractDsnFromGo(code)).toBeNull(); + }); + }); + }); + + describe("goDetector configuration", () => { + test("has correct name", () => { + expect(goDetector.name).toBe("Go"); + }); + + test("includes .go extension", () => { + expect(goDetector.extensions).toContain(".go"); + }); + + test("skips vendor directory", () => { + expect(goDetector.skipDirs).toContain("vendor"); + }); + + test("skips testdata directory", () => { + expect(goDetector.skipDirs).toContain("testdata"); + }); + + test("extractDsn is the extractDsnFromGo function", () => { + expect(goDetector.extractDsn).toBe(extractDsnFromGo); + }); + }); +}); diff --git a/packages/cli/test/lib/dsn/languages/java.test.ts b/packages/cli/test/lib/dsn/languages/java.test.ts new file mode 100644 index 000000000..b1e21c675 --- /dev/null +++ b/packages/cli/test/lib/dsn/languages/java.test.ts @@ -0,0 +1,204 @@ +/** + * Java/Kotlin DSN Detector Tests + * + * Tests for extracting DSN from Java and Kotlin source code, + * as well as sentry.properties files. + */ + +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", () => { + describe("extractDsnFromJava", () => { + describe("setDsn pattern", () => { + test("extracts DSN from options.setDsn with double quotes", () => { + const code = ` +import io.sentry.Sentry; +import io.sentry.SentryOptions; + +public class SentryConfig { + public static void init() { + Sentry.init(options -> { + options.setDsn("${TEST_DSN}"); + }); + } +} +`; + expect(extractDsnFromJava(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from setDsn with single quotes (Kotlin)", () => { + const code = ` +Sentry.init { options -> + options.setDsn('${TEST_DSN}') +} +`; + expect(extractDsnFromJava(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from chained setDsn call", () => { + const code = ` +Sentry.init(options -> options + .setDsn("${TEST_DSN}") + .setEnvironment("production") +); +`; + expect(extractDsnFromJava(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from options object", () => { + const code = ` +SentryOptions options = new SentryOptions(); +options.setDsn("${TEST_DSN}"); +options.setEnvironment("production"); +Sentry.init(options); +`; + expect(extractDsnFromJava(code)).toBe(TEST_DSN); + }); + }); + + describe("properties file pattern", () => { + test("extracts DSN from sentry.properties", () => { + const content = ` +# Sentry configuration +dsn=${TEST_DSN} +environment=production +`; + expect(extractDsnFromJava(content)).toBe(TEST_DSN); + }); + + test("extracts DSN from properties with no spaces", () => { + const content = `dsn=${TEST_DSN}`; + expect(extractDsnFromJava(content)).toBe(TEST_DSN); + }); + + test("extracts DSN from properties with spaces around equals", () => { + const content = `dsn = ${TEST_DSN}`; + expect(extractDsnFromJava(content)).toBe(TEST_DSN); + }); + + test("ignores invalid DSN in properties", () => { + const content = "dsn=not-a-valid-dsn"; + expect(extractDsnFromJava(content)).toBeNull(); + }); + }); + + describe("generic pattern", () => { + test("extracts DSN from annotation-style config", () => { + const code = ` +@Configuration +public class SentryConfig { + private String dsn = "${TEST_DSN}"; +} +`; + expect(extractDsnFromJava(code)).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("extracts DSN from Map initialization", () => { + const code = ` +Map config = Map.of( + "dsn", "${TEST_DSN}", + "environment", "production" +); +`; + expect(extractDsnFromJava(code)).toBe(TEST_DSN); + }); + }); + + describe("edge cases", () => { + test("returns null when no DSN found", () => { + const code = ` +public class Main { + public static void main(String[] args) { + System.out.println("Hello world"); + } +} +`; + expect(extractDsnFromJava(code)).toBeNull(); + }); + + test("returns null for empty content", () => { + expect(extractDsnFromJava("")).toBeNull(); + }); + + test("returns null for DSN from System.getenv", () => { + const code = ` +Sentry.init(options -> { + options.setDsn(System.getenv("SENTRY_DSN")); +}); +`; + expect(extractDsnFromJava(code)).toBeNull(); + }); + + test("returns null for DSN from properties.getProperty", () => { + const code = ` +Sentry.init(options -> { + options.setDsn(properties.getProperty("sentry.dsn")); +}); +`; + expect(extractDsnFromJava(code)).toBeNull(); + }); + + test("returns null for DSN from BuildConfig (Android)", () => { + const code = ` +Sentry.init(options -> { + options.setDsn(BuildConfig.SENTRY_DSN); +}); +`; + expect(extractDsnFromJava(code)).toBeNull(); + }); + }); + }); + + describe("javaDetector configuration", () => { + test("has correct name", () => { + expect(javaDetector.name).toBe("Java"); + }); + + test("includes Java extension", () => { + expect(javaDetector.extensions).toContain(".java"); + }); + + test("includes Kotlin extension", () => { + expect(javaDetector.extensions).toContain(".kt"); + }); + + test("includes properties extension", () => { + expect(javaDetector.extensions).toContain(".properties"); + }); + + test("skips target directory (Maven)", () => { + expect(javaDetector.skipDirs).toContain("target"); + }); + + test("skips build directory (Gradle)", () => { + expect(javaDetector.skipDirs).toContain("build"); + }); + + test("skips .gradle directory", () => { + expect(javaDetector.skipDirs).toContain(".gradle"); + }); + + test("skips .idea directory", () => { + expect(javaDetector.skipDirs).toContain(".idea"); + }); + + test("extractDsn is the extractDsnFromJava function", () => { + expect(javaDetector.extractDsn).toBe(extractDsnFromJava); + }); + }); +}); diff --git a/packages/cli/test/lib/dsn/languages/javascript.test.ts b/packages/cli/test/lib/dsn/languages/javascript.test.ts new file mode 100644 index 000000000..bd98683cc --- /dev/null +++ b/packages/cli/test/lib/dsn/languages/javascript.test.ts @@ -0,0 +1,159 @@ +/** + * JavaScript DSN Detector Tests + * + * Tests for extracting DSN from JavaScript/TypeScript source code. + */ + +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", () => { + describe("extractDsnFromCode", () => { + describe("Sentry.init pattern", () => { + test("extracts DSN from basic Sentry.init", () => { + const code = ` + import * as Sentry from "@sentry/react"; + + Sentry.init({ + dsn: "${TEST_DSN}", + }); + `; + expect(extractDsnFromCode(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from single-line Sentry.init", () => { + const code = `Sentry.init({ dsn: "${TEST_DSN}" });`; + expect(extractDsnFromCode(code)).toBe(TEST_DSN); + }); + + test("extracts DSN with single quotes", () => { + const code = `Sentry.init({ dsn: '${TEST_DSN}' });`; + expect(extractDsnFromCode(code)).toBe(TEST_DSN); + }); + + test("extracts DSN with template literal", () => { + const code = `Sentry.init({ dsn: \`${TEST_DSN}\` });`; + expect(extractDsnFromCode(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from Sentry.init with multiple options", () => { + const code = ` + Sentry.init({ + dsn: "${TEST_DSN}", + tracesSampleRate: 1.0, + environment: "production", + }); + `; + expect(extractDsnFromCode(code)).toBe(TEST_DSN); + }); + + test("extracts DSN when dsn is not first property", () => { + const code = ` + Sentry.init({ + environment: "production", + dsn: "${TEST_DSN}", + debug: true, + }); + `; + expect(extractDsnFromCode(code)).toBe(TEST_DSN); + }); + }); + + describe("generic dsn pattern", () => { + test("extracts DSN from config object", () => { + const code = ` + const config = { + dsn: "${TEST_DSN}", + }; + `; + expect(extractDsnFromCode(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from exported config", () => { + const code = ` + export const sentryConfig = { + dsn: "${TEST_DSN}", + enabled: true, + }; + `; + expect(extractDsnFromCode(code)).toBe(TEST_DSN); + }); + }); + + describe("edge cases", () => { + test("returns null when no DSN found", () => { + const code = ` + import * as Sentry from "@sentry/react"; + console.log("No DSN here"); + `; + expect(extractDsnFromCode(code)).toBeNull(); + }); + + test("returns null for empty content", () => { + expect(extractDsnFromCode("")).toBeNull(); + }); + + test("returns null for DSN from env variable (not hardcoded)", () => { + const code = ` + Sentry.init({ + dsn: process.env.SENTRY_DSN, + }); + `; + expect(extractDsnFromCode(code)).toBeNull(); + }); + + test("ignores commented out DSN", () => { + const code = ` + // dsn: "${TEST_DSN}", + Sentry.init({}); + `; + // Our regex will still match this - we're testing actual behavior + // A more sophisticated parser could skip comments + 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); + }); + }); + }); + + describe("javascriptDetector configuration", () => { + test("has correct name", () => { + expect(javascriptDetector.name).toBe("JavaScript"); + }); + + test("includes all JS/TS extensions", () => { + expect(javascriptDetector.extensions).toContain(".ts"); + expect(javascriptDetector.extensions).toContain(".tsx"); + expect(javascriptDetector.extensions).toContain(".js"); + expect(javascriptDetector.extensions).toContain(".jsx"); + expect(javascriptDetector.extensions).toContain(".mjs"); + expect(javascriptDetector.extensions).toContain(".cjs"); + }); + + test("skips node_modules", () => { + expect(javascriptDetector.skipDirs).toContain("node_modules"); + }); + + test("skips common build directories", () => { + expect(javascriptDetector.skipDirs).toContain("dist"); + expect(javascriptDetector.skipDirs).toContain("build"); + expect(javascriptDetector.skipDirs).toContain(".next"); + }); + + test("extractDsn is the extractDsnFromCode function", () => { + expect(javascriptDetector.extractDsn).toBe(extractDsnFromCode); + }); + }); +}); diff --git a/packages/cli/test/lib/dsn/languages/php.test.ts b/packages/cli/test/lib/dsn/languages/php.test.ts new file mode 100644 index 000000000..d6635a002 --- /dev/null +++ b/packages/cli/test/lib/dsn/languages/php.test.ts @@ -0,0 +1,154 @@ +/** + * PHP DSN Detector Tests + * + * Tests for extracting DSN from PHP source code. + */ + +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", () => { + describe("extractDsnFromPhp", () => { + describe("Sentry\\init pattern", () => { + test("extracts DSN from Sentry\\init with single quotes", () => { + const code = ` + '${TEST_DSN}']); +`; + expect(extractDsnFromPhp(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from Sentry\\init with double quotes", () => { + const code = ` + "${TEST_DSN}"]); +`; + expect(extractDsnFromPhp(code)).toBe(TEST_DSN); + }); + + test("extracts DSN without leading backslash", () => { + const code = ` + '${TEST_DSN}']); +`; + expect(extractDsnFromPhp(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from multiline init", () => { + const code = ` + '${TEST_DSN}', + 'environment' => 'production', +]); +`; + expect(extractDsnFromPhp(code)).toBe(TEST_DSN); + }); + + test("extracts DSN when not first in array", () => { + const code = ` + 'production', + 'dsn' => '${TEST_DSN}', + 'traces_sample_rate' => 1.0, +]); +`; + expect(extractDsnFromPhp(code)).toBe(TEST_DSN); + }); + }); + + describe("generic array pattern", () => { + test("extracts DSN from config array", () => { + const code = ` + '${TEST_DSN}', + 'release' => 'v1.0.0', +]; +`; + 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 in array", () => { + const code = ` + "${TEST_DSN}", +]; +`; + expect(extractDsnFromPhp(code)).toBe(TEST_DSN); + }); + }); + + describe("edge cases", () => { + test("returns null when no DSN found", () => { + const code = ` + { + expect(extractDsnFromPhp("")).toBeNull(); + }); + + test("returns null for DSN from env function", () => { + const code = ` + env('SENTRY_DSN')]); +`; + expect(extractDsnFromPhp(code)).toBeNull(); + }); + + test("returns null for DSN from getenv", () => { + const code = ` + getenv('SENTRY_DSN')]); +`; + expect(extractDsnFromPhp(code)).toBeNull(); + }); + }); + }); + + describe("phpDetector configuration", () => { + test("has correct name", () => { + expect(phpDetector.name).toBe("PHP"); + }); + + test("includes .php extension", () => { + expect(phpDetector.extensions).toContain(".php"); + }); + + test("skips vendor directory", () => { + expect(phpDetector.skipDirs).toContain("vendor"); + }); + + test("skips cache directory", () => { + expect(phpDetector.skipDirs).toContain("cache"); + }); + + test("extractDsn is the extractDsnFromPhp function", () => { + expect(phpDetector.extractDsn).toBe(extractDsnFromPhp); + }); + }); +}); diff --git a/packages/cli/test/lib/dsn/languages/python.test.ts b/packages/cli/test/lib/dsn/languages/python.test.ts new file mode 100644 index 000000000..d1eb54822 --- /dev/null +++ b/packages/cli/test/lib/dsn/languages/python.test.ts @@ -0,0 +1,159 @@ +/** + * Python DSN Detector Tests + * + * Tests for extracting DSN from Python source code. + */ + +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", () => { + describe("extractDsnFromPython", () => { + describe("sentry_sdk.init pattern", () => { + test("extracts DSN from basic sentry_sdk.init with double quotes", () => { + const code = ` +import sentry_sdk + +sentry_sdk.init(dsn="${TEST_DSN}") +`; + expect(extractDsnFromPython(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from sentry_sdk.init with single quotes", () => { + const code = `sentry_sdk.init(dsn='${TEST_DSN}')`; + expect(extractDsnFromPython(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from multiline sentry_sdk.init", () => { + const code = ` +sentry_sdk.init( + dsn="${TEST_DSN}", + traces_sample_rate=1.0, +) +`; + expect(extractDsnFromPython(code)).toBe(TEST_DSN); + }); + + test("extracts DSN when not first argument", () => { + const code = ` +sentry_sdk.init( + environment="production", + dsn="${TEST_DSN}", +) +`; + expect(extractDsnFromPython(code)).toBe(TEST_DSN); + }); + + test("extracts DSN with integrations", () => { + const code = ` +sentry_sdk.init( + dsn="${TEST_DSN}", + integrations=[ + DjangoIntegration(), + ], +) +`; + expect(extractDsnFromPython(code)).toBe(TEST_DSN); + }); + }); + + describe("dict-style config pattern", () => { + test("extracts DSN from dict with double quotes", () => { + const code = ` +SENTRY_CONFIG = { + "dsn": "${TEST_DSN}", + "environment": "production", +} +`; + expect(extractDsnFromPython(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from dict with single quotes", () => { + const code = ` +SENTRY_CONFIG = { + 'dsn': '${TEST_DSN}', +} +`; + 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); + }); + }); + + describe("edge cases", () => { + test("returns null when no DSN found", () => { + const code = ` +import sentry_sdk +print("Hello world") +`; + expect(extractDsnFromPython(code)).toBeNull(); + }); + + test("returns null for empty content", () => { + expect(extractDsnFromPython("")).toBeNull(); + }); + + 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("returns null for DSN from getenv", () => { + const code = ` +sentry_sdk.init(dsn=os.getenv("SENTRY_DSN")) +`; + expect(extractDsnFromPython(code)).toBeNull(); + }); + }); + }); + + describe("pythonDetector configuration", () => { + test("has correct name", () => { + expect(pythonDetector.name).toBe("Python"); + }); + + test("includes .py extension", () => { + expect(pythonDetector.extensions).toContain(".py"); + }); + + test("skips virtual environment directories", () => { + expect(pythonDetector.skipDirs).toContain("venv"); + expect(pythonDetector.skipDirs).toContain(".venv"); + expect(pythonDetector.skipDirs).toContain("env"); + }); + + test("skips __pycache__", () => { + expect(pythonDetector.skipDirs).toContain("__pycache__"); + }); + + test("skips common cache directories", () => { + expect(pythonDetector.skipDirs).toContain(".pytest_cache"); + expect(pythonDetector.skipDirs).toContain(".mypy_cache"); + }); + + test("extractDsn is the extractDsnFromPython function", () => { + expect(pythonDetector.extractDsn).toBe(extractDsnFromPython); + }); + }); +}); diff --git a/packages/cli/test/lib/dsn/languages/ruby.test.ts b/packages/cli/test/lib/dsn/languages/ruby.test.ts new file mode 100644 index 000000000..6719d627d --- /dev/null +++ b/packages/cli/test/lib/dsn/languages/ruby.test.ts @@ -0,0 +1,143 @@ +/** + * Ruby DSN Detector Tests + * + * Tests for extracting DSN from Ruby source code. + */ + +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", () => { + describe("extractDsnFromRuby", () => { + describe("config.dsn pattern", () => { + test("extracts DSN from Sentry.init block with single quotes", () => { + const code = ` +Sentry.init do |config| + config.dsn = '${TEST_DSN}' +end +`; + expect(extractDsnFromRuby(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from Sentry.init block with double quotes", () => { + 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 with spaces around equals", () => { + const code = `config.dsn = "${TEST_DSN}"`; + expect(extractDsnFromRuby(code)).toBe(TEST_DSN); + }); + }); + + describe("hash pattern", () => { + test("extracts DSN from symbol key hash (new syntax)", () => { + const code = ` +sentry_config = { + dsn: '${TEST_DSN}', + environment: 'production' +} +`; + expect(extractDsnFromRuby(code)).toBe(TEST_DSN); + }); + + test("extracts DSN from hash rocket syntax", () => { + const code = ` +sentry_config = { + :dsn => '${TEST_DSN}', + :environment => 'production' +} +`; + expect(extractDsnFromRuby(code)).toBe(TEST_DSN); + }); + + test("extracts DSN with double quotes in hash", () => { + const code = ` +config = { + dsn: "${TEST_DSN}" +} +`; + expect(extractDsnFromRuby(code)).toBe(TEST_DSN); + }); + }); + + describe("edge cases", () => { + test("returns null when no DSN found", () => { + const code = ` +puts "Hello world" +`; + expect(extractDsnFromRuby(code)).toBeNull(); + }); + + test("returns null for empty content", () => { + expect(extractDsnFromRuby("")).toBeNull(); + }); + + test("returns null for DSN from ENV", () => { + const code = ` +Sentry.init do |config| + config.dsn = ENV['SENTRY_DSN'] +end +`; + expect(extractDsnFromRuby(code)).toBeNull(); + }); + + test("returns null for DSN from ENV.fetch", () => { + const code = ` +Sentry.init do |config| + config.dsn = ENV.fetch('SENTRY_DSN') +end +`; + expect(extractDsnFromRuby(code)).toBeNull(); + }); + }); + }); + + describe("rubyDetector configuration", () => { + test("has correct name", () => { + expect(rubyDetector.name).toBe("Ruby"); + }); + + test("includes .rb extension", () => { + expect(rubyDetector.extensions).toContain(".rb"); + }); + + test("skips vendor/bundle", () => { + expect(rubyDetector.skipDirs).toContain("vendor/bundle"); + }); + + test("skips .bundle", () => { + expect(rubyDetector.skipDirs).toContain(".bundle"); + }); + + test("skips tmp and log directories", () => { + expect(rubyDetector.skipDirs).toContain("tmp"); + expect(rubyDetector.skipDirs).toContain("log"); + }); + + test("extractDsn is the extractDsnFromRuby function", () => { + expect(rubyDetector.extractDsn).toBe(extractDsnFromRuby); + }); + }); +});