import { afterEach, describe, expect, it, vi } 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'])( 'известный дефолт JWT_SECRET "%s" отклоняется', (secret) => { expect(() => loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }), ).toThrow(/JWT_SECRET/) }, ) it('короткий, но уникальный секрет в production не роняет старт (только предупреждение)', () => { const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) expect(() => loadConfig({ NODE_ENV: 'production', JWT_SECRET: 'short-secret' }), ).not.toThrow() expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET')) warn.mockRestore() }) it('нормальный секрет в production принимается без предупреждений; в test слабый разрешён', () => { const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) expect(() => loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }), ).not.toThrow() expect(warn).not.toHaveBeenCalled() expect(() => loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }), ).not.toThrow() warn.mockRestore() }) 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 | 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') }) })