278 lines
8.8 KiB
TypeScript
278 lines
8.8 KiB
TypeScript
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<ReturnType<typeof buildTestApp>> | undefined
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks()
|
|
await app?.close()
|
|
app = undefined
|
|
})
|
|
|
|
describe('TtlCache', () => {
|
|
it('истекает по TTL', () => {
|
|
let t = 0
|
|
const c = new TtlCache<string, number>({ 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<string, number>({ 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<typeof app>,
|
|
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')
|
|
})
|
|
})
|