import { describe, it, expect } from 'vitest'; import { detectProjectType, bootPathSet, matchesBootPattern, intersectEditedBootFiles, extractPhpMethodBody, decideBootScan, } from './framework-boot-scanner.mjs'; describe('detectProjectType', () => { it('detects PHP/Laravel from composer.json + AppServiceProvider', () => { const exists = (p) => p === 'composer.json' || p === 'app/Providers/AppServiceProvider.php'; expect(detectProjectType(exists)).toContain('laravel'); }); it('detects Rails from Gemfile + config/application.rb', () => { const exists = (p) => p === 'Gemfile' || p === 'config/application.rb'; expect(detectProjectType(exists)).toContain('rails'); }); it('detects Express from package.json + server.js', () => { const exists = (p) => p === 'package.json' || p === 'server.js'; expect(detectProjectType(exists)).toContain('express'); }); it('detects Django from manage.py', () => { const exists = (p) => p === 'manage.py'; expect(detectProjectType(exists)).toContain('django'); }); it('returns [] when no markers present', () => { expect(detectProjectType(() => false)).toEqual([]); }); it('can detect multiple project types simultaneously', () => { const exists = (p) => ['composer.json', 'app/Providers/AppServiceProvider.php', 'package.json', 'app.js'].includes(p); const types = detectProjectType(exists); expect(types).toContain('laravel'); expect(types).toContain('express'); }); }); describe('bootPathSet', () => { it('returns Laravel boot-path glob patterns', () => { const set = bootPathSet('laravel'); expect(set).toContain('app/Providers/**/*.php'); expect(set).toContain('routes/*.php'); expect(set).toContain('app/Http/Kernel.php'); expect(set).toContain('app/Models/*.php'); }); it('returns [] for unknown project type', () => { expect(bootPathSet('cobol')).toEqual([]); }); }); describe('matchesBootPattern', () => { it('matches ** recursively across directories', () => { expect(matchesBootPattern('app/Providers/Sub/MyProvider.php', 'app/Providers/**/*.php')).toBe(true); expect(matchesBootPattern('app/Providers/AppServiceProvider.php', 'app/Providers/**/*.php')).toBe(true); }); it('matches single-segment * within one directory only', () => { expect(matchesBootPattern('routes/web.php', 'routes/*.php')).toBe(true); expect(matchesBootPattern('routes/admin/web.php', 'routes/*.php')).toBe(false); }); it('matches exact literal paths', () => { expect(matchesBootPattern('app/Http/Kernel.php', 'app/Http/Kernel.php')).toBe(true); expect(matchesBootPattern('app/Http/Other.php', 'app/Http/Kernel.php')).toBe(false); }); it('normalizes backslashes before matching', () => { expect(matchesBootPattern('app\\Providers\\AppServiceProvider.php', 'app/Providers/**/*.php')).toBe(true); }); }); describe('intersectEditedBootFiles', () => { it('returns edited files that fall under a boot-path pattern', () => { const edited = ['app/Providers/AppServiceProvider.php', 'app/Services/Foo.php', 'routes/web.php']; const hit = intersectEditedBootFiles('laravel', edited); expect(hit).toContain('app/Providers/AppServiceProvider.php'); expect(hit).toContain('routes/web.php'); expect(hit).not.toContain('app/Services/Foo.php'); }); it('returns [] when no edited file is a boot file', () => { expect(intersectEditedBootFiles('laravel', ['app/Services/Foo.php'])).toEqual([]); }); it('returns [] for empty / non-array edited list', () => { expect(intersectEditedBootFiles('laravel', [])).toEqual([]); expect(intersectEditedBootFiles('laravel', null)).toEqual([]); }); }); describe('extractPhpMethodBody', () => { it('extracts a method body by brace matching', () => { const src = 'class M { public function boot() { exec($c); } public function x() { return 1; } }'; const body = extractPhpMethodBody(src, 'boot'); expect(body).toContain('exec($c);'); expect(body).not.toContain('return 1;'); }); it('handles nested braces', () => { const src = 'function boot() { if (true) { unlink($f); } }'; expect(extractPhpMethodBody(src, 'boot')).toContain('unlink($f);'); }); it('returns empty string when method absent', () => { expect(extractPhpMethodBody('function other() {}', 'boot')).toBe(''); }); }); describe('decideBootScan', () => { const editedProvider = ['app/Providers/AppServiceProvider.php']; function readerFor(map) { return (p) => { const key = p.replace(/\\/g, '/'); if (!(key in map)) throw new Error('ENOENT'); return map[key]; }; } it('passes (block:false) when command is not a test runner', () => { const r = decideBootScan({ command: 'git status', projectTypes: ['laravel'], editedFiles: editedProvider, readFile: readerFor({}) }); expect(r.block).toBe(false); }); it('passes when no project type detected', () => { const r = decideBootScan({ command: 'composer test', projectTypes: [], editedFiles: editedProvider, readFile: readerFor({}) }); expect(r.block).toBe(false); }); it('blocks on non-empty boot-file intersection (test runner + edited boot file)', () => { const r = decideBootScan({ command: 'composer test', projectTypes: ['laravel'], editedFiles: editedProvider, readFile: readerFor({ 'app/Providers/AppServiceProvider.php': ' { const r = decideBootScan({ command: 'composer test', projectTypes: ['laravel'], editedFiles: editedProvider, readFile: readerFor({ 'app/Providers/AppServiceProvider.php': ' f.name === 'exec')).toBe(true); }); it('scans only boot() body for Model files', () => { const r = decideBootScan({ command: 'php artisan test', projectTypes: ['laravel'], editedFiles: ['app/Models/Deal.php'], readFile: readerFor({ 'app/Models/Deal.php': 'class Deal { public function scopeActive() { exec($c); } public function boot() {} }' }), }); expect(r.block).toBe(true); expect(r.findings.some((f) => f.name === 'exec')).toBe(false); }); it('still blocks even if a payload is hidden behind a } inside a string (findings are best-effort, block is the control)', () => { const r = decideBootScan({ command: 'php artisan test', projectTypes: ['laravel'], editedFiles: ['app/Models/Deal.php'], readFile: readerFor({ 'app/Models/Deal.php': 'class Deal { public function boot() { $s = "}"; exec($x); } }' }), }); // The brace matcher is string/comment-naive, so the hidden exec may be absent // from `findings` — but the intersection-based hard-block still fires, which is // the actual security control. This test pins that guarantee. expect(r.block).toBe(true); expect(r.intersection).toContain('app/Models/Deal.php'); }); });