/ tests / agents / claude-security-api.test.js
claude-security-api.test.js
  1  /**
  2   * Claude Security API Tests
  3   *
  4   * Tests for Claude-powered security analysis, threat modeling, and fix generation
  5   */
  6  
  7  import { test } from 'node:test';
  8  import assert from 'node:assert';
  9  // createPgMock is the standard pg-mock helper (used here for consistency with the test suite)
 10  import { createPgMock } from '../helpers/pg-mock.js'; // eslint-disable-line no-unused-vars
 11  import {
 12    callClaude,
 13    analyzeCodeSecurity,
 14    generateSecureFix,
 15    performThreatModeling,
 16  } from '../../src/agents/utils/claude-api.js';
 17  
 18  // Skip integration tests if OPENROUTER_API_KEY not configured
 19  const hasApiKey = !!process.env.OPENROUTER_API_KEY;
 20  
 21  test('callClaude - validates required parameters', async () => {
 22    const originalKey = process.env.OPENROUTER_API_KEY;
 23  
 24    // Test missing API key
 25    delete process.env.OPENROUTER_API_KEY;
 26    await assert.rejects(
 27      async () => await callClaude({ prompt: 'test' }),
 28      /OPENROUTER_API_KEY not configured/
 29    );
 30  
 31    // Test missing prompt
 32    process.env.OPENROUTER_API_KEY = originalKey || 'test-key';
 33    await assert.rejects(async () => await callClaude({}), /Prompt is required/);
 34  
 35    process.env.OPENROUTER_API_KEY = originalKey;
 36  });
 37  
 38  test('analyzeCodeSecurity - SQL injection detection', { skip: !hasApiKey }, async () => {
 39    const vulnerableCode = `
 40      import Database from 'better-sqlite3';
 41      const db = new Database('data.db');
 42      const userId = req.params.id;
 43      const result = db.exec(\`SELECT * FROM users WHERE id = \${userId}\`);
 44    `;
 45  
 46    const analysis = await analyzeCodeSecurity(vulnerableCode, 'sql_injection', 'api.js');
 47  
 48    assert.ok(analysis.findings, 'Should return findings');
 49    assert.ok(Array.isArray(analysis.findings), 'Findings should be array');
 50    assert.ok(analysis.summary, 'Should return summary');
 51  
 52    // Should detect the SQL injection
 53    if (analysis.findings.length > 0) {
 54      assert.ok(analysis.findings[0].type);
 55      assert.ok(analysis.findings[0].severity);
 56      assert.ok(['critical', 'high', 'medium', 'low'].includes(analysis.findings[0].severity));
 57    }
 58  });
 59  
 60  test('generateSecureFix - validates parameters', async () => {
 61    await assert.rejects(
 62      async () => await generateSecureFix({ code: 'test' }),
 63      /Cannot read properties of undefined/
 64    );
 65  
 66    await assert.rejects(
 67      async () => await generateSecureFix({ finding: {} }),
 68      err => {
 69        // finding.type is undefined, either causes a runtime error or API error when code is missing
 70        assert.ok(err instanceof Error, 'Should throw an Error');
 71        return true;
 72      }
 73    );
 74  });
 75  
 76  test('generateSecureFix - produces fix for SQL injection', { skip: !hasApiKey }, async () => {
 77    const vulnerableCode = `const result = db.exec(\`SELECT * FROM users WHERE id = \${userId}\`);`;
 78  
 79    const finding = {
 80      type: 'sql_injection',
 81      severity: 'critical',
 82      line: 1,
 83      description: 'SQL injection via string interpolation',
 84      recommendation: 'Use parameterized queries with placeholders',
 85    };
 86  
 87    const fix = await generateSecureFix({
 88      code: vulnerableCode,
 89      finding,
 90      fileName: 'test.js',
 91    });
 92  
 93    assert.ok(fix.old_string, 'Should have old_string');
 94    assert.ok(fix.new_string, 'Should have new_string');
 95    assert.ok(fix.explanation, 'Should have explanation');
 96    assert.ok(fix.testing_notes, 'Should have testing notes');
 97  
 98    // old_string should be in vulnerable code
 99    assert.ok(vulnerableCode.includes(fix.old_string.trim()));
100  
101    // new_string should be different
102    assert.notStrictEqual(fix.old_string, fix.new_string);
103  });
104  
105  test('performThreatModeling - validates component parameter', async () => {
106    await assert.rejects(
107      async () => await performThreatModeling({}),
108      /[\s\S]+/ // any error - component is undefined which causes API or runtime error
109    );
110  });
111  
112  test('performThreatModeling - STRIDE analysis', { skip: !hasApiKey }, async () => {
113    const component = `
114      // Authentication endpoint
115      app.post('/api/login', (req, res) => {
116        const { username, password } = req.body;
117        // SQL injection vulnerability
118        const user = db.query(\`SELECT * FROM users WHERE username = '\${username}'\`)[0];
119  
120        // Weak password comparison
121        if (user && user.password === password) {
122          // No session management
123          res.json({ success: true, userId: user.id });
124        } else {
125          res.status(401).json({ error: 'Invalid credentials' });
126        }
127      });
128    `;
129  
130    const result = await performThreatModeling({
131      component,
132      componentType: 'auth',
133      dataFlow: 'Client → API → Database → Response',
134    });
135  
136    assert.ok(result.threats, 'Should return threats');
137    assert.ok(Array.isArray(result.threats), 'Threats should be array');
138    assert.ok(result.summary, 'Should have summary');
139  
140    if (result.threats.length > 0) {
141      const threat = result.threats[0];
142  
143      // Validate STRIDE category
144      assert.ok(threat.stride_category, 'Should have STRIDE category');
145      const validStride = [
146        'Spoofing',
147        'Tampering',
148        'Repudiation',
149        'InformationDisclosure',
150        'DoS',
151        'ElevationOfPrivilege',
152      ];
153      assert.ok(validStride.includes(threat.stride_category));
154  
155      // Validate threat structure
156      assert.ok(threat.title);
157      assert.ok(threat.description);
158      assert.ok(threat.attack_scenario);
159      assert.ok(threat.mitigation);
160  
161      // Validate DREAD scores
162      assert.ok(threat.dread);
163      assert.ok(typeof threat.dread.damage === 'number');
164      assert.ok(typeof threat.dread.reproducibility === 'number');
165      assert.ok(typeof threat.dread.exploitability === 'number');
166      assert.ok(typeof threat.dread.affected_users === 'number');
167      assert.ok(typeof threat.dread.discoverability === 'number');
168  
169      // DREAD scores should be 1-10
170      assert.ok(threat.dread.damage >= 1 && threat.dread.damage <= 10);
171  
172      // Validate risk level
173      assert.ok(threat.risk_level);
174      assert.ok(['critical', 'high', 'medium', 'low'].includes(threat.risk_level));
175    }
176  });
177  
178  test('callClaude - accepts custom model', { skip: !hasApiKey }, async () => {
179    const response = await callClaude({
180      prompt: 'Say "test" and nothing else.',
181      model: 'anthropic/claude-3.5-sonnet',
182      maxTokens: 20,
183    });
184  
185    assert.ok(response);
186    assert.ok(typeof response === 'string');
187  });
188  
189  test('callClaude - accepts system prompt', { skip: !hasApiKey }, async () => {
190    const response = await callClaude({
191      prompt: 'What is your role?',
192      systemPrompt: 'You are a security expert. Always respond with security focus.',
193      maxTokens: 50,
194    });
195  
196    assert.ok(response);
197    assert.ok(typeof response === 'string');
198  });
199  
200  test('analyzeCodeSecurity - handles safe code', { skip: !hasApiKey }, async () => {
201    const safeCode = `
202      import Database from 'better-sqlite3';
203      const db = new Database('data.db');
204      const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
205      const result = stmt.get(userId);
206    `;
207  
208    const analysis = await analyzeCodeSecurity(safeCode, 'sql_injection', 'safe.js');
209  
210    assert.ok(analysis.findings);
211    assert.ok(Array.isArray(analysis.findings));
212    // Safe code should have minimal or no findings
213  });
214  
215  test('generateSecureFix - command injection fix', { skip: !hasApiKey }, async () => {
216    const vulnerableCode = `const output = execSync(\`ls -la \${userInput}\`);`;
217  
218    const finding = {
219      type: 'command_injection',
220      severity: 'high',
221      line: 1,
222      description: 'Command injection via string interpolation',
223      recommendation: 'Use spawn() with array arguments',
224    };
225  
226    const fix = await generateSecureFix({
227      code: vulnerableCode,
228      finding,
229      fileName: 'commands.js',
230    });
231  
232    assert.ok(fix.old_string);
233    assert.ok(fix.new_string);
234    assert.ok(fix.explanation);
235  
236    // Fix should recommend spawn or input sanitization
237    assert.ok(
238      fix.new_string.includes('spawn') ||
239        fix.explanation.toLowerCase().includes('spawn') ||
240        fix.explanation.toLowerCase().includes('sanitize')
241    );
242  });
243  
244  test('performThreatModeling - file upload threats', { skip: !hasApiKey }, async () => {
245    const component = `
246      app.post('/upload', (req, res) => {
247        const { filename, content } = req.body;
248  
249        // Path traversal vulnerability
250        const filepath = \`./uploads/\${filename}\`;
251        fs.writeFileSync(filepath, content);
252  
253        // No file type validation
254        res.json({ success: true, path: filepath });
255      });
256    `;
257  
258    const result = await performThreatModeling({
259      component,
260      componentType: 'file_upload',
261      dataFlow: 'Client → Upload endpoint → File system',
262    });
263  
264    assert.ok(result.threats.length > 0, 'Should identify file upload threats');
265  
266    // Should identify path traversal or file upload risks
267    const threatTypes = result.threats.map(t => t.stride_category);
268    assert.ok(
269      threatTypes.includes('Tampering') ||
270        threatTypes.includes('ElevationOfPrivilege') ||
271        threatTypes.includes('InformationDisclosure')
272    );
273  });
274  
275  test('analyzeCodeSecurity - XSS detection', { skip: !hasApiKey }, async () => {
276    const vulnerableCode = `
277      app.get('/profile', (req, res) => {
278        const username = req.query.name;
279        res.send(\`<h1>Welcome \${username}</h1>\`);
280      });
281    `;
282  
283    const analysis = await analyzeCodeSecurity(vulnerableCode, 'xss', 'profile.js');
284  
285    assert.ok(analysis.findings);
286  
287    if (analysis.findings.length > 0) {
288      // Should detect XSS or HTML injection
289      assert.ok(
290        analysis.findings.some(
291          f =>
292            f.description.toLowerCase().includes('xss') ||
293            f.description.toLowerCase().includes('injection') ||
294            f.description.toLowerCase().includes('escape')
295        )
296      );
297    }
298  });
299  
300  test(
301    'performThreatModeling - identifies multiple STRIDE categories',
302    {
303      skip: !hasApiKey,
304    },
305    async () => {
306      const component = `
307      // Complex vulnerable component
308      app.post('/admin/users', (req, res) => {
309        // No authentication check (ElevationOfPrivilege)
310        const { username, role } = req.body;
311  
312        // SQL injection (Tampering)
313        db.exec(\`INSERT INTO users (username, role) VALUES ('\${username}', '\${role}')\`);
314  
315        // No logging (Repudiation)
316        res.json({ success: true });
317      });
318    `;
319  
320      const result = await performThreatModeling({
321        component,
322        componentType: 'admin_api',
323      });
324  
325      assert.ok(result.threats.length > 0);
326  
327      const strideCategories = [...new Set(result.threats.map(t => t.stride_category))];
328  
329      // Should identify multiple different threat types
330      assert.ok(
331        strideCategories.length >= 2,
332        `Should identify multiple STRIDE categories, found: ${strideCategories.join(', ')}`
333      );
334    }
335  );
336  
337  test('generateSecureFix - preserves code style', { skip: !hasApiKey }, async () => {
338    const vulnerableCode = `
339    function getUser(id) {
340      const query = \`SELECT * FROM users WHERE id = \${id}\`;
341      return db.exec(query);
342    }
343    `;
344  
345    const finding = {
346      type: 'sql_injection',
347      severity: 'critical',
348      line: 2,
349      description: 'SQL injection',
350      recommendation: 'Use prepared statements',
351    };
352  
353    const fix = await generateSecureFix({
354      code: vulnerableCode,
355      finding,
356      fileName: 'users.js',
357    });
358  
359    assert.ok(fix.old_string);
360    assert.ok(fix.new_string);
361  
362    // Should preserve function structure
363    assert.ok(fix.new_string.includes('function') || fix.old_string.includes('function'));
364  });