diff --git a/proxy.js b/proxy.js index 19972ec..6d4ac6e 100644 --- a/proxy.js +++ b/proxy.js @@ -196,7 +196,7 @@ async function getContent(httpModule, origReq, origRes) { let isHit = ''; const requestDetails = { - host: mappedUrl.host || origUrl.host, + host: mappedUrl.hostname || origUrl.hostname || mappedUrl.host || origUrl.host, port: mappedUrl.port || origUrl.port, path: mappedUrl.path || origUrl.path, username: origUrl.username, diff --git a/test/proxy.test.js b/test/proxy.test.js index f0be4ee..aa23d92 100644 --- a/test/proxy.test.js +++ b/test/proxy.test.js @@ -4,7 +4,15 @@ process.env.PROXY_KUTTI_CONFIG = '/nonexistent/proxy-kutti-test-config'; const os = require('os'); const fs = require('fs'); const path = require('path'); +const http = require('http'); +const net = require('net'); +const { spawn } = require('child_process'); const tmpCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxy-kutti-test-')); +const proxyScriptPath = path.resolve(__dirname, '../proxy.js'); +const PROXY_STARTUP_TIMEOUT_MS = 5000; +const PROXY_REQUEST_TIMEOUT_MS = 3000; +const PROXY_SHUTDOWN_TIMEOUT_MS = 1000; +const PROXY_INTEGRATION_TEST_TIMEOUT_MS = 15000; process.env.PROXY_KUTTI_cache_dir = tmpCacheDir; const { describe, it, after } = require('node:test'); @@ -26,6 +34,25 @@ const { after(() => fs.rmSync(tmpCacheDir, { recursive: true, force: true })); const hoursAgo = hours => new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); +const waitFor = async (predicate, timeoutMs = 1000, intervalMs = 10) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await wait(intervalMs); + } + throw new Error(`Timed out after ${timeoutMs}ms`); +}; + +const getFreePort = () => + new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); describe('parseUrlMappings', () => { it('parses a sed-style pattern into search regex and replacement', () => { @@ -175,6 +202,96 @@ describe('addHoursToDate', () => { }); }); +describe('cacheHit', () => { + it('returns null for stale entries that match cache_control', async () => { + const cachedFile = path.join(tmpCacheDir, 'stale-hit.data'); + const cachedFileMeta = `${cachedFile}.meta`; + fs.writeFileSync(cachedFile, 'stale-content'); + fs.writeFileSync( + cachedFileMeta, + JSON.stringify({ + headers: { 'content-type': 'text/plain' }, + statusCode: 200, + 'proxy-kutti-orig-request': { 'cache-date': hoursAgo(25) }, + }) + ); + + const result = await cacheHit({ + host: 'registry.npmjs.org', + path: '/lodash', + method: 'GET', + headers: {}, + state: { cachedFile, cachedFileMeta }, + }); + assert.strictEqual(result, null); + }); + + it('returns an empty body stream for HEAD cache hits', async () => { + const cachedFile = path.join(tmpCacheDir, 'head-hit.data'); + const cachedFileMeta = `${cachedFile}.meta`; + fs.writeFileSync(cachedFile, 'should-not-be-read-for-head'); + fs.writeFileSync( + cachedFileMeta, + JSON.stringify({ + headers: { 'content-type': 'application/octet-stream' }, + statusCode: 204, + 'proxy-kutti-orig-request': { 'cache-date': hoursAgo(1) }, + }) + ); + + const proxyRes = await cacheHit({ + host: 'example.com', + path: '/head', + method: 'HEAD', + headers: {}, + state: { cachedFile, cachedFileMeta }, + }); + assert.notStrictEqual(proxyRes, null); + assert.strictEqual(proxyRes.statusCode, 204); + let body = ''; + for await (const chunk of proxyRes) body += chunk; + assert.strictEqual(body, ''); + }); + + it('backfills missing proxy-kutti-orig-request in legacy metadata', async () => { + const cachedFile = path.join(tmpCacheDir, 'legacy-hit.data'); + const cachedFileMeta = `${cachedFile}.meta`; + fs.writeFileSync(cachedFile, 'legacy-body'); + fs.writeFileSync( + cachedFileMeta, + JSON.stringify({ + headers: { 'content-type': 'text/plain' }, + statusCode: 200, + }) + ); + + const requestDetails = { + host: 'legacy.example.com', + path: '/legacy-hit', + method: 'GET', + headers: {}, + state: { cachedFile, cachedFileMeta }, + }; + const proxyRes = await cacheHit(requestDetails); + assert.notStrictEqual(proxyRes, null); + + await waitFor(() => { + try { + const updatedMeta = JSON.parse(fs.readFileSync(cachedFileMeta, 'utf8')); + return Boolean(updatedMeta['proxy-kutti-orig-request']); + } catch { + return false; + } + }); + + const updatedMeta = JSON.parse(fs.readFileSync(cachedFileMeta, 'utf8')); + assert.ok(updatedMeta['proxy-kutti-orig-request']); + assert.strictEqual(updatedMeta['proxy-kutti-orig-request'].host, requestDetails.host); + assert.strictEqual(updatedMeta['proxy-kutti-orig-request'].state, null); + assert.ok(updatedMeta['proxy-kutti-orig-request']['cache-date']); + }); +}); + describe('guessContentType', () => { it('maps known extensions case-insensitively', () => { assert.strictEqual(guessContentType('foo-1.2.3.zip'), 'application/zip'); @@ -242,4 +359,143 @@ describe('importIntoCache', () => { const metaData = JSON.parse(fs.readFileSync(`${cachedFile}.meta`, 'utf8')); assert.strictEqual(metaData.headers['content-type'], 'application/x-mystery'); }); + + it('supports --content-type= syntax', async () => { + const srcFile = path.join(tmpCacheDir, 'src-asset-equals.bin'); + fs.writeFileSync(srcFile, 'binary stuff'); + + const importUrl = 'https://example.com/downloads/asset-equals.bin'; + await importIntoCache([importUrl, srcFile, '--content-type=application/x-equals']); + + const { cachedFile } = computeCacheDetails('https', 'GET', importUrl); + const metaData = JSON.parse(fs.readFileSync(`${cachedFile}.meta`, 'utf8')); + assert.strictEqual(metaData.headers['content-type'], 'application/x-equals'); + }); + + it('supports --content-type before positional args', async () => { + const srcFile = path.join(tmpCacheDir, 'src-asset-leading-flag.tgz'); + fs.writeFileSync(srcFile, 'binary stuff'); + + const importUrl = 'https://example.com/downloads/asset-leading-flag.tgz'; + await importIntoCache(['--content-type', 'application/custom-tgz', importUrl, srcFile]); + + const { cachedFile } = computeCacheDetails('https', 'GET', importUrl); + const metaData = JSON.parse(fs.readFileSync(`${cachedFile}.meta`, 'utf8')); + assert.strictEqual(metaData.headers['content-type'], 'application/custom-tgz'); + }); + + it('falls back to inferred content type when --content-type has no value', async () => { + const srcFile = path.join(tmpCacheDir, 'src-asset-missing-override.tgz'); + fs.writeFileSync(srcFile, 'binary stuff'); + + const importUrl = 'https://example.com/downloads/asset-missing-override.tgz'; + await importIntoCache([importUrl, srcFile, '--content-type']); + + const { cachedFile } = computeCacheDetails('https', 'GET', importUrl); + const metaData = JSON.parse(fs.readFileSync(`${cachedFile}.meta`, 'utf8')); + assert.strictEqual(metaData.headers['content-type'], 'application/gzip'); + }); +}); + +describe('proxy integration cache behavior', () => { + it( + 'serves identical second request from cache without hitting origin again', + { timeout: PROXY_INTEGRATION_TEST_TIMEOUT_MS }, + async () => { + const e2eCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxy-kutti-e2e-')); + const originPort = await getFreePort(); + const proxyPort = await getFreePort(); + let originRequestCount = 0; + const originServer = http.createServer((req, res) => { + originRequestCount++; + const body = JSON.stringify({ requestCount: originRequestCount, path: req.url }); + res.writeHead(200, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }); + res.end(body); + }); + await new Promise(resolve => originServer.listen(originPort, '127.0.0.1', resolve)); + + const proxyProcess = spawn('node', [proxyScriptPath], { + cwd: path.dirname(proxyScriptPath), + env: { + ...process.env, + PROXY_KUTTI_CONFIG: '/nonexistent/proxy-kutti-e2e-config', + PROXY_KUTTI_host: '127.0.0.1', + PROXY_KUTTI_port: String(proxyPort), + PROXY_KUTTI_cache_dir: e2eCacheDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const waitForProxyReady = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for proxy to start')), PROXY_STARTUP_TIMEOUT_MS); + proxyProcess.stdout.on('data', data => { + if (data.toString().includes('Proxy-kutti is running')) { + clearTimeout(timeout); + resolve(); + } + }); + proxyProcess.on('exit', code => { + clearTimeout(timeout); + reject(new Error(`Proxy exited early with code ${code}`)); + } + ); + }); + + const requestViaProxy = pathName => + new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port: proxyPort, + method: 'GET', + path: `http://127.0.0.1:${originPort}${pathName}`, + }, + res => { + let body = ''; + res.on('data', chunk => (body += chunk)); + res.on('end', () => resolve({ statusCode: res.statusCode, body })); + } + ); + req.setTimeout(PROXY_REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Timed out waiting for proxy response'))); + req.on('error', reject); + req.end(); + }); + + try { + await waitForProxyReady; + const first = await requestViaProxy('/api/e2e-cache-hit'); + const countAfterFirst = originRequestCount; + const second = await requestViaProxy('/api/e2e-cache-hit'); + + assert.strictEqual(first.statusCode, 200); + assert.strictEqual(second.statusCode, 200); + assert.strictEqual(originRequestCount, countAfterFirst); + assert.strictEqual( + JSON.parse(first.body).requestCount, + JSON.parse(second.body).requestCount + ); + + const cachedFile = `${e2eCacheDir}/http/127.0.0.1:${originPort}/GET/api/e2e-cache-hit.data`; + assert.ok(fs.existsSync(cachedFile)); + assert.ok(fs.existsSync(`${cachedFile}.meta`)); + } finally { + await new Promise(resolve => originServer.close(resolve)); + const proxyExit = new Promise(resolve => proxyProcess.once('exit', resolve)); + if (proxyProcess.exitCode === null) { + proxyProcess.kill('SIGTERM'); + } + const exitedAfterTerm = await Promise.race([ + proxyExit.then(() => true), + wait(PROXY_SHUTDOWN_TIMEOUT_MS).then(() => false), + ]); + if (!exitedAfterTerm && proxyProcess.exitCode === null) { + proxyProcess.kill('SIGKILL'); + await proxyExit; + } + fs.rmSync(e2eCacheDir, { recursive: true, force: true }); + } + }); });