Add comprehensive test framework with vitest and TDD tests
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:
OpenClawd 2026-02-24 16:23:06 +00:00
parent b07b9cfd25
commit cda259a3c6
6 changed files with 2214 additions and 3 deletions

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

View 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')
})
})
})