import { afterEach, describe, expect, it, vi } from 'vitest' import { hash } from '@node-rs/argon2' import { TtlCache } from '../src/lib/cache.js' import { parseCookie } from '../src/lib/cookies.js' import { checkClientSecret, hashClientSecret } from '../src/lib/client-secret.js' import { hashPassword, passwordHashStats, setPasswordConcurrency, verifyPassword, } from '../src/lib/password.js' import { parseTrustProxy } from '../src/config.js' import { adminToken, buildTestApp } from './helpers.js' let app: Awaited> | undefined afterEach(async () => { vi.restoreAllMocks() await app?.close() app = undefined }) describe('TtlCache', () => { it('истекает по TTL', () => { let t = 0 const c = new TtlCache({ max: 10, ttlMs: 100, now: () => t }) c.set('a', 1) t = 99 expect(c.get('a')).toBe(1) t = 100 expect(c.get('a')).toBeUndefined() expect(c.size).toBe(0) }) it('вытесняет давно не использованное (LRU)', () => { const c = new TtlCache({ max: 2, ttlMs: 1000 }) c.set('a', 1) c.set('b', 2) c.get('a') // освежили a c.set('c', 3) expect(c.get('b')).toBeUndefined() expect(c.get('a')).toBe(1) expect(c.get('c')).toBe(3) c.delete('a') expect(c.get('a')).toBeUndefined() c.clear() expect(c.size).toBe(0) }) }) describe('parseCookie', () => { it('находит значение и декодирует', () => { expect(parseCookie('a=1; refresh_token=abc%20d; b=2', 'refresh_token')).toBe( 'abc d', ) }) it('не путает суффикс имени', () => { expect(parseCookie('x_refresh_token=bad', 'refresh_token')).toBeNull() }) it('пусто/нет → null', () => { expect(parseCookie(undefined, 'a')).toBeNull() expect(parseCookie('a=; b=1', 'a')).toBeNull() expect(parseCookie('b=1', 'a')).toBeNull() }) }) describe('client secret', () => { it('sha256-формат: верный/неверный, без rehash', async () => { const stored = hashClientSecret('s3cret') expect(stored.startsWith('sha256:')).toBe(true) expect(await checkClientSecret(stored, 's3cret')).toEqual({ ok: true, needsRehash: false, }) expect((await checkClientSecret(stored, 'nope')).ok).toBe(false) }) it('argon2-формат принимается и помечается на rehash', async () => { const legacy = await hash('old-secret') expect(await checkClientSecret(legacy, 'old-secret')).toEqual({ ok: true, needsRehash: true, }) expect(await checkClientSecret(legacy, 'bad')).toEqual({ ok: false, needsRehash: false, }) expect((await checkClientSecret('garbage', 'x')).ok).toBe(false) }) }) describe('password limiter', () => { it('не допускает больше N одновременных хешей', async () => { setPasswordConcurrency(2) let maxActive = 0 const poll = setInterval(() => { maxActive = Math.max(maxActive, passwordHashStats().active) }, 1) const h = await hashPassword('pw') await Promise.all( Array.from({ length: 6 }, () => verifyPassword(h, 'pw')), ) clearInterval(poll) expect(maxActive).toBeLessThanOrEqual(2) expect(passwordHashStats()).toEqual({ active: 0, waiting: 0 }) setPasswordConcurrency(2) }) }) describe('parseTrustProxy', () => { it('true/false/список', () => { expect(parseTrustProxy('true')).toBe(true) expect(parseTrustProxy('false')).toBe(false) expect(parseTrustProxy('10.0.0.0/8, ::1')).toEqual(['10.0.0.0/8', '::1']) }) }) async function createUserAndLogin( a: NonNullable, adminTok: string, email: string, ) { const create = await a.inject({ method: 'POST', url: '/api/v1/admin/users', headers: { authorization: `Bearer ${adminTok}` }, payload: { email, name: 'U', password: 'secret12', is_admin: false, apps: ['cfdm'], permissions: [], }, }) expect(create.statusCode).toBe(201) const id = (create.json() as { id: string }).id const login = await a.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email, password: 'secret12' }, }) expect(login.statusCode).toBe(200) return { id, token: (login.json() as { access_token: string }).access_token } } describe('кэш профиля пользователя', () => { it('повторные запросы не обращаются к user_apps/user_permissions', async () => { app = await buildTestApp() const admin = await adminToken(app) const { token } = await createUserAndLogin(app, admin, 'cache@test.local') const me = () => app!.inject({ method: 'GET', url: '/api/v1/auth/me', headers: { authorization: `Bearer ${token}` }, }) expect((await me()).statusCode).toBe(200) // прогрев const spy = vi.spyOn(app.sqlite, 'prepare') for (let i = 0; i < 5; i++) expect((await me()).statusCode).toBe(200) const sqls = spy.mock.calls.map((c) => String(c[0])) expect(sqls.filter((s) => /user_apps|user_permissions|from "users"/i.test(s))) .toHaveLength(0) }) it('блокировка пользователя действует немедленно', async () => { app = await buildTestApp() const admin = await adminToken(app) const { id, token } = await createUserAndLogin(app, admin, 'ban@test.local') const me = () => app!.inject({ method: 'GET', url: '/api/v1/auth/me', headers: { authorization: `Bearer ${token}` }, }) expect((await me()).statusCode).toBe(200) const patch = await app.inject({ method: 'PATCH', url: `/api/v1/admin/users/${id}`, headers: { authorization: `Bearer ${admin}` }, payload: { disabled: true }, }) expect(patch.statusCode).toBe(200) expect((await me()).statusCode).toBe(401) }) it('смена доступа отражается сразу, удаление → 401', async () => { app = await buildTestApp() const admin = await adminToken(app) const { id, token } = await createUserAndLogin(app, admin, 'acc@test.local') const me = async () => ( await app!.inject({ method: 'GET', url: '/api/v1/auth/me', headers: { authorization: `Bearer ${token}` }, }) ).json() as { apps?: string[] } expect((await me()).apps).toEqual(['cfdm']) const put = await app.inject({ method: 'PUT', url: `/api/v1/admin/users/${id}/access`, headers: { authorization: `Bearer ${admin}` }, payload: { apps: ['dns'], permissions: [] }, }) expect(put.statusCode).toBe(200) expect((await me()).apps).toEqual(['dns']) await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}`, headers: { authorization: `Bearer ${admin}` }, }) const gone = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: { authorization: `Bearer ${token}` }, }) expect(gone.statusCode).toBe(401) }) }) describe('app-switcher кэш', () => { it('PUT сразу виден в GET', async () => { app = await buildTestApp() const admin = await adminToken(app) const before = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' }) const cfg = await app.inject({ method: 'GET', url: '/api/v1/admin/app-switcher', headers: { authorization: `Bearer ${admin}` }, }) expect(before.statusCode).toBe(200) expect(cfg.statusCode).toBe(200) }) }) describe('rate-limit', () => { it('/health не лимитируется', async () => { app = await buildTestApp() for (let i = 0; i < 250; i++) { const res = await app.inject({ method: 'GET', url: '/health' }) expect(res.statusCode).toBe(200) } }) it('логин ограничен 20/мин', async () => { app = await buildTestApp() let last = 0 for (let i = 0; i < 22; i++) { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'nobody@test.local', password: 'wrongpass' }, }) last = res.statusCode } expect(last).toBe(429) }) it('X-Forwarded-For от недоверенного клиента игнорируется', async () => { app = await buildTestApp({ TRUST_PROXY: '10.0.0.0/8' }) const res = await app.inject({ method: 'POST', url: '/api/v1/auth/login', remoteAddress: '203.0.113.9', headers: { 'x-forwarded-for': '198.51.100.7' }, payload: { email: 'nobody@test.local', password: 'wrongpass' }, }) expect(res.statusCode).toBe(401) const row = app.sqlite .prepare(`SELECT ip FROM audit_log WHERE action='auth.login_failed'`) .get() as { ip: string } expect(row.ip).toBe('203.0.113.9') }) })