diff --git a/src/index.js b/src/index.js index d350521..ed86537 100644 --- a/src/index.js +++ b/src/index.js @@ -39,6 +39,7 @@ import { getAllowedMethods, isDockerRequest, validateRequest } from './utils/val */ async function handleRequest(request, env, ctx) { let response; + let responseGeneratedLocally = false; const monitor = new PerformanceMonitor(); try { @@ -174,6 +175,9 @@ async function handleRequest(request, env, ctx) { // Check if this is a Hugging Face API request const isHF = isHuggingFaceAPIRequest(request, url); + const canUseCache = request.method === 'GET' || request.method === 'HEAD'; + const shouldPassthroughRequest = + isGit || isGitLFS || isDocker || isAI || isHF || !canUseCache; // Check cache first (skip cache for Git, Git LFS, Docker, AI inference, and HF API operations) /** @type {Cache | null} */ @@ -185,6 +189,7 @@ async function handleRequest(request, env, ctx) { if ( cache && + canUseCache && !isGit && !isGitLFS && !isDocker && @@ -234,21 +239,16 @@ async function handleRequest(request, env, ctx) { redirect: 'follow' }; - // Add body for POST/PUT/PATCH/DELETE requests (Git/Docker/AI/HF operations) - if ( - ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method) && - (isGit || isGitLFS || isDocker || isAI || isHF) - ) { + if (request.body !== null && !canUseCache) { fetchOptions.body = request.body; } // Cast headers to Headers for proper typing const requestHeaders = /** @type {Headers} */ (fetchOptions.headers); - // Set appropriate headers for Git/Docker/AI/HF vs regular requests - if (isGit || isGitLFS || isDocker || isAI || isHF) { - // For Git/Docker/AI/HF operations, copy all headers from the original request - // This ensures protocol compliance + // Preserve caller-supplied headers for protocol requests and for + // explicitly enabled non-GET/HEAD methods on regular platforms. + if (shouldPassthroughRequest) { for (const [key, value] of request.headers.entries()) { // Skip headers that might cause issues with proxying if ( @@ -273,7 +273,7 @@ async function handleRequest(request, env, ctx) { configureHuggingFaceHeaders(requestHeaders, request); } } else { - // Regular file download headers + // Regular GET/HEAD file download headers Object.assign(fetchOptions, { cf: { http3: true, @@ -312,11 +312,13 @@ async function handleRequest(request, env, ctx) { // Implement retry mechanism let attempts = 0; while (attempts < config.MAX_RETRIES) { + /** @type {ReturnType | undefined} */ + let timeoutId; try { monitor.mark(`attempt_${attempts}`); const controller = new AbortController(); - const timeoutId = setTimeout( + timeoutId = setTimeout( () => controller.abort(), config.TIMEOUT_SECONDS * 1000 ); @@ -373,8 +375,6 @@ async function handleRequest(request, env, ctx) { response = await fetch(targetUrl, finalFetchOptions); } - clearTimeout(timeoutId); - // Handle manual redirect for Docker if ( isDocker && @@ -496,30 +496,28 @@ async function handleRequest(request, env, ctx) { attempts++; if (error instanceof Error && error.name === 'AbortError') { response = createErrorResponse('Request timeout', 408); + responseGeneratedLocally = true; break; } if (attempts >= config.MAX_RETRIES) { - const message = error instanceof Error ? error.message : String(error); - response = createErrorResponse( - `Failed after ${config.MAX_RETRIES} attempts: ${message}`, - 500, - true - ); + response = createErrorResponse('Upstream request failed', 502); + responseGeneratedLocally = true; break; } await new Promise(resolve => setTimeout(resolve, config.RETRY_DELAY_MS * attempts) ); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } } } if (!response) { - response = createErrorResponse( - 'No response received after all retry attempts', - 500, - true - ); - } else if (!response.ok && response.status !== 206) { + response = createErrorResponse('No response received after all retry attempts', 500); + responseGeneratedLocally = true; + } else if (!responseGeneratedLocally && !response.ok && response.status !== 206) { if (isDocker && response.status === 401) { if (!response.headers.has('WWW-Authenticate')) { // Handle Docker 401 responses that might not have been caught by the retry loop @@ -578,7 +576,9 @@ async function handleRequest(request, env, ctx) { } if (!isGit && !isGitLFS && !isDocker && !isAI && !isHF) { - if (hasSensitiveHeaders) { + if (!canUseCache) { + headers.set('Cache-Control', 'no-store'); + } else if (hasSensitiveHeaders) { headers.set('Cache-Control', 'private, no-store'); const existingVary = headers.get('Vary'); headers.set( @@ -673,8 +673,7 @@ async function handleRequest(request, env, ctx) { } } catch (error) { console.error('Error handling request:', error); - const message = error instanceof Error ? error.message : String(error); - response = createErrorResponse(`Internal Server Error: ${message}`, 500, true); + response = createErrorResponse('Internal Server Error', 500); } // Ensure performance headers are added to the final response diff --git a/test/unit/utils.test.js b/test/unit/utils.test.js index df048ab..e5e133a 100644 --- a/test/unit/utils.test.js +++ b/test/unit/utils.test.js @@ -1,130 +1,13 @@ import { describe, expect, it } from 'vitest'; -// Mock utility functions for testing -// These would normally be imported from actual utility modules - -/** - * Check if request is a Git request - * @param {Request} request - Request object - * @param {URL} url - URL object - * @returns {boolean} True if Git request - */ -function isGitRequest(request, url) { - // Check for Git-specific endpoints - if (url.pathname.endsWith('/info/refs')) { - return true; - } - - if (url.pathname.endsWith('/git-upload-pack') || url.pathname.endsWith('/git-receive-pack')) { - return true; - } - - // Check for Git user agents - const userAgent = request.headers.get('User-Agent') || ''; - if (userAgent.includes('git/') || userAgent.startsWith('git/')) { - return true; - } - - // Check for Git-specific query parameters - if (url.searchParams.has('service')) { - const service = url.searchParams.get('service'); - return service === 'git-upload-pack' || service === 'git-receive-pack'; - } - - // Check for Git-specific content types - const contentType = request.headers.get('Content-Type') || ''; - if (contentType.includes('git-upload-pack') || contentType.includes('git-receive-pack')) { - return true; - } - - return false; -} - -/** - * Check if request is a Git LFS request - * @param {Request} request - Request object - * @param {URL} url - URL object - * @returns {boolean} True if Git LFS request - */ -function isGitLFSRequest(request, url) { - // Check for LFS-specific endpoints - if (url.pathname.includes('/info/lfs')) { - return true; - } - - if (url.pathname.includes('/objects/batch')) { - return true; - } - - // Check for LFS object storage endpoints (SHA-256 hash is 64 hex characters) - if (url.pathname.match(/\/objects\/[a-fA-F0-9]{64}$/)) { - return true; - } - - // Check for LFS-specific headers - const accept = request.headers.get('Accept') || ''; - const contentType = request.headers.get('Content-Type') || ''; - - if ( - accept.includes('application/vnd.git-lfs') || - contentType.includes('application/vnd.git-lfs') - ) { - return true; - } - - // Check for LFS user agent - const userAgent = request.headers.get('User-Agent') || ''; - if (userAgent.includes('git-lfs')) { - return true; - } - - return false; -} - -/** - * Validate request method and path - * @param {Request} request - Request object - * @param {URL} url - URL object - * @returns {{valid: boolean, error?: string, status?: number}} Validation result - */ -function validateRequest(request, url) { - const CONFIG = { - SECURITY: { - ALLOWED_METHODS: ['GET', 'HEAD'], - MAX_PATH_LENGTH: 2048 - } - }; - - // Allow POST method for Git operations - const allowedMethods = isGitRequest(request, url) - ? ['GET', 'HEAD', 'POST'] - : CONFIG.SECURITY.ALLOWED_METHODS; - - if (!allowedMethods.includes(request.method)) { - return { valid: false, error: 'Method not allowed', status: 405 }; - } - - if (url.pathname.length > CONFIG.SECURITY.MAX_PATH_LENGTH) { - return { valid: false, error: 'Path too long', status: 414 }; - } - - return { valid: true }; -} - -/** - * Add security headers to response headers - * @param {Headers} headers - Response headers - * @returns {Headers} Headers with security headers added - */ -function addSecurityHeaders(headers) { - headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); - headers.set('X-Frame-Options', 'DENY'); - headers.set('X-XSS-Protection', '1; mode=block'); - headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - headers.set('Content-Security-Policy', "default-src 'none'; img-src 'self'; script-src 'none'"); - headers.set('Permissions-Policy', 'interest-cohort=()'); - return headers; -} +import { createConfig } from '../../src/config/index.js'; +import { isGitLFSRequest, isGitRequest } from '../../src/protocols/git.js'; +import { + addSecurityHeaders, + createErrorResponse, + resolveAllowedOrigin +} from '../../src/utils/security.js'; +import { getAllowedMethods, validateRequest } from '../../src/utils/validation.js'; describe('Utility Functions', () => { describe('isGitRequest', () => { @@ -135,20 +18,6 @@ describe('Utility Functions', () => { expect(isGitRequest(request, url)).toBe(true); }); - it('should identify Git upload-pack requests', () => { - const request = new Request('https://example.com/repo.git/git-upload-pack'); - const url = new URL(request.url); - - expect(isGitRequest(request, url)).toBe(true); - }); - - it('should identify Git receive-pack requests', () => { - const request = new Request('https://example.com/repo.git/git-receive-pack'); - const url = new URL(request.url); - - expect(isGitRequest(request, url)).toBe(true); - }); - it('should identify Git requests by User-Agent', () => { const request = new Request('https://example.com/repo.git', { headers: { 'User-Agent': 'git/2.34.1' } @@ -158,46 +27,15 @@ describe('Utility Functions', () => { expect(isGitRequest(request, url)).toBe(true); }); - it('should identify Git requests by service parameter', () => { - const request = new Request('https://example.com/repo.git/info/refs?service=git-upload-pack'); - const url = new URL(request.url); - - expect(isGitRequest(request, url)).toBe(true); - }); - - it('should identify Git requests by content type', () => { - const request = new Request('https://example.com/repo.git/git-upload-pack', { - method: 'POST', - headers: { 'Content-Type': 'application/x-git-upload-pack-request' } - }); - const url = new URL(request.url); - - expect(isGitRequest(request, url)).toBe(true); - }); - it('should not identify regular file requests as Git', () => { const request = new Request('https://example.com/repo/file.txt'); const url = new URL(request.url); expect(isGitRequest(request, url)).toBe(false); }); - - it('should handle edge cases gracefully', () => { - const request = new Request('https://example.com/'); - const url = new URL(request.url); - - expect(isGitRequest(request, url)).toBe(false); - }); }); describe('isGitLFSRequest', () => { - it('should identify LFS info/lfs requests', () => { - const request = new Request('https://example.com/repo.git/info/lfs'); - const url = new URL(request.url); - - expect(isGitLFSRequest(request, url)).toBe(true); - }); - it('should identify LFS batch API requests', () => { const request = new Request('https://example.com/repo.git/objects/batch', { method: 'POST', @@ -208,34 +46,6 @@ describe('Utility Functions', () => { expect(isGitLFSRequest(request, url)).toBe(true); }); - it('should identify LFS object storage requests by path', () => { - const request = new Request( - 'https://example.com/repo.git/objects/a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd' - ); - const url = new URL(request.url); - - expect(isGitLFSRequest(request, url)).toBe(true); - }); - - it('should identify LFS requests by Accept header', () => { - const request = new Request('https://example.com/repo.git/objects/batch', { - headers: { Accept: 'application/vnd.git-lfs+json' } - }); - const url = new URL(request.url); - - expect(isGitLFSRequest(request, url)).toBe(true); - }); - - it('should identify LFS requests by Content-Type header', () => { - const request = new Request('https://example.com/repo.git/objects/batch', { - method: 'POST', - headers: { 'Content-Type': 'application/vnd.git-lfs+json' } - }); - const url = new URL(request.url); - - expect(isGitLFSRequest(request, url)).toBe(true); - }); - it('should identify LFS requests by User-Agent', () => { const request = new Request('https://example.com/repo.git', { headers: { 'User-Agent': 'git-lfs/3.0.0 (GitHub; darwin amd64; go 1.17.2)' } @@ -251,20 +61,6 @@ describe('Utility Functions', () => { expect(isGitLFSRequest(request, url)).toBe(false); }); - - it('should not identify standard Git requests as LFS', () => { - const request = new Request('https://example.com/repo.git/info/refs'); - const url = new URL(request.url); - - expect(isGitLFSRequest(request, url)).toBe(false); - }); - - it('should handle edge cases gracefully', () => { - const request = new Request('https://example.com/'); - const url = new URL(request.url); - - expect(isGitLFSRequest(request, url)).toBe(false); - }); }); describe('validateRequest', () => { @@ -272,27 +68,10 @@ describe('Utility Functions', () => { const request = new Request('https://example.com/test', { method: 'GET' }); const url = new URL(request.url); - const result = validateRequest(request, url); + const result = validateRequest(request, url, createConfig()); expect(result.valid).toBe(true); }); - it('should allow HEAD requests', () => { - const request = new Request('https://example.com/test', { method: 'HEAD' }); - const url = new URL(request.url); - - const result = validateRequest(request, url); - expect(result.valid).toBe(true); - }); - - it('should reject PUT requests for non-Git operations', () => { - const request = new Request('https://example.com/test', { method: 'PUT' }); - const url = new URL(request.url); - - const result = validateRequest(request, url); - expect(result.valid).toBe(false); - expect(result.status).toBe(405); - }); - it('should allow POST requests for Git operations', () => { const request = new Request('https://example.com/repo.git/git-upload-pack', { method: 'POST', @@ -300,27 +79,27 @@ describe('Utility Functions', () => { }); const url = new URL(request.url); - const result = validateRequest(request, url); + const result = validateRequest(request, url, createConfig()); expect(result.valid).toBe(true); }); - it('should reject extremely long paths', () => { - const longPath = `/${'a'.repeat(3000)}`; - const request = new Request(`https://example.com${longPath}`); + it('should reject encoded traversal attempts against the production validator', () => { + const request = new Request('https://example.com/gh/user/repo/%2e%2e%2fsecret'); const url = new URL(request.url); - const result = validateRequest(request, url); + const result = validateRequest(request, url, createConfig()); expect(result.valid).toBe(false); - expect(result.status).toBe(414); + expect(result.status).toBe(400); }); + }); - it('should accept normal length paths', () => { - const normalPath = '/gh/microsoft/vscode/archive/refs/heads/main.zip'; - const request = new Request(`https://example.com${normalPath}`); + describe('getAllowedMethods', () => { + it('should respect configured methods for regular requests', () => { + const config = createConfig({ ALLOWED_METHODS: 'GET,HEAD,POST' }); + const request = new Request('https://example.com/gh/test/repo/issues', { method: 'POST' }); const url = new URL(request.url); - const result = validateRequest(request, url); - expect(result.valid).toBe(true); + expect(getAllowedMethods(request, url, config)).toEqual(['GET', 'HEAD', 'POST']); }); }); @@ -329,41 +108,12 @@ describe('Utility Functions', () => { const headers = new Headers(); const result = addSecurityHeaders(headers); - expect(result.get('Strict-Transport-Security')).toBeTruthy(); + expect(result.get('Strict-Transport-Security')).toContain('max-age=31536000'); expect(result.get('X-Frame-Options')).toBe('DENY'); expect(result.get('X-XSS-Protection')).toBe('1; mode=block'); expect(result.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin'); - expect(result.get('Content-Security-Policy')).toBeTruthy(); - expect(result.get('Permissions-Policy')).toBeTruthy(); - }); - - it('should set HSTS with proper directives', () => { - const headers = new Headers(); - const result = addSecurityHeaders(headers); - - const hsts = result.get('Strict-Transport-Security'); - expect(hsts).toContain('max-age=31536000'); - expect(hsts).toContain('includeSubDomains'); - expect(hsts).toContain('preload'); - }); - - it('should set CSP with restrictive policy', () => { - const headers = new Headers(); - const result = addSecurityHeaders(headers); - - const csp = result.get('Content-Security-Policy'); - expect(csp).toContain("default-src 'none'"); - expect(csp).toContain("script-src 'none'"); - }); - - it('should not overwrite existing headers', () => { - const headers = new Headers(); - headers.set('X-Custom-Header', 'custom-value'); - - const result = addSecurityHeaders(headers); - - expect(result.get('X-Custom-Header')).toBe('custom-value'); - expect(result.get('X-Frame-Options')).toBe('DENY'); + expect(result.get('Content-Security-Policy')).toContain("default-src 'none'"); + expect(result.get('Permissions-Policy')).toContain('interest-cohort=()'); }); it('should return the same Headers object', () => { @@ -374,101 +124,34 @@ describe('Utility Functions', () => { }); }); - describe('URL and Path Utilities', () => { - it('should handle URL parsing correctly', () => { - const testUrls = [ - 'https://example.com/gh/user/repo/file.txt', - 'https://example.com/gl/group/project/-/blob/main/README.md', - 'https://example.com/hf/microsoft/model/resolve/main/config.json' - ]; - - testUrls.forEach(urlString => { - expect(() => new URL(urlString)).not.toThrow(); - - const url = new URL(urlString); - expect(url.protocol).toBe('https:'); - expect(url.hostname).toBe('example.com'); - expect(url.pathname).toBeTruthy(); + describe('resolveAllowedOrigin', () => { + it('should return the matching origin from the production config', () => { + const config = createConfig({ ALLOWED_ORIGINS: 'https://app.example.com' }); + const request = new Request('https://example.com/gh/test/repo', { + headers: { Origin: 'https://app.example.com' } }); + + expect(resolveAllowedOrigin(request, config)).toBe('https://app.example.com'); }); - it('should handle query parameters correctly', () => { - const url = new URL('https://example.com/gh/repo?ref=main&path=src'); + it('should reject origins that are not configured', () => { + const config = createConfig({ ALLOWED_ORIGINS: 'https://app.example.com' }); + const request = new Request('https://example.com/gh/test/repo', { + headers: { Origin: 'https://evil.example.com' } + }); - expect(url.searchParams.get('ref')).toBe('main'); - expect(url.searchParams.get('path')).toBe('src'); - expect(url.searchParams.has('nonexistent')).toBe(false); - }); - - it('should handle URL fragments correctly', () => { - const url = new URL('https://example.com/gh/repo/README.md#section'); - - expect(url.hash).toBe('#section'); - expect(url.pathname).toBe('/gh/repo/README.md'); + expect(resolveAllowedOrigin(request, config)).toBeNull(); }); }); - describe('Request and Response Utilities', () => { - it('should create requests with proper headers', () => { - const request = new Request('https://example.com/test', { - method: 'GET', - headers: { - 'User-Agent': 'Xget/1.0', - Accept: 'application/json' - } - }); + describe('createErrorResponse', () => { + it('should create a plain-text error response with security headers', async () => { + const response = createErrorResponse('Bad Request', 400); - expect(request.method).toBe('GET'); - expect(request.headers.get('User-Agent')).toBe('Xget/1.0'); - expect(request.headers.get('Accept')).toBe('application/json'); - }); - - it('should handle request cloning', () => { - const originalRequest = new Request('https://example.com/test', { - method: 'POST', - body: 'test data', - headers: { 'Content-Type': 'text/plain' } - }); - - const clonedRequest = originalRequest.clone(); - - expect(clonedRequest.method).toBe(originalRequest.method); - expect(clonedRequest.url).toBe(originalRequest.url); - expect(clonedRequest.headers.get('Content-Type')).toBe('text/plain'); - }); - - it('should create responses with proper status codes', () => { - const responses = [ - new Response('OK', { status: 200 }), - new Response('Not Found', { status: 404 }), - new Response('Server Error', { status: 500 }) - ]; - - expect(responses[0].status).toBe(200); - expect(responses[1].status).toBe(404); - expect(responses[2].status).toBe(500); - }); - }); - - describe('Error Handling Utilities', () => { - it('should create proper error responses', () => { - const errorResponse = new Response('Bad Request', { - status: 400, - statusText: 'Bad Request', - headers: { 'Content-Type': 'text/plain' } - }); - - expect(errorResponse.status).toBe(400); - expect(errorResponse.statusText).toBe('Bad Request'); - expect(errorResponse.headers.get('Content-Type')).toBe('text/plain'); - }); - - it('should handle async error scenarios', async () => { - const asyncFunction = async () => { - throw new Error('Test error'); - }; - - await expect(asyncFunction()).rejects.toThrow('Test error'); + expect(response.status).toBe(400); + expect(response.headers.get('Content-Type')).toBe('text/plain'); + expect(response.headers.get('X-Frame-Options')).toBe('DENY'); + expect(await response.text()).toBe('Bad Request'); }); }); }); diff --git a/test/unit/worker-regressions.test.js b/test/unit/worker-regressions.test.js new file mode 100644 index 0000000..013eaa7 --- /dev/null +++ b/test/unit/worker-regressions.test.js @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import worker from '../../src/index.js'; + +/** @type {ExecutionContext} */ +const executionContext = { + waitUntil() {}, + passThroughOnException() {} +}; + +describe('Worker regression coverage', () => { + /** @type {{ match: ReturnType, put: ReturnType }} */ + let cacheDefault; + + beforeEach(() => { + cacheDefault = { + match: vi.fn(async () => null), + put: vi.fn(async () => undefined) + }; + + vi.stubGlobal('caches', { + default: cacheDefault + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('does not leak thrown upstream error details to clients', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('secret-upstream-detail')); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + { MAX_RETRIES: '1', RETRY_DELAY_MS: '0', TIMEOUT_SECONDS: '1' }, + executionContext + ); + + const body = await response.text(); + + expect(response.status).toBe(502); + expect(body).not.toContain('secret-upstream-detail'); + expect(body).not.toContain('Failed after'); + }); + + it('clears timeout handles when upstream fetch rejects', async () => { + const timeoutToken = { id: 'timeout-token' }; + const setTimeoutSpy = vi.fn(() => timeoutToken); + const clearTimeoutSpy = vi.fn(); + + vi.stubGlobal('setTimeout', setTimeoutSpy); + vi.stubGlobal('clearTimeout', clearTimeoutSpy); + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('boom')); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + { MAX_RETRIES: '1', RETRY_DELAY_MS: '0', TIMEOUT_SECONDS: '5' }, + executionContext + ); + + expect(response.status).toBe(502); + expect(setTimeoutSpy).toHaveBeenCalled(); + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutToken); + }); + + it('forwards body and content type for configured non-protocol POST requests', async () => { + /** @type {{ url: string, method: string | undefined, body: string | null, contentType: string | null, cf: unknown }} */ + let observed = { + url: '', + method: undefined, + body: null, + contentType: null, + cf: undefined + }; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + observed = { + url: String(input), + method: init?.method, + body: init?.body ? await new Response(init.body).text() : null, + contentType: new Headers(init?.headers).get('Content-Type'), + cf: /** @type {RequestInit & { cf?: unknown }} */ (init || {}).cf + }; + + return new Response('created', { + status: 201, + headers: { 'Content-Type': 'text/plain' } + }); + }); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/issues', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'test' }) + }), + { ALLOWED_METHODS: 'GET,HEAD,POST' }, + executionContext + ); + + expect(response.status).toBe(201); + expect(observed).toEqual({ + url: 'https://github.com/user/repo/issues', + method: 'POST', + body: JSON.stringify({ title: 'test' }), + contentType: 'application/json', + cf: undefined + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(cacheDefault.match).not.toHaveBeenCalled(); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + }); +});