Files
kvarta_project/apps/api/src/app.test.ts
T
nunuchek ea0535001c feat(api): rate-limit, honeypot, коды ошибок
rateLimit.ts: 5 запросов / 10 минут на IP в памяти, фиксированное окно,
чистка устаревших записей по ходу запросов, 429 с Retry-After. IP из
X-Real-IP только от loopback-сокета (плюс TRUSTED_PROXIES для шлюза
docker), иначе адрес сокета; в базу IP не пишем. Honeypot → 201 без
записи и уведомления. isPaused() → 503 на время восстановления базы.
Тесты на каждый код ответа из таблицы плана.
2026-10-17 15:41:04 +03:00

132 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { MAX_BODY_BYTES } from './app.js';
import { flush, setup, T0, validBody } from './testing.js';
describe('GET /api/health', () => {
it('отдаёт версию', async () => {
const { app } = setup({ version: 'abc123' });
const res = await app.request('/api/health');
expect(await res.json()).toEqual({ ok: true, version: 'abc123' });
});
});
describe('POST /api/lead', () => {
it('201: сохраняет заявку с нормализованным телефоном, касаниями и user agent', async () => {
const { db, post } = setup();
const res = await post(validBody(), { 'user-agent': 'Mozilla/5.0 test' });
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ ok: true, duplicate: false });
const row = db.prepare('SELECT * FROM leads').get() as Record<string, unknown>;
expect(row).toMatchObject({
id: 1,
created_at: T0.toISOString(),
phone: '+375291234567',
name: 'Аня',
interest: 'subscription',
comment: 'Под эспрессо',
user_agent: 'Mozilla/5.0 test',
tg_status: 'pending',
tg_attempts: 0,
});
expect(JSON.parse(row.first_touch as string)).toMatchObject({ utm_source: 'instagram' });
expect(JSON.parse(row.last_touch as string)).toMatchObject({ utm_source: 'yandex', submitPage: '/#form' });
});
it('номер заявки клиенту не отдаёт', async () => {
const { post } = setup();
const text = await (await post(validBody())).text();
expect(text).toBe('{"ok":true,"duplicate":false}');
});
it('notifier получает id заявки уже после ответа', async () => {
const { post, events } = setup();
await post(validBody());
expect(events).toEqual([]);
await flush();
expect(events).toEqual([{ kind: 'lead', leadId: 1 }]);
});
it('упавший notifier не ломает ответ', async () => {
const { post, db } = setup({
notifier: {
leadSaved: () => Promise.reject(new Error('telegram down')),
},
});
const res = await post(validBody());
await flush();
expect(res.status).toBe(201);
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 1 });
});
it('400 validation: ошибки по полям, ничего не пишет', async () => {
const { post, db, events } = setup();
const res = await post(validBody({ name: ' ', phone: '+7 999 123-45-67' }));
expect(res.status).toBe(400);
const body = (await res.json()) as { fieldErrors: Record<string, string> };
expect(body).toMatchObject({ ok: false, error: 'validation' });
expect(Object.keys(body.fieldErrors).sort()).toEqual(['name', 'phone']);
await flush();
expect(events).toEqual([]);
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
});
it('400 bad_request: битый JSON', async () => {
const { post } = setup();
const res = await post('{"name":');
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ ok: false, error: 'bad_request' });
});
it('415: не JSON', async () => {
const { post } = setup();
const res = await post('name=Аня', { 'content-type': 'application/x-www-form-urlencoded' });
expect(res.status).toBe(415);
expect(await res.json()).toEqual({ ok: false, error: 'bad_request' });
});
it('JSON с charset принимается', async () => {
const { post } = setup();
const res = await post(validBody(), { 'content-type': 'application/json; charset=utf-8' });
expect(res.status).toBe(201);
});
it('413: тело больше 16 КБ', async () => {
const { post, db } = setup();
const res = await post(validBody({ comment: 'а'.repeat(MAX_BODY_BYTES) }));
expect(res.status).toBe(413);
expect(await res.json()).toEqual({ ok: false, error: 'bad_request' });
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
});
it('honeypot: 201 как на обычную заявку, ничего не пишет, notifier молчит', async () => {
const { post, db, events } = setup();
const res = await post(validBody({ company: 'ООО Спам' }));
await flush();
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ ok: true, duplicate: false });
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
expect(events).toEqual([]);
});
it('503 unavailable: приём на паузе (восстановление базы)', async () => {
let paused = true;
const { post, db } = setup({ isPaused: () => paused });
const res = await post(validBody());
expect(res.status).toBe(503);
expect(await res.json()).toEqual({ ok: false, error: 'unavailable' });
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
paused = false;
expect((await post(validBody())).status).toBe(201);
});
it('500 server: исключение при записи', async () => {
const { post, db } = setup();
db.close();
const res = await post(validBody());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ ok: false, error: 'server' });
});
});