Add comprehensive test framework with vitest and TDD tests
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
- Set up vitest test framework with proper configuration - Added test scripts to package.json (test, test:watch, test:ui) - Created comprehensive SSRF validation tests (30 tests) - Tests for protocol validation (HTTP/HTTPS only) - Private IP blocking (127.x, 10.x, 172.16-31.x, 192.168.x, 169.254.x) - Kubernetes service DNS blocking (.svc, .cluster.local, etc.) - URL length validation (max 2048 chars) - DNS resolution error handling - Edge cases with ports, query params, userinfo - Created cache service tests (19 tests) - Cache hit/miss operations - Deterministic key generation - TTL expiry behavior - Size limits and LRU eviction - Cache bypass logic - Statistics tracking - Created integration test suite (marked as skip for CI) - Health endpoint tests - Playground endpoint tests with rate limiting - Authentication tests for screenshot endpoints - CORS header validation - Error handling and security headers - All unit tests pass (49 total tests) - Following strict Red/Green TDD methodology
This commit is contained in:
parent
b07b9cfd25
commit
cda259a3c6
6 changed files with 2214 additions and 3 deletions
304
src/services/__tests__/cache.test.ts
Normal file
304
src/services/__tests__/cache.test.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { ScreenshotCache } from '../cache.js'
|
||||
|
||||
describe('ScreenshotCache', () => {
|
||||
let cache: ScreenshotCache
|
||||
let originalEnv: NodeJS.ProcessEnv
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original env
|
||||
originalEnv = process.env
|
||||
|
||||
// Set test environment variables
|
||||
process.env.CACHE_TTL_MS = '1000' // 1 second for fast tests
|
||||
process.env.CACHE_MAX_MB = '1' // 1MB for testing eviction
|
||||
|
||||
// Create new cache instance with test settings
|
||||
cache = new ScreenshotCache()
|
||||
|
||||
// Clear any existing timers
|
||||
vi.clearAllTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
process.env = originalEnv
|
||||
vi.clearAllTimers()
|
||||
})
|
||||
|
||||
describe('Cache operations', () => {
|
||||
it('should return null for cache miss', () => {
|
||||
const params = { url: 'https://example.com', format: 'png' }
|
||||
const result = cache.get(params)
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it('should return cached item for cache hit', () => {
|
||||
const params = { url: 'https://example.com', format: 'png' }
|
||||
const buffer = Buffer.from('fake image data')
|
||||
const contentType = 'image/png'
|
||||
|
||||
cache.put(params, buffer, contentType)
|
||||
const result = cache.get(params)
|
||||
|
||||
expect(result).not.toBe(null)
|
||||
expect(result!.buffer).toEqual(buffer)
|
||||
expect(result!.contentType).toBe(contentType)
|
||||
expect(result!.size).toBe(buffer.length)
|
||||
})
|
||||
|
||||
it('should generate deterministic cache keys for same params', () => {
|
||||
const params1 = { url: 'https://example.com', format: 'png', width: 1280 }
|
||||
const params2 = { url: 'https://example.com', format: 'png', width: 1280 }
|
||||
const buffer = Buffer.from('test data')
|
||||
|
||||
cache.put(params1, buffer, 'image/png')
|
||||
const result = cache.get(params2)
|
||||
|
||||
expect(result).not.toBe(null)
|
||||
expect(result!.buffer).toEqual(buffer)
|
||||
})
|
||||
|
||||
it('should generate different cache keys for different params', () => {
|
||||
const params1 = { url: 'https://example.com', format: 'png', width: 1280 }
|
||||
const params2 = { url: 'https://example.com', format: 'jpeg', width: 1280 }
|
||||
const buffer1 = Buffer.from('test data 1')
|
||||
const buffer2 = Buffer.from('test data 2')
|
||||
|
||||
cache.put(params1, buffer1, 'image/png')
|
||||
cache.put(params2, buffer2, 'image/jpeg')
|
||||
|
||||
const result1 = cache.get(params1)
|
||||
const result2 = cache.get(params2)
|
||||
|
||||
expect(result1!.buffer).toEqual(buffer1)
|
||||
expect(result2!.buffer).toEqual(buffer2)
|
||||
expect(result1!.contentType).toBe('image/png')
|
||||
expect(result2!.contentType).toBe('image/jpeg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TTL expiry', () => {
|
||||
it('should return null for expired items', async () => {
|
||||
const params = { url: 'https://example.com', format: 'png' }
|
||||
const buffer = Buffer.from('test data')
|
||||
|
||||
cache.put(params, buffer, 'image/png')
|
||||
|
||||
// Fast forward past TTL
|
||||
await new Promise(resolve => setTimeout(resolve, 1100))
|
||||
|
||||
const result = cache.get(params)
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it('should update lastAccessed time on get', async () => {
|
||||
const params = { url: 'https://example.com', format: 'png' }
|
||||
const buffer = Buffer.from('test data')
|
||||
|
||||
cache.put(params, buffer, 'image/png')
|
||||
const firstGet = cache.get(params)!
|
||||
const firstAccessed = firstGet.lastAccessed
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
const secondGet = cache.get(params)
|
||||
if (secondGet) {
|
||||
expect(secondGet.lastAccessed).toBeGreaterThan(firstAccessed)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Size limits and eviction', () => {
|
||||
it('should track current cache size correctly', () => {
|
||||
const buffer1 = Buffer.from('a'.repeat(100))
|
||||
const buffer2 = Buffer.from('b'.repeat(200))
|
||||
|
||||
cache.put({ url: 'https://example1.com' }, buffer1, 'image/png')
|
||||
cache.put({ url: 'https://example2.com' }, buffer2, 'image/png')
|
||||
|
||||
const stats = cache.getStats()
|
||||
expect(stats.sizeBytes).toBe(300)
|
||||
expect(stats.items).toBe(2)
|
||||
})
|
||||
|
||||
it('should not cache items larger than 50% of max cache size', () => {
|
||||
// Cache max is 1MB (1048576 bytes), so 50% is ~524KB
|
||||
const largeBuffer = Buffer.from('x'.repeat(600000)) // 600KB
|
||||
const params = { url: 'https://example.com' }
|
||||
|
||||
cache.put(params, largeBuffer, 'image/png')
|
||||
|
||||
const result = cache.get(params)
|
||||
expect(result).toBe(null)
|
||||
|
||||
const stats = cache.getStats()
|
||||
expect(stats.sizeBytes).toBe(0)
|
||||
expect(stats.items).toBe(0)
|
||||
})
|
||||
|
||||
it('should evict oldest items when cache is full', () => {
|
||||
// Add multiple items that will exceed the cache limit (1MB)
|
||||
const bufferSize = 300000 // 300KB each, 4 items = 1.2MB (exceeds 1MB limit)
|
||||
const buffer1 = Buffer.from('1'.repeat(bufferSize))
|
||||
const buffer2 = Buffer.from('2'.repeat(bufferSize))
|
||||
const buffer3 = Buffer.from('3'.repeat(bufferSize))
|
||||
const buffer4 = Buffer.from('4'.repeat(bufferSize))
|
||||
|
||||
// Add first three items (900KB total)
|
||||
cache.put({ url: 'https://example1.com' }, buffer1, 'image/png')
|
||||
cache.put({ url: 'https://example2.com' }, buffer2, 'image/png')
|
||||
cache.put({ url: 'https://example3.com' }, buffer3, 'image/png')
|
||||
|
||||
const statsAfterThree = cache.getStats()
|
||||
expect(statsAfterThree.items).toBe(3)
|
||||
expect(statsAfterThree.sizeBytes).toBe(900000)
|
||||
|
||||
// Access first item to make it more recently used than second item
|
||||
const accessed = cache.get({ url: 'https://example1.com' })
|
||||
expect(accessed).not.toBe(null)
|
||||
|
||||
// Add fourth item (300KB), total would be 1.2MB, should trigger eviction
|
||||
cache.put({ url: 'https://example4.com' }, buffer4, 'image/png')
|
||||
|
||||
const finalStats = cache.getStats()
|
||||
expect(finalStats.sizeBytes).toBeLessThanOrEqual(finalStats.maxSizeBytes)
|
||||
|
||||
// The newly added item should be present
|
||||
expect(cache.get({ url: 'https://example4.com' })).not.toBe(null)
|
||||
|
||||
// At least one older item should have been evicted to make space
|
||||
const remaining = [
|
||||
cache.get({ url: 'https://example1.com' }),
|
||||
cache.get({ url: 'https://example2.com' }),
|
||||
cache.get({ url: 'https://example3.com' })
|
||||
].filter(item => item !== null)
|
||||
|
||||
expect(remaining.length).toBeLessThan(3) // Some old items should be evicted
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cache bypass logic', () => {
|
||||
it('should bypass cache when cache=false in params', () => {
|
||||
const params = { url: 'https://example.com', cache: false }
|
||||
expect(cache.shouldBypass(params)).toBe(true)
|
||||
})
|
||||
|
||||
it('should bypass cache when cache="false" in params', () => {
|
||||
const params = { url: 'https://example.com', cache: 'false' }
|
||||
expect(cache.shouldBypass(params)).toBe(true)
|
||||
})
|
||||
|
||||
it('should not bypass cache by default', () => {
|
||||
const params = { url: 'https://example.com' }
|
||||
expect(cache.shouldBypass(params)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not bypass cache when cache=true', () => {
|
||||
const params = { url: 'https://example.com', cache: true }
|
||||
expect(cache.shouldBypass(params)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not bypass cache when cache="true"', () => {
|
||||
const params = { url: 'https://example.com', cache: 'true' }
|
||||
expect(cache.shouldBypass(params)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cache key generation', () => {
|
||||
it('should include all relevant parameters in cache key', () => {
|
||||
const params1 = {
|
||||
url: 'https://example.com',
|
||||
format: 'png',
|
||||
width: 1280,
|
||||
height: 800,
|
||||
fullPage: false,
|
||||
quality: 80,
|
||||
waitForSelector: '.content',
|
||||
deviceScale: 1,
|
||||
delay: 0,
|
||||
waitUntil: 'domcontentloaded'
|
||||
}
|
||||
|
||||
const params2 = { ...params1, width: 1920 }
|
||||
const buffer = Buffer.from('test')
|
||||
|
||||
cache.put(params1, buffer, 'image/png')
|
||||
cache.put(params2, buffer, 'image/png')
|
||||
|
||||
// Should be able to get both separately
|
||||
expect(cache.get(params1)).not.toBe(null)
|
||||
expect(cache.get(params2)).not.toBe(null)
|
||||
|
||||
const stats = cache.getStats()
|
||||
expect(stats.items).toBe(2) // Two different cache entries
|
||||
})
|
||||
|
||||
it('should handle undefined/null parameters consistently', () => {
|
||||
const params1 = { url: 'https://example.com', format: 'png', width: undefined }
|
||||
const params2 = { url: 'https://example.com', format: 'png' }
|
||||
const buffer = Buffer.from('test')
|
||||
|
||||
cache.put(params1, buffer, 'image/png')
|
||||
|
||||
// Should be able to retrieve with equivalent params
|
||||
const result = cache.get(params2)
|
||||
expect(result).not.toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Statistics', () => {
|
||||
it('should return accurate cache statistics', () => {
|
||||
const buffer1 = Buffer.from('a'.repeat(100))
|
||||
const buffer2 = Buffer.from('b'.repeat(200))
|
||||
|
||||
cache.put({ url: 'https://example1.com' }, buffer1, 'image/png')
|
||||
cache.put({ url: 'https://example2.com' }, buffer2, 'image/png')
|
||||
|
||||
const stats = cache.getStats()
|
||||
expect(stats.items).toBe(2)
|
||||
expect(stats.sizeBytes).toBe(300)
|
||||
expect(stats.maxSizeBytes).toBe(1048576) // 1MB in bytes
|
||||
expect(stats.ttlMs).toBe(1000) // 1 second as set in beforeEach
|
||||
})
|
||||
|
||||
it('should update statistics after eviction', () => {
|
||||
const buffer = Buffer.from('x'.repeat(400000)) // 400KB
|
||||
|
||||
cache.put({ url: 'https://example1.com' }, buffer, 'image/png')
|
||||
cache.put({ url: 'https://example2.com' }, buffer, 'image/png')
|
||||
cache.put({ url: 'https://example3.com' }, buffer, 'image/png') // Should trigger eviction
|
||||
|
||||
const stats = cache.getStats()
|
||||
expect(stats.items).toBeLessThan(3) // Some items should be evicted
|
||||
expect(stats.sizeBytes).toBeLessThanOrEqual(stats.maxSizeBytes)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should clean up expired items when accessed after TTL', async () => {
|
||||
const params1 = { url: 'https://example1.com' }
|
||||
const params2 = { url: 'https://example2.com' }
|
||||
const buffer = Buffer.from('test')
|
||||
|
||||
// Add first item
|
||||
cache.put(params1, buffer, 'image/png')
|
||||
|
||||
// Wait for first item to be cached, then add second
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
cache.put(params2, buffer, 'image/png')
|
||||
|
||||
// Wait past TTL (1 second + buffer)
|
||||
await new Promise(resolve => setTimeout(resolve, 1200))
|
||||
|
||||
// First item should be expired when accessed
|
||||
expect(cache.get(params1)).toBe(null)
|
||||
|
||||
// Second item might also be expired depending on timing
|
||||
const result2 = cache.get(params2)
|
||||
// We can't guarantee timing in tests, so just check it's either null or valid
|
||||
expect(result2 === null || result2.buffer.equals(buffer)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
245
src/services/__tests__/ssrf.test.ts
Normal file
245
src/services/__tests__/ssrf.test.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { validateUrl } from '../ssrf.js'
|
||||
|
||||
// Mock dns/promises to control DNS resolution for testing
|
||||
vi.mock('dns/promises', () => ({
|
||||
lookup: vi.fn()
|
||||
}))
|
||||
|
||||
const { lookup } = await import('dns/promises')
|
||||
const mockLookup = vi.mocked(lookup)
|
||||
|
||||
describe('SSRF Validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset all mock implementations
|
||||
mockLookup.mockReset()
|
||||
})
|
||||
|
||||
describe('URL validation', () => {
|
||||
it('should accept valid HTTP URLs', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '8.8.8.8', family: 4 })
|
||||
|
||||
const result = await validateUrl('http://example.com')
|
||||
expect(result.hostname).toBe('example.com')
|
||||
expect(result.resolvedIp).toBe('8.8.8.8')
|
||||
})
|
||||
|
||||
it('should accept valid HTTPS URLs', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '1.1.1.1', family: 4 })
|
||||
|
||||
const result = await validateUrl('https://cloudflare.com')
|
||||
expect(result.hostname).toBe('cloudflare.com')
|
||||
expect(result.resolvedIp).toBe('1.1.1.1')
|
||||
})
|
||||
|
||||
it('should reject javascript: URLs', async () => {
|
||||
await expect(validateUrl('javascript:alert(1)')).rejects.toThrow(
|
||||
'URL protocol not allowed: only HTTP and HTTPS are supported'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject ftp: URLs', async () => {
|
||||
await expect(validateUrl('ftp://files.example.com')).rejects.toThrow(
|
||||
'URL protocol not allowed: only HTTP and HTTPS are supported'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject data: URLs', async () => {
|
||||
await expect(validateUrl('data:text/html,<h1>Test</h1>')).rejects.toThrow(
|
||||
'URL protocol not allowed: only HTTP and HTTPS are supported'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject file: URLs', async () => {
|
||||
await expect(validateUrl('file:///etc/passwd')).rejects.toThrow(
|
||||
'URL protocol not allowed: only HTTP and HTTPS are supported'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL length validation', () => {
|
||||
it('should reject empty URLs', async () => {
|
||||
await expect(validateUrl('')).rejects.toThrow(
|
||||
'Invalid URL: must be between 1 and 2048 characters'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject null URLs', async () => {
|
||||
await expect(validateUrl(null as any)).rejects.toThrow(
|
||||
'Invalid URL: must be between 1 and 2048 characters'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject undefined URLs', async () => {
|
||||
await expect(validateUrl(undefined as any)).rejects.toThrow(
|
||||
'Invalid URL: must be between 1 and 2048 characters'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject URLs over 2048 characters', async () => {
|
||||
const longUrl = 'https://example.com/' + 'a'.repeat(2100)
|
||||
await expect(validateUrl(longUrl)).rejects.toThrow(
|
||||
'Invalid URL: must be between 1 and 2048 characters'
|
||||
)
|
||||
})
|
||||
|
||||
it('should accept URLs exactly 2048 characters', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '8.8.8.8', family: 4 })
|
||||
const exactUrl = 'https://example.com/' + 'a'.repeat(2048 - 'https://example.com/'.length)
|
||||
|
||||
const result = await validateUrl(exactUrl)
|
||||
expect(result.hostname).toBe('example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Private IP blocking', () => {
|
||||
it('should block loopback IP 127.0.0.1', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '127.0.0.1', family: 4 })
|
||||
|
||||
// Use a domain that won't be caught by hostname filtering
|
||||
await expect(validateUrl('http://example-internal.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block private IP 10.0.0.1', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '10.0.0.1', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://internal.company.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block private IP 172.16.0.1', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '172.16.0.1', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://internal2.company.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block private IP 172.31.255.255', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '172.31.255.255', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://internal3.company.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block private IP 192.168.1.1', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '192.168.1.1', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://router.local')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block cloud metadata IP 169.254.169.254', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '169.254.169.254', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://metadata.evil.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block zero IP 0.0.0.0', async () => {
|
||||
mockLookup.mockResolvedValueOnce({ address: '0.0.0.0', family: 4 })
|
||||
|
||||
await expect(validateUrl('http://zero.example.com')).rejects.toThrow(
|
||||
'URL resolves to a blocked IP range'
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow public IPs', async () => {
|
||||
mockLookup.mockReset()
|
||||
mockLookup.mockResolvedValueOnce({ address: '8.8.8.8', family: 4 })
|
||||
|
||||
const result = await validateUrl('http://dns.google')
|
||||
expect(result.resolvedIp).toBe('8.8.8.8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Kubernetes service DNS blocking', () => {
|
||||
it('should block .svc domains', async () => {
|
||||
await expect(validateUrl('http://kubernetes.default.svc')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block .svc.cluster.local domains', async () => {
|
||||
await expect(validateUrl('http://api.default.svc.cluster.local')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block .cluster.local domains', async () => {
|
||||
await expect(validateUrl('http://node1.cluster.local')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block .internal domains', async () => {
|
||||
await expect(validateUrl('http://service.internal')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block localhost', async () => {
|
||||
await expect(validateUrl('http://localhost')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should block kubernetes prefixed domains', async () => {
|
||||
await expect(validateUrl('http://kubernetes-dashboard')).rejects.toThrow(
|
||||
'URL hostname is not allowed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DNS resolution errors', () => {
|
||||
it('should reject URLs that fail DNS resolution', async () => {
|
||||
// Clear any previous mocks and set up a proper rejection
|
||||
mockLookup.mockReset()
|
||||
mockLookup.mockRejectedValueOnce(new Error('ENOTFOUND'))
|
||||
|
||||
await expect(validateUrl('http://nonexistent.invalid')).rejects.toThrow(
|
||||
'Could not resolve hostname'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle URLs with ports', async () => {
|
||||
mockLookup.mockReset()
|
||||
mockLookup.mockResolvedValueOnce({ address: '1.2.3.4', family: 4 })
|
||||
|
||||
const result = await validateUrl('https://example.com:8080/path')
|
||||
expect(result.hostname).toBe('example.com')
|
||||
expect(result.resolvedIp).toBe('1.2.3.4')
|
||||
})
|
||||
|
||||
it('should handle URLs with query parameters', async () => {
|
||||
mockLookup.mockReset()
|
||||
mockLookup.mockResolvedValueOnce({ address: '5.6.7.8', family: 4 })
|
||||
|
||||
const result = await validateUrl('https://api.example.com/v1/data?key=value&test=123')
|
||||
expect(result.hostname).toBe('api.example.com')
|
||||
expect(result.resolvedIp).toBe('5.6.7.8')
|
||||
})
|
||||
|
||||
it('should handle malformed URLs', async () => {
|
||||
await expect(validateUrl('not-a-url')).rejects.toThrow('Invalid URL')
|
||||
})
|
||||
|
||||
it('should handle URLs with userinfo', async () => {
|
||||
mockLookup.mockReset()
|
||||
mockLookup.mockResolvedValueOnce({ address: '9.10.11.12', family: 4 })
|
||||
|
||||
const result = await validateUrl('https://user:pass@example.com/path')
|
||||
expect(result.hostname).toBe('example.com')
|
||||
expect(result.resolvedIp).toBe('9.10.11.12')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue