fix(web): refresh stale sync csrf token

This commit is contained in:
fy59 2026-09-22 13:57:09 +02:00
parent 3347de3dbb
commit c11ea07a19
2 changed files with 43 additions and 6 deletions

View file

@ -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', {

View file

@ -116,10 +116,7 @@ export async function fetchSyncStatus(signal?: AbortSignal): Promise<SyncStatus>
return parse(await response.json())
}
export async function startSync(requestId: string): Promise<SyncStatus> {
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<SyncStatus> {
},
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<SyncStatus> {
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 {