Files
flyer-crawler.projectium.com/src/tests/integration/edge-cases.integration.test.ts
Torben Sorensen c24103d9a0
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m42s
frontend direct testing result and fixes
2026-01-18 13:57:47 -08:00

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);
});
});
});
});