All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m42s
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
// src/tests/integration/edge-cases.integration.test.ts
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import supertest from 'supertest';
|
|
import { createAndLoginUser } from '../utils/testHelpers';
|
|
import { cleanupDb } from '../utils/cleanup';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as crypto from 'crypto';
|
|
|
|
/**
|
|
* @vitest-environment node
|
|
*
|
|
* Integration tests for edge cases discovered during manual frontend testing.
|
|
* These tests cover file upload validation, input sanitization, and authorization boundaries.
|
|
*/
|
|
|
|
describe('Edge Cases Integration Tests', () => {
|
|
let request: ReturnType<typeof supertest>;
|
|
let authToken: string;
|
|
let otherUserToken: string;
|
|
const createdUserIds: string[] = [];
|
|
const createdShoppingListIds: number[] = [];
|
|
|
|
beforeAll(async () => {
|
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
|
const app = (await import('../../../server')).default;
|
|
request = supertest(app);
|
|
|
|
// Create primary test user
|
|
const { user, token } = await createAndLoginUser({
|
|
email: `edge-case-user-${Date.now()}@example.com`,
|
|
fullName: 'Edge Case Test User',
|
|
request,
|
|
});
|
|
authToken = token;
|
|
createdUserIds.push(user.user.user_id);
|
|
|
|
// Create secondary test user for cross-user tests
|
|
const { user: user2, token: token2 } = await createAndLoginUser({
|
|
email: `edge-case-other-${Date.now()}@example.com`,
|
|
fullName: 'Other Test User',
|
|
request,
|
|
});
|
|
otherUserToken = token2;
|
|
createdUserIds.push(user2.user.user_id);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
vi.unstubAllEnvs();
|
|
await cleanupDb({
|
|
userIds: createdUserIds,
|
|
shoppingListIds: createdShoppingListIds,
|
|
});
|
|
});
|
|
|
|
describe('File Upload Validation', () => {
|
|
describe('Checksum Validation', () => {
|
|
it('should reject missing checksum', async () => {
|
|
// Create a small valid PNG
|
|
const testImagePath = path.join(__dirname, '../assets/flyer-test.png');
|
|
if (!fs.existsSync(testImagePath)) {
|
|
// Skip if test asset doesn't exist
|
|
return;
|
|
}
|
|
|
|
const response = await request
|
|
.post('/api/ai/upload-and-process')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.attach('flyerFile', testImagePath);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.error.message).toContain('checksum');
|
|
});
|
|
|
|
it('should reject invalid checksum format (non-hex)', async () => {
|
|
const testImagePath = path.join(__dirname, '../assets/flyer-test.png');
|
|
if (!fs.existsSync(testImagePath)) {
|
|
return;
|
|
}
|
|
|
|
const response = await request
|
|
.post('/api/ai/upload-and-process')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.attach('flyerFile', testImagePath)
|
|
.field('checksum', 'not-a-valid-hex-checksum-at-all!!!!');
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.success).toBe(false);
|
|
});
|
|
|
|
it('should reject short checksum (not 64 characters)', async () => {
|
|
const testImagePath = path.join(__dirname, '../assets/flyer-test.png');
|
|
if (!fs.existsSync(testImagePath)) {
|
|
return;
|
|
}
|
|
|
|
const response = await request
|
|
.post('/api/ai/upload-and-process')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.attach('flyerFile', testImagePath)
|
|
.field('checksum', 'abc123'); // Too short
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('File Type Validation', () => {
|
|
it('should require flyerFile field', async () => {
|
|
const checksum = crypto.randomBytes(32).toString('hex');
|
|
|
|
const response = await request
|
|
.post('/api/ai/upload-and-process')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.field('checksum', checksum);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.error.message).toContain('file');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Input Sanitization', () => {
|
|
describe('Shopping List Names', () => {
|
|
it('should accept unicode characters and emojis', async () => {
|
|
const response = await request
|
|
.post('/api/users/shopping-lists')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ name: 'Grocery List 🛒 日本語 émoji' });
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.name).toBe('Grocery List 🛒 日本語 émoji');
|
|
|
|
if (response.body.data.shopping_list_id) {
|
|
createdShoppingListIds.push(response.body.data.shopping_list_id);
|
|
}
|
|
});
|
|
|
|
it('should store XSS payloads as-is (frontend must escape)', async () => {
|
|
const xssPayload = '<script>alert("xss")</script>';
|
|
const response = await request
|
|
.post('/api/users/shopping-lists')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ name: xssPayload });
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.success).toBe(true);
|
|
// The payload is stored as-is - frontend is responsible for escaping
|
|
expect(response.body.data.name).toBe(xssPayload);
|
|
|
|
if (response.body.data.shopping_list_id) {
|
|
createdShoppingListIds.push(response.body.data.shopping_list_id);
|
|
}
|
|
});
|
|
|
|
it('should reject null bytes in JSON', async () => {
|
|
// Null bytes in JSON should be rejected by the JSON parser
|
|
const response = await request
|
|
.post('/api/users/shopping-lists')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.set('Content-Type', 'application/json')
|
|
.send('{"name":"test\u0000value"}');
|
|
|
|
// JSON parser may reject this or sanitize it
|
|
expect([400, 201]).toContain(response.status);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Authorization Boundaries', () => {
|
|
describe('Cross-User Resource Access', () => {
|
|
it("should return 404 (not 403) for accessing another user's shopping list", async () => {
|
|
// Create a shopping list as the primary user
|
|
const createResponse = await request
|
|
.post('/api/users/shopping-lists')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ name: 'Private List' });
|
|
|
|
expect(createResponse.status).toBe(201);
|
|
const listId = createResponse.body.data.shopping_list_id;
|
|
createdShoppingListIds.push(listId);
|
|
|
|
// Try to access it as the other user
|
|
const accessResponse = await request
|
|
.get(`/api/users/shopping-lists/${listId}`)
|
|
.set('Authorization', `Bearer ${otherUserToken}`);
|
|
|
|
// Should return 404 to hide resource existence
|
|
expect(accessResponse.status).toBe(404);
|
|
expect(accessResponse.body.success).toBe(false);
|
|
expect(accessResponse.body.error.code).toBe('NOT_FOUND');
|
|
});
|
|
|
|
it("should return 404 when trying to update another user's shopping list", async () => {
|
|
// Create a shopping list as the primary user
|
|
const createResponse = await request
|
|
.post('/api/users/shopping-lists')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ name: 'Another Private List' });
|
|
|
|
expect(createResponse.status).toBe(201);
|
|
const listId = createResponse.body.data.shopping_list_id;
|
|
createdShoppingListIds.push(listId);
|
|
|
|
// Try to update it as the other user
|
|
const updateResponse = await request
|
|
.put(`/api/users/shopping-lists/${listId}`)
|
|
.set('Authorization', `Bearer ${otherUserToken}`)
|
|
.send({ name: 'Hacked List' });
|
|
|
|
// Should return 404 to hide resource existence
|
|
expect(updateResponse.status).toBe(404);
|
|
});
|
|
|
|
it("should return 404 when trying to delete another user's shopping list", async () => {
|
|
// Create a shopping list as the primary user
|
|
const createResponse = await request
|
|
.post('/api/users/shopping-lists')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ name: 'Delete Test List' });
|
|
|
|
expect(createResponse.status).toBe(201);
|
|
const listId = createResponse.body.data.shopping_list_id;
|
|
createdShoppingListIds.push(listId);
|
|
|
|
// Try to delete it as the other user
|
|
const deleteResponse = await request
|
|
.delete(`/api/users/shopping-lists/${listId}`)
|
|
.set('Authorization', `Bearer ${otherUserToken}`);
|
|
|
|
// Should return 404 to hide resource existence
|
|
expect(deleteResponse.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('SQL Injection Prevention', () => {
|
|
it('should safely handle SQL injection in query params', async () => {
|
|
// Attempt SQL injection in limit param
|
|
const response = await request
|
|
.get('/api/personalization/master-items')
|
|
.query({ limit: '10; DROP TABLE users; --' });
|
|
|
|
// Should either return normal data or a validation error, not crash
|
|
expect([200, 400]).toContain(response.status);
|
|
expect(response.body).toBeDefined();
|
|
});
|
|
|
|
it('should safely handle SQL injection in search params', async () => {
|
|
// Attempt SQL injection in flyer search
|
|
const response = await request.get('/api/flyers').query({
|
|
search: "'; DROP TABLE flyers; --",
|
|
});
|
|
|
|
// Should handle safely
|
|
expect([200, 400]).toContain(response.status);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('API Error Handling', () => {
|
|
it('should return 404 for non-existent resources with clear message', async () => {
|
|
const response = await request
|
|
.get('/api/flyers/99999999')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.error.code).toBe('NOT_FOUND');
|
|
});
|
|
|
|
it('should return validation error for malformed JSON body', async () => {
|
|
const response = await request
|
|
.post('/api/users/shopping-lists')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.set('Content-Type', 'application/json')
|
|
.send('{ invalid json }');
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return validation error for missing required fields', async () => {
|
|
const response = await request
|
|
.post('/api/budgets')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({}); // Empty body
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.error.code).toBe('VALIDATION_ERROR');
|
|
});
|
|
|
|
it('should return validation error for invalid data types', async () => {
|
|
const response = await request
|
|
.post('/api/budgets')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
name: 'Test Budget',
|
|
amount_cents: 'not-a-number', // Should be number
|
|
period: 'weekly',
|
|
start_date: '2025-01-01',
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Concurrent Operations', () => {
|
|
it('should handle concurrent writes without data loss', async () => {
|
|
// Create 5 shopping lists concurrently
|
|
const promises = Array.from({ length: 5 }, (_, i) =>
|
|
request
|
|
.post('/api/users/shopping-lists')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ name: `Concurrent List ${i + 1}` }),
|
|
);
|
|
|
|
const results = await Promise.all(promises);
|
|
|
|
// All should succeed
|
|
results.forEach((response) => {
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.success).toBe(true);
|
|
if (response.body.data.shopping_list_id) {
|
|
createdShoppingListIds.push(response.body.data.shopping_list_id);
|
|
}
|
|
});
|
|
|
|
// Verify all lists were created
|
|
const listResponse = await request
|
|
.get('/api/users/shopping-lists')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(listResponse.status).toBe(200);
|
|
const lists = listResponse.body.data;
|
|
const concurrentLists = lists.filter((l: { name: string }) =>
|
|
l.name.startsWith('Concurrent List'),
|
|
);
|
|
expect(concurrentLists.length).toBe(5);
|
|
});
|
|
|
|
it('should handle concurrent reads without errors', async () => {
|
|
// Make 10 concurrent read requests
|
|
const promises = Array.from({ length: 10 }, () =>
|
|
request.get('/api/personalization/master-items'),
|
|
);
|
|
|
|
const results = await Promise.all(promises);
|
|
|
|
// All should succeed
|
|
results.forEach((response) => {
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
});
|