From 8e5550c70915efc3098bd56af84763c0c878444c Mon Sep 17 00:00:00 2001 From: Xi Xu Date: Fri, 20 Mar 2026 13:03:50 +0800 Subject: [PATCH] test: raise coverage above 95 percent --- codecov.yml | 9 +- test/unit/docker-helpers.test.js | 144 +++++++ test/unit/protocol-helpers.test.js | 136 +++++++ test/unit/runtime-helpers.test.js | 65 +++ test/unit/utils.test.js | 155 ++++++- test/unit/worker-regressions.test.js | 583 +++++++++++++++++++++++++++ vitest.coverage.config.js | 1 + 7 files changed, 1087 insertions(+), 6 deletions(-) create mode 100644 test/unit/docker-helpers.test.js create mode 100644 test/unit/protocol-helpers.test.js create mode 100644 test/unit/runtime-helpers.test.js diff --git a/codecov.yml b/codecov.yml index 64475fe..27c6a91 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,13 +9,12 @@ coverage: status: project: default: - target: 70% - threshold: 1% + target: auto + threshold: 0.5% patch: default: - target: auto - threshold: 5% - informational: true + target: 85% + threshold: 2% ignore: - "test/**" diff --git a/test/unit/docker-helpers.test.js b/test/unit/docker-helpers.test.js new file mode 100644 index 0000000..c3dc72c --- /dev/null +++ b/test/unit/docker-helpers.test.js @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CONFIG } from '../../src/config/index.js'; +import { + fetchToken, + getScopeFromUrl, + handleDockerAuth, + normalizeRegistryApiPath, + parseAuthenticate, + readRegistryTokenResponse +} from '../../src/protocols/docker.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Docker helper coverage', () => { + it('throws on malformed authenticate headers', () => { + expect(() => parseAuthenticate('Bearer service="registry.docker.io"')).toThrow( + /invalid WWW-Authenticate/ + ); + }); + + it('includes authorization when fetching registry tokens', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + + await fetchToken( + { realm: 'https://auth.example.com/token', service: 'registry.example.com' }, + 'repository:demo/app:pull', + 'Bearer registry-secret' + ); + + const upstreamHeaders = new Headers(fetchSpy.mock.calls[0][1]?.headers); + expect(String(fetchSpy.mock.calls[0][0])).toContain('scope=repository%3Ademo%2Fapp%3Apull'); + expect(upstreamHeaders.get('Authorization')).toBe('Bearer registry-secret'); + }); + + it('reads both token formats and rejects malformed token payloads', async () => { + await expect( + readRegistryTokenResponse( + new Response(JSON.stringify({ token: 'abc123' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + ).resolves.toBe('abc123'); + + await expect( + readRegistryTokenResponse( + new Response(JSON.stringify({ access_token: 'def456' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + ).resolves.toBe('def456'); + + await expect( + readRegistryTokenResponse( + new Response(JSON.stringify('invalid-shape'), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + ).resolves.toBeNull(); + + await expect( + readRegistryTokenResponse( + new Response('{not-json', { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + ).resolves.toBeNull(); + }); + + it('derives catalog and empty scopes from registry paths', () => { + const catalogUrl = new URL('https://example.com/cr/ghcr/v2/_catalog'); + const unsupportedUrl = new URL('https://example.com/cr/ghcr/v2'); + + expect(getScopeFromUrl(catalogUrl, catalogUrl.pathname, 'cr-ghcr')).toBe('registry:catalog:*'); + expect(getScopeFromUrl(unsupportedUrl, unsupportedUrl.pathname, 'cr-ghcr')).toBe(''); + }); + + it('leaves normalized registry paths untouched when no library prefix is needed', () => { + expect(normalizeRegistryApiPath('cr-ghcr', '/v2/org/app/manifests/latest')).toBe( + '/v2/org/app/manifests/latest' + ); + expect(normalizeRegistryApiPath('cr-docker', '/v2/library/nginx/manifests/latest')).toBe( + '/v2/library/nginx/manifests/latest' + ); + expect(normalizeRegistryApiPath('cr-docker', '/v2/_catalog')).toBe('/v2/_catalog'); + }); + + it('returns a generic error for unsupported Docker auth scopes', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const request = new Request( + 'https://example.com/v2/auth?scope=repository:cr/unknown/private:pull' + ); + + const response = await handleDockerAuth(request, new URL(request.url), CONFIG); + + expect(response.status).toBe(400); + expect(await response.text()).toBe('Invalid Docker authentication request'); + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to resolve Docker auth target:', + expect.any(Error) + ); + }); + + it('forwards upstream auth responses that are not challenges', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('already-authorized', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const request = new Request('https://example.com/cr/ghcr/v2/auth?service=Xget'); + const response = await handleDockerAuth(request, new URL(request.url), CONFIG); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('already-authorized'); + }); + + it('forwards 401 responses without authenticate headers from the upstream root probe', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('missing-authenticate', { + status: 401, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const request = new Request('https://example.com/cr/ghcr/v2/auth?service=Xget'); + const response = await handleDockerAuth(request, new URL(request.url), CONFIG); + + expect(response.status).toBe(401); + expect(await response.text()).toBe('missing-authenticate'); + }); +}); diff --git a/test/unit/protocol-helpers.test.js b/test/unit/protocol-helpers.test.js new file mode 100644 index 0000000..a9c1a99 --- /dev/null +++ b/test/unit/protocol-helpers.test.js @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { configureAIHeaders } from '../../src/protocols/ai.js'; +import { configureGitHeaders, isGitLFSRequest, isGitRequest } from '../../src/protocols/git.js'; +import { + configureHuggingFaceHeaders, + isHuggingFaceAPIRequest +} from '../../src/protocols/huggingface.js'; + +describe('Protocol helper coverage', () => { + it('detects Git requests from service queries and content types', () => { + const serviceRequest = new Request('https://example.com/repo.git?service=git-receive-pack'); + const contentTypeRequest = new Request('https://example.com/repo.git', { + method: 'POST', + headers: { 'Content-Type': 'application/x-git-upload-pack-request' } + }); + + expect(isGitRequest(serviceRequest, new URL(serviceRequest.url))).toBe(true); + expect(isGitRequest(contentTypeRequest, new URL(contentTypeRequest.url))).toBe(true); + }); + + it('detects Git LFS requests from object paths and headers', () => { + const infoRequest = new Request('https://example.com/repo.git/info/lfs'); + const objectRequest = new Request( + 'https://example.com/repo.git/objects/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + ); + const headerRequest = new Request('https://example.com/repo.git/download', { + headers: { Accept: 'application/vnd.git-lfs+json' } + }); + + expect(isGitLFSRequest(infoRequest, new URL(infoRequest.url))).toBe(true); + expect(isGitLFSRequest(objectRequest, new URL(objectRequest.url))).toBe(true); + expect(isGitLFSRequest(headerRequest, new URL(headerRequest.url))).toBe(true); + }); + + it('configures standard Git upload and receive pack headers', () => { + const uploadHeaders = new Headers(); + const uploadRequest = new Request('https://example.com/repo.git/git-upload-pack', { + method: 'POST' + }); + configureGitHeaders(uploadHeaders, uploadRequest, new URL(uploadRequest.url), false); + + const receiveHeaders = new Headers(); + const receiveRequest = new Request('https://example.com/repo.git/git-receive-pack', { + method: 'POST' + }); + configureGitHeaders(receiveHeaders, receiveRequest, new URL(receiveRequest.url), false); + + expect(uploadHeaders.get('User-Agent')).toBe('git/2.34.1'); + expect(uploadHeaders.get('Content-Type')).toBe('application/x-git-upload-pack-request'); + expect(receiveHeaders.get('User-Agent')).toBe('git/2.34.1'); + expect(receiveHeaders.get('Content-Type')).toBe('application/x-git-receive-pack-request'); + }); + + it('preserves existing Git headers when already provided', () => { + const headers = new Headers({ + 'Content-Type': 'application/custom', + 'User-Agent': 'custom-git/9.9.9' + }); + const request = new Request('https://example.com/repo.git/git-upload-pack', { + method: 'POST' + }); + + configureGitHeaders(headers, request, new URL(request.url), false); + + expect(headers.get('User-Agent')).toBe('custom-git/9.9.9'); + expect(headers.get('Content-Type')).toBe('application/custom'); + }); + + it('configures Git LFS batch and object download headers', () => { + const batchHeaders = new Headers(); + const batchRequest = new Request('https://example.com/repo.git/objects/batch', { + method: 'POST' + }); + configureGitHeaders(batchHeaders, batchRequest, new URL(batchRequest.url), true); + + const objectHeaders = new Headers(); + const objectRequest = new Request( + 'https://example.com/repo.git/objects/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + ); + configureGitHeaders(objectHeaders, objectRequest, new URL(objectRequest.url), true); + + expect(batchHeaders.get('User-Agent')).toContain('git-lfs/'); + expect(batchHeaders.get('Accept')).toBe('application/vnd.git-lfs+json'); + expect(batchHeaders.get('Content-Type')).toBe('application/vnd.git-lfs+json'); + expect(objectHeaders.get('Accept')).toBe('application/octet-stream'); + }); + + it('detects Hugging Face API and token passthrough endpoints', () => { + const apiRequest = new Request('https://example.com/hf/api/models/demo'); + const tokenRequest = new Request('https://example.com/hf/token'); + const regularRequest = new Request( + 'https://example.com/hf/meta-llama/model/resolve/main/config.json' + ); + + expect(isHuggingFaceAPIRequest(apiRequest, new URL(apiRequest.url))).toBe(true); + expect(isHuggingFaceAPIRequest(tokenRequest, new URL(tokenRequest.url))).toBe(true); + expect(isHuggingFaceAPIRequest(regularRequest, new URL(regularRequest.url))).toBe(false); + }); + + it('configures Hugging Face headers without overwriting explicit content types', () => { + const headers = new Headers(); + const request = new Request('https://example.com/hf/api/models/demo', { + method: 'POST', + headers: { Authorization: 'Bearer secret-token' } + }); + + configureHuggingFaceHeaders(headers, request); + + const preconfiguredHeaders = new Headers({ 'Content-Type': 'multipart/form-data' }); + configureHuggingFaceHeaders(preconfiguredHeaders, request); + + expect(headers.get('Authorization')).toBe('Bearer secret-token'); + expect(headers.get('Content-Type')).toBe('application/json'); + expect(preconfiguredHeaders.get('Content-Type')).toBe('multipart/form-data'); + }); + + it('configures AI passthrough headers and preserves explicit values', () => { + const headers = new Headers(); + const request = new Request('https://example.com/ip/openai/v1/chat/completions', { + method: 'POST' + }); + configureAIHeaders(headers, request); + + const preconfiguredHeaders = new Headers({ + 'Content-Type': 'application/x-ndjson', + 'User-Agent': 'custom-ai-proxy/2.0' + }); + configureAIHeaders(preconfiguredHeaders, request); + + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('User-Agent')).toBe('Xget-AI-Proxy/1.0'); + expect(preconfiguredHeaders.get('Content-Type')).toBe('application/x-ndjson'); + expect(preconfiguredHeaders.get('User-Agent')).toBe('custom-ai-proxy/2.0'); + }); +}); diff --git a/test/unit/runtime-helpers.test.js b/test/unit/runtime-helpers.test.js new file mode 100644 index 0000000..d19a4bb --- /dev/null +++ b/test/unit/runtime-helpers.test.js @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { PerformanceMonitor, addPerformanceHeaders } from '../../src/utils/performance.js'; +import { + isFlatpakReferenceFilePath, + rewriteTextResponse, + shouldRewriteTextResponse +} from '../../src/utils/rewrite.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Runtime helper coverage', () => { + it('serializes performance metrics and warns on duplicate marks', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const monitor = new PerformanceMonitor(); + + monitor.mark('request-start'); + monitor.mark('request-start'); + monitor.mark('complete'); + + const response = addPerformanceHeaders( + new Response('ok', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }), + monitor + ); + const metrics = JSON.parse(response.headers.get('X-Performance-Metrics') || '{}'); + + expect(warnSpy).toHaveBeenCalledWith('Mark with name request-start already exists.'); + expect(metrics).toHaveProperty('request-start'); + expect(metrics).toHaveProperty('complete'); + expect(response.headers.get('X-Frame-Options')).toBe('DENY'); + }); + + it('rewrites only supported upstream response types', () => { + expect(shouldRewriteTextResponse('pypi', '/pypi/simple/demo/', 'text/html')).toBe(true); + expect(shouldRewriteTextResponse('npm', '/npm/demo', 'application/json')).toBe(true); + expect( + shouldRewriteTextResponse( + 'flathub', + '/flathub/repo/demo.flatpakrepo', + 'application/octet-stream' + ) + ).toBe(true); + expect(shouldRewriteTextResponse('gh', '/gh/user/repo/file.txt', 'text/plain')).toBe(false); + + expect(isFlatpakReferenceFilePath('/flathub/repo/demo.flatpakref')).toBe(true); + expect(isFlatpakReferenceFilePath('/flathub/repo/summary')).toBe(false); + + expect( + rewriteTextResponse( + 'flathub', + '/flathub/repo/demo.flatpakrepo', + 'Url=https://dl.flathub.org/repo/', + 'https://example.com' + ) + ).toContain('https://example.com/flathub/repo/'); + expect( + rewriteTextResponse('gh', '/gh/user/repo/file.txt', 'unchanged', 'https://example.com') + ).toBe('unchanged'); + }); +}); diff --git a/test/unit/utils.test.js b/test/unit/utils.test.js index e5e133a..f946772 100644 --- a/test/unit/utils.test.js +++ b/test/unit/utils.test.js @@ -3,11 +3,12 @@ import { describe, expect, it } from 'vitest'; import { createConfig } from '../../src/config/index.js'; import { isGitLFSRequest, isGitRequest } from '../../src/protocols/git.js'; import { + addCorsHeaders, addSecurityHeaders, createErrorResponse, resolveAllowedOrigin } from '../../src/utils/security.js'; -import { getAllowedMethods, validateRequest } from '../../src/utils/validation.js'; +import { getAllowedMethods, isDockerRequest, validateRequest } from '../../src/utils/validation.js'; describe('Utility Functions', () => { describe('isGitRequest', () => { @@ -91,6 +92,73 @@ describe('Utility Functions', () => { expect(result.valid).toBe(false); expect(result.status).toBe(400); }); + + it('should reject raw traversal sequences from the original request URL', () => { + const request = /** @type {Request} */ ({ + headers: new Headers(), + method: 'GET', + url: 'https://example.com/gh/user/repo/../secret' + }); + const url = new URL('https://example.com/gh/user/secret'); + + const result = validateRequest(request, url, createConfig()); + expect(result.valid).toBe(false); + expect(result.status).toBe(400); + }); + + it('should reject paths containing ASCII control characters', () => { + const baseUrl = new URL('https://example.com/gh/user/repo/%00file'); + const request = /** @type {Request} */ ({ + headers: new Headers(), + method: 'GET', + url: 'https://example.com/gh/user/repo/%00file' + }); + const url = /** @type {URL} */ ({ + origin: 'https://example.com', + pathname: '/gh/user/repo/\u0000file', + searchParams: baseUrl.searchParams + }); + + const result = validateRequest(request, url, createConfig()); + expect(result.valid).toBe(false); + expect(result.status).toBe(400); + }); + + it('should reject malformed percent-encoded paths', () => { + const baseUrl = new URL('https://example.com/gh/user/repo/%E0%A4%A'); + const request = /** @type {Request} */ ({ + headers: new Headers(), + method: 'GET', + url: 'https://example.com/gh/user/repo/%E0%A4%A' + }); + const url = /** @type {URL} */ ({ + origin: 'https://example.com', + pathname: '/gh/user/repo/%E0%A4%A', + searchParams: baseUrl.searchParams + }); + + const result = validateRequest(request, url, createConfig()); + expect(result.valid).toBe(false); + expect(result.status).toBe(400); + }); + + it('should reject unsupported methods for regular requests', () => { + const request = new Request('https://example.com/gh/user/repo/file.txt', { method: 'PATCH' }); + const url = new URL(request.url); + + const result = validateRequest(request, url, createConfig()); + expect(result.valid).toBe(false); + expect(result.status).toBe(405); + }); + + it('should reject paths longer than the configured maximum', () => { + const request = new Request(`https://example.com/gh/${'a'.repeat(200)}`); + const url = new URL(request.url); + + const result = validateRequest(request, url, createConfig({ MAX_PATH_LENGTH: '32' })); + expect(result.valid).toBe(false); + expect(result.status).toBe(414); + }); }); describe('getAllowedMethods', () => { @@ -101,6 +169,52 @@ describe('Utility Functions', () => { expect(getAllowedMethods(request, url, config)).toEqual(['GET', 'HEAD', 'POST']); }); + + it('should allow mutating methods for Hugging Face API endpoints', () => { + const request = new Request('https://example.com/hf/token', { method: 'DELETE' }); + const url = new URL(request.url); + + expect(getAllowedMethods(request, url)).toEqual([ + 'GET', + 'HEAD', + 'POST', + 'PUT', + 'PATCH', + 'DELETE' + ]); + }); + }); + + describe('isDockerRequest', () => { + it('should identify canonical registry API paths', () => { + const request = new Request('https://example.com/cr/ghcr/v2/demo/manifests/latest'); + const url = new URL(request.url); + + expect(isDockerRequest(request, url)).toBe(true); + }); + + it('should identify Docker requests by user agent or manifest headers', () => { + const userAgentRequest = new Request('https://example.com/cr/docker/library/nginx', { + headers: { 'User-Agent': 'docker/27.0.0' } + }); + const acceptRequest = new Request('https://example.com/cr/docker/library/nginx', { + headers: { Accept: 'application/vnd.oci.image.manifest.v1+json' } + }); + const contentTypeRequest = new Request('https://example.com/cr/docker/library/nginx', { + headers: { 'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json' } + }); + + expect(isDockerRequest(userAgentRequest, new URL(userAgentRequest.url))).toBe(true); + expect(isDockerRequest(acceptRequest, new URL(acceptRequest.url))).toBe(true); + expect(isDockerRequest(contentTypeRequest, new URL(contentTypeRequest.url))).toBe(true); + }); + + it('should not treat generic /cr/ requests as Docker traffic without registry hints', () => { + const request = new Request('https://example.com/cr/docker/library/nginx/readme'); + const url = new URL(request.url); + + expect(isDockerRequest(request, url)).toBe(false); + }); }); describe('addSecurityHeaders', () => { @@ -142,6 +256,33 @@ describe('Utility Functions', () => { expect(resolveAllowedOrigin(request, config)).toBeNull(); }); + + it('should allow any origin when wildcard CORS is configured', () => { + const config = createConfig({ ALLOWED_ORIGINS: '*' }); + const request = new Request('https://example.com/gh/test/repo', { + headers: { Origin: 'https://app.example.com' } + }); + + expect(resolveAllowedOrigin(request, config)).toBe('*'); + }); + }); + + describe('addCorsHeaders', () => { + it('should append allow headers and preserve existing Vary values', () => { + const config = createConfig({ ALLOWED_ORIGINS: '*' }); + const request = new Request('https://example.com/gh/test/repo', { + headers: { + Origin: 'https://app.example.com', + 'Access-Control-Request-Headers': 'X-Test-Header' + } + }); + + const headers = addCorsHeaders(new Headers({ Vary: 'Accept-Encoding' }), request, config); + + expect(headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(headers.get('Access-Control-Allow-Headers')).toBe('X-Test-Header'); + expect(headers.get('Vary')).toBe('Accept-Encoding, Origin'); + }); }); describe('createErrorResponse', () => { @@ -153,5 +294,17 @@ describe('Utility Functions', () => { expect(response.headers.get('X-Frame-Options')).toBe('DENY'); expect(await response.text()).toBe('Bad Request'); }); + + it('should create detailed JSON error responses when requested', async () => { + const response = createErrorResponse('Unauthorized', 401, true); + const body = await response.json(); + + expect(response.headers.get('Content-Type')).toBe('application/json'); + expect(body).toMatchObject({ + error: 'Unauthorized', + status: 401 + }); + expect(body.timestamp).toBeTruthy(); + }); }); }); diff --git a/test/unit/worker-regressions.test.js b/test/unit/worker-regressions.test.js index b6ab3c2..2f837a3 100644 --- a/test/unit/worker-regressions.test.js +++ b/test/unit/worker-regressions.test.js @@ -85,6 +85,7 @@ describe('Worker regression coverage', () => { expect(response.headers.get('Cache-Control')).toBe('no-store'); expect(cacheDefault.put).not.toHaveBeenCalled(); }); + 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 = { @@ -132,4 +133,586 @@ describe('Worker regression coverage', () => { expect(cacheDefault.match).not.toHaveBeenCalled(); expect(response.headers.get('Cache-Control')).toBe('no-store'); }); + + it('returns Docker registry version metadata for /v2/ probes', async () => { + const response = await worker.fetch( + new Request('https://example.com/v2/'), + {}, + executionContext + ); + + expect(response.status).toBe(200); + expect(response.headers.get('Docker-Distribution-Api-Version')).toBe('registry/2.0'); + expect(response.headers.get('X-Performance-Metrics')).toBeNull(); + expect(await response.text()).toBe('{}'); + }); + + it('redirects unknown platforms and bare platform prefixes to the homepage', async () => { + const unknownPlatform = await worker.fetch( + new Request('https://example.com/not-a-platform/resource'), + {}, + executionContext + ); + const barePlatform = await worker.fetch( + new Request('https://example.com/gh/', { method: 'GET' }), + {}, + executionContext + ); + + expect(unknownPlatform.status).toBe(302); + expect(unknownPlatform.headers.get('Location')).toBe('https://github.com/xixu-me/Xget'); + expect(barePlatform.status).toBe(302); + expect(barePlatform.headers.get('Location')).toBe('https://github.com/xixu-me/Xget'); + }); + + it('rejects Docker requests that do not use a /cr/ prefix', async () => { + const response = await worker.fetch( + new Request('https://example.com/v2/library/nginx/manifests/latest'), + {}, + executionContext + ); + + expect(response.status).toBe(400); + expect(await response.text()).toContain('/cr/ prefix'); + }); + + it('rejects disallowed CORS preflight methods before proxying upstream', async () => { + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo', { + method: 'OPTIONS', + headers: { + Origin: 'https://app.example.com', + 'Access-Control-Request-Method': 'POST' + } + }), + { ALLOWED_ORIGINS: 'https://app.example.com' }, + executionContext + ); + + expect(response.status).toBe(405); + expect(await response.text()).toBe('Method not allowed'); + }); + + it('serves cached responses without proxying upstream', async () => { + cacheDefault.match.mockResolvedValueOnce( + new Response('cached-body', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('should-not-run', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + {}, + executionContext + ); + const metrics = JSON.parse(response.headers.get('X-Performance-Metrics') || '{}'); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('cached-body'); + expect(metrics).toHaveProperty('cache_hit'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('reuses cached full content for range requests when a ranged entry is absent', async () => { + cacheDefault.match.mockResolvedValueOnce(null).mockResolvedValueOnce( + new Response('full-body', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('should-not-run', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt', { + headers: { Range: 'bytes=0-3' } + }), + {}, + executionContext + ); + const metrics = JSON.parse(response.headers.get('X-Performance-Metrics') || '{}'); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('full-body'); + expect(metrics).toHaveProperty('cache_hit_full_content'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('falls back to upstream fetch when cache lookup throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + cacheDefault.match.mockRejectedValueOnce(new Error('cache-down')); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('ok', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + {}, + executionContext + ); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith('Cache API unavailable:', expect.any(Error)); + }); + + it('configures Git passthrough headers for upload-pack requests', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('', { + status: 200, + headers: { 'Content-Type': 'application/x-git-upload-pack-result' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo.git/git-upload-pack', { + method: 'POST' + }), + {}, + executionContext + ); + const upstreamHeaders = new Headers(fetchSpy.mock.calls[0][1]?.headers); + + expect(response.status).toBe(200); + expect(upstreamHeaders.get('User-Agent')).toBe('git/2.34.1'); + expect(upstreamHeaders.get('Content-Type')).toBe('application/x-git-upload-pack-request'); + }); + + it('derives HEAD content length from a range probe when the upstream omits it', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + if (init?.method === 'HEAD') { + return new Response(null, { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }); + } + + expect(new Headers(init?.headers).get('Range')).toBe('bytes=0-0'); + return new Response(null, { + status: 206, + headers: { 'Content-Range': 'bytes 0-0/123' } + }); + }); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt', { method: 'HEAD' }), + {}, + executionContext + ); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Length')).toBe('123'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('uses a successful GET probe to recover missing HEAD content length', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + if (init?.method === 'HEAD') { + return new Response(null, { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }); + } + + return new Response(null, { + status: 200, + headers: { 'Content-Length': '321' } + }); + }); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt', { method: 'HEAD' }), + {}, + executionContext + ); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Length')).toBe('321'); + }); + + it('wraps upstream client errors in detailed JSON responses', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('teapot', { + status: 418, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + { MAX_RETRIES: '1', RETRY_DELAY_MS: '0' }, + executionContext + ); + const body = await response.json(); + + expect(response.status).toBe(418); + expect(body.error).toContain('Upstream server error (418): teapot'); + }); + + it('retries upstream 5xx responses before succeeding', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response('busy', { + status: 503, + headers: { 'Content-Type': 'text/plain' } + }) + ) + .mockResolvedValueOnce( + new Response('ok', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + { MAX_RETRIES: '2', RETRY_DELAY_MS: '0' }, + executionContext + ); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('retries rejected upstream fetches before succeeding', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValueOnce(new Error('temporary-network-failure')) + .mockResolvedValueOnce( + new Response('ok', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + { MAX_RETRIES: '2', RETRY_DELAY_MS: '0' }, + executionContext + ); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('times out requests when the abort timer fires', async () => { + const timeoutToken = { id: 'abort-timeout' }; + const clearTimeoutSpy = vi.fn(); + + vi.stubGlobal( + 'setTimeout', + vi.fn((callback, delay) => { + void delay; + callback(); + return timeoutToken; + }) + ); + vi.stubGlobal('clearTimeout', clearTimeoutSpy); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + if (init?.signal?.aborted) { + const error = new Error(`Aborted before fetching ${String(input)}`); + error.name = 'AbortError'; + throw error; + } + + return new Response('unexpected-success', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }); + }); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + { MAX_RETRIES: '2', RETRY_DELAY_MS: '0', TIMEOUT_SECONDS: '1' }, + executionContext + ); + + expect(response.status).toBe(408); + expect(await response.text()).toBe('Request timeout'); + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutToken); + }); + + it('returns a generic 500 when retry configuration prevents any upstream attempt', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('ok', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + { MAX_RETRIES: '-1' }, + executionContext + ); + + expect(response.status).toBe(500); + expect(await response.text()).toBe('No response received after all retry attempts'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('logs and recovers when request setup throws unexpectedly', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const redirectSpy = vi.spyOn(Response, 'redirect').mockImplementation(() => { + throw new Error('boom'); + }); + + const response = await worker.fetch(new Request('https://example.com/'), {}, executionContext); + + expect(response.status).toBe(500); + expect(await response.text()).toBe('Internal Server Error'); + expect(errorSpy).toHaveBeenCalledWith('Error handling request:', expect.any(Error)); + expect(redirectSpy).toHaveBeenCalled(); + }); + + it('retries Docker requests with an anonymous token and follows redirects on success', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = String(input); + const headers = new Headers(init?.headers); + + if (url === 'https://ghcr.io/v2/private/repo/manifests/latest') { + if (!headers.has('Authorization')) { + return new Response('', { + status: 401, + headers: { + 'WWW-Authenticate': 'Bearer realm="https://ghcr.io/token",service="ghcr.io"' + } + }); + } + + expect(headers.get('Authorization')).toBe('Bearer token-123'); + return new Response(null, { + status: 302, + headers: { Location: 'https://pkg.example.com/manifest' } + }); + } + + if (url.startsWith('https://ghcr.io/token')) { + return new Response(JSON.stringify({ token: 'token-123' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + + if (url === 'https://pkg.example.com/manifest') { + expect(headers.get('Authorization')).toBeNull(); + return new Response('', { + status: 200, + headers: { 'Content-Length': '0' } + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const response = await worker.fetch( + new Request('https://example.com/cr/ghcr/v2/private/repo/manifests/latest', { + headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' } + }), + {}, + executionContext + ); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(4); + }); + + it('warns and falls back to a Docker auth challenge when token negotiation fails', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('', { + status: 401, + headers: { 'WWW-Authenticate': 'Bearer realm="https://ghcr.io/token"' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/cr/ghcr/v2/private/repo/manifests/latest', { + headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' } + }), + {}, + executionContext + ); + + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toBe( + 'Bearer realm="https://example.com/cr/ghcr/v2/auth",service="Xget"' + ); + expect(warnSpy).toHaveBeenCalledWith('Token fetch failed:', expect.any(Error)); + }); + + it('returns a ranged response after caching the full upstream body', async () => { + cacheDefault.match + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + new Response('xy', { + status: 206, + headers: { 'Content-Range': 'bytes 0-1/6' } + }) + ); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('xyz123', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.bin', { + headers: { Range: 'bytes=0-1' } + }), + {}, + executionContext + ); + const metrics = JSON.parse(response.headers.get('X-Performance-Metrics') || '{}'); + + expect(response.status).toBe(206); + expect(metrics).toHaveProperty('range_cache_hit_after_full_cache'); + }); + + it('warns when cache writes fail without waitUntil support', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + cacheDefault.put.mockRejectedValueOnce(new Error('cache-put-down')); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('cached', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + {}, + /** @type {ExecutionContext} */ ({}) + ); + + await Promise.resolve(); + + expect(response.status).toBe(200); + expect(warnSpy).toHaveBeenCalledWith('Cache put failed:', expect.any(Error)); + }); + + it('warns when post-store cache lookups fail for range requests', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + cacheDefault.match + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('range-cache-down')); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('abcdef', { + status: 200, + headers: { 'Content-Type': 'text/plain' } + }) + ); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.bin', { + headers: { Range: 'bytes=0-1' } + }), + {}, + executionContext + ); + + expect(response.status).toBe(200); + expect(warnSpy).toHaveBeenCalledWith('Cache put/match failed:', expect.any(Error)); + }); + + it('copies upstream content length from non-standard header objects when needed', async () => { + const upstreamHeaders = { + /** + * Reads an upstream header value. + * @param {string} name + */ + get(name) { + const header = name.toLowerCase(); + if (header === 'content-type') { + return 'text/plain'; + } + if (header === 'content-length') { + return '777'; + } + return null; + }, + *[Symbol.iterator]() { + yield ['Content-Type', 'text/plain']; + } + }; + + const fakeResponse = /** @type {Response} */ ({ + body: null, + headers: upstreamHeaders, + ok: true, + status: 200, + statusText: 'OK', + text: async () => 'ok' + }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue(fakeResponse); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + {}, + executionContext + ); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Length')).toBe('777'); + }); + + it('warns when upstream content length cannot be read during response finalization', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const upstreamHeaders = { + /** + * Reads an upstream header value. + * @param {string} name + */ + get(name) { + if (name.toLowerCase() === 'content-type') { + return 'text/plain'; + } + + throw new Error('content-length unavailable'); + }, + *[Symbol.iterator]() { + yield ['Content-Type', 'text/plain']; + } + }; + + const fakeResponse = /** @type {Response} */ ({ + body: null, + headers: upstreamHeaders, + ok: true, + status: 200, + statusText: 'OK', + text: async () => 'ok' + }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue(fakeResponse); + + const response = await worker.fetch( + new Request('https://example.com/gh/user/repo/file.txt'), + {}, + executionContext + ); + + expect(response.status).toBe(200); + expect(warnSpy).toHaveBeenCalledWith('Could not set Content-Length header:', expect.any(Error)); + }); }); diff --git a/vitest.coverage.config.js b/vitest.coverage.config.js index f693ce4..83f6246 100644 --- a/vitest.coverage.config.js +++ b/vitest.coverage.config.js @@ -5,6 +5,7 @@ export default defineConfig({ testTimeout: 60000, hookTimeout: 30000, include: [ + 'test/features/auth.test.js', 'test/unit/**/*.test.js', 'test/platforms/crates.test.js', 'test/platforms/cran.test.js',