import { describe, expect, it, vi } from 'vitest'; import { ApiError, AuthSession, type AuthTokens, type AuthUser, type RefreshStore } from './auth'; const user = { id: '00000000-0000-0000-0000-000000000001', username: 'alice', display_name: null, role: 'user' as const, locale: 'ru', ui_prefs: {}, must_change_password: false, must_change_credentials: false, reviews_uploads: false, created_at: '2026-01-01T00:00:00Z', }; const tokens = (access: string, refresh?: string): AuthTokens => ({ access_token: access, token_type: 'Bearer', expires_in: 900, refresh_token: refresh, user, }); const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' }, }); const problem = (status: number, code: string, extra: object = {}) => json({ status, code, ...extra }, status); type Handler = (req: Request) => Response | Promise; /** Фейковый сервер: маршруты по «METHOD path», журнал запросов. */ function server(routes: Record) { const log: { route: string; auth: string | null; body: string }[] = []; const fetchImpl = async (input: RequestInfo | URL) => { const req = input as Request; const route = `${req.method} ${new URL(req.url).pathname}`; log.push({ route, auth: req.headers.get('Authorization'), body: await req.clone().text() }); const h = routes[route]; if (!h) return problem(404, 'common.not_found'); return h(req); }; return { fetch: fetchImpl as typeof fetch, log }; } const memoryStore = (initial: string | null = null): RefreshStore & { value: string | null } => ({ value: initial, async get() { return this.value; }, async set(v) { this.value = v; }, async clear() { this.value = null; }, }); describe('AuthSession', () => { it('logs in and sends the bearer token', async () => { const s = server({ 'POST /api/v1/auth/login': () => json(tokens('A1')), 'GET /api/v1/me': (req) => req.headers.get('Authorization') === 'Bearer A1' ? json(user) : problem(401, 'auth.unauthorized'), }); const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web', deviceName: 'Test' }); const states: string[] = []; auth.subscribe((st) => states.push(st.status)); await auth.login('alice', 'secret'); expect(JSON.parse(s.log[0]!.body)).toEqual({ username: 'alice', password: 'secret', client_type: 'web', device_name: 'Test', }); const me = await auth.client.GET('/api/v1/me'); expect(me.data?.username).toBe('alice'); expect(states).toEqual(['signed_in']); }); it('reports proxy HTML 413 as a request size error', async () => { const s = server({ 'POST /api/v1/auth/login': () => new Response('Request Entity Too Large', { status: 413 }) }); const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); const error = await auth.login('alice', 'password').catch((e: unknown) => e); expect(error).toBeInstanceOf(ApiError); expect((error as ApiError).status).toBe(413); expect((error as ApiError).code).toBe('common.request_too_large'); }); it('turns problem+json into ApiError with field errors', async () => { const s = server({ 'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'), }); const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); const err = await auth.login('alice', 'bad').catch((e: unknown) => e); expect(err).toBeInstanceOf(ApiError); expect((err as ApiError).code).toBe('auth.invalid_credentials'); expect(auth.state.status).toBe('unknown'); const e2 = new ApiError(422, 'validation.password_too_short', { min: 8 }, [ { field: 'new_password', code: 'validation.password_too_short' }, ]); expect(e2.fieldCode('new_password')).toBe('validation.password_too_short'); expect(e2.fieldCode('other')).toBeUndefined(); }); it('refreshes once for parallel expired requests and retries them', async () => { let refreshes = 0; const s = server({ 'POST /api/v1/auth/login': () => json(tokens('old')), 'POST /api/v1/auth/refresh': async () => { refreshes++; await new Promise((r) => setTimeout(r, 10)); return json(tokens('new')); }, 'GET /api/v1/me': (req) => req.headers.get('Authorization') === 'Bearer new' ? json(user) : problem(401, 'auth.token_expired'), }); const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); await auth.login('alice', 'secret'); const results = await Promise.all([1, 2, 3].map(() => auth.client.GET('/api/v1/me'))); expect(results.every((r) => r.data?.username === 'alice')).toBe(true); expect(refreshes).toBe(1); }); it('does not refresh on other 401 codes and signs out a blocked user', async () => { const s = server({ 'POST /api/v1/auth/login': () => json(tokens('A')), 'GET /api/v1/me': () => problem(401, 'auth.user_blocked'), }); const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); await auth.login('alice', 'secret'); const res = await auth.client.GET('/api/v1/me'); expect(res.response.status).toBe(401); expect(s.log.some((l) => l.route.includes('refresh'))).toBe(false); expect(auth.state).toEqual({ status: 'signed_out', reason: 'auth.user_blocked' }); }); it('signs out when refresh is rejected', async () => { const s = server({ 'POST /api/v1/auth/refresh': () => problem(401, 'auth.refresh_reused'), }); const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); expect(await auth.restore()).toEqual({ status: 'signed_out', reason: 'auth.refresh_reused' }); }); it('retries a refresh race once', async () => { let n = 0; const s = server({ 'POST /api/v1/auth/refresh': () => (++n === 1 ? problem(401, 'auth.refresh_race') : json(tokens('A'))), }); const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); expect((await auth.restore()).status).toBe('signed_in'); expect(n).toBe(2); }); it('keeps the session on network errors during refresh', async () => { const auth = new AuthSession({ baseUrl: 'http://x', fetch: async () => { throw new TypeError('Failed to fetch'); }, clientType: 'web', }); expect(await auth.refresh()).toBe(false); expect(auth.state.status).toBe('unknown'); }); it('android keeps the refresh token in the store', async () => { const store = memoryStore('R0'); const s = server({ 'POST /api/v1/auth/refresh': async (req) => { const body = (await req.json()) as { refresh_token: string }; return body.refresh_token === 'R0' ? json(tokens('A1', 'R1')) : problem(401, 'auth.refresh_invalid'); }, 'POST /api/v1/auth/logout': () => new Response(null, { status: 204 }), }); const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'android', refreshStore: store }); expect((await auth.restore()).status).toBe('signed_in'); expect(store.value).toBe('R1'); await auth.logout(); expect(store.value).toBeNull(); expect(auth.state.status).toBe('signed_out'); expect(s.log.at(-1)).toMatchObject({ route: 'POST /api/v1/auth/logout', auth: 'Bearer A1' }); expect(await auth.refresh()).toBe(false); }); it('opens offline with the cached user and refreshes on the first request', async () => { let online = false; const store = { ...memoryStore('R0'), cached: null as AuthUser | null }; store.getUser = async () => store.cached; store.setUser = async (u) => { store.cached = u; }; store.cached = user; const s = server({ 'POST /api/v1/auth/refresh': () => json(tokens('A1', 'R1')), 'GET /api/v1/me': () => json(user), }); const fetchImpl = (async (input: RequestInfo | URL) => { if (!online) throw new TypeError('Network request failed'); return s.fetch(input); }) as typeof fetch; const auth = new AuthSession({ baseUrl: 'http://x', fetch: fetchImpl, clientType: 'android', refreshStore: store }); expect(await auth.restore()).toEqual({ status: 'signed_in', user, offline: true }); online = true; await auth.client.GET('/api/v1/me'); expect(s.log.map((l) => l.route)).toEqual(['POST /api/v1/auth/refresh', 'GET /api/v1/me']); expect(s.log[1]?.auth).toBe('Bearer A1'); expect(auth.state).toEqual({ status: 'signed_in', user }); }); it('requires a refresh store for android', () => { expect(() => new AuthSession({ clientType: 'android' })).toThrow(/refreshStore/); }); it('notifies subscribers about profile updates', async () => { const s = server({ 'POST /api/v1/auth/login': () => json(tokens('A')) }); const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); const fn = vi.fn(); auth.subscribe(fn); await auth.login('alice', 'x'); auth.setUser({ ...user, display_name: 'Alice' }); expect(fn).toHaveBeenLastCalledWith({ status: 'signed_in', user: { ...user, display_name: 'Alice' } }); }); });