import { describe, it, expect } from 'vitest'; import { TEST_RUNNERS, isTestRunner, detectLanguage, scanContent, scanFileWith, } from './static-content-scanner.mjs'; describe('isTestRunner', () => { it('matches PHP/Laravel test runners', () => { expect(isTestRunner('composer test')).toBe(true); expect(isTestRunner('php artisan test --parallel')).toBe(true); expect(isTestRunner('vendor/bin/pest')).toBe(true); expect(isTestRunner('vendor/bin/phpunit tests/Unit')).toBe(true); }); it('matches Ruby / Go / Java / Rust / .NET / JS runners', () => { expect(isTestRunner('bundle exec rspec')).toBe(true); expect(isTestRunner('go test ./...')).toBe(true); expect(isTestRunner('mvn test')).toBe(true); expect(isTestRunner('gradle build')).toBe(true); expect(isTestRunner('cargo test')).toBe(true); expect(isTestRunner('dotnet test')).toBe(true); expect(isTestRunner('npx vitest run')).toBe(true); expect(isTestRunner('npm run test')).toBe(true); }); it('does not match unrelated commands', () => { expect(isTestRunner('git status')).toBe(false); expect(isTestRunner('ls -la')).toBe(false); expect(isTestRunner('composer install')).toBe(false); expect(isTestRunner('')).toBe(false); expect(isTestRunner(null)).toBe(false); }); it('exposes TEST_RUNNERS as a non-empty array', () => { expect(Array.isArray(TEST_RUNNERS)).toBe(true); expect(TEST_RUNNERS.length).toBeGreaterThanOrEqual(11); }); }); describe('detectLanguage', () => { it('maps extensions to language keys', () => { expect(detectLanguage('app/Providers/AppServiceProvider.php')).toBe('php'); expect(detectLanguage('config/routes.rb')).toBe('ruby'); expect(detectLanguage('main.go')).toBe('go'); expect(detectLanguage('src/Main.java')).toBe('java'); expect(detectLanguage('src/Config.kt')).toBe('java'); // JVM expect(detectLanguage('src/lib.rs')).toBe('rust'); expect(detectLanguage('Program.cs')).toBe('dotnet'); }); it('is case-insensitive on extension', () => { expect(detectLanguage('Foo.PHP')).toBe('php'); }); it('returns null for unknown / extensionless / non-string', () => { expect(detectLanguage('notes.txt')).toBeNull(); expect(detectLanguage('Makefile')).toBeNull(); expect(detectLanguage(42)).toBeNull(); }); }); describe('scanContent — always-suspicious patterns', () => { it('flags PHP code-execution sinks', () => { const names = scanContent(' f.name); expect(names).toContain('exec'); expect(names).toContain('eval'); }); it('flags PHP shell_exec / proc_open / pcntl / backticks', () => { const names = scanContent(' f.name); expect(names).toContain('shell_exec'); expect(names).toContain('proc_open'); expect(names).toContain('backtick'); }); it('flags Ruby eval/system/popen/backticks', () => { const names = scanContent('Kernel.eval(x); system("rm"); IO.popen(c); y = `ls`', 'ruby').map((f) => f.name); expect(names).toContain('Kernel.eval'); expect(names).toContain('system'); expect(names).toContain('IO.popen'); expect(names).toContain('backtick'); }); it('flags Go exec + reflect co-occurrence', () => { const src = 'import "os/exec"\nexec.Command("sh")\nv := reflect.ValueOf(f); v.Call(args)'; const names = scanContent(src, 'go').map((f) => f.name); expect(names).toContain('exec.Command'); expect(names).toContain('reflect.Call'); }); it('does NOT flag Go reflect.Call when only ValueOf present (co-occurrence requires both)', () => { const names = scanContent('v := reflect.ValueOf(f)', 'go').map((f) => f.name); expect(names).not.toContain('reflect.Call'); }); it('flags Java Runtime.exec / ProcessBuilder / reflective invoke', () => { const src = 'Runtime.getRuntime().exec(c); new ProcessBuilder(c); m = cls.getMethod("x"); m.invoke(o);'; const names = scanContent(src, 'java').map((f) => f.name); expect(names).toContain('Runtime.exec'); expect(names).toContain('ProcessBuilder'); expect(names).toContain('Method.invoke'); }); it('flags Rust process::Command', () => { const names = scanContent('let o = std::process::Command::new("sh");', 'rust').map((f) => f.name); expect(names).toContain('process.Command'); }); it('flags .NET Process.Start + Assembly.Load reflective invoke', () => { const src = 'Process.Start(p); var a = Assembly.Load(b); mi.Invoke(o, null);'; const names = scanContent(src, 'dotnet').map((f) => f.name); expect(names).toContain('Process.Start'); expect(names).toContain('Assembly.Load'); }); }); describe('scanContent — protected-sensitive (file deletion / write)', () => { it('includes protected-sensitive findings by default', () => { const findings = scanContent(' f.category === 'protected_sensitive').map((f) => f.name); expect(prot).toContain('unlink'); expect(prot).toContain('file_put_contents'); }); it('omits protected-sensitive findings when includeProtectedSensitive: false', () => { const findings = scanContent(' f.category === 'protected_sensitive')).toBeUndefined(); }); it('still flags always-suspicious even with includeProtectedSensitive: false', () => { const findings = scanContent(' f.name)).toContain('exec'); }); }); describe('scanContent — guards', () => { it('returns [] for non-string source or unknown lang', () => { expect(scanContent(null, 'php')).toEqual([]); expect(scanContent('exec(', 'cobol')).toEqual([]); expect(scanContent('exec(', null)).toEqual([]); }); it('returns [] for clean source', () => { expect(scanContent(' { it('detects language from path and scans injected content', () => { const reader = (p) => (p.endsWith('.php') ? ' f.name)).toContain('system'); }); it('returns [] for unknown language path without calling reader', () => { let called = false; const reader = () => { called = true; return ''; }; expect(scanFileWith('notes.txt', reader)).toEqual([]); expect(called).toBe(false); }); it('returns [] when reader throws (unreadable file)', () => { const reader = () => { throw new Error('ENOENT'); }; expect(scanFileWith('app/Foo.php', reader)).toEqual([]); }); });