CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
- /api/contact enforces per-field length caps (name/email/subject/message) before sanitization/persistence; over-long message returns 400 without touching Mongo (test added) - logger: crypto-based request id (randomUUID) instead of Math.random().substr; defaultMeta.version prefers DEPLOY_VERSION over npm_package_version - next.config: move Turbopack svg rule from deprecated experimental.turbo to top-level turbopack - deps: bump next 15.5.2 → 15.5.23; npm audit fix (19 → 3, remaining are transitive sharp/libvips DoS advisories, not exploitable for static images) Closes MITHOME-78, MITHOME-79
286 lines
9.1 KiB
TypeScript
Executable File
286 lines
9.1 KiB
TypeScript
Executable File
/**
|
|
* Unit tests for Contact API route
|
|
* These tests focus on testing the business logic without complex mocking
|
|
*/
|
|
import { POST } from './route'
|
|
|
|
const mockInsertOne = jest.fn()
|
|
|
|
// Mock the logger to avoid complex setup
|
|
jest.mock('@/lib/logger', () => ({
|
|
createComponentLogger: () => ({
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
})
|
|
}))
|
|
|
|
jest.mock('@/lib/mongodb', () => ({
|
|
getCollection: jest.fn(async () => ({ insertOne: mockInsertOne })),
|
|
}))
|
|
|
|
describe('/api/contact Unit Tests', () => {
|
|
beforeEach(() => {
|
|
mockInsertOne.mockReset()
|
|
})
|
|
|
|
describe('MongoDB persistence', () => {
|
|
const validData = {
|
|
name: 'Test User',
|
|
email: 'test@example.com',
|
|
subject: 'Test Subject',
|
|
message: 'This is a test message with enough content',
|
|
gdprConsent: true,
|
|
}
|
|
|
|
it('persists a valid submission before reporting success', async () => {
|
|
mockInsertOne.mockResolvedValue({ insertedId: { toHexString: () => 'submission-123' } })
|
|
const request = {
|
|
headers: new Headers({ 'x-forwarded-for': 'persistence-success' }),
|
|
json: async () => validData,
|
|
} as any
|
|
|
|
const response = await POST(request)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.submissionId).toBe('submission-123')
|
|
expect(mockInsertOne).toHaveBeenCalledWith(expect.objectContaining({
|
|
...validData,
|
|
status: 'new',
|
|
createdAt: expect.any(Date),
|
|
}))
|
|
})
|
|
|
|
it('returns a server error when persistence fails', async () => {
|
|
mockInsertOne.mockRejectedValue(new Error('MongoDB unavailable'))
|
|
const request = {
|
|
headers: new Headers({ 'x-forwarded-for': 'persistence-failure' }),
|
|
json: async () => validData,
|
|
} as any
|
|
|
|
const response = await POST(request)
|
|
|
|
expect(response.status).toBe(500)
|
|
expect(mockInsertOne).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('rejects an over-long message before persisting', async () => {
|
|
const longMessage = { ...validData, message: 'x'.repeat(5001) }
|
|
const request = {
|
|
headers: new Headers({ 'x-forwarded-for': 'too-long' }),
|
|
json: async () => longMessage,
|
|
} as any
|
|
|
|
const response = await POST(request)
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(mockInsertOne).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// TC-001: Email Format Validation Test (ZEE-48)
|
|
describe('Input validation logic', () => {
|
|
it('should validate required fields', () => {
|
|
const validData = {
|
|
name: 'Test User',
|
|
email: 'test@example.com',
|
|
subject: 'Test Subject',
|
|
message: 'This is a test message with enough content',
|
|
gdprConsent: true
|
|
}
|
|
|
|
// Test individual field validation logic
|
|
expect(validData.name.length).toBeGreaterThan(1)
|
|
expect(validData.email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
|
|
expect(validData.subject.length).toBeGreaterThan(2)
|
|
expect(validData.message.length).toBeGreaterThan(9)
|
|
expect(validData.gdprConsent).toBe(true)
|
|
})
|
|
|
|
it('TC-001: should detect invalid email formats', () => {
|
|
const invalidEmails = [
|
|
'invalid-email',
|
|
'test@',
|
|
'@example.com',
|
|
'test.example.com',
|
|
''
|
|
]
|
|
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
|
invalidEmails.forEach(email => {
|
|
expect(email).not.toMatch(emailRegex)
|
|
})
|
|
})
|
|
|
|
it('should validate field lengths', () => {
|
|
const testCases = [
|
|
{ field: 'name', value: 'T', minLength: 2, valid: false },
|
|
{ field: 'name', value: 'Test User', minLength: 2, valid: true },
|
|
{ field: 'subject', value: 'Te', minLength: 3, valid: false },
|
|
{ field: 'subject', value: 'Test Subject', minLength: 3, valid: true },
|
|
{ field: 'message', value: 'Short', minLength: 10, valid: false },
|
|
{ field: 'message', value: 'This is a longer message', minLength: 10, valid: true }
|
|
]
|
|
|
|
testCases.forEach(testCase => {
|
|
const isValid = testCase.value.length >= testCase.minLength
|
|
expect(isValid).toBe(testCase.valid)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Spam detection logic', () => {
|
|
it('should detect spam keywords', () => {
|
|
const spamKeywords = [
|
|
'free money', 'click here', 'buy now', 'urgent business',
|
|
'viagra', 'cheap', 'limited time', 'act now'
|
|
]
|
|
|
|
const spamText = 'FREE MONEY CLICK HERE NOW BUY VIAGRA CHEAP URGENT BUSINESS PROPOSAL'
|
|
|
|
let spamCount = 0
|
|
spamKeywords.forEach(keyword => {
|
|
if (spamText.toLowerCase().includes(keyword.toLowerCase())) {
|
|
spamCount++
|
|
}
|
|
})
|
|
|
|
// Should detect multiple spam keywords
|
|
expect(spamCount).toBeGreaterThan(3)
|
|
})
|
|
|
|
it('should allow legitimate business content', () => {
|
|
const legitimateText = 'Hello, I would like to buy your web hosting service. Can you provide more information about your business offerings?'
|
|
|
|
const spamKeywords = [
|
|
'free money', 'click here now', 'urgent business proposal',
|
|
'viagra', 'cheap pills', 'limited time offer'
|
|
]
|
|
|
|
let spamCount = 0
|
|
spamKeywords.forEach(keyword => {
|
|
if (legitimateText.toLowerCase().includes(keyword.toLowerCase())) {
|
|
spamCount++
|
|
}
|
|
})
|
|
|
|
// Should not trigger spam detection
|
|
expect(spamCount).toBeLessThan(2)
|
|
})
|
|
})
|
|
|
|
describe('Input sanitization logic', () => {
|
|
it('should handle potentially dangerous characters', () => {
|
|
const dangerousInput = '<script>alert("xss")</script>Test User'
|
|
|
|
// Simple sanitization check - removing script tags
|
|
const sanitized = dangerousInput.replace(/<script[^>]*>.*?<\/script>/gi, '')
|
|
|
|
expect(sanitized).toBe('Test User')
|
|
expect(sanitized).not.toContain('<script>')
|
|
})
|
|
|
|
it('should preserve safe HTML entities', () => {
|
|
const inputWithEntities = 'Test & Company "Quotes" and \'apostrophes\''
|
|
|
|
// Should preserve normal business text
|
|
expect(inputWithEntities.length).toBeGreaterThan(0)
|
|
expect(inputWithEntities).toContain('&')
|
|
expect(inputWithEntities).toContain('"')
|
|
expect(inputWithEntities).toContain("'")
|
|
})
|
|
})
|
|
|
|
describe('Rate limiting logic', () => {
|
|
it('should implement rate limiting concept', () => {
|
|
// Simple rate limiting simulation
|
|
const requests = []
|
|
const timeWindow = 60000 // 1 minute
|
|
const maxRequests = 3
|
|
|
|
// Simulate requests
|
|
const now = Date.now()
|
|
requests.push(now)
|
|
requests.push(now + 1000)
|
|
requests.push(now + 2000)
|
|
requests.push(now + 3000) // This should be rate limited
|
|
|
|
// Filter requests within time window
|
|
const recentRequests = requests.filter(time =>
|
|
(now + 3000) - time < timeWindow
|
|
)
|
|
|
|
expect(recentRequests.length).toBe(4)
|
|
expect(recentRequests.length > maxRequests).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Response format validation', () => {
|
|
it('should validate success response structure', () => {
|
|
const successResponse = {
|
|
message: 'Üzenet sikeresen elküldve!',
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
|
|
expect(successResponse).toHaveProperty('message')
|
|
expect(successResponse).toHaveProperty('timestamp')
|
|
expect(typeof successResponse.message).toBe('string')
|
|
expect(typeof successResponse.timestamp).toBe('string')
|
|
expect(() => new Date(successResponse.timestamp)).not.toThrow()
|
|
})
|
|
|
|
it('should validate error response structure', () => {
|
|
const errorResponse = {
|
|
error: 'Validációs hiba: hiányzó mezők'
|
|
}
|
|
|
|
expect(errorResponse).toHaveProperty('error')
|
|
expect(typeof errorResponse.error).toBe('string')
|
|
expect(errorResponse.error.length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('Business logic helpers', () => {
|
|
it('should validate GDPR consent requirement', () => {
|
|
const testCases = [
|
|
{ gdprConsent: true, valid: true },
|
|
{ gdprConsent: false, valid: false },
|
|
{ gdprConsent: undefined, valid: false },
|
|
{ gdprConsent: null, valid: false }
|
|
]
|
|
|
|
testCases.forEach(testCase => {
|
|
const isValid = testCase.gdprConsent === true
|
|
expect(isValid).toBe(testCase.valid)
|
|
})
|
|
})
|
|
|
|
it('should generate proper timestamps', () => {
|
|
const timestamp = new Date().toISOString()
|
|
|
|
expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
|
expect(() => new Date(timestamp)).not.toThrow()
|
|
|
|
const parsedDate = new Date(timestamp)
|
|
expect(parsedDate.getTime()).toBeCloseTo(Date.now(), -3) // Within 1 second
|
|
})
|
|
|
|
it('should handle IP address extraction logic', () => {
|
|
// Simulate IP extraction from headers
|
|
const mockHeaders = {
|
|
'x-forwarded-for': '192.168.1.100, 10.0.0.1',
|
|
'x-real-ip': '192.168.1.100',
|
|
'remote-addr': '127.0.0.1'
|
|
}
|
|
|
|
// Extract first IP from x-forwarded-for
|
|
const forwardedFor = mockHeaders['x-forwarded-for']
|
|
const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : mockHeaders['x-real-ip']
|
|
|
|
expect(clientIp).toBe('192.168.1.100')
|
|
})
|
|
})
|
|
})
|