233 lines
8.5 KiB
TypeScript
233 lines
8.5 KiB
TypeScript
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<ReturnType<typeof buildTestApp>> | 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'), '<html>spa</html>')
|
|
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('<html>')
|
|
})
|
|
|
|
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:')
|
|
})
|
|
})
|