From c11ea07a197c68a19e4fac30a9a0681dc53f2949 Mon Sep 17 00:00:00 2001 From: fy59 Date: Tue, 22 Sep 2026 13:57:09 +0200 Subject: [PATCH] fix(web): refresh stale sync csrf token --- web/src/api/sync.test.ts | 20 +++++++++++++++++++- web/src/api/sync.ts | 29 ++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/web/src/api/sync.test.ts b/web/src/api/sync.test.ts index 8a4eb63..91f896a 100644 --- a/web/src/api/sync.test.ts +++ b/web/src/api/sync.test.ts @@ -1,9 +1,27 @@ import { beforeEach,describe,expect,it,vi } from 'vitest' import { fetchSyncStatus, newRequestId, startSync, syncIsActive } from './sync' const token='b'.repeat(64) -function response(value:unknown,status=200){return {ok:status>=200&&status<300,status,headers:new Headers({'X-Trainlog-CSRF-Token':token}),json:()=>Promise.resolve(value)}} +function response(value:unknown,status=200,csrf=token){return {ok:status>=200&&status<300,status,headers:new Headers({'X-Trainlog-CSRF-Token':csrf}),json:()=>Promise.resolve(value)}} describe('sync API',()=>{beforeEach(()=>vi.restoreAllMocks()) it('obtient passivement le statut et utilise le jeton pour la mutation',async()=>{const fetch=vi.fn().mockResolvedValueOnce(response({phase:'idle',result:'disabled'})).mockResolvedValueOnce(response({phase:'requested',result:'running'},202));vi.stubGlobal('fetch',fetch);await fetchSyncStatus();await startSync('sy_11111111-1111-4111-8111-111111111111');expect(fetch).toHaveBeenCalledTimes(2);const init=fetch.mock.calls[1][1];expect(init.method).toBe('POST');expect(init.headers['X-Trainlog-CSRF-Token']).toBe(token)}) + it('rafraîchit une fois un jeton CSRF devenu caduc après redémarrage du serveur',async()=>{ + const oldToken='a'.repeat(64) + const freshToken='d'.repeat(64) + const requestId='sy_22222222-2222-4222-8222-222222222222' + const fetch=vi.fn() + .mockResolvedValueOnce(response({phase:'failed',result:'failed'},200,oldToken)) + .mockResolvedValueOnce(response({error:'mutation_forbidden'},403,oldToken)) + .mockResolvedValueOnce(response({phase:'failed',result:'failed'},200,freshToken)) + .mockResolvedValueOnce(response({phase:'requested',result:'running'},202,freshToken)) + vi.stubGlobal('fetch',fetch) + await fetchSyncStatus() + await expect(startSync(requestId)).resolves.toMatchObject({phase:'requested'}) + expect(fetch).toHaveBeenCalledTimes(4) + expect(fetch.mock.calls[1][1].headers['X-Trainlog-CSRF-Token']).toBe(oldToken) + expect(fetch.mock.calls[2][0]).toBe('/api/v1/sync/status') + expect(fetch.mock.calls[3][1].headers['X-Trainlog-CSRF-Token']).toBe(freshToken) + expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[3][1].body) + }) it('rejette un statut inconnu et classe les phases actives',async()=>{vi.stubGlobal('fetch',vi.fn().mockResolvedValue(response({phase:'invented',result:'ok'})));await expect(fetchSyncStatus()).rejects.toThrow();expect(syncIsActive('waiting_acknowledgement')).toBe(true);expect(syncIsActive('completed')).toBe(false)}) it('crée un UUIDv4 sans dépendre de randomUUID', () => { vi.stubGlobal('crypto', { diff --git a/web/src/api/sync.ts b/web/src/api/sync.ts index 9b61c9f..8d16b83 100644 --- a/web/src/api/sync.ts +++ b/web/src/api/sync.ts @@ -116,10 +116,7 @@ export async function fetchSyncStatus(signal?: AbortSignal): Promise return parse(await response.json()) } -export async function startSync(requestId: string): Promise { - if (!/^sy_[0-9a-f-]{36}$/.test(requestId) || csrfToken.length !== 64) { - throw new Error('requête sync invalide') - } +async function postSync(requestId: string): Promise<{ response: Response; value: any }> { const response = await fetch('/api/v1/sync', { method: 'POST', headers: { @@ -129,10 +126,32 @@ export async function startSync(requestId: string): Promise { }, body: JSON.stringify({ request_id: requestId, trigger: 'web' }), }) - const value = await response.json() + return { response, value: await response.json() } +} + +export async function startSync(requestId: string): Promise { + if (!/^sy_[0-9a-f-]{36}$/.test(requestId) || csrfToken.length !== 64) { + throw new Error('requête sync invalide') + } + let attempt = await postSync(requestId) + if (attempt.response.status === 403 && attempt.value?.error === 'mutation_forbidden') { + /* + * WHY: the browser can outlive a restarted Trainlog Web process, so its + * cached CSRF token may be structurally valid but no longer authoritative. + * CONTRACT: refresh passive status once, then replay the exact same + * request_id. INVARIANT: no second business intent is created and the + * bounded retry cannot loop across repeated server restarts. + */ + csrfToken = '' + await fetchSyncStatus() + attempt = await postSync(requestId) + } + const { response, value } = attempt if (!response.ok) { throw new Error(typeof value?.error === 'string' ? value.error : `sync HTTP ${response.status}`) } + const token = response.headers.get('X-Trainlog-CSRF-Token') ?? '' + if (/^[0-9a-f]{64}$/.test(token)) csrfToken = token return parse(value) } export function newRequestId(): string {