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
87 changes: 87 additions & 0 deletions diagnostic/build-eae330fb.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
{
"generated_at": "2026-06-21T11:11:01.810516+00:00",
"commit": "eae330fb",
"diagnostic_logd": "diagnostic/build-eae330fb.logd",
"diagnostic_logd_error": null,
"message_blocker": null,
"chunked": false,
"chunk_size_bytes": null,
"password": "9bf6e433d57ca831179a",
"decrypt_command": "encryptly unpack diagnostic/build-eae330fb.logd <outdir> --password 9bf6e433d57ca831179a",
"total_modules": 10,
"passed": 2,
"failed": 8,
"modules": [
{
"name": "backend",
"status": "FAIL",
"elapsed_seconds": 0.074,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'cargo'"
},
{
"name": "frontend",
"status": "PASS",
"elapsed_seconds": 45.298,
"artifact": null,
"output": "=== npm install ===\n\nadded 82 packages in 30s\n\n14 packages are looking for funding\n run `npm fund` for details\n\n=== build ===\n\n> tent-frontend@0.0.0 build\n> tsc -b && vite build\n\nvite v6.4.3 building for production...\ntransforming...\n\u2713 100 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.63 kB \u2502 gzip: 0.35 kB\ndist/assets/state-BkjSKDbY.js 8.91 kB \u2502 gzip: 3.54 kB \u2502 map: 57.15 kB\ndist/assets/vendor-CREcWLHI.js 48.93 kB \u2502 gzip: 17.25 kB \u2502 map: 481.27 kB\ndist/assets/index-CyxcoTyU.js 231.32 kB \u2502 gzip: 72.16 kB \u2502 map: 1,045.57 kB\n\u2713 built in 3.92s\n"
},
{
"name": "market",
"status": "FAIL",
"elapsed_seconds": 0.112,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'go'"
},
{
"name": "frailbox",
"status": "PASS",
"elapsed_seconds": 1.947,
"artifact": null,
"output": "=== build ===\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/arena.c -o build/src/arena.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/logger.c -o build/src/logger.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/sandbox.c -o build/src/sandbox.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c main.c -o build/main.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude build/src/arena.o build/src/logger.o build/src/sandbox.o build/main.o -o frailbox -pie -z relro -z now\nsrc/arena.c: In function \u2018arena_contains\u2019:\nsrc/arena.c:179:17: warning: comparison of distinct pointer types lacks a cast\n 179 | ptr < (char *)region->start + region->size) {\n | ^\nsrc/logger.c: In function \u2018log_message\u2019:\nsrc/logger.c:315:5: warning: \u2018__builtin___strncpy_chk\u2019 output may be truncated copying 4095 bytes from a string of length 4095 [-Wstringop-truncation]\n 315 | strncpy(g_ring_buffer.entries[g_ring_buffer.head], message, MAX_LOG_LINE - 1);\n | ^\n"
},
{
"name": "engine",
"status": "FAIL",
"elapsed_seconds": 0.116,
"artifact": null,
"output": "=== build ===\nCMake Error: The current CMakeCache.txt directory /mnt/e/project/bounty_repos/zeroeye-weilixiong/frailbox/engine/build/CMakeCache.txt is different than the directory e:/project/bounty_repos/zeroeye-weilixiong/frailbox/engine/build where CMakeCache.txt was created. This may result in binaries being created in the wrong place. If you are not sure, reedit the CMakeCache.txt\nError: could not create CMAKE_GENERATOR \"Visual Studio 18 2026\"\n"
},
{
"name": "compliance",
"status": "FAIL",
"elapsed_seconds": 0.08,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'javac'"
},
{
"name": "v2-market-stream",
"status": "FAIL",
"elapsed_seconds": 0.081,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'ruby'"
},
{
"name": "nfc-scanner",
"status": "FAIL",
"elapsed_seconds": 0.069,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'luac'"
},
{
"name": "openapi-haskell",
"status": "FAIL",
"elapsed_seconds": 0.077,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'ghc'"
},
{
"name": "openapi-tools",
"status": "FAIL",
"elapsed_seconds": 0.067,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'luac'"
}
],
"pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-eae330fb.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging."
}
Binary file added diagnostic/build-eae330fb.logd
Binary file not shown.
8 changes: 6 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.75.5",
Expand All @@ -24,6 +26,8 @@
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "~5.8.3",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vitest": "^2.1.8",
"jsdom": "^25.0.1"
}
}
117 changes: 117 additions & 0 deletions frontend/src/services/__tests__/api.refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthenticationError, get, post } from '../api';

const REFRESH_URL = /\/auth\/refresh$/;

function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}

function freshTokens(): Record<string, unknown> {
return { accessToken: 'fresh', refreshToken: 'refresh-2', expiresIn: 3600, tokenType: 'Bearer' };
}

function setStoredTokens(accessToken: string, refreshToken: string): void {
localStorage.setItem('auth_token', accessToken);
localStorage.setItem(
'tot_auth_tokens',
JSON.stringify({ accessToken, refreshToken, expiresIn: 3600, tokenType: 'Bearer' }),
);
}

function authHeader(init?: RequestInit): string {
const headers = init?.headers as Record<string, string> | undefined;
return headers?.Authorization ?? '';
}

describe('guarded token refresh', () => {
let originalFetch: typeof globalThis.fetch;

beforeEach(() => {
originalFetch = globalThis.fetch;
localStorage.clear();
});

afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});

it('deduplicates concurrent 401s into a single refresh request', async () => {
setStoredTokens('expired', 'refresh-1');
let refreshCalls = 0;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
if (REFRESH_URL.test(url)) {
refreshCalls += 1;
await new Promise((r) => setTimeout(r, 10));
return jsonResponse(200, { data: { tokens: freshTokens() } });
}
return authHeader(init) === 'Bearer fresh'
? jsonResponse(200, { ok: true })
: jsonResponse(401, { message: 'expired' });
}) as typeof globalThis.fetch;

const [a, b] = await Promise.all([get('/users/me'), get('/users/me')]);

expect(refreshCalls).toBe(1);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(localStorage.getItem('auth_token')).toBe('fresh');
});

it('retries the original request exactly once after a successful refresh', async () => {
setStoredTokens('expired', 'refresh-1');
let originalCalls = 0;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
if (REFRESH_URL.test(url)) {
return jsonResponse(200, { data: { tokens: freshTokens() } });
}
originalCalls += 1;
return authHeader(init) === 'Bearer fresh'
? jsonResponse(200, { value: 42 })
: jsonResponse(401, { message: 'expired' });
}) as typeof globalThis.fetch;

const res = await get('/users/me');

expect(originalCalls).toBe(2);
expect(res.status).toBe(200);
expect(res.data).toEqual({ value: 42 });
});

it('clears auth state and throws a typed error when refresh fails', async () => {
setStoredTokens('expired', 'refresh-1');
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (REFRESH_URL.test(url)) {
return jsonResponse(401, { message: 'refresh token invalid' });
}
return jsonResponse(401, { message: 'expired' });
}) as typeof globalThis.fetch;

await expect(post('/users/me', { x: 1 })).rejects.toThrow(AuthenticationError);
expect(localStorage.getItem('auth_token')).toBeNull();
expect(localStorage.getItem('tot_auth_tokens')).toBeNull();
});

it('does not loop when the refresh endpoint itself returns 401', async () => {
setStoredTokens('expired', 'refresh-1');
let refreshCalls = 0;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (REFRESH_URL.test(url)) {
refreshCalls += 1;
return jsonResponse(401, { message: 'no' });
}
return jsonResponse(401, { message: 'expired' });
}) as typeof globalThis.fetch;

await expect(get('/users/me')).rejects.toThrow(AuthenticationError);
expect(refreshCalls).toBe(1);
});
});
Loading