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
86 changes: 86 additions & 0 deletions diagnostic/build-2b54872c.json

Large diffs are not rendered by default.

86 changes: 86 additions & 0 deletions diagnostic/build-6c033d18.json

Large diffs are not rendered by default.

86 changes: 86 additions & 0 deletions diagnostic/build-d52dc82e.json

Large diffs are not rendered by default.

75 changes: 74 additions & 1 deletion frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,51 @@ function generateTraceId(): string {
// CORE API FUNCTIONS
// ---------------------------------------------------------------------------

let refreshPromise: Promise<string> | null = null;

async function handleTokenRefresh(): Promise<string> {
if (refreshPromise) {
return refreshPromise;
}

refreshPromise = (async () => {
try {
const url = buildUrl('/auth/refresh');
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});

if (!response.ok) {
throw new Error(`Refresh failed with status ${response.status}`);
}

const data = await response.json();
const token = data.token || data.accessToken || data.access_token;

if (token) {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('auth_token', token);
}
return token;
}
throw new Error('No token in refresh response');
} catch (error) {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('auth_token');
}
throw error;
} finally {
refreshPromise = null;
}
})();

return refreshPromise;
}

async function request<T>(
method: string,
path: string,
Expand Down Expand Up @@ -231,7 +276,31 @@ async function request<T>(
const timeoutId = setTimeout(() => controller.abort(), timeout);
requestConfig.signal = controller.signal;

const response = await fetch(requestConfig.url, requestConfig);
let response = await fetch(requestConfig.url, requestConfig);

if (response.status === 401 && !requestConfig.url.includes('/auth/refresh')) {
try {
const newToken = await handleTokenRefresh();
const newHeaders = { ...(requestConfig.headers as Record<string, string>) };
newHeaders['Authorization'] = `Bearer ${newToken}`;
newHeaders[LEGACY_API_KEY_HEADER] = newToken;
requestConfig.headers = newHeaders;

response = await fetch(requestConfig.url, requestConfig);
} catch (refreshErr) {
clearTimeout(timeoutId);
const authError: ApiError = {
code: 401,
message: 'Authentication failed. Please log in again.',
};
let processedError = authError;
for (const interceptor of errorInterceptors) {
processedError = interceptor(processedError);
}
throw processedError;
}
}

clearTimeout(timeoutId);

const responseData = await parseResponse<T>(response);
Expand All @@ -246,6 +315,10 @@ async function request<T>(
} catch (error) {
lastError = error as Error;

if ((error as ApiError).code === 401) {
throw error;
}

if (attempt < maxRetries && method === 'GET') {
const delay = RETRY_BASE_DELAY * Math.pow(2, attempt) + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
Expand Down
106 changes: 106 additions & 0 deletions frontend/src/services/api_test_fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { get } from './api.js';
import assert from 'assert';

// Mock localStorage
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {}
} as any;

let fetchCalls = 0;
let refreshCalls = 0;
let shouldRefreshSucceed = true;

globalThis.fetch = async (url: string | URL | globalThis.Request, config?: any) => {
const urlStr = url.toString();

if (urlStr.includes('/auth/refresh')) {
// Artificial delay to test concurrency
await new Promise(r => setTimeout(r, 50));
refreshCalls++;
if (shouldRefreshSucceed) {
return new Response(JSON.stringify({ token: 'new-valid-token' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} else {
return new Response(null, { status: 403 });
}
}

fetchCalls++;

const token = config?.headers?.['Authorization'] || (config?.headers as any)?.get?.('Authorization');

if (token === 'Bearer new-valid-token') {
return new Response(JSON.stringify({ success: true, data: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}

return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
};

async function runTests() {
console.log('Test 1: Single 401 request with successful refresh');
fetchCalls = 0;
refreshCalls = 0;
shouldRefreshSucceed = true;

const result = await get<any>('/data');
assert.strictEqual(result.data.success, true);
assert.strictEqual(fetchCalls, 2); // 1st fails, 2nd succeeds
assert.strictEqual(refreshCalls, 1);
console.log('✓ Test 1 passed\n');

console.log('Test 2: Concurrent 401 requests with successful refresh');
fetchCalls = 0;
refreshCalls = 0;
shouldRefreshSucceed = true;

const [res1, res2, res3] = await Promise.all([
get<any>('/data1'),
get<any>('/data2'),
get<any>('/data3')
]);

assert.strictEqual(res1.data.success, true);
assert.strictEqual(res2.data.success, true);
assert.strictEqual(res3.data.success, true);

assert.strictEqual(fetchCalls, 6);
assert.strictEqual(refreshCalls, 1); // Only 1 refresh despite 3 requests
console.log('✓ Test 2 passed\n');

console.log('Test 3: Refresh failure surfaces correctly');
fetchCalls = 0;
refreshCalls = 0;
shouldRefreshSucceed = false;

let caughtError: any = null;
try {
await get<any>('/data-fail');
} catch (e) {
caughtError = e;
}

assert.ok(caughtError);
assert.strictEqual(caughtError.code, 401);
assert.strictEqual(caughtError.message, 'Authentication failed. Please log in again.');
assert.strictEqual(fetchCalls, 1);
assert.strictEqual(refreshCalls, 1);
console.log('✓ Test 3 passed\n');
}

runTests().then(() => {
console.log('All tests passed!');
process.exit(0);
}).catch(err => {
console.error('Test failed:', err);
process.exit(1);
});
79 changes: 79 additions & 0 deletions tests/test_log_aggregator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import json
import os
import tempfile
import unittest
import sys
from pathlib import Path

# Add the tools directory to the path so we can import log_aggregator
sys.path.insert(0, str(Path(__file__).parent.parent / "tools"))

from log_aggregator import LogAggregator

class TestLogAggregator(unittest.TestCase):
def setUp(self):
self.aggregator = LogAggregator()
self.temp_dir = tempfile.TemporaryDirectory()

def tearDown(self):
self.temp_dir.cleanup()

def test_valid_json_log(self):
log_content = '{"timestamp": 1704110400, "level": "info", "message": "Test"}\n'
log_path = os.path.join(self.temp_dir.name, "valid.log")
with open(log_path, 'w') as f:
f.write(log_content)

count = self.aggregator.process_file(log_path)
self.assertEqual(count, 1)
self.assertEqual(len(self.aggregator.parse_failures), 0)

def test_malformed_json_log(self):
# A malformed JSON line that will trigger JSONDecodeError
log_content = '{"timestamp": 1704110400, "level": "info", "message": "Test"\n'
log_path = os.path.join(self.temp_dir.name, "invalid.log")
with open(log_path, 'w') as f:
f.write(log_content)

count = self.aggregator.process_file(log_path)
self.assertEqual(count, 0)
self.assertEqual(len(self.aggregator.parse_failures), 1)

failure = self.aggregator.parse_failures[0]
self.assertEqual(failure["file"], log_path)
self.assertEqual(failure["line"], 1)
self.assertEqual(failure["parser"], "JSONLogParser")
self.assertIn("JSON Parse Error", failure["error"])

def test_empty_lines_skipped(self):
log_content = '\n\n'
log_path = os.path.join(self.temp_dir.name, "empty.log")
with open(log_path, 'w') as f:
f.write(log_content)

count = self.aggregator.process_file(log_path)
self.assertEqual(count, 0)
self.assertEqual(len(self.aggregator.parse_failures), 0)

def test_export_parse_error_report(self):
log_content = '{invalid}\n'
log_path = os.path.join(self.temp_dir.name, "invalid.log")
with open(log_path, 'w') as f:
f.write(log_content)

self.aggregator.process_file(log_path)

report_path = os.path.join(self.temp_dir.name, "report.json")
self.aggregator.export_parse_error_report(report_path)

self.assertTrue(os.path.exists(report_path))
with open(report_path, 'r') as f:
report = json.load(f)

self.assertEqual(report["total_failures"], 1)
self.assertEqual(len(report["failures"]), 1)
self.assertEqual(report["failures"][0]["parser"], "JSONLogParser")
self.assertEqual(report["failures"][0]["line"], 1)

if __name__ == '__main__':
unittest.main()
Loading