Public Access
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:
1 parent
5ff8d48c53
commit
d9a39dda22
34 files changed
+297
-147
No files matched your search
+118
-20
@@ -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'}));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user