Files
zeekayandHanzo Dev bc729bbba5 browser: an expired token is not a session
`getValidAccessToken` handed back the access token it had just found expired
whenever there was no refresh token to spend. Every caller reads a non-null
token as "signed in", so the answer surface searched with a dead bearer and
rendered the 401 as a failed search rather than as a session that had ended.

A token read answers one question — is there a token this client may use right
now — and "expired, unrefreshable" is a no. It now clears the session and
returns null, so the surfaces ask for a sign-in instead of discovering the
refusal a request later. A token whose expiry was never recorded is untouched:
the direct-token login path stores no `expires_in`, so absence of an expiry is
not evidence of staleness.

Seven cases, two of which fail against the previous implementation.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 04:31:50 -07:00

102 lines
3.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getValidAccessToken } from '../src/shared/auth';
import { STORAGE_KEYS } from '../src/shared/config';
/**
* A token read answers ONE question: is there a token this client may use right
* now? Anything else — expired, unrefreshable — is "no", and the caller signs in.
* The surfaces are built on that answer: the answer engine treats a non-null
* token as signed in, so a stale one turns a signed-out session into a search
* that fails with a 401 the user cannot act on.
*/
const HOUR = 3_600_000;
/** A storage double over a plain record — the same shape BrowserStorage exposes. */
function storageOf(seed: Record<string, unknown>) {
const data = { ...seed };
return {
data,
async get(keys: string[]) {
return Object.fromEntries(keys.filter((k) => k in data).map((k) => [k, data[k]]));
},
async set(next: Record<string, unknown>) {
Object.assign(data, next);
},
async remove(keys: string[]) {
for (const k of keys) delete data[k];
},
};
}
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
beforeEach(() => mockFetch.mockReset());
describe('getValidAccessToken', () => {
it('returns a token that has not expired', async () => {
const storage = storageOf({
[STORAGE_KEYS.accessToken]: 'fresh',
[STORAGE_KEYS.expiresAt]: Date.now() + HOUR,
});
expect(await getValidAccessToken(storage as any)).toBe('fresh');
expect(mockFetch).not.toHaveBeenCalled();
});
it('refuses an expired token that cannot be refreshed, and clears the session', async () => {
const storage = storageOf({
[STORAGE_KEYS.accessToken]: 'stale',
[STORAGE_KEYS.expiresAt]: Date.now() - HOUR,
});
expect(await getValidAccessToken(storage as any)).toBeNull();
// Nothing left to make a later read believe it is still signed in.
expect(storage.data[STORAGE_KEYS.accessToken]).toBeUndefined();
});
it('treats a token inside the last minute of its life as expired', async () => {
const storage = storageOf({
[STORAGE_KEYS.accessToken]: 'about-to-die',
[STORAGE_KEYS.expiresAt]: Date.now() + 30_000,
});
expect(await getValidAccessToken(storage as any)).toBeNull();
});
it('refreshes an expired token when a refresh token is stored', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ access_token: 'minted', token_type: 'Bearer', expires_in: 3600 }),
});
const storage = storageOf({
[STORAGE_KEYS.accessToken]: 'stale',
[STORAGE_KEYS.refreshToken]: 'refresh-me',
[STORAGE_KEYS.expiresAt]: Date.now() - HOUR,
});
expect(await getValidAccessToken(storage as any)).toBe('minted');
expect(storage.data[STORAGE_KEYS.accessToken]).toBe('minted');
});
it('clears the session when the refresh is rejected', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 400, json: async () => ({}) });
const storage = storageOf({
[STORAGE_KEYS.accessToken]: 'stale',
[STORAGE_KEYS.refreshToken]: 'revoked',
[STORAGE_KEYS.expiresAt]: Date.now() - HOUR,
});
expect(await getValidAccessToken(storage as any)).toBeNull();
expect(storage.data[STORAGE_KEYS.refreshToken]).toBeUndefined();
});
it('keeps a token whose expiry was never recorded', async () => {
// The direct-token login path stores no expires_in, so an absent expiry is
// not evidence of staleness.
const storage = storageOf({ [STORAGE_KEYS.accessToken]: 'no-expiry-known' });
expect(await getValidAccessToken(storage as any)).toBe('no-expiry-known');
});
it('is null when nothing is stored', async () => {
expect(await getValidAccessToken(storageOf({}) as any)).toBeNull();
});
});