Skip to content
This repository was archived by the owner on Feb 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/backend/tests/integration/api/api-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,82 @@ describe('API Security', () => {
const body = JSON.parse(response.body);
expect(body).toHaveProperty('bodyErrors');
});

it('should sanitize XSS attempts in project name', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/v1/scans',
payload: {
url: `${baseUrl}/test-page`,
name: '<script>alert("XSS")</script>',
sync: false,
},
});

// Should either reject (400) or sanitize the input
expect([200, 202, 400]).toContain(response.statusCode);

// If accepted, verify the script tag is not stored verbatim
if (response.statusCode === 202 || response.statusCode === 200) {
const body = JSON.parse(response.body);
// Name should not contain script tags
expect(JSON.stringify(body)).not.toContain('<script>');
}
});

it('should sanitize XSS attempts in project description', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/v1/scans',
payload: {
url: `${baseUrl}/test-page`,
description: '<img src=x onerror="alert(1)">',
sync: false,
},
});

// Should either reject (400) or sanitize the input
expect([200, 202, 400]).toContain(response.statusCode);

// If accepted, verify the malicious markup is not stored
if (response.statusCode === 202 || response.statusCode === 200) {
const body = JSON.parse(response.body);
expect(JSON.stringify(body)).not.toContain('onerror=');
}
});
});

describe('Command Injection Prevention', () => {
it('should prevent command injection via URL parameter', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/v1/scans',
payload: {
url: 'http://example.com; ls -la',
sync: false,
},
});

// Should reject malicious URL
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body).toHaveProperty('bodyErrors');
});

it('should prevent command injection via pipe character', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/v1/scans',
payload: {
url: 'http://example.com | cat /etc/passwd',
sync: false,
},
});

expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body).toHaveProperty('bodyErrors');
});
});

describe('Malformed JSON Handling', () => {
Expand Down
119 changes: 119 additions & 0 deletions packages/backend/tests/integration/api/error-handling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* API Error Handling tests
* Tests for error responses, payload limits, and concurrent operations
*/

import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import type { FastifyInstance } from 'fastify';
import * as tmp from 'tmp';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp } from '../../../src/api/app.js';
import {
closeDatabase,
createDatabase,
type DatabaseInstance,
} from '../../../src/storage/database.js';
import { createDrizzleDb } from '../../../src/storage/drizzle/db.js';
import { HTML_FIXTURES } from '../../fixtures/html/index.js';
import { MockServer } from '../../helpers/mock-server.js';

describe('API Error Handling', () => {
let app: FastifyInstance;
let db: DatabaseInstance;
let mockServer: MockServer;
let baseUrl: string;
let testDir: string;

beforeAll(async () => {
testDir = tmp.dirSync({ unsafeCleanup: true, prefix: 'diff-voyager-error-test-' }).name;

const dbPath = join(testDir, 'test.db');
const artifactsDir = join(testDir, 'artifacts');
await mkdir(artifactsDir, { recursive: true });

db = createDatabase({ dbPath, baseDir: testDir, artifactsDir });
const drizzleDb = createDrizzleDb(db);

app = await createApp({ db, drizzleDb, artifactsDir, disableLogging: true });

mockServer = new MockServer({
routes: [{ path: '/test-page', body: HTML_FIXTURES.baseline.simple }],
});
baseUrl = await mockServer.start();
});

afterAll(async () => {
await mockServer.stop();
closeDatabase(db);
await rm(testDir, { recursive: true, force: true });
});

describe('Payload Size Limits', () => {
it('should handle large but valid payloads', async () => {
// Create a large but reasonable payload (100KB description)
const largeDescription = 'x'.repeat(100000);
const response = await app.inject({
method: 'POST',
url: '/api/v1/scans',
payload: {
url: `${baseUrl}/test-page`,
name: 'Test Project',
description: largeDescription,
sync: false,
},
});

// Should accept large but valid payloads (or reject gracefully)
expect([200, 202, 400, 413]).toContain(response.statusCode);
});

it('should reject extremely large payloads', async () => {
// Create an extremely large payload (10MB)
const hugePayload = { url: `${baseUrl}/test-page`, data: 'x'.repeat(10000000) };
const response = await app.inject({
method: 'POST',
url: '/api/v1/scans',
payload: hugePayload,
});

// Should reject with 413 (Payload Too Large) or 400 (Bad Request)
expect([400, 413]).toContain(response.statusCode);
});
});

describe('Concurrent Operations', () => {
it('should handle concurrent scan creations', async () => {
// Two simultaneous scan creations
const [response1, response2] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/v1/scans',
payload: {
url: `${baseUrl}/test-page`,
name: 'Project A',
sync: false,
},
}),
app.inject({
method: 'POST',
url: '/api/v1/scans',
payload: {
url: `${baseUrl}/test-page`,
name: 'Project B',
sync: false,
},
}),
]);

// Both should succeed (different projects)
expect([200, 202]).toContain(response1.statusCode);
expect([200, 202]).toContain(response2.statusCode);

// Should create different projects
const body1 = JSON.parse(response1.body);
const body2 = JSON.parse(response2.body);
expect(body1.projectId).not.toBe(body2.projectId);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -505,4 +505,24 @@ describe('Advanced Path Traversal Security', () => {
expect(invalidResponses.length).toBe(5);
});
});

describe('Additional security edge cases', () => {
it('should reject pageId with control characters', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/v1/artifacts/page-\x00-123/screenshot',
});
expect(response.statusCode).toBe(400);
});

it('should reject oversized path segments', async () => {
const longPath = 'a'.repeat(5000);
const response = await app.inject({
method: 'GET',
url: `/api/v1/artifacts/${longPath}/screenshot`,
});
// Oversized paths can be rejected as either 400 (bad request) or 404 (not found)
expect([400, 404]).toContain(response.statusCode);
});
});
});