Skip to content
Closed
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
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
"bin": {
"proxy-kutti": "./proxy.js"
},
"scripts": {
"test": "node test/real-proxy.test.js",
"test:cache": "node test/proxy-cache.test.js",
"test:integration": "node test/integration.test.js",
"test:all": "npm run test && npm run test:cache && npm run test:integration"
},
"dependencies": {
"node-forge": "^1.3.1"
},
Expand Down
86 changes: 86 additions & 0 deletions test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Proxy-Kutti Integration Tests

This directory contains integration tests that prove the core caching functionality of proxy-kutti works correctly.

## Test Files

### `real-proxy.test.js` (Main Test Suite)
The primary integration test that validates proxy-kutti's caching behavior:

- **Cache Miss Test**: Verifies that requests are forwarded to the origin server when no cache exists
- **Cache Hit Test**: Verifies that subsequent identical requests are served from cache without hitting the origin
- **Cache Structure Test**: Validates that cache files are created in the correct proxy-kutti directory structure
- **Conditional Headers Test**: Ensures ETag and Last-Modified headers are properly cached

### `proxy-cache.test.js`
Simplified cache logic test that demonstrates the cache hit/miss behavior with a mock implementation.

### `integration.test.js`
Basic integration test framework (simplified proxy implementation for testing concepts).

## Running Tests

```bash
# Run the main test suite
npm test

# Run specific tests
npm run test:cache # Run simplified cache test
npm run test:integration # Run basic integration test

# Run all tests
npm run test:all
```

## What These Tests Prove

### 1. Cache Miss Behavior
- When no cache exists for a request, the proxy forwards the request to the origin server
- The origin server receives and processes the request
- The response is cached in the filesystem with the correct structure
- Cache files include both data (`.data`) and metadata (`.data.meta`)

### 2. Cache Hit Behavior
- When a cache exists for a request, the proxy serves the response from cache
- The origin server does NOT receive the request
- The cached response matches the original response exactly
- No additional network requests are made

### 3. Cache File Structure
- Cache files are stored in `cache_dir/protocol/host:port/method/path.data`
- Metadata files use the same path with `.meta` extension
- Metadata includes headers, status code, and original request information
- Cache structure matches the documented proxy-kutti format

### 4. Conditional Headers
- ETag and Last-Modified headers are properly preserved in cache
- These headers can be used for cache validation in future requests

## Test Architecture

The tests use:
- **Mock Origin Server**: HTTP server that tracks request counts to verify cache behavior
- **Temporary Cache Directory**: Isolated cache storage for each test run
- **File System Verification**: Direct inspection of cache files to ensure correct structure
- **Request Counting**: Tracking origin server requests to prove cache hits vs misses

## Example Test Output

```
🎉 All real proxy integration tests passed!
Total origin server requests: 4
Cache directory: /tmp/proxy-kutti-real-test

Cache directory structure:
/tmp/proxy-kutti-real-test/http/127.0.0.1:9006/GET/api/etag-test.data
/tmp/proxy-kutti-real-test/http/127.0.0.1:9006/GET/api/cache-miss-test.data.meta
/tmp/proxy-kutti-real-test/http/127.0.0.1:9006/GET/api/cache-miss-test.data
...
```

## Key Assertions

1. **Cache Miss**: `originRequestCount` increments when cache doesn't exist
2. **Cache Hit**: `originRequestCount` stays the same when serving from cache
3. **Cache Structure**: Files exist at expected paths with correct content
4. **Data Integrity**: Cached responses match original responses exactly
293 changes: 293 additions & 0 deletions test/e2e-proxy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
#!/usr/bin/env node

const http = require('http');
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawn } = require('child_process');

/**
* End-to-end integration tests for proxy-kutti
* Tests the actual proxy server with real HTTP requests
*/

class E2EProxyTest {
constructor() {
this.originServerPort = 9004;
this.proxyPort = 9005;
this.testCacheDir = path.join(os.tmpdir(), 'proxy-kutti-e2e-test');
this.originServer = null;
this.proxyProcess = null;
this.originRequestCount = 0;
}

async setup() {
await this.cleanupTestCache();
await fs.promises.mkdir(this.testCacheDir, { recursive: true });
await this.setupOriginServer();
await this.setupProxyServer();
// Give proxy server time to start
await this.sleep(2000);
}

async cleanup() {
if (this.originServer) {
this.originServer.close();
}
if (this.proxyProcess) {
this.proxyProcess.kill();
}
await this.cleanupTestCache();
}

async cleanupTestCache() {
try {
await fs.promises.rm(this.testCacheDir, { recursive: true, force: true });
} catch (err) {
// Ignore if directory doesn't exist
}
}

sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

async setupOriginServer() {
return new Promise((resolve) => {
this.originServer = http.createServer((req, res) => {
this.originRequestCount++;

console.log(`Origin server: Received request ${this.originRequestCount} for ${req.url}`);

const responseBody = JSON.stringify({
message: 'Response from origin server',
requestCount: this.originRequestCount,
path: req.url,
timestamp: new Date().toISOString()
});

res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(responseBody)
});
res.end(responseBody);
});

this.originServer.listen(this.originServerPort, '127.0.0.1', () => {
console.log(`Origin server started on http://127.0.0.1:${this.originServerPort}`);
resolve();
});
});
}

async setupProxyServer() {
return new Promise((resolve) => {
// Start proxy-kutti with custom configuration
const env = {
...process.env,
PROXY_KUTTI_port: this.proxyPort.toString(),
PROXY_KUTTI_host: '127.0.0.1',
PROXY_KUTTI_cache_dir: this.testCacheDir
};

this.proxyProcess = spawn('node', ['proxy.js'], {
cwd: '/home/runner/work/node-proxy-kutti/node-proxy-kutti',
env: env,
stdio: ['pipe', 'pipe', 'pipe']
});

this.proxyProcess.stdout.on('data', (data) => {
const output = data.toString();
console.log('Proxy output:', output);
if (output.includes('Proxy-kutti is running')) {
resolve();
}
});

this.proxyProcess.stderr.on('data', (data) => {
console.error('Proxy error:', data.toString());
});

this.proxyProcess.on('close', (code) => {
console.log(`Proxy process exited with code ${code}`);
});
});
}

async makeRequestThroughProxy(path = '/test') {
return new Promise((resolve, reject) => {
const options = {
hostname: '127.0.0.1',
port: this.proxyPort,
path: `http://127.0.0.1:${this.originServerPort}${path}`,
method: 'GET'
};

const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
resolve({
statusCode: res.statusCode,
headers: res.headers,
body: data
});
});
});

req.on('error', reject);
req.end();
});
}

async getCacheFilePath(path) {
const host = `127.0.0.1:${this.originServerPort}`;
return `${this.testCacheDir}/http/${host}/GET${path}.data`;
}

async getCacheMetaPath(path) {
return `${await this.getCacheFilePath(path)}.meta`;
}

async cacheExists(path) {
try {
await fs.promises.access(await this.getCacheMetaPath(path));
return true;
} catch {
return false;
}
}

async testE2ECacheMiss() {
console.log('\n--- Testing E2E Cache Miss Scenario ---');

const testPath = '/api/e2e-miss';
const initialRequestCount = this.originRequestCount;

// Ensure no cache exists
const cacheExists = await this.cacheExists(testPath);
assert.strictEqual(cacheExists, false, 'Cache should not exist initially');

// Make request through proxy - should be a cache miss
const response = await this.makeRequestThroughProxy(testPath);

assert.strictEqual(response.statusCode, 200, 'Request should succeed');
assert.strictEqual(this.originRequestCount, initialRequestCount + 1,
'Origin server should receive the request');

const responseData = JSON.parse(response.body);
assert.strictEqual(responseData.requestCount, initialRequestCount + 1,
'Response should show correct request count from origin');

// Verify cache was created
await this.sleep(100); // Give cache time to be written
const cacheExistsAfter = await this.cacheExists(testPath);
assert.strictEqual(cacheExistsAfter, true, 'Cache should exist after first request');

console.log('✓ E2E Cache miss test passed: Request forwarded to origin via proxy and cached');
return responseData;
}

async testE2ECacheHit() {
console.log('\n--- Testing E2E Cache Hit Scenario ---');

const testPath = '/api/e2e-hit';

// First request to populate cache
console.log('Making first request to populate cache...');
const response1 = await this.makeRequestThroughProxy(testPath);
const requestCountAfterFirst = this.originRequestCount;

assert.strictEqual(response1.statusCode, 200, 'First request should succeed');

// Wait for cache to be written
await this.sleep(100);

// Second request should be served from cache
console.log('Making second request (should be served from cache)...');
const response2 = await this.makeRequestThroughProxy(testPath);

assert.strictEqual(response2.statusCode, 200, 'Second request should succeed');
assert.strictEqual(this.originRequestCount, requestCountAfterFirst,
'Origin server should NOT receive second request when serving from cache');

// Verify both responses have same content (from cache)
const data1 = JSON.parse(response1.body);
const data2 = JSON.parse(response2.body);
assert.strictEqual(data1.requestCount, data2.requestCount,
'Cached response should have same request count as original');

console.log('✓ E2E Cache hit test passed: Second request served from cache via proxy without hitting origin');
}

async testE2ECacheStructure() {
console.log('\n--- Testing E2E Cache File Structure ---');

const testPath = '/api/structure-test';

// Make request to create cache
await this.makeRequestThroughProxy(testPath);
await this.sleep(100);

const cacheFile = await this.getCacheFilePath(testPath);
const cacheMetaFile = await this.getCacheMetaPath(testPath);

// Verify cache files exist
assert.strictEqual(fs.existsSync(cacheFile), true, 'Cache data file should exist');
assert.strictEqual(fs.existsSync(cacheMetaFile), true, 'Cache meta file should exist');

// Verify cache file contents
const cachedData = await fs.promises.readFile(cacheFile, 'utf8');
const metaData = JSON.parse(await fs.promises.readFile(cacheMetaFile, 'utf8'));

const parsedData = JSON.parse(cachedData);
assert.strictEqual(parsedData.path, testPath, 'Cached data should contain correct path');
assert.strictEqual(metaData.statusCode, 200, 'Metadata should contain status code');
assert.strictEqual(typeof metaData.headers, 'object', 'Metadata should contain headers');
assert.strictEqual(typeof metaData['proxy-kutti-orig-request'], 'object',
'Metadata should contain original request info');

console.log('✓ E2E Cache structure test passed: Cache files created with correct proxy-kutti structure');
console.log(` Cache data: ${cacheFile}`);
console.log(` Cache meta: ${cacheMetaFile}`);
}

async run() {
console.log('Starting Proxy-Kutti E2E Integration Tests...\n');

try {
await this.setup();

// Test cache miss scenario
await this.testE2ECacheMiss();

// Test cache hit scenario
await this.testE2ECacheHit();

// Test cache file structure
await this.testE2ECacheStructure();

console.log('\n🎉 All E2E integration tests passed!');
console.log(`Total origin server requests: ${this.originRequestCount}`);
console.log(`Cache directory: ${this.testCacheDir}`);

} catch (error) {
console.error('❌ E2E Test failed:', error.message);
console.error(error.stack);
process.exit(1);
} finally {
await this.cleanup();
}
}
}

// Run tests if this file is executed directly
if (require.main === module) {
const test = new E2EProxyTest();
test.run().catch(console.error);
}

module.exports = E2EProxyTest;
Loading