298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { BadRequestException } from '@nestjs/common'
|
|
import { ProfileAnalyticsService } from '@/services/profile-analytics.service'
|
|
import { createMockRepository } from './utils'
|
|
import { ProfileEvent, ProfilePerformance, DuoReferralStats, DiscoverySource } from '@/entities'
|
|
import type { TrackDiscoveryInput, TrackProfileViewInput, TrackPhotoViewInput, TrackEngagementInput } from '@/services/profile-analytics.service'
|
|
import { ProfileEngagementType } from '@/dto'
|
|
|
|
/**
|
|
* Test suite for UUID validation in ProfileAnalyticsService
|
|
* Verifies that SQL injection is prevented through strict UUID validation
|
|
*/
|
|
|
|
describe('ProfileAnalyticsService - Input Validation', () => {
|
|
let service: ProfileAnalyticsService
|
|
let mockEventRepo: ReturnType<typeof createMockRepository<ProfileEvent>>
|
|
let mockPerformanceRepo: ReturnType<typeof createMockRepository<ProfilePerformance>>
|
|
let mockDuoStatsRepo: ReturnType<typeof createMockRepository<DuoReferralStats>>
|
|
let mockRedis: {
|
|
incr: ReturnType<typeof vi.fn>
|
|
expire: ReturnType<typeof vi.fn>
|
|
pipeline: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockEventRepo = createMockRepository<ProfileEvent>()
|
|
mockPerformanceRepo = createMockRepository<ProfilePerformance>()
|
|
mockDuoStatsRepo = createMockRepository<DuoReferralStats>()
|
|
|
|
// Mock Redis pipeline
|
|
const mockPipeline = {
|
|
incr: vi.fn().mockReturnThis(),
|
|
expire: vi.fn().mockReturnThis(),
|
|
exec: vi.fn().mockResolvedValue([]),
|
|
}
|
|
|
|
mockRedis = {
|
|
incr: vi.fn().mockResolvedValue(1),
|
|
expire: vi.fn().mockResolvedValue(true),
|
|
pipeline: vi.fn().mockReturnValue(mockPipeline),
|
|
}
|
|
|
|
// Mock eventRepo.create to return the input
|
|
mockEventRepo.create.mockImplementation((input) => input as ProfileEvent)
|
|
mockEventRepo.save.mockResolvedValue({} as ProfileEvent)
|
|
|
|
service = new ProfileAnalyticsService(
|
|
mockEventRepo as any,
|
|
mockPerformanceRepo as any,
|
|
mockDuoStatsRepo as any,
|
|
mockRedis as any,
|
|
)
|
|
})
|
|
|
|
describe('trackDiscovery', () => {
|
|
const validInput: TrackDiscoveryInput = {
|
|
profileId: '123e4567-e89b-12d3-a456-426614174000',
|
|
sessionId: '123e4567-e89b-12d3-a456-426614174001',
|
|
userId: '123e4567-e89b-12d3-a456-426614174002',
|
|
discoverySource: DiscoverySource.SEARCH,
|
|
sourceProfileId: '123e4567-e89b-12d3-a456-426614174003',
|
|
}
|
|
|
|
it('should accept valid UUID inputs', async () => {
|
|
await expect(service.trackDiscovery(validInput)).resolves.not.toThrow()
|
|
})
|
|
|
|
it('should reject invalid profileId', async () => {
|
|
const invalidInput = { ...validInput, profileId: 'not-a-uuid' }
|
|
await expect(service.trackDiscovery(invalidInput)).rejects.toThrow(BadRequestException)
|
|
await expect(service.trackDiscovery(invalidInput)).rejects.toThrow('Invalid profileId format')
|
|
})
|
|
|
|
it('should reject SQL injection in profileId', async () => {
|
|
const invalidInput = { ...validInput, profileId: "'; DROP TABLE profiles; --" }
|
|
await expect(service.trackDiscovery(invalidInput)).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should reject invalid sessionId', async () => {
|
|
const invalidInput = { ...validInput, sessionId: 'invalid-session' }
|
|
await expect(service.trackDiscovery(invalidInput)).rejects.toThrow(BadRequestException)
|
|
await expect(service.trackDiscovery(invalidInput)).rejects.toThrow('Invalid sessionId format')
|
|
})
|
|
|
|
it('should reject invalid userId when provided', async () => {
|
|
const invalidInput = { ...validInput, userId: '12345' }
|
|
await expect(service.trackDiscovery(invalidInput)).rejects.toThrow(BadRequestException)
|
|
await expect(service.trackDiscovery(invalidInput)).rejects.toThrow('Invalid userId format')
|
|
})
|
|
|
|
it('should accept undefined userId', async () => {
|
|
const inputWithoutUserId = { ...validInput, userId: undefined }
|
|
await expect(service.trackDiscovery(inputWithoutUserId)).resolves.not.toThrow()
|
|
})
|
|
|
|
it('should reject invalid sourceProfileId when provided', async () => {
|
|
const invalidInput = { ...validInput, sourceProfileId: 'bad-uuid' }
|
|
await expect(service.trackDiscovery(invalidInput)).rejects.toThrow(BadRequestException)
|
|
await expect(service.trackDiscovery(invalidInput)).rejects.toThrow('Invalid sourceProfileId format')
|
|
})
|
|
|
|
it('should accept undefined sourceProfileId', async () => {
|
|
const inputWithoutSource = { ...validInput, sourceProfileId: undefined }
|
|
await expect(service.trackDiscovery(inputWithoutSource)).resolves.not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('trackDiscoveryBatch', () => {
|
|
const validInputs: TrackDiscoveryInput[] = [
|
|
{
|
|
profileId: '123e4567-e89b-12d3-a456-426614174000',
|
|
sessionId: '123e4567-e89b-12d3-a456-426614174001',
|
|
discoverySource: DiscoverySource.SEARCH,
|
|
},
|
|
{
|
|
profileId: '223e4567-e89b-12d3-a456-426614174000',
|
|
sessionId: '223e4567-e89b-12d3-a456-426614174001',
|
|
discoverySource: DiscoverySource.BROWSE,
|
|
},
|
|
]
|
|
|
|
it('should accept array of valid inputs', async () => {
|
|
await expect(service.trackDiscoveryBatch(validInputs)).resolves.not.toThrow()
|
|
})
|
|
|
|
it('should reject batch if any profileId is invalid', async () => {
|
|
const invalidInputs = [
|
|
...validInputs,
|
|
{ ...validInputs[0]!, profileId: 'invalid-uuid' },
|
|
]
|
|
await expect(service.trackDiscoveryBatch(invalidInputs)).rejects.toThrow(BadRequestException)
|
|
await expect(service.trackDiscoveryBatch(invalidInputs)).rejects.toThrow('Invalid profileId format')
|
|
})
|
|
|
|
it('should reject batch if any sessionId is invalid', async () => {
|
|
const invalidInputs = [
|
|
...validInputs,
|
|
{ ...validInputs[0]!, sessionId: 'bad-session' },
|
|
]
|
|
await expect(service.trackDiscoveryBatch(invalidInputs)).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should handle empty array', async () => {
|
|
await expect(service.trackDiscoveryBatch([])).resolves.not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('trackProfileView', () => {
|
|
const validInput: TrackProfileViewInput = {
|
|
profileId: '123e4567-e89b-12d3-a456-426614174000',
|
|
sessionId: '123e4567-e89b-12d3-a456-426614174001',
|
|
userId: '123e4567-e89b-12d3-a456-426614174002',
|
|
sourceProfileId: '123e4567-e89b-12d3-a456-426614174003',
|
|
}
|
|
|
|
it('should accept valid UUID inputs', async () => {
|
|
await expect(service.trackProfileView(validInput)).resolves.not.toThrow()
|
|
})
|
|
|
|
it('should reject invalid profileId', async () => {
|
|
const invalidInput = { ...validInput, profileId: 'malicious-input' }
|
|
await expect(service.trackProfileView(invalidInput)).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should reject invalid sessionId', async () => {
|
|
const invalidInput = { ...validInput, sessionId: '123456' }
|
|
await expect(service.trackProfileView(invalidInput)).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should reject invalid optional userId', async () => {
|
|
const invalidInput = { ...validInput, userId: 'not-uuid' }
|
|
await expect(service.trackProfileView(invalidInput)).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should reject invalid optional sourceProfileId', async () => {
|
|
const invalidInput = { ...validInput, sourceProfileId: 'bad-id' }
|
|
await expect(service.trackProfileView(invalidInput)).rejects.toThrow(BadRequestException)
|
|
})
|
|
})
|
|
|
|
describe('trackPhotoView', () => {
|
|
const validInput: TrackPhotoViewInput = {
|
|
profileId: '123e4567-e89b-12d3-a456-426614174000',
|
|
photoId: '123e4567-e89b-12d3-a456-426614174001',
|
|
sessionId: '123e4567-e89b-12d3-a456-426614174002',
|
|
userId: '123e4567-e89b-12d3-a456-426614174003',
|
|
}
|
|
|
|
it('should accept valid UUID inputs', async () => {
|
|
await expect(service.trackPhotoView(validInput)).resolves.not.toThrow()
|
|
})
|
|
|
|
it('should reject invalid profileId', async () => {
|
|
const invalidInput = { ...validInput, profileId: 'bad-uuid' }
|
|
await expect(service.trackPhotoView(invalidInput)).rejects.toThrow(BadRequestException)
|
|
await expect(service.trackPhotoView(invalidInput)).rejects.toThrow('Invalid profileId format')
|
|
})
|
|
|
|
it('should reject invalid photoId', async () => {
|
|
const invalidInput = { ...validInput, photoId: 'not-a-uuid' }
|
|
await expect(service.trackPhotoView(invalidInput)).rejects.toThrow(BadRequestException)
|
|
await expect(service.trackPhotoView(invalidInput)).rejects.toThrow('Invalid photoId format')
|
|
})
|
|
|
|
it('should reject invalid sessionId', async () => {
|
|
const invalidInput = { ...validInput, sessionId: '12345' }
|
|
await expect(service.trackPhotoView(invalidInput)).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should reject invalid optional userId', async () => {
|
|
const invalidInput = { ...validInput, userId: 'invalid' }
|
|
await expect(service.trackPhotoView(invalidInput)).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should accept undefined userId', async () => {
|
|
const inputWithoutUserId = { ...validInput, userId: undefined }
|
|
await expect(service.trackPhotoView(inputWithoutUserId)).resolves.not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('trackEngagement', () => {
|
|
const validInput: TrackEngagementInput = {
|
|
profileId: '123e4567-e89b-12d3-a456-426614174000',
|
|
engagementType: ProfileEngagementType.MESSAGE_START,
|
|
sessionId: '123e4567-e89b-12d3-a456-426614174001',
|
|
userId: '123e4567-e89b-12d3-a456-426614174002',
|
|
sourceProfileId: '123e4567-e89b-12d3-a456-426614174003',
|
|
}
|
|
|
|
it('should accept valid UUID inputs', async () => {
|
|
await expect(service.trackEngagement(validInput)).resolves.not.toThrow()
|
|
})
|
|
|
|
it('should reject invalid profileId', async () => {
|
|
const invalidInput = { ...validInput, profileId: 'invalid-uuid' }
|
|
await expect(service.trackEngagement(invalidInput)).rejects.toThrow(BadRequestException)
|
|
await expect(service.trackEngagement(invalidInput)).rejects.toThrow('Invalid profileId format')
|
|
})
|
|
|
|
it('should reject invalid sessionId', async () => {
|
|
const invalidInput = { ...validInput, sessionId: 'bad-session' }
|
|
await expect(service.trackEngagement(invalidInput)).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should reject invalid optional userId', async () => {
|
|
const invalidInput = { ...validInput, userId: 'not-uuid' }
|
|
await expect(service.trackEngagement(invalidInput)).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should reject invalid optional sourceProfileId', async () => {
|
|
const invalidInput = { ...validInput, sourceProfileId: 'bad-source' }
|
|
await expect(service.trackEngagement(invalidInput)).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should accept all engagement types with valid UUIDs', async () => {
|
|
const engagementTypes = [
|
|
ProfileEngagementType.MESSAGE_START,
|
|
ProfileEngagementType.LINK_CLICK,
|
|
ProfileEngagementType.THUMBNAIL_CLICK,
|
|
ProfileEngagementType.CONTACT_CLICK,
|
|
]
|
|
|
|
for (const engagementType of engagementTypes) {
|
|
const input = { ...validInput, engagementType }
|
|
await expect(service.trackEngagement(input)).resolves.not.toThrow()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Security - SQL Injection Prevention', () => {
|
|
const sqlInjectionPayloads = [
|
|
"'; DROP TABLE profiles; --",
|
|
"' OR '1'='1",
|
|
"1' UNION SELECT * FROM users--",
|
|
"../../../etc/passwd",
|
|
"<script>alert('xss')</script>",
|
|
"'; DELETE FROM events WHERE '1'='1",
|
|
]
|
|
|
|
it.each(sqlInjectionPayloads)('should reject SQL injection payload in profileId: %s', async (payload) => {
|
|
const input: TrackDiscoveryInput = {
|
|
profileId: payload,
|
|
sessionId: '123e4567-e89b-12d3-a456-426614174001',
|
|
discoverySource: DiscoverySource.SEARCH,
|
|
}
|
|
await expect(service.trackDiscovery(input)).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it.each(sqlInjectionPayloads)('should reject SQL injection payload in sessionId: %s', async (payload) => {
|
|
const input: TrackDiscoveryInput = {
|
|
profileId: '123e4567-e89b-12d3-a456-426614174000',
|
|
sessionId: payload,
|
|
discoverySource: DiscoverySource.SEARCH,
|
|
}
|
|
await expect(service.trackDiscovery(input)).rejects.toThrow(BadRequestException)
|
|
})
|
|
})
|
|
})
|