mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
136 lines
7.2 KiB
TypeScript
136 lines
7.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { ReaderApi } from '../src/api/client.ts';
|
|
import { ApiError } from '../src/api/errors.ts';
|
|
import type { HttpResponse } from '../src/core/platform.ts';
|
|
import type { Session } from '../src/api/types.ts';
|
|
import { FakeTransport, makePlatform } from './helpers/env.ts';
|
|
|
|
function session(id: string): Session {
|
|
return {
|
|
user: { id, username: id, displayName: id, role: 'member', createdAt: 0 },
|
|
accessToken: `access-${id}`, refreshToken: `refresh-${id}`,
|
|
accessTokenExpiresAt: 9e15, refreshTokenExpiresAt: 9e15,
|
|
};
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((accept) => { resolve = accept; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
const expired: HttpResponse = { status: 401, headers: {}, json: { error: { code: 'TOKEN_EXPIRED', message: 'expired' } } };
|
|
const ok = (json: unknown): HttpResponse => ({ status: 200, headers: {}, json });
|
|
|
|
async function setup() {
|
|
const transport = new FakeTransport();
|
|
let stored: Session | null = session('a');
|
|
const api = new ReaderApi(makePlatform(transport), {
|
|
async load() { return stored; },
|
|
async save(value) { stored = value; },
|
|
async clear() { stored = null; },
|
|
});
|
|
await api.restore();
|
|
return { api, transport, stored: () => stored };
|
|
}
|
|
|
|
describe('requests across an account change', () => {
|
|
it.each([503, 0])('preserves the session when renewal temporarily fails (%s)', async status => {
|
|
const env = await setup();
|
|
env.transport.respondWith(request => {
|
|
if (!request.url.endsWith('/refresh')) return expired;
|
|
if (!status) throw new ApiError('offline','network unavailable');
|
|
return {status,headers:{},json:{error:{code:'UNAVAILABLE',message:'temporary failure'}}};
|
|
});
|
|
await expect(env.api.listBooks()).rejects.toBeDefined();
|
|
expect(env.stored()?.user.id).toBe('a');
|
|
});
|
|
it('renews near expiry once for concurrent requests and persists the rotation', async () => {
|
|
const env = await setup(); env.api.currentSession()!.accessTokenExpiresAt = Date.now()+60_000;
|
|
env.transport.respondWith(request => request.url.endsWith('/refresh') ? ok({...session('a'),accessToken:'renewed',refreshToken:'rotated'}) : ok({items:[]}));
|
|
await Promise.all([env.api.listBooks(),env.api.listBooks()]);
|
|
expect(env.transport.requests.filter(request=>request.url.endsWith('/refresh'))).toHaveLength(1);
|
|
expect(env.transport.requests.filter(request=>request.url.endsWith('/books')).every(request=>request.headers.authorization==='Bearer renewed')).toBe(true);
|
|
expect(env.stored()?.refreshToken).toBe('rotated');
|
|
});
|
|
it('clears the session only when the refresh credential is rejected', async () => {
|
|
const env = await setup(); env.api.currentSession()!.accessTokenExpiresAt = Date.now()-1;
|
|
env.transport.respondWith(()=>({status:401,headers:{},json:{error:{code:'REFRESH_EXPIRED',message:'expired'}}}));
|
|
await expect(env.api.renewSessionIfNeeded()).rejects.toMatchObject({code:'REFRESH_EXPIRED'});
|
|
expect(env.stored()).toBeNull();
|
|
});
|
|
it.each(['AUTH_REQUIRED', 'AUTH_EXPIRED', 'VERIFICATION_REQUIRED'])('keeps the Reader session for source error %s', async code => {
|
|
const env = await setup();
|
|
env.transport.respondWith(() => ({ status: 401, headers: {}, json: { error: { code, message: '站点需要验证' } } }));
|
|
await expect(env.api.sourceCatalog('private')).rejects.toMatchObject({ code, isAuthFailure: false });
|
|
expect(env.api.currentSession()?.user.id).toBe('a');
|
|
expect(env.transport.requests).toHaveLength(1);
|
|
});
|
|
it('rotates an expired token before loading chapter bytes through the same guarded request path', async () => {
|
|
const env = await setup();
|
|
env.transport.respondWith((request) => {
|
|
if (request.url.endsWith('/refresh')) return ok({ ...session('a'), accessToken: 'access-a-rotated' });
|
|
if (request.headers.authorization === 'Bearer access-a') return expired;
|
|
return { status: 200, headers: {}, bytes: new TextEncoder().encode('chapter body') };
|
|
});
|
|
expect(await (await env.api.asset('book', 'resource:revision:chapter')).text()).toBe('chapter body');
|
|
expect(env.transport.requests.at(-1)?.headers.authorization).toBe('Bearer access-a-rotated');
|
|
expect(env.transport.requests.at(-1)?.headers.accept).toBe('*/*');
|
|
});
|
|
it('does not refresh, retry with B credentials, or clear B when A receives a delayed 401', async () => {
|
|
const env = await setup();
|
|
const delayed = deferred<HttpResponse>();
|
|
env.transport.respondWith((request) => request.url.endsWith('/login') ? ok(session('b')) : delayed.promise);
|
|
const old = env.api.listBooks().catch((error: unknown) => error);
|
|
await env.api.login('b', 'password');
|
|
delayed.resolve(expired);
|
|
expect(await old).toMatchObject({ kind: 'aborted', code: 'ACCOUNT_CHANGED' });
|
|
expect(env.api.currentSession()?.user.id).toBe('b');
|
|
expect(env.stored()?.user.id).toBe('b');
|
|
expect(env.transport.requests.filter((request) => request.url.endsWith('/books'))).toHaveLength(1);
|
|
expect(env.transport.requests.some((request) => request.url.endsWith('/refresh'))).toBe(false);
|
|
});
|
|
|
|
it.each([false, true])('does not adopt or reject B based on an old in-flight token refresh (failed=%s)', async (failed) => {
|
|
const env = await setup();
|
|
const started = deferred<void>();
|
|
const delayed = deferred<HttpResponse>();
|
|
env.transport.respondWith((request) => {
|
|
if (request.url.endsWith('/login')) return ok(session('b'));
|
|
if (request.url.endsWith('/refresh')) {
|
|
started.resolve();
|
|
return delayed.promise;
|
|
}
|
|
return expired;
|
|
});
|
|
const old = env.api.listBooks().catch((error: unknown) => error);
|
|
await started.promise;
|
|
await env.api.login('b', 'password');
|
|
delayed.resolve(failed ? expired : ok({ ...session('a'), accessToken: 'access-a-rotated' }));
|
|
expect(await old).toMatchObject({ kind: 'aborted', code: 'ACCOUNT_CHANGED' });
|
|
expect(env.api.currentSession()?.user.id).toBe('b');
|
|
expect(env.stored()?.user.id).toBe('b');
|
|
expect(env.transport.requests.filter((request) => request.url.endsWith('/books'))).toHaveLength(1);
|
|
});
|
|
|
|
it('rejects a successful stale response so callers cannot put A data into B caches', async () => {
|
|
const env = await setup();
|
|
const delayed = deferred<HttpResponse>();
|
|
env.transport.respondWith((request) => request.url.endsWith('/login') ? ok(session('b')) : delayed.promise);
|
|
const old = env.api.listBooks().catch((error: unknown) => error);
|
|
await env.api.login('b', 'password');
|
|
delayed.resolve(ok({ items: ['a-private-book'] }));
|
|
expect(await old).toMatchObject({ kind: 'aborted' });
|
|
});
|
|
});
|
|
|
|
it('extracts and persists the nested registration session for authenticated requests and reload', async () => {
|
|
const env = await setup(), registered = session('new-admin');
|
|
registered.user.role = 'admin';
|
|
env.transport.respondWith(request => request.url.endsWith('/register') ? ok({ user: registered.user, session: registered }) : ok({ user: registered.user }));
|
|
expect(await env.api.register('new-admin', 'password123')).toEqual(registered);
|
|
expect(env.stored()).toEqual(registered);
|
|
await env.api.restore(); await env.api.me();
|
|
expect(env.transport.requests.at(-1)?.headers.authorization).toBe('Bearer access-new-admin');
|
|
});
|