Advertisement

← ubon

src/__tests__/react-security-scanner.test.ts

11.4 KB Download file
import { mkdirSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { ReactSecurityScanner } from '../scanners/react-security-scanner';

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

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

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

  describe('TAILWIND001: Dynamic className with Unvalidated Input', () => {
    it('detects className with variable without validation', async () => {
      const file = join(tmp, 'dynamic-class.tsx');
      writeFileSync(file, `
        import React from 'react';

        export function Button({ variant }: { variant: string }) {
          return <button className={variant}>Click me</button>;
        }
      `);

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

    it('detects className with props variable', async () => {
      const file = join(tmp, 'props-class.tsx');
      writeFileSync(file, `
        export function Card({ cardClass }) {
          return <div className={cardClass}>Content</div>;
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('props-class.tsx')
      );
      expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
    });

    it('detects className with template literal and variable', async () => {
      const file = join(tmp, 'template-class.tsx');
      writeFileSync(file, `
        export function Alert({ type }) {
          return <div className={\`alert alert-\${type}\`}>Message</div>;
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('template-class.tsx')
      );
      expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
    });

    it('detects className with user input in template literal', async () => {
      const file = join(tmp, 'user-input-class.tsx');
      writeFileSync(file, `
        export function CustomBox({ userStyle }) {
          return <div className={\`box \${userStyle}\`}>Content</div>;
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('user-input-class.tsx')
      );
      expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
    });

    it('detects className with props in template literal', async () => {
      const file = join(tmp, 'props-template.tsx');
      writeFileSync(file, `
        export function Badge({ props }) {
          return <span className={\`badge \${props.color}\`}>New</span>;
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('props-template.tsx')
      );
      expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
    });

    it('detects className with string concatenation', async () => {
      const file = join(tmp, 'concat-class.tsx');
      writeFileSync(file, `
        export function Panel({ theme }) {
          return <div className={'panel ' + theme}>Content</div>;
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('concat-class.tsx')
      );
      expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
    });

    it('detects className with variable concatenation', async () => {
      const file = join(tmp, 'var-concat.tsx');
      writeFileSync(file, `
        export function Box({ size }) {
          return <div className={'box-' + size}>Content</div>;
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('var-concat.tsx')
      );
      expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
    });

    it('does not flag className with validation', async () => {
      const file = join(tmp, 'validated-class.tsx');
      writeFileSync(file, `
        const allowedVariants = ['primary', 'secondary', 'danger'];

        export function Button({ variant }) {
          const validVariant = allowedVariants.includes(variant) ? variant : 'primary';
          return <button className={validVariant}>Click me</button>;
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('validated-class.tsx')
      );
      expect(tailwind001Results.length).toBe(0);
    });

    it('does not flag className with switch statement', async () => {
      const file = join(tmp, 'switch-class.tsx');
      writeFileSync(file, `
        export function Alert({ type }) {
          let alertClass;
          switch (type) {
            case 'success': alertClass = 'bg-green-500'; break;
            case 'error': alertClass = 'bg-red-500'; break;
            default: alertClass = 'bg-gray-500';
          }
          return <div className={alertClass}>Message</div>;
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('switch-class.tsx')
      );
      expect(tailwind001Results.length).toBe(0);
    });

    it('does not flag className with clsx/classnames utility', async () => {
      const file = join(tmp, 'clsx-class.tsx');
      writeFileSync(file, `
        import clsx from 'clsx';

        export function Button({ isActive, variant }) {
          return (
            <button className={clsx('btn', {
              'btn-active': isActive,
              'btn-primary': variant === 'primary'
            })}>
              Click me
            </button>
          );
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('clsx-class.tsx')
      );
      expect(tailwind001Results.length).toBe(0);
    });

    it('does not flag className with cn utility', async () => {
      const file = join(tmp, 'cn-class.tsx');
      writeFileSync(file, `
        import { cn } from '@/lib/utils';

        export function Card({ variant, className }) {
          return (
            <div className={cn('card', variant === 'dark' && 'card-dark', className)}>
              Content
            </div>
          );
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('cn-class.tsx')
      );
      expect(tailwind001Results.length).toBe(0);
    });

    it('does not flag static className strings', async () => {
      const file = join(tmp, 'static-class.tsx');
      writeFileSync(file, `
        export function Header() {
          return (
            <header className="bg-white shadow-md p-4">
              <h1 className="text-2xl font-bold">Title</h1>
            </header>
          );
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('static-class.tsx')
      );
      expect(tailwind001Results.length).toBe(0);
    });

    it('handles multiple className violations in one file', async () => {
      const file = join(tmp, 'multiple-violations.tsx');
      writeFileSync(file, `
        export function Dashboard({ theme, userStyle, cardClass }) {
          return (
            <div className={theme}>
              <div className={\`card \${userStyle}\`}>
                <div className={'header-' + cardClass}>
                  Header
                </div>
              </div>
            </div>
          );
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('multiple-violations.tsx')
      );
      expect(tailwind001Results.length).toBeGreaterThanOrEqual(3);
    });

    it('does not flag common safe variable names', async () => {
      const file = join(tmp, 'safe-names.tsx');
      writeFileSync(file, `
        export function Component() {
          const className = 'btn btn-primary';
          const classes = 'card shadow-lg';

          return (
            <div>
              <button className={className}>Button</button>
              <div className={classes}>Card</div>
            </div>
          );
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const tailwind001Results = results.filter(r =>
        r.ruleId === 'TAILWIND001' && r.file?.includes('safe-names.tsx')
      );
      expect(tailwind001Results.length).toBe(0);
    });
  });

  describe('Integration Tests', () => {
    it('does not scan plain .ts files (no JSX surface)', async () => {
      const tsFile = join(tmp, 'utils.ts');
      writeFileSync(tsFile, `
        export function getClassName(variant: string) {
          return variant;
        }
      `);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const utilsResults = results.filter(r => r.file?.includes('utils.ts'));
      expect(utilsResults.length).toBe(0);
    });

    it('scans .jsx and .tsx files', async () => {
      const jsxFile = join(tmp, 'comp.jsx');
      const tsxFile = join(tmp, 'comp.tsx');

      const code = `
        export function Component({ style }) {
          return <div className={style}>Content</div>;
        }
      `;

      writeFileSync(jsxFile, code);
      writeFileSync(tsxFile, code);

      const scanner = new ReactSecurityScanner();
      const results = await scanner.scan({ directory: tmp });
      const compResults = results.filter(r => r.file?.startsWith('comp.'));
      expect(compResults.length).toBeGreaterThanOrEqual(2);
    });
  });
});