139 lines
4.5 KiB
TypeScript
139 lines
4.5 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { Writable } from 'node:stream'
|
|
import Fastify from 'fastify'
|
|
import { assertBootstrapPassword, loadConfig } from '../src/config.js'
|
|
import { buildApp } from '../src/app.js'
|
|
|
|
const base = { JWT_SECRET: 'a-sufficiently-long-secret-123' }
|
|
|
|
describe('loadConfig', () => {
|
|
it('значения по умолчанию для новых параметров', () => {
|
|
const c = loadConfig({ ...base, NODE_ENV: 'test' })
|
|
expect(c.shutdownTimeoutMs).toBe(8000)
|
|
expect(c.sessionRetentionDays).toBe(7)
|
|
expect(c.argon2Concurrency).toBe(2)
|
|
expect(c.backupKeep).toBe(7)
|
|
expect(c.backupIntervalHours).toBe(0)
|
|
expect(c.corsOrigins).toBe('')
|
|
expect(c.trustProxy).toContain('10.0.0.0/8')
|
|
})
|
|
|
|
it('читает переопределения из env', () => {
|
|
const c = loadConfig({
|
|
...base,
|
|
NODE_ENV: 'test',
|
|
SHUTDOWN_TIMEOUT_MS: '1500',
|
|
SESSION_RETENTION_DAYS: '30',
|
|
ARGON2_CONCURRENCY: '4',
|
|
BACKUP_KEEP: '3',
|
|
BACKUP_INTERVAL_HOURS: '12',
|
|
CORS_ORIGINS: 'https://a.example',
|
|
TRUST_PROXY: 'false',
|
|
})
|
|
expect(c).toMatchObject({
|
|
shutdownTimeoutMs: 1500,
|
|
sessionRetentionDays: 30,
|
|
argon2Concurrency: 4,
|
|
backupKeep: 3,
|
|
backupIntervalHours: 12,
|
|
corsOrigins: 'https://a.example',
|
|
trustProxy: 'false',
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
['SHUTDOWN_TIMEOUT_MS', '0'],
|
|
['ARGON2_CONCURRENCY', '-1'],
|
|
['BACKUP_KEEP', 'abc'],
|
|
['BACKUP_INTERVAL_HOURS', '-5'],
|
|
])('невалидный %s=%s → ошибка', (key, value) => {
|
|
expect(() => loadConfig({ ...base, NODE_ENV: 'test', [key]: value })).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('production guard', () => {
|
|
it.each(['change-me-in-production', 'dev-secret-change-me', 'short-secret'])(
|
|
'слабый JWT_SECRET "%s" отклоняется',
|
|
(secret) => {
|
|
expect(() =>
|
|
loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }),
|
|
).toThrow(/JWT_SECRET/)
|
|
},
|
|
)
|
|
|
|
it('нормальный секрет в production принимается; в test слабый разрешён', () => {
|
|
expect(() =>
|
|
loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }),
|
|
).not.toThrow()
|
|
expect(() =>
|
|
loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }),
|
|
).not.toThrow()
|
|
})
|
|
|
|
it('пароль первого админа: слабый запрещён только в production', () => {
|
|
const prod = (pw?: string) =>
|
|
loadConfig({ NODE_ENV: 'production', ...base, ADMIN_PASSWORD: pw })
|
|
expect(() => assertBootstrapPassword(prod('admin'))).toThrow(/ADMIN_PASSWORD/)
|
|
expect(() => assertBootstrapPassword(prod(''))).toThrow(/ADMIN_PASSWORD/)
|
|
expect(() => assertBootstrapPassword(prod('short'))).toThrow(/ADMIN_PASSWORD/)
|
|
expect(() => assertBootstrapPassword(prod('long-enough-pw'))).not.toThrow()
|
|
expect(() =>
|
|
assertBootstrapPassword(loadConfig({ NODE_ENV: 'test', ...base })),
|
|
).not.toThrow()
|
|
})
|
|
|
|
it('buildApp в production без нормального пароля падает при создании админа', async () => {
|
|
await expect(
|
|
buildApp({
|
|
config: loadConfig({
|
|
NODE_ENV: 'production',
|
|
...base,
|
|
ADMIN_PASSWORD: 'admin',
|
|
DATABASE_URL: 'sqlite::memory:',
|
|
}),
|
|
databaseUrl: 'sqlite::memory:',
|
|
}),
|
|
).rejects.toThrow(/ADMIN_PASSWORD/)
|
|
})
|
|
})
|
|
|
|
describe('redact в логах', () => {
|
|
let app: ReturnType<typeof Fastify> | undefined
|
|
afterEach(async () => {
|
|
await app?.close()
|
|
})
|
|
|
|
it('authorization и cookie не попадают в лог', async () => {
|
|
const lines: string[] = []
|
|
const stream = new Writable({
|
|
write(chunk, _enc, cb) {
|
|
lines.push(String(chunk))
|
|
cb()
|
|
},
|
|
})
|
|
// Тот же набор путей, что и в buildApp.
|
|
app = Fastify({
|
|
logger: {
|
|
level: 'info',
|
|
stream,
|
|
redact: {
|
|
paths: ['req.headers.authorization', 'req.headers.cookie'],
|
|
censor: '[redacted]',
|
|
},
|
|
serializers: {
|
|
req: (req) => ({ method: req.method, headers: req.headers }),
|
|
},
|
|
},
|
|
})
|
|
app.get('/x', async () => ({ ok: true }))
|
|
await app.inject({
|
|
method: 'GET',
|
|
url: '/x',
|
|
headers: { authorization: 'Bearer SECRET', cookie: 'refresh_token=SECRET' },
|
|
})
|
|
const joined = lines.join('')
|
|
expect(joined).toContain('[redacted]')
|
|
expect(joined).not.toContain('SECRET')
|
|
})
|
|
})
|