SECURITY FIXES: - Remove dangerous public DELETE /api/locations/:id endpoint - Add rate limiting to POST /api/locations (10 requests per 15 minutes) - Add input validation with length limits (500 chars address, 1000 chars description) - Add suspicious activity logging for abuse detection - Install express-rate-limit for protection against spam/DoS CHANGES: - Removed LocationDeleteRequest interface (no longer needed) - Updated tests to expect new security validation behavior - Added comprehensive tests for length validation - Fixed test setup issue with undefined constants Security Impact: - CRITICAL: Prevents unauthorized deletion of location reports - HIGH: Prevents spam submissions and DoS attacks - MEDIUM: Prevents buffer overflow and injection attacks via oversized inputs All 125 tests passing with new security validations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
376 lines
No EOL
11 KiB
TypeScript
376 lines
No EOL
11 KiB
TypeScript
import request from 'supertest';
|
|
import express, { Application } from 'express';
|
|
import configRoutes from '../../../src/routes/config';
|
|
import locationRoutes from '../../../src/routes/locations';
|
|
import Location from '../../../src/models/Location';
|
|
import { createTestDatabase } from '../../setup';
|
|
import { Database } from 'sqlite3';
|
|
|
|
describe('Public API Routes', () => {
|
|
let app: Application;
|
|
let db: Database;
|
|
let locationModel: Location;
|
|
|
|
beforeEach(async () => {
|
|
// Setup Express app
|
|
app = express();
|
|
app.use(express.json());
|
|
|
|
// Setup test database and models
|
|
db = await createTestDatabase();
|
|
locationModel = new Location(db);
|
|
|
|
// Mock profanity filter
|
|
const mockProfanityFilter = {
|
|
analyzeProfanity: jest.fn().mockReturnValue({
|
|
hasProfanity: false,
|
|
matches: [],
|
|
severity: 'none',
|
|
count: 0,
|
|
filtered: 'test text'
|
|
})
|
|
};
|
|
|
|
// Setup routes
|
|
app.use('/api/config', configRoutes());
|
|
app.use('/api/locations', locationRoutes(locationModel, mockProfanityFilter));
|
|
});
|
|
|
|
afterEach((done) => {
|
|
db.close(done);
|
|
});
|
|
|
|
describe('GET /api/config', () => {
|
|
it('should return API configuration', async () => {
|
|
const response = await request(app)
|
|
.get('/api/config')
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('mapboxAccessToken');
|
|
expect(response.body).toHaveProperty('hasMapbox');
|
|
expect(typeof response.body.hasMapbox).toBe('boolean');
|
|
});
|
|
|
|
it('should return mapbox token when configured', async () => {
|
|
process.env.MAPBOX_ACCESS_TOKEN = 'pk.test_token';
|
|
|
|
const response = await request(app)
|
|
.get('/api/config')
|
|
.expect(200);
|
|
|
|
expect(response.body.mapboxAccessToken).toBe('pk.test_token');
|
|
expect(response.body.hasMapbox).toBe(true);
|
|
});
|
|
|
|
it('should handle missing mapbox token', async () => {
|
|
delete process.env.MAPBOX_ACCESS_TOKEN;
|
|
|
|
const response = await request(app)
|
|
.get('/api/config')
|
|
.expect(200);
|
|
|
|
expect(response.body.mapboxAccessToken).toBeNull();
|
|
expect(response.body.hasMapbox).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/locations', () => {
|
|
beforeEach(async () => {
|
|
// Add test locations
|
|
await locationModel.create({
|
|
address: 'Test Location 1',
|
|
latitude: 42.9634,
|
|
longitude: -85.6681,
|
|
description: 'Test description 1'
|
|
});
|
|
|
|
await locationModel.create({
|
|
address: 'Test Location 2',
|
|
latitude: 42.9584,
|
|
longitude: -85.6706,
|
|
description: 'Test description 2'
|
|
});
|
|
});
|
|
|
|
it('should return all active locations', async () => {
|
|
const response = await request(app)
|
|
.get('/api/locations')
|
|
.expect(200);
|
|
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
expect(response.body).toHaveLength(2);
|
|
expect(response.body[0]).toHaveProperty('id');
|
|
expect(response.body[0]).toHaveProperty('address');
|
|
expect(response.body[0]).toHaveProperty('latitude');
|
|
expect(response.body[0]).toHaveProperty('longitude');
|
|
});
|
|
|
|
it('should return empty array when no locations exist', async () => {
|
|
// Clear all locations
|
|
await locationModel.delete(1);
|
|
await locationModel.delete(2);
|
|
|
|
const response = await request(app)
|
|
.get('/api/locations')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual([]);
|
|
});
|
|
|
|
it('should handle database errors gracefully', async () => {
|
|
// Create a new app with broken database to simulate error
|
|
const brokenApp = express();
|
|
brokenApp.use(express.json());
|
|
|
|
// Create a broken location model that throws errors
|
|
const brokenLocationModel = {
|
|
getActive: jest.fn().mockRejectedValue(new Error('Database error'))
|
|
};
|
|
|
|
const mockProfanityFilter = {
|
|
analyzeProfanity: jest.fn().mockReturnValue({
|
|
hasProfanity: false,
|
|
matches: [],
|
|
severity: 'none',
|
|
count: 0,
|
|
filtered: 'test text'
|
|
})
|
|
};
|
|
|
|
brokenApp.use('/api/locations', locationRoutes(brokenLocationModel as any, mockProfanityFilter));
|
|
|
|
const response = await request(brokenApp)
|
|
.get('/api/locations')
|
|
.expect(500);
|
|
|
|
expect(response.body).toHaveProperty('error');
|
|
expect(response.body.error).toBe('Internal server error');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/locations', () => {
|
|
it('should create a new location with valid data', async () => {
|
|
const locationData = {
|
|
address: 'New Test Location',
|
|
latitude: 42.9634,
|
|
longitude: -85.6681,
|
|
description: 'New test description'
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.send(locationData)
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('id');
|
|
expect(response.body.address).toBe(locationData.address);
|
|
expect(response.body.latitude).toBe(locationData.latitude);
|
|
expect(response.body.longitude).toBe(locationData.longitude);
|
|
expect(response.body.description).toBe(locationData.description);
|
|
expect(response.body).toHaveProperty('created_at');
|
|
});
|
|
|
|
it('should create location with only required address', async () => {
|
|
const locationData = {
|
|
address: 'Minimal Location'
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.send(locationData)
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('id');
|
|
expect(response.body.address).toBe(locationData.address);
|
|
});
|
|
|
|
it('should reject request without address', async () => {
|
|
const locationData = {
|
|
latitude: 42.9634,
|
|
longitude: -85.6681,
|
|
description: 'Missing address'
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.send(locationData)
|
|
.expect(400);
|
|
|
|
expect(response.body).toHaveProperty('error');
|
|
expect(response.body.error).toBe('Address is required');
|
|
});
|
|
|
|
it('should reject request with profanity in description', async () => {
|
|
// Setup mock to detect profanity
|
|
const app2 = express();
|
|
app2.use(express.json());
|
|
|
|
const mockProfanityFilter = {
|
|
analyzeProfanity: jest.fn().mockReturnValue({
|
|
hasProfanity: true,
|
|
matches: [{ word: 'badword', category: 'general' }],
|
|
severity: 'medium',
|
|
count: 1,
|
|
filtered: '*** text'
|
|
})
|
|
};
|
|
|
|
app2.use('/api/locations', locationRoutes(locationModel, mockProfanityFilter));
|
|
|
|
const locationData = {
|
|
address: 'Test Location',
|
|
description: 'This contains profanity'
|
|
};
|
|
|
|
const response = await request(app2)
|
|
.post('/api/locations')
|
|
.send(locationData)
|
|
.expect(400);
|
|
|
|
expect(response.body).toHaveProperty('error');
|
|
expect(response.body.error).toBe('Submission rejected');
|
|
expect(response.body).toHaveProperty('message');
|
|
expect(response.body).toHaveProperty('details');
|
|
});
|
|
|
|
it('should handle empty description', async () => {
|
|
const locationData = {
|
|
address: 'Test Location',
|
|
description: ''
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.send(locationData)
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('id');
|
|
expect(response.body.address).toBe(locationData.address);
|
|
});
|
|
|
|
it('should handle missing optional fields', async () => {
|
|
const locationData = {
|
|
address: 'Test Location'
|
|
// No latitude, longitude, or description
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.send(locationData)
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('id');
|
|
expect(response.body.address).toBe(locationData.address);
|
|
});
|
|
|
|
it('should handle profanity filter errors gracefully', async () => {
|
|
const app2 = express();
|
|
app2.use(express.json());
|
|
|
|
const mockProfanityFilter = {
|
|
analyzeProfanity: jest.fn().mockImplementation(() => {
|
|
throw new Error('Filter error');
|
|
})
|
|
};
|
|
|
|
app2.use('/api/locations', locationRoutes(locationModel, mockProfanityFilter));
|
|
|
|
const locationData = {
|
|
address: 'Test Location',
|
|
description: 'Test description'
|
|
};
|
|
|
|
// Should still succeed if profanity filter fails
|
|
const response = await request(app2)
|
|
.post('/api/locations')
|
|
.send(locationData)
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('id');
|
|
});
|
|
});
|
|
|
|
describe('Content-Type validation', () => {
|
|
it('should accept JSON content type', async () => {
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.set('Content-Type', 'application/json')
|
|
.send({ address: 'Test Location' })
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('id');
|
|
});
|
|
|
|
it('should handle malformed JSON', async () => {
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.set('Content-Type', 'application/json')
|
|
.send('{"address": "Test Location"') // Missing closing brace
|
|
.expect(400);
|
|
|
|
// Should return bad request for malformed JSON
|
|
});
|
|
});
|
|
|
|
describe('Request validation', () => {
|
|
it('should reject overly long addresses for security', async () => {
|
|
const longAddress = 'A'.repeat(1000); // Over 500 character limit
|
|
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.send({ address: longAddress })
|
|
.expect(400);
|
|
|
|
expect(response.body).toHaveProperty('error');
|
|
expect(response.body.error).toBe('Address must be a string with maximum 500 characters');
|
|
});
|
|
|
|
it('should accept addresses within the limit', async () => {
|
|
const validLongAddress = 'A'.repeat(400); // Under 500 character limit
|
|
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.send({ address: validLongAddress })
|
|
.expect(200);
|
|
|
|
expect(response.body.address).toBe(validLongAddress);
|
|
});
|
|
|
|
it('should reject overly long descriptions for security', async () => {
|
|
const longDescription = 'B'.repeat(1500); // Over 1000 character limit
|
|
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.send({
|
|
address: 'Test Address',
|
|
description: longDescription
|
|
})
|
|
.expect(400);
|
|
|
|
expect(response.body).toHaveProperty('error');
|
|
expect(response.body.error).toBe('Description must be a string with maximum 1000 characters');
|
|
});
|
|
|
|
it('should handle special characters in address', async () => {
|
|
const specialAddress = 'Main St & Oak Ave, Grand Rapids, MI 49503';
|
|
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.send({ address: specialAddress })
|
|
.expect(200);
|
|
|
|
expect(response.body.address).toBe(specialAddress);
|
|
});
|
|
|
|
it('should handle unicode characters', async () => {
|
|
const unicodeAddress = 'Straße München, Deutschland 🇩🇪';
|
|
|
|
const response = await request(app)
|
|
.post('/api/locations')
|
|
.send({ address: unicodeAddress })
|
|
.expect(200);
|
|
|
|
expect(response.body.address).toBe(unicodeAddress);
|
|
});
|
|
});
|
|
}); |