Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
256 changes: 256 additions & 0 deletions test/proxy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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=<mime-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 });
}
});
});
Loading