feat(auth): use the cookie session and refresh on 401

The token is no longer stored; a tbp_session flag remembers the
login. apiFetch renews the session once on a 401 and sends the
request again. Parallel requests and other tabs share one refresh,
because Auth revokes the session when a refresh token is used twice.
Only a 401 from the refresh ends the session; a 502 or no network
shows the normal error.
This commit is contained in:
lobo committed 2026-09-25 13:45:08 +02:00
1 parent 5ff8d48c53
commit d9a39dda22
34 files changed
+297 -147

No files matched your search

+118 -20
View File
@@ -1,40 +1,138 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {apiFetch} from './api.js';
import {apiFetch, clientFetch, logout} from './api.js';
describe('apiFetch', () => {
beforeEach(() => {
globalThis.fetch = vi.fn(async () => new Response(null, {status: 204}));
});
const response = (status) => ({ok: status >= 200 && status < 300, status, json: async () => ({})});
const isRefresh = (url) => url.endsWith('/api/auth/refresh');
const dataCalls = () => fetch.mock.calls.filter(([url]) => !isRefresh(url));
const refreshCalls = () => fetch.mock.calls.filter(([url]) => isRefresh(url));
afterEach(() => {
localStorage.clear();
});
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
it('sends the stored token and keeps the other headers and options', async () => {
localStorage.setItem('tbp_jwt', 'the-token');
afterEach(() => {
localStorage.clear();
});
await apiFetch('/api/x', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: '{}'});
describe('clientFetch', () => {
it('sends the cookies and the client header and keeps the other options', async () => {
globalThis.fetch = vi.fn(async () => response(204));
await clientFetch('/api/x', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: '{}'});
expect(fetch).toHaveBeenCalledWith('/api/x', {
method: 'PUT',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer the-token'},
headers: {'Content-Type': 'application/json', 'X-TBP-Client': 'web'},
body: '{}',
credentials: 'include',
});
});
});
it('sends no Authorization header without a token', async () => {
await apiFetch('/api/x');
describe('apiFetch', () => {
it('renews the session once on a 401 and sends the request again', async () => {
let dataAnswers = [401, 200];
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : dataAnswers.shift()));
expect(fetch).toHaveBeenCalledWith('/api/x', {headers: undefined});
const result = await apiFetch('/api/x', {method: 'PUT', body: '{"a":1}'});
expect(result.status).toBe(200);
expect(refreshCalls()).toHaveLength(1);
expect(dataCalls()).toHaveLength(2);
expect(dataCalls()[1][1]).toMatchObject({method: 'PUT', body: '{"a":1}'});
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('reads the token on every call', async () => {
localStorage.setItem('tbp_jwt', 'old');
await apiFetch('/api/x');
localStorage.setItem('tbp_jwt', 'new');
it('asks only once when parallel calls get a 401', async () => {
const seen = new Set();
globalThis.fetch = vi.fn(async (url) => {
if (isRefresh(url)) return response(204);
if (seen.has(url)) return response(200);
seen.add(url);
return response(401);
});
const results = await Promise.all(['/a', '/b', '/c'].map((url) => apiFetch(url)));
expect(results.map((r) => r.status)).toEqual([200, 200, 200]);
expect(refreshCalls()).toHaveLength(1);
});
it('returns the 401 when the refresh fails, without a loop, and leaves ending the session to the caller', async () => {
globalThis.fetch = vi.fn(async () => response(401));
const result = await apiFetch('/api/x');
expect(result.status).toBe(401);
expect(refreshCalls()).toHaveLength(1);
expect(dataCalls()).toHaveLength(1);
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('keeps the session and rejects when the refresh has no network', async () => {
globalThis.fetch = vi.fn(async (url) => {
if (isRefresh(url)) throw new TypeError('offline');
return response(401);
});
await expect(apiFetch('/api/x')).rejects.toThrow('offline');
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('keeps the session and rejects when Auth is down during a deploy', async () => {
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 502 : 401));
await expect(apiFetch('/api/x')).rejects.toThrow('502');
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('tries the refresh again on the next 401 after a failed one', async () => {
const refreshAnswers = [502, 204];
const dataAnswers = [401, 401, 200];
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? refreshAnswers.shift() : dataAnswers.shift()));
await expect(apiFetch('/api/x')).rejects.toThrow();
expect((await apiFetch('/api/x')).status).toBe(200);
});
it('passes other errors through without a refresh', async () => {
globalThis.fetch = vi.fn(async () => response(403));
expect((await apiFetch('/api/x')).status).toBe(403);
expect(refreshCalls()).toHaveLength(0);
});
it('gives up on a refresh that hangs, so the lock does not block every tab', async () => {
globalThis.fetch = vi.fn(async (url, init) => {
if (!isRefresh(url)) return response(401);
expect(init.signal).toBeInstanceOf(AbortSignal);
throw new DOMException('timed out', 'TimeoutError');
});
await expect(apiFetch('/api/x')).rejects.toThrow('timed out');
});
it('runs the refresh under a lock that other tabs share', async () => {
const request = vi.fn((name, callback) => callback());
vi.stubGlobal('navigator', {...navigator, locks: {request}});
let dataAnswers = [401, 200];
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : dataAnswers.shift()));
await apiFetch('/api/x');
expect(fetch).toHaveBeenLastCalledWith('/api/x', {headers: {'Authorization': 'Bearer new'}});
expect(request).toHaveBeenCalledWith('tbp-refresh', expect.any(Function));
vi.unstubAllGlobals();
});
});
describe('logout', () => {
it('posts to Auth and ignores a network error', async () => {
globalThis.fetch = vi.fn(async () => {
throw new TypeError('offline');
});
await expect(logout()).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/logout'), expect.objectContaining({method: 'POST'}));
});
});