test: raise coverage above 95 percent
This commit is contained in:
1 parent
c9f1286216
commit
8e5550c709
7 files changed
+1087
-6
No files matched your search
+4
-5
@@ -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/**"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+154
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user