Public Access
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.
139 lines
5.4 KiB
JavaScript
139 lines
5.4 KiB
JavaScript
// @vitest-environment jsdom
|
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
import {apiFetch, clientFetch, logout} from './api.js';
|
|
|
|
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));
|
|
|
|
beforeEach(() => {
|
|
localStorage.setItem('tbp_session', '1');
|
|
});
|
|
|
|
afterEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
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', 'X-TBP-Client': 'web'},
|
|
body: '{}',
|
|
credentials: 'include',
|
|
});
|
|
});
|
|
});
|
|
|
|
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()));
|
|
|
|
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('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(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'}));
|
|
});
|
|
});
|