import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { createOriginChecker } from '../src/lib/cors.js' import { loadConfig } from '../src/config.js' import { buildTestApp } from './helpers.js' let app: Awaited> | undefined afterEach(async () => { await app?.close() app = undefined }) function staticDir(): string { const dir = mkdtempSync(join(tmpdir(), 'ap-static-')) mkdirSync(join(dir, 'assets')) writeFileSync(join(dir, 'index.html'), 'spa') writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log(1)') return dir } describe('статика', () => { it('hashed-ассеты immutable, index.html no-cache', async () => { app = await buildTestApp({ STATIC_DIR: staticDir() }) const asset = await app.inject({ method: 'GET', url: '/assets/app-abc123.js' }) expect(asset.statusCode).toBe(200) expect(asset.headers['cache-control']).toBe( 'public, max-age=31536000, immutable', ) const index = await app.inject({ method: 'GET', url: '/index.html' }) expect(index.headers['cache-control']).toBe('no-cache') const spa = await app.inject({ method: 'GET', url: '/users/42' }) expect(spa.statusCode).toBe(200) expect(spa.headers['cache-control']).toBe('no-cache') }) it('отсутствующий ассет → 404, а не index.html (иначе после деплоя чанк-JS = HTML)', async () => { app = await buildTestApp({ STATIC_DIR: staticDir() }) const res = await app.inject({ method: 'GET', url: '/assets/missing.js' }) expect(res.statusCode).toBe(404) expect(res.body).not.toContain('') }) it('отдаёт предсжатый .br по Accept-Encoding', async () => { const dir = staticDir() writeFileSync(join(dir, 'assets', 'app-abc123.js.br'), 'BR-BYTES') app = await buildTestApp({ STATIC_DIR: dir }) const res = await app.inject({ method: 'GET', url: '/assets/app-abc123.js', headers: { 'accept-encoding': 'br' }, }) expect(res.headers['content-encoding']).toBe('br') expect(res.body).toBe('BR-BYTES') }) }) describe('кэш-заголовки API', () => { it('auth/config кэшируется, app-switcher — нет (админка должна видеть правки сразу)', async () => { app = await buildTestApp() const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' }) expect(sw.headers['cache-control']).toBeUndefined() const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' }) expect(cfg.headers['cache-control']).toBe('public, max-age=60') }) }) describe('health / ready', () => { it('/health не трогает БД и живёт после закрытия БД', async () => { app = await buildTestApp() app.sqlite.close() const res = await app.inject({ method: 'GET', url: '/health' }) expect(res.statusCode).toBe(200) }) it('/ready 200 в норме и 503 при недоступной БД', async () => { app = await buildTestApp() const ok = await app.inject({ method: 'GET', url: '/ready' }) expect(ok.statusCode).toBe(200) app.sqlite.close() const bad = await app.inject({ method: 'GET', url: '/ready' }) expect(bad.statusCode).toBe(503) expect(bad.json()).toEqual({ ok: false, reason: 'db' }) }) }) describe('CORS', () => { const origin = (url: string) => ({ origin: url }) it('разрешает issuer/allowlist, блокирует чужие', async () => { app = await buildTestApp({ RETURN_TO_ALLOWLIST: '.test.local,localhost' }) const allowed = await app.inject({ method: 'GET', url: '/api/v1/auth/config', headers: origin('https://auth.test.local'), }) expect(allowed.headers['access-control-allow-origin']).toBe( 'https://auth.test.local', ) expect(allowed.headers['access-control-allow-credentials']).toBe('true') const foreign = await app.inject({ method: 'GET', url: '/api/v1/auth/config', headers: origin('https://evil.example'), }) expect(foreign.headers['access-control-allow-origin']).toBeUndefined() }) it('CORS_ORIGINS добавляет origin; запрос без Origin проходит', async () => { app = await buildTestApp({ CORS_ORIGINS: 'https://extra.example' }) const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config', headers: origin('https://extra.example'), }) expect(res.headers['access-control-allow-origin']).toBe( 'https://extra.example', ) const none = await app.inject({ method: 'GET', url: '/api/v1/auth/config' }) expect(none.statusCode).toBe(200) }) it('боевой RETURN_TO_ALLOWLIST из env.traefik.example не теряет клиентов', () => { const list = '.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top' const check = createOriginChecker( loadConfig({ JWT_SECRET: 'test-secret-at-least-8', ISSUER: 'https://auth.shnt.top', RETURN_TO_ALLOWLIST: list, }), () => [], ) for (const o of [ 'https://vps.shnt.top', 'https://dns.shnt.top', 'https://shnt.top', 'https://anything.shnt.top', ]) { expect(check(o)).toBe(true) } expect(check('https://evil.example')).toBe(false) }) it('private в allowlist пускает LAN-хосты', () => { const check = createOriginChecker( loadConfig({ JWT_SECRET: 'test-secret-at-least-8', RETURN_TO_ALLOWLIST: '.shnt.top,private', }), () => [], ) expect(check('http://192.168.1.10:8080')).toBe(true) expect(check('http://10.0.0.5')).toBe(true) expect(check('https://8.8.8.8')).toBe(false) }) it('createOriginChecker: суффикс, localhost, switcher, *', () => { const base = { JWT_SECRET: 'test-secret-at-least-8', ISSUER: 'https://auth.test.local', RETURN_TO_ALLOWLIST: '.shnt.top,localhost,private', } const check = createOriginChecker(loadConfig(base), () => [ 'https://dns.custom.io/path', ]) expect(check('https://cfdm.shnt.top')).toBe(true) expect(check('http://localhost:5173')).toBe(true) expect(check('https://dns.custom.io')).toBe(true) expect(check('https://evilshnt.top')).toBe(false) expect(check('not a url')).toBe(false) const all = createOriginChecker( loadConfig({ ...base, CORS_ORIGINS: '*' }), () => [], ) expect(all('https://anything.example')).toBe(true) }) }) describe('сжатие в Node (ENABLE_COMPRESS)', () => { async function withBigRoute(env: NodeJS.ProcessEnv) { app = await buildTestApp(env) app.get('/__big', async () => ({ data: 'x'.repeat(5000) })) app.get('/__small', async () => ({ ok: true })) return app } it('по умолчанию выключено', async () => { const a = await withBigRoute({}) const res = await a.inject({ method: 'GET', url: '/__big', headers: { 'accept-encoding': 'gzip' }, }) expect(res.headers['content-encoding']).toBeUndefined() }) it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => { const a = await withBigRoute({ ENABLE_COMPRESS: 'true' }) const big = await a.inject({ method: 'GET', url: '/__big', headers: { 'accept-encoding': 'gzip' }, }) expect(big.headers['content-encoding']).toBe('gzip') expect(big.rawPayload.length).toBeLessThan(500) const small = await a.inject({ method: 'GET', url: '/__small', headers: { 'accept-encoding': 'gzip' }, }) expect(small.headers['content-encoding']).toBeUndefined() const none = await a.inject({ method: 'GET', url: '/__big' }) expect(none.headers['content-encoding']).toBeUndefined() }) }); describe('CSP', () => { it('отдаётся жёсткая политика без внешних источников', async () => { app = await buildTestApp() const res = await app.inject({ method: 'GET', url: '/health' }) const csp = String(res.headers['content-security-policy']) expect(csp).toContain("default-src 'self'") expect(csp).toContain("script-src 'self'") expect(csp).toContain("frame-ancestors 'none'") expect(csp).toContain("object-src 'none'") expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/) expect(csp).not.toContain('http:') }) })