Advertisement

← ubon

src/__tests__/vite-scanner.test.ts

12.4 KB Download file
import { mkdirSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { ViteScanner } from '../scanners/vite-scanner';

describe('ViteScanner', () => {
  const tmp = join(process.cwd(), '.tmp-vite-tests');

  beforeAll(() => {
    try { rmSync(tmp, { recursive: true, force: true }); } catch {}
    mkdirSync(tmp, { recursive: true });
    // Create vite.config.ts to indicate this is a Vite project
    writeFileSync(join(tmp, 'vite.config.ts'), `
      import { defineConfig } from 'vite';
      export default defineConfig({});
    `);
  });

  afterAll(() => {
    try { rmSync(tmp, { recursive: true, force: true }); } catch {}
  });

  describe('VITE001: Non-VITE_ Prefixed Environment Variables', () => {
    it('detects environment variables without VITE_ prefix', async () => {
      const file = join(tmp, 'env-usage.ts');
      writeFileSync(file, `
        const apiUrl = import.meta.env.API_URL;
        const apiKey = import.meta.env.SECRET_KEY;
        const dbHost = import.meta.env.DATABASE_HOST;
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite001Results = results.filter(r => r.ruleId === 'VITE001');
      expect(vite001Results.length).toBeGreaterThanOrEqual(3);
    });

    it('detects mixed valid and invalid env vars', async () => {
      const file = join(tmp, 'mixed-env.ts');
      writeFileSync(file, `
        const apiUrl = import.meta.env.VITE_API_URL; // Valid
        const secret = import.meta.env.SECRET; // Invalid
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite001Results = results.filter(r =>
        r.ruleId === 'VITE001' && r.file?.includes('mixed-env.ts')
      );
      expect(vite001Results.length).toBe(1);
      expect(vite001Results[0].message).toContain('VITE_');
    });

    it('does not flag VITE_ prefixed variables', async () => {
      const file = join(tmp, 'valid-env.ts');
      writeFileSync(file, `
        const apiUrl = import.meta.env.VITE_API_URL;
        const publicKey = import.meta.env.VITE_PUBLIC_KEY;
        const appName = import.meta.env.VITE_APP_NAME;
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite001Results = results.filter(r =>
        r.ruleId === 'VITE001' && r.file?.includes('valid-env.ts')
      );
      expect(vite001Results.length).toBe(0);
    });

    it('does not flag special Vite variables (MODE, DEV, PROD, SSR, BASE_URL)', async () => {
      const file = join(tmp, 'special-vars.ts');
      writeFileSync(file, `
        const mode = import.meta.env.MODE;
        const isDev = import.meta.env.DEV;
        const isProd = import.meta.env.PROD;
        const isSSR = import.meta.env.SSR;
        const baseUrl = import.meta.env.BASE_URL;
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite001Results = results.filter(r =>
        r.ruleId === 'VITE001' && r.file?.includes('special-vars.ts')
      );
      expect(vite001Results.length).toBe(0);
    });

    it('detects env vars in JSX expressions', async () => {
      const file = join(tmp, 'jsx-env.tsx');
      writeFileSync(file, `
        import React from 'react';

        export function ApiComponent() {
          return <div>{import.meta.env.API_ENDPOINT}</div>;
        }
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite001Results = results.filter(r =>
        r.ruleId === 'VITE001' && r.file?.includes('jsx-env.tsx')
      );
      expect(vite001Results.length).toBeGreaterThanOrEqual(1);
    });
  });

  describe('VITE002: Development Code Without Production Fallback', () => {
    it('detects DEV check without else clause', async () => {
      const file = join(tmp, 'dev-only.ts');
      writeFileSync(file, `
        if (import.meta.env.DEV) {
          console.log('Debug info:', data);
        }
        // No else clause - missing production fallback
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite002Results = results.filter(r => r.ruleId === 'VITE002');
      expect(vite002Results.length).toBeGreaterThanOrEqual(1);
    });

    it('detects MODE === "development" without else', async () => {
      const file = join(tmp, 'mode-dev.ts');
      writeFileSync(file, `
        if (import.meta.env.MODE === 'development') {
          api.enableDebugMode();
        }
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite002Results = results.filter(r =>
        r.ruleId === 'VITE002' && r.file?.includes('mode-dev.ts')
      );
      expect(vite002Results.length).toBeGreaterThanOrEqual(1);
    });

    it('does not flag when else clause is present', async () => {
      const file = join(tmp, 'with-fallback.ts');
      writeFileSync(file, `
        if (import.meta.env.DEV) {
          console.log('Debug info:', data);
        } else {
          // Production logging
          logger.info('Request processed');
        }
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite002Results = results.filter(r =>
        r.ruleId === 'VITE002' && r.file?.includes('with-fallback.ts')
      );
      expect(vite002Results.length).toBe(0);
    });

    it('does not flag ternary operators', async () => {
      const file = join(tmp, 'ternary.ts');
      writeFileSync(file, `
        const apiUrl = import.meta.env.DEV ? 'http://localhost:3000' : 'https://api.example.com';
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite002Results = results.filter(r =>
        r.ruleId === 'VITE002' && r.file?.includes('ternary.ts')
      );
      expect(vite002Results.length).toBe(0);
    });

    it('detects !PROD check without else', async () => {
      const file = join(tmp, 'not-prod.ts');
      writeFileSync(file, `
        if (!import.meta.env.PROD) {
          mockData.initialize();
        }
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite002Results = results.filter(r =>
        r.ruleId === 'VITE002' && r.file?.includes('not-prod.ts')
      );
      expect(vite002Results.length).toBeGreaterThanOrEqual(1);
    });
  });

  describe('VITE003: Unsafe Dynamic Imports', () => {
    it('detects dynamic import with template literal and variable', async () => {
      const file = join(tmp, 'dynamic-import.ts');
      writeFileSync(file, `
        export async function loadModule(moduleName: string) {
          const module = await import(\`./modules/\${moduleName}.ts\`);
          return module;
        }
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite003Results = results.filter(r => r.ruleId === 'VITE003');
      expect(vite003Results.length).toBeGreaterThanOrEqual(1);
    });

    it('detects dynamic import with user input', async () => {
      const file = join(tmp, 'user-import.ts');
      writeFileSync(file, `
        export async function loadUserComponent(componentName: string) {
          // User controls the import path - dangerous!
          const Component = await import(\`@/components/\${componentName}\`);
          return Component;
        }
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite003Results = results.filter(r =>
        r.ruleId === 'VITE003' && r.file?.includes('user-import.ts')
      );
      expect(vite003Results.length).toBeGreaterThanOrEqual(1);
    });

    it('detects import with string concatenation', async () => {
      const file = join(tmp, 'concat-import.ts');
      writeFileSync(file, `
        export async function loadPlugin(name: string) {
          const plugin = await import('./plugins/' + name + '.js');
          return plugin;
        }
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite003Results = results.filter(r =>
        r.ruleId === 'VITE003' && r.file?.includes('concat-import.ts')
      );
      expect(vite003Results.length).toBeGreaterThanOrEqual(1);
    });

    it('does not flag static imports', async () => {
      const file = join(tmp, 'static-import.ts');
      writeFileSync(file, `
        export async function loadDashboard() {
          const module = await import('./pages/Dashboard');
          return module;
        }
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite003Results = results.filter(r =>
        r.ruleId === 'VITE003' && r.file?.includes('static-import.ts')
      );
      expect(vite003Results.length).toBe(0);
    });

    it('does not flag validated dynamic imports', async () => {
      const file = join(tmp, 'validated-import.ts');
      writeFileSync(file, `
        const allowedModules = ['dashboard', 'settings', 'profile'];

        export async function loadModule(name: string) {
          if (!allowedModules.includes(name)) {
            throw new Error('Invalid module');
          }
          const module = await import(\`./pages/\${name}\`);
          return module;
        }
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const vite003Results = results.filter(r =>
        r.ruleId === 'VITE003' && r.file?.includes('validated-import.ts')
      );
      expect(vite003Results.length).toBe(0);
    });
  });

  describe('Integration Tests', () => {
    it('only scans projects with vite.config or import.meta usage', async () => {
      // Clean tmp directory and create fresh for this test
      rmSync(tmp, { recursive: true, force: true });
      mkdirSync(tmp, { recursive: true });

      const file = join(tmp, 'no-vite.ts');
      writeFileSync(file, `
        const config = {
          api: 'https://example.com'
        };
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      // Should return empty because no vite.config and no import.meta usage
      expect(results.length).toBe(0);

      // Restore vite.config.ts
      writeFileSync(join(tmp, 'vite.config.ts'), `
        import { defineConfig } from 'vite';
        export default defineConfig({});
      `);
    });

    it('scans files with import.meta even without vite.config', async () => {
      // Remove vite.config.ts
      rmSync(join(tmp, 'vite.config.ts'), { force: true });

      const file = join(tmp, 'has-import-meta.ts');
      writeFileSync(file, `
        const url = import.meta.url;
        const env = import.meta.env.API_KEY;
      `);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const hasImportMetaResults = results.filter(r => r.file?.includes('has-import-meta.ts'));
      expect(hasImportMetaResults.length).toBeGreaterThanOrEqual(1);

      // Restore vite.config.ts
      writeFileSync(join(tmp, 'vite.config.ts'), `
        import { defineConfig } from 'vite';
        export default defineConfig({});
      `);
    });

    it('scans multiple file types (.js, .jsx, .ts, .tsx, .svelte)', async () => {
      const jsFile = join(tmp, 'test.js');
      const tsFile = join(tmp, 'test.ts');
      const jsxFile = join(tmp, 'test.jsx');
      const tsxFile = join(tmp, 'test.tsx');
      const svelteFile = join(tmp, 'test.svelte');

      const code = `const key = import.meta.env.SECRET_KEY;`;

      writeFileSync(jsFile, code);
      writeFileSync(tsFile, code);
      writeFileSync(jsxFile, code);
      writeFileSync(tsxFile, code);
      writeFileSync(svelteFile, `<script>${code}</script>`);

      const scanner = new ViteScanner();
      const results = await scanner.scan({ directory: tmp });
      const testResults = results.filter(r => r.file?.startsWith('test.'));
      expect(testResults.length).toBeGreaterThanOrEqual(5);
    });
  });
});