Skip to content
Open
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
17 changes: 16 additions & 1 deletion src/middleware/rateLimit.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,24 @@ setInterval(() => {

/**
* Get rate limit key from request
*
* Parses Authorization header directly instead of relying on req.token
* to avoid race conditions with auth middleware execution order.
*
* @see https://github.com/moltbook/api/issues/5
*/
function getKey(req, limitType) {
const identifier = req.token || req.ip || 'anonymous';
let identifier;

// Parse Authorization header directly to avoid dependency on req.token
// which may not be set if rate limiter runs before auth middleware completes
const authHeader = req.headers?.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
identifier = authHeader.slice(7); // Extract token after "Bearer "
} else {
identifier = req.ip || 'anonymous';
}

return `rl:${limitType}:${identifier}`;
}

Expand Down
210 changes: 210 additions & 0 deletions test/integration/comments-401.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* Integration Test: Comments 401 Race Condition
*
* This test verifies that the POST /posts/:id/comments endpoint
* correctly handles rate limiting when the Authorization header is present,
* even if req.token hasn't been set by the auth middleware yet.
*
* @see https://github.com/moltbook/api/issues/5
*/

const http = require('http');
const app = require('../../src/app');

const TEST_PORT = 3999;
let server;

// Test helpers
function request(method, path, headers = {}, body = null) {
return new Promise((resolve, reject) => {
const options = {
hostname: 'localhost',
port: TEST_PORT,
path: `/api/v1${path}`,
method,
headers: {
'Content-Type': 'application/json',
...headers
}
};

const req = http.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve({
status: res.statusCode,
headers: res.headers,
body: data ? JSON.parse(data) : null
});
} catch (e) {
resolve({
status: res.statusCode,
headers: res.headers,
body: data
});
}
});
});

req.on('error', reject);

if (body) {
req.write(JSON.stringify(body));
}

req.end();
});
}

async function runTests() {
console.log('\n╔════════════════════════════════════════════════════════╗');
console.log('║ Integration Test: POST /posts/:id/comments 401 Fix ║');
console.log('╚════════════════════════════════════════════════════════╝\n');

let passed = 0;
let failed = 0;

async function test(name, fn) {
try {
await fn();
console.log(` ✓ ${name}`);
passed++;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
failed++;
}
}

function assertEqual(actual, expected, message) {
if (actual !== expected) {
throw new Error(message || `Expected ${expected}, got ${actual}`);
}
}

function assertNotEqual(actual, expected, message) {
if (actual === expected) {
throw new Error(message || `Values should differ, both are "${actual}"`);
}
}

// Start server
console.log('[Setup]\n');
await new Promise((resolve) => {
server = app.listen(TEST_PORT, () => {
console.log(` Test server started on port ${TEST_PORT}\n`);
resolve();
});
});

try {
// ============================================================
// Health Check
// ============================================================
console.log('[Health Check]\n');

await test('server responds to health check', async () => {
const res = await request('GET', '/health');
assertEqual(res.status, 200);
assertEqual(res.body.success, true);
});

// ============================================================
// Rate Limit Headers
// ============================================================
console.log('\n[Rate Limit Headers]\n');

await test('rate limit headers present on requests', async () => {
const res = await request('GET', '/health');
if (!res.headers['x-ratelimit-limit']) {
throw new Error('Missing X-RateLimit-Limit header');
}
if (!res.headers['x-ratelimit-remaining']) {
throw new Error('Missing X-RateLimit-Remaining header');
}
});

// ============================================================
// Comments Endpoint with Auth Header
// ============================================================
console.log('\n[Comments Endpoint with Auth]\n');

await test('POST /posts/:id/comments with Bearer token does not return 401 from rate limiter', async () => {
// Use a fake post ID - we expect 401 from AUTH (invalid token)
// NOT from rate limiter (which was the bug)
const res = await request(
'POST',
'/posts/00000000-0000-0000-0000-000000000001/comments',
{ 'Authorization': 'Bearer moltbook_test_key_for_rate_limiter' },
{ content: 'Test comment' }
);

// We should get 401 from auth middleware (invalid token)
// NOT from rate limiter race condition
assertEqual(res.status, 401);

// The error should be about invalid token, not rate limiting
if (res.body.error) {
assertNotEqual(res.body.error, 'Rate limit exceeded');
// Should mention auth-related issues
const errorLower = res.body.error.toLowerCase();
if (!errorLower.includes('token') && !errorLower.includes('auth') && !errorLower.includes('unauthorized')) {
throw new Error(`Unexpected error message: ${res.body.error}`);
}
}
});

await test('rapid requests with same token use consistent rate limit key', async () => {
const token = 'moltbook_rapid_test_token_consistency_check';
const results = [];

// Make 5 rapid requests
for (let i = 0; i < 5; i++) {
const res = await request(
'POST',
'/posts/00000000-0000-0000-0000-000000000002/comments',
{ 'Authorization': `Bearer ${token}` },
{ content: `Test ${i}` }
);
results.push({
status: res.status,
remaining: res.headers['x-ratelimit-remaining']
});
}

// Rate limit remaining should decrease consistently
// (not reset due to key switching between token and IP)
const remainingValues = results.map(r => parseInt(r.remaining, 10));
for (let i = 1; i < remainingValues.length; i++) {
if (remainingValues[i] >= remainingValues[i-1]) {
throw new Error(`Rate limit not decreasing: ${remainingValues.join(' -> ')}`);
}
}
});

// ============================================================
// Results
// ============================================================
console.log('\n' + '═'.repeat(58));
console.log(`\n Results: ${passed} passed, ${failed} failed\n`);

if (failed > 0) {
console.log(' ⚠️ INTEGRATION TESTS FAILED\n');
process.exit(1);
} else {
console.log(' ✓ All integration tests passed\n');
}

} finally {
// Cleanup
server.close();
}
}

runTests().catch(err => {
console.error('Test error:', err);
if (server) server.close();
process.exit(1);
});
Loading