Completed HIGH severity security fix (CVSS 6.5) to prevent Google Maps API key exposure to frontend clients. Issue: API key was embedded in photo URLs sent to frontend, allowing potential abuse and quota exhaustion. Solution: Implemented backend proxy endpoint for photos. Backend Changes: - google-maps.client.ts: Changed photoUrl to photoReference, added fetchPhoto() - stations.types.ts: Updated type definition (photoUrl → photoReference) - stations.controller.ts: Added getStationPhoto() proxy method - stations.routes.ts: Added GET /api/stations/photo/:reference route - stations.service.ts: Updated to use photoReference - stations.repository.ts: Updated database queries and mappings - admin controllers/services: Updated for consistency - Created migration 003 to rename photo_url column Frontend Changes: - stations.types.ts: Updated type definition (photoUrl → photoReference) - photo-utils.ts: NEW - Helper to generate proxy URLs - StationCard.tsx: Use photoReference with helper function Tests & Docs: - Updated mock data to use photoReference - Updated test expectations for proxy URLs - Updated API.md and TESTING.md documentation Database Migration: - 003_rename_photo_url_to_photo_reference.sql: Renames column in station_cache Security Benefits: - API key never sent to frontend - All photo requests proxied through authenticated endpoint - Photos cached for 24 hours (Cache-Control header) - No client-side API key exposure Files modified: 16 files New files: 2 (photo-utils.ts, migration 003) Status: All 3 P0 security fixes now complete - Fix 1: crypto.randomBytes() ✓ - Fix 2: Magic byte validation ✓ - Fix 3: API key proxy ✓ Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
21 KiB
Gas Stations Feature - Testing Guide
Overview
Comprehensive testing guide for the Gas Stations feature. This feature includes unit tests, integration tests, and guidance for writing new tests. All tests follow MotoVaultPro's container-first development approach.
Test Structure
backend/src/features/stations/tests/
├── fixtures/ # Mock data and helpers
│ ├── mock-stations.ts # Sample station data
│ └── mock-google-response.ts # Google API response mocks
├── unit/ # Unit tests
│ ├── stations.service.test.ts # Service layer tests
│ └── google-maps.client.test.ts # External API client tests
└── integration/ # Integration tests
└── stations.api.test.ts # Full API workflow tests
Running Tests
Container-Based Testing (Recommended)
All tests should be run inside Docker containers to match production environment.
Run all stations tests:
docker compose exec mvp-backend npm test -- features/stations
Run specific test file:
docker compose exec mvp-backend npm test -- features/stations/tests/unit/stations.service.test.ts
Run tests in watch mode:
docker compose exec mvp-backend npm test -- --watch features/stations
Run tests with coverage:
docker compose exec mvp-backend npm test -- --coverage features/stations
Local Development (Optional)
For rapid iteration during test development:
cd backend
npm test -- features/stations
Note: Always validate passing tests in containers before committing.
Test Database Setup
Test Database Configuration
Tests use a separate test database to avoid polluting development data.
Environment Variables (set in docker-compose.yml):
NODE_ENV: test
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/motovaultpro_test
Before Running Tests
Ensure test database exists:
# Create test database (one-time setup)
docker compose exec postgres psql -U postgres -c "CREATE DATABASE motovaultpro_test;"
# Run migrations on test database
docker compose exec mvp-backend npm run migrate:test
Test Data Isolation
Each test should:
- Create its own test data
- Use unique user IDs
- Clean up after execution (via beforeEach/afterEach)
Example:
describe('StationsService', () => {
beforeEach(async () => {
// Clear test data
await pool.query('DELETE FROM saved_stations WHERE user_id LIKE $1', ['test-%']);
await pool.query('DELETE FROM station_cache WHERE created_at < NOW()');
});
afterEach(async () => {
// Additional cleanup if needed
});
});
Writing Unit Tests
Service Layer Tests
Location: tests/unit/stations.service.test.ts
Purpose: Test business logic in isolation
Pattern: Mock external dependencies (repository, Google Maps client)
Example:
import { StationsService } from '../../domain/stations.service';
import { StationsRepository } from '../../data/stations.repository';
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
jest.mock('../../data/stations.repository');
jest.mock('../../external/google-maps/google-maps.client');
describe('StationsService', () => {
let service: StationsService;
let mockRepository: jest.Mocked<StationsRepository>;
beforeEach(() => {
mockRepository = {
cacheStation: jest.fn().mockResolvedValue(undefined),
getCachedStation: jest.fn(),
saveStation: jest.fn(),
getUserSavedStations: jest.fn(),
deleteSavedStation: jest.fn()
} as unknown as jest.Mocked<StationsRepository>;
service = new StationsService(mockRepository);
});
it('should search nearby stations and cache results', async () => {
const mockStations = [
{ placeId: 'station-1', name: 'Shell', latitude: 37.7749, longitude: -122.4194 }
];
(googleMapsClient.searchNearbyStations as jest.Mock).mockResolvedValue(mockStations);
const result = await service.searchNearbyStations({
latitude: 37.7749,
longitude: -122.4194,
radius: 5000
}, 'user-123');
expect(result.stations).toHaveLength(1);
expect(mockRepository.cacheStation).toHaveBeenCalledWith(mockStations[0]);
});
});
Repository Layer Tests
Location: tests/unit/stations.repository.test.ts (create if needed)
Purpose: Test SQL query construction and database interaction
Pattern: Use in-memory database or transaction rollback
Example:
import { StationsRepository } from '../../data/stations.repository';
import { pool } from '../../../../core/config/database';
describe('StationsRepository', () => {
let repository: StationsRepository;
beforeEach(() => {
repository = new StationsRepository(pool);
});
it('should save station with user isolation', async () => {
const userId = 'test-user-123';
const placeId = 'test-place-456';
const saved = await repository.saveStation(userId, placeId, {
nickname: 'Test Station'
});
expect(saved.userId).toBe(userId);
expect(saved.stationId).toBe(placeId);
expect(saved.nickname).toBe('Test Station');
});
it('should enforce unique constraint per user', async () => {
const userId = 'test-user-123';
const placeId = 'test-place-456';
await repository.saveStation(userId, placeId, {});
// Attempt duplicate save
await expect(
repository.saveStation(userId, placeId, {})
).rejects.toThrow();
});
});
External Client Tests
Location: tests/unit/google-maps.client.test.ts
Purpose: Test API call construction and response parsing
Pattern: Mock axios/fetch, test request format and error handling
Example:
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
import axios from 'axios';
jest.mock('axios');
describe('GoogleMapsClient', () => {
it('should construct correct API request', async () => {
const mockResponse = {
data: {
results: [
{
place_id: 'station-1',
name: 'Shell',
geometry: { location: { lat: 37.7749, lng: -122.4194 } }
}
]
}
};
(axios.get as jest.Mock).mockResolvedValue(mockResponse);
await googleMapsClient.searchNearbyStations(37.7749, -122.4194, 5000);
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('https://maps.googleapis.com/maps/api/place/nearbysearch'),
expect.objectContaining({
params: expect.objectContaining({
location: '37.7749,-122.4194',
radius: 5000,
type: 'gas_station'
})
})
);
});
it('should handle API errors gracefully', async () => {
(axios.get as jest.Mock).mockRejectedValue(new Error('API Error'));
await expect(
googleMapsClient.searchNearbyStations(37.7749, -122.4194, 5000)
).rejects.toThrow('API Error');
});
});
Writing Integration Tests
API Workflow Tests
Location: tests/integration/stations.api.test.ts
Purpose: Test complete request/response flows with real database
Pattern: Use Fastify app instance, real JWT, test database
Example:
import { buildApp } from '../../../../app';
import { FastifyInstance } from 'fastify';
import { pool } from '../../../../core/config/database';
describe('Stations API Integration', () => {
let app: FastifyInstance;
let authToken: string;
beforeAll(async () => {
app = await buildApp();
// Generate test JWT token
authToken = await generateTestToken({ sub: 'test-user-123' });
});
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
// Clear test data
await pool.query('DELETE FROM saved_stations WHERE user_id = $1', ['test-user-123']);
await pool.query('DELETE FROM station_cache');
});
describe('POST /api/stations/search', () => {
it('should search for nearby stations', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: {
authorization: `Bearer ${authToken}`
},
payload: {
latitude: 37.7749,
longitude: -122.4194,
radius: 5000
}
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body).toHaveProperty('stations');
expect(body.stations).toBeInstanceOf(Array);
expect(body).toHaveProperty('searchLocation');
expect(body).toHaveProperty('searchRadius');
});
it('should return 400 for invalid coordinates', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: {
authorization: `Bearer ${authToken}`
},
payload: {
latitude: 999,
longitude: -122.4194
}
});
expect(response.statusCode).toBe(400);
});
it('should return 401 without auth token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
payload: {
latitude: 37.7749,
longitude: -122.4194
}
});
expect(response.statusCode).toBe(401);
});
});
describe('POST /api/stations/save', () => {
it('should save a station', async () => {
// First, search to populate cache
const searchResponse = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: {
authorization: `Bearer ${authToken}`
},
payload: {
latitude: 37.7749,
longitude: -122.4194,
radius: 5000
}
});
const searchBody = JSON.parse(searchResponse.body);
const placeId = searchBody.stations[0].placeId;
// Then save station
const saveResponse = await app.inject({
method: 'POST',
url: '/api/stations/save',
headers: {
authorization: `Bearer ${authToken}`
},
payload: {
placeId,
nickname: 'My Favorite Station',
isFavorite: true
}
});
expect(saveResponse.statusCode).toBe(201);
const saveBody = JSON.parse(saveResponse.body);
expect(saveBody.nickname).toBe('My Favorite Station');
expect(saveBody.isFavorite).toBe(true);
});
it('should return 404 if station not in cache', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/save',
headers: {
authorization: `Bearer ${authToken}`
},
payload: {
placeId: 'non-existent-place-id'
}
});
expect(response.statusCode).toBe(404);
});
});
describe('User Isolation', () => {
it('should isolate saved stations by user', async () => {
const user1Token = await generateTestToken({ sub: 'user-1' });
const user2Token = await generateTestToken({ sub: 'user-2' });
// User 1 saves a station
const searchResponse = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: { authorization: `Bearer ${user1Token}` },
payload: { latitude: 37.7749, longitude: -122.4194 }
});
const placeId = JSON.parse(searchResponse.body).stations[0].placeId;
await app.inject({
method: 'POST',
url: '/api/stations/save',
headers: { authorization: `Bearer ${user1Token}` },
payload: { placeId }
});
// User 2 cannot see User 1's saved station
const user2Response = await app.inject({
method: 'GET',
url: '/api/stations/saved',
headers: { authorization: `Bearer ${user2Token}` }
});
const user2Body = JSON.parse(user2Response.body);
expect(user2Body).toEqual([]);
// User 1 can see their saved station
const user1Response = await app.inject({
method: 'GET',
url: '/api/stations/saved',
headers: { authorization: `Bearer ${user1Token}` }
});
const user1Body = JSON.parse(user1Response.body);
expect(user1Body).toHaveLength(1);
});
});
});
Mock Data and Fixtures
Creating Test Fixtures
Location: tests/fixtures/mock-stations.ts
Purpose: Reusable test data for all tests
Example:
export const mockUserId = 'test-user-123';
export const searchCoordinates = {
sanFrancisco: { latitude: 37.7749, longitude: -122.4194 },
newYork: { latitude: 40.7128, longitude: -74.0060 }
};
export const mockStations = [
{
placeId: 'ChIJN1t_tDeuEmsRUsoyG83frY4',
name: 'Shell Gas Station - Downtown',
address: '123 Main St, San Francisco, CA 94102',
latitude: 37.7750,
longitude: -122.4195,
rating: 4.2,
photoReference: 'mock-photo-reference-1',
distance: 150
},
{
placeId: 'ChIJN1t_tDeuEmsRUsoyG83frY5',
name: 'Chevron - Market Street',
address: '456 Market St, San Francisco, CA 94103',
latitude: 37.7755,
longitude: -122.4190,
rating: 4.0,
photoReference: null,
distance: 300
}
];
export const mockSavedStations = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
userId: mockUserId,
stationId: mockStations[0].placeId,
nickname: 'Work Gas Station',
notes: 'Close to office',
isFavorite: true,
createdAt: new Date('2025-01-15T10:00:00Z'),
updatedAt: new Date('2025-01-15T10:00:00Z'),
deletedAt: null
}
];
Mocking External APIs
Location: tests/fixtures/mock-google-response.ts
Purpose: Simulate Google Maps API responses
Example:
export const mockGooglePlacesResponse = {
results: [
{
place_id: 'ChIJN1t_tDeuEmsRUsoyG83frY4',
name: 'Shell Gas Station',
vicinity: '123 Main St, San Francisco',
geometry: {
location: { lat: 37.7750, lng: -122.4195 }
},
rating: 4.2,
photos: [
{
photo_reference: 'CmRaAAAA...',
height: 400,
width: 300
}
]
}
],
status: 'OK'
};
export const mockGoogleErrorResponse = {
results: [],
status: 'ZERO_RESULTS',
error_message: 'No results found'
};
Coverage Goals
Target Coverage
- Overall Feature Coverage: >80%
- Service Layer: >90% (critical business logic)
- Repository Layer: >80% (database operations)
- Controller Layer: >70% (error handling)
- External Client: >70% (API integration)
Checking Coverage
# Generate coverage report
docker compose exec mvp-backend npm test -- --coverage features/stations
# View HTML report (generated in backend/coverage/)
open backend/coverage/lcov-report/index.html
Coverage Exemptions
Lines exempt from coverage requirements:
- Logger statements
- Type guards (if rarely hit)
- Unreachable error handlers
Mark with comment:
/* istanbul ignore next */
logger.debug('This log is exempt from coverage');
Testing Best Practices
Test Naming Convention
Use descriptive test names that explain what is being tested:
Good:
it('should return 404 if station not found in cache')
it('should isolate saved stations by user_id')
it('should sort stations by distance ascending')
Bad:
it('works')
it('test save')
it('error case')
Arrange-Act-Assert Pattern
Structure tests with clear sections:
it('should save station with metadata', async () => {
// Arrange
const userId = 'test-user-123';
const placeId = 'station-1';
mockRepository.getCachedStation.mockResolvedValue(mockStations[0]);
mockRepository.saveStation.mockResolvedValue(mockSavedStations[0]);
// Act
const result = await service.saveStation(placeId, userId, {
nickname: 'Test Station'
});
// Assert
expect(result.nickname).toBe('Test Station');
expect(mockRepository.saveStation).toHaveBeenCalledWith(
userId,
placeId,
{ nickname: 'Test Station' }
);
});
Test Data Cleanup
Always clean up test data to avoid interference:
afterEach(async () => {
await pool.query('DELETE FROM saved_stations WHERE user_id LIKE $1', ['test-%']);
await pool.query('DELETE FROM station_cache WHERE created_at < NOW()');
});
Testing Error Scenarios
Test both happy path and error cases:
describe('saveStation', () => {
it('should save station successfully', async () => {
// Happy path test
});
it('should throw error if station not in cache', async () => {
mockRepository.getCachedStation.mockResolvedValue(null);
await expect(
service.saveStation('unknown-id', 'user-123')
).rejects.toThrow('Station not found');
});
it('should throw error if database fails', async () => {
mockRepository.getCachedStation.mockResolvedValue(mockStations[0]);
mockRepository.saveStation.mockRejectedValue(new Error('DB Error'));
await expect(
service.saveStation('station-1', 'user-123')
).rejects.toThrow('DB Error');
});
});
Testing User Isolation
Always verify user data isolation:
it('should only return stations for authenticated user', async () => {
const user1 = 'user-1';
const user2 = 'user-2';
await repository.saveStation(user1, 'station-1', {});
await repository.saveStation(user2, 'station-2', {});
const user1Stations = await repository.getUserSavedStations(user1);
const user2Stations = await repository.getUserSavedStations(user2);
expect(user1Stations).toHaveLength(1);
expect(user1Stations[0].stationId).toBe('station-1');
expect(user2Stations).toHaveLength(1);
expect(user2Stations[0].stationId).toBe('station-2');
});
CI/CD Integration
Running Tests in CI Pipeline
GitHub Actions Example (.github/workflows/test.yml):
name: Test Gas Stations Feature
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Docker
run: docker compose up -d postgres redis
- name: Run migrations
run: docker compose exec mvp-backend npm run migrate:test
- name: Run tests
run: docker compose exec mvp-backend npm test -- features/stations
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
files: ./backend/coverage/lcov.info
Pre-Commit Hook
Add to .git/hooks/pre-commit:
#!/bin/sh
echo "Running stations feature tests..."
docker compose exec mvp-backend npm test -- features/stations
if [ $? -ne 0 ]; then
echo "Tests failed. Commit aborted."
exit 1
fi
Debugging Tests
Enable Verbose Logging
docker compose exec mvp-backend npm test -- --verbose features/stations
Debug Single Test
it.only('should search nearby stations', async () => {
// This test runs in isolation
});
Inspect Test Database
# Connect to test database
docker compose exec postgres psql -U postgres -d motovaultpro_test
# Query test data
SELECT * FROM saved_stations WHERE user_id LIKE 'test-%';
SELECT * FROM station_cache;
View Test Logs
docker compose logs mvp-backend | grep -i "test"
Common Test Failures
"Station not found" Error
Cause: Station not in cache before save attempt
Fix: Ensure search populates cache first:
await service.searchNearbyStations({ latitude, longitude }, userId);
await service.saveStation(placeId, userId);
"Unique constraint violation"
Cause: Test data not cleaned up between tests
Fix: Add proper cleanup in beforeEach:
beforeEach(async () => {
await pool.query('DELETE FROM saved_stations WHERE user_id = $1', ['test-user']);
});
"JWT token invalid"
Cause: Test token expired or malformed
Fix: Use proper test token generation:
const token = await generateTestToken({ sub: 'test-user', exp: Date.now() + 3600 });
"Circuit breaker open"
Cause: Too many failed Google API calls in tests
Fix: Mock Google client to avoid real API calls:
jest.mock('../../external/google-maps/google-maps.client');
Adding New Tests
Checklist for New Test Files
- Create in appropriate directory (unit/ or integration/)
- Import necessary fixtures from tests/fixtures/
- Mock external dependencies (repository, Google client)
- Add beforeEach/afterEach cleanup
- Follow naming conventions
- Test happy path
- Test error scenarios
- Test user isolation
- Run in container to verify
- Check coverage impact
Template for New Unit Test
import { /* imports */ } from '../../domain/stations.service';
jest.mock('../../data/stations.repository');
jest.mock('../../external/google-maps/google-maps.client');
describe('FeatureName', () => {
let service: YourService;
let mockDependency: jest.Mocked<Dependency>;
beforeEach(() => {
jest.clearAllMocks();
// Setup mocks
service = new YourService(mockDependency);
});
describe('methodName', () => {
it('should handle happy path', async () => {
// Arrange
// Act
// Assert
});
it('should handle error case', async () => {
// Arrange
// Act
// Assert
});
});
});
References
- Jest Documentation: https://jestjs.io/docs/getting-started
- Fastify Testing: https://www.fastify.io/docs/latest/Guides/Testing/
- Feature README:
/backend/src/features/stations/README.md - Architecture:
/backend/src/features/stations/docs/ARCHITECTURE.md - API Documentation:
/backend/src/features/stations/docs/API.md