From ed5b3c8367c62f07051a3b26a7e65848f3ea9b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 30 May 2026 19:44:37 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20comprehensive=20API=20tests=20(104=20pa?= =?UTF-8?q?ss)=20+=20fix:=20validation=20errors=20=E2=86=92=20400,=20comme?= =?UTF-8?q?nt=20length=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/routes/entries.js | 14 + tests/api.js | 903 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 917 insertions(+) create mode 100644 tests/api.js diff --git a/src/api/routes/entries.js b/src/api/routes/entries.js index 96852b2..7f48a17 100644 --- a/src/api/routes/entries.js +++ b/src/api/routes/entries.js @@ -10,6 +10,7 @@ function createEntriesRouter({ q }) { // Маппинг сообщений ошибок на HTTP-коды function handleQueryError(e, res) { const msg = e.message || ''; + // 409: бизнес-конфликты (дубликат, пересечение, лимит) if ( msg.includes('Лимит исчерпан') || msg.includes('Такой адрес уже существует') || @@ -17,9 +18,20 @@ function createEntriesRouter({ q }) { ) { return res.status(409).json({ error: msg }); } + // 404: запись не найдена if (msg.includes('Запись не найдена')) { return res.status(404).json({ error: msg }); } + // 400: ошибки валидации от validate() и blocked-ranges + if ( + msg.includes('Некорректн') || + msg.includes('Маска должна быть') || + msg.includes('Пустое значение') || + msg.includes('IPv6 не поддерживается') || + msg.includes('пересекается с запрещённым') + ) { + return res.status(400).json({ error: msg }); + } return res.status(500).json({ error: msg }); } @@ -80,6 +92,7 @@ function createEntriesRouter({ q }) { router.post('/', json, async (req, res) => { const { value, comment } = req.body || {}; if (!value) return res.status(400).json({ error: 'value required' }); + if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' }); try { const company = await resolveCompany(req); const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email); @@ -94,6 +107,7 @@ function createEntriesRouter({ q }) { router.patch('/:id', json, async (req, res) => { const { value, comment } = req.body || {}; if (!value) return res.status(400).json({ error: 'value required' }); + if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' }); try { const company = await resolveCompany(req); const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email); diff --git a/tests/api.js b/tests/api.js new file mode 100644 index 0000000..143c75f --- /dev/null +++ b/tests/api.js @@ -0,0 +1,903 @@ +'use strict'; + +/** + * tests/api.js — интеграционные тесты REST API /api/v1/* против реальной БД. + * + * Запуск: node tests/api.js + * Требует: .env с данными БД, DEV_MODE=true. + * + * В отличие от tests/integration.js — тестирует API-слой напрямую + * через Bearer JWT (без сессий, без CSRF, без HTML-форм). + * + * Разделы: + * A. Auth — no/invalid token, 401 + * B. Entries CRUD — user: создать, читать, обновить, удалить + * C. Валидация — неверный CIDR, дубликат, пересечение, нет value + * D. Нормализация — 9.9.9.5/24 → 9.9.9.0/24 + * E. Изоляция — user2 не видит/меняет записи user + * F. Export — text/plain, изоляция + * G. Admin /companies — список, лимит + * H. Admin /audit — журнал + * I. Лимит — enforcement через API + * J. Безопасность — XSS, длинный comment, пустой comment + * K. Граничные значения — /0, /32, /22, маска > 32, IPv6 + * L. Admin — CRUD по чужой компании (?company=) + * M. Admin ACL — user не может вызвать admin-эндпоинты + */ + +require('dotenv').config(); + +const supertest = require('supertest'); +const { pool } = require('../src/db'); + +let pass = 0, fail = 0; +const errors = []; + +function log(ok, name, detail = '') { + if (ok) { + console.log(' PASS', name); + pass++; + } else { + const msg = ' FAIL ' + name + (detail ? ': ' + detail : ''); + console.error(msg); + errors.push(msg); + fail++; + } +} + +// ── Хелперы ────────────────────────────────────────────────────────────────── + +function bearer(token) { + return { Authorization: 'Bearer ' + token }; +} + +/** Получить body как JSON, независимо от Content-Type */ +function json(res) { + if (typeof res.body === 'object' && res.body !== null) return res.body; + try { return JSON.parse(res.text); } catch { return {}; } +} + +// ── Тестовые CIDR (публичные диапазоны, не попадающие в BLOCKED_RANGES) ──────── +// BLOCKED_RANGES включает: 10/8, 172.16/12, 192.168/16, 100.64/10, 127/8, +// 169.254/16, 192.0.0/24, 192.0.2/24, 198.51.100/24, 203.0.113/24, +// 198.18/15, 224/4, 240/4, 255.255.255.255/32 +const C = { + A: '8.8.4.1', // /32 хост (Google DNS range — публичный) + B: '8.8.4.2', + C: '8.8.4.3', + D: '8.8.4.4', + E: '8.8.4.5', + F: '8.8.4.6', + G: '8.8.4.7', + SUBNET: '9.9.9.5/24', // нормализуется в 9.9.9.0/24 (Quad9, публичный) + OVERLAP: '9.9.9.100', // пересекается с 9.9.9.0/24 + ADMIN: '1.1.1.100', // admin добавляет в компанию user (Cloudflare range) + XSS: '1.1.1.10', + LIMIT: '1.1.1.20', + EXPORT: '1.1.1.30', + SLASH32: '1.2.0.1/32', // явный /32 + SLASH22: '1.2.4.0/22', // /22 подсеть (не пересекается с SLASH32) + SLASH0: '0.0.0.0/0', // маска < /22 → ошибка валидации + EMPTY_C: '1.1.1.40', + LONG_C: '1.1.1.50', + NULL_C: '1.1.1.60', + BLOCKED: '192.168.1.1', // заблокированный приватный диапазон → 400 + RACE1: '8.8.5.1', + RACE2: '8.8.5.2', + RACE3: '8.8.5.3', + RACE4: '8.8.5.4', + RACE5: '8.8.5.5', +}; + +// CIDR как они хранятся в БД (с /32 для хостов) +function cidr(ip) { + return ip.includes('/') ? ip : ip + '/32'; +} + +// Нормализованный CIDR (host-биты обнулены) +function normCidr(ipCidr) { + // для 9.9.9.5/24 → 9.9.9.0/24 + if (ipCidr === C.SUBNET) return '9.9.9.0/24'; + return cidr(ipCidr); +} + +// ── Cleanup ─────────────────────────────────────────────────────────────────── +async function cleanup() { + const allCidrs = Object.values(C) + .filter(c => !['0.0.0.0/0', '192.168.1.1'].includes(c)) // нельзя добавить → не нужно чистить + .map(c => c.includes('/') ? c : c + '/32'); + // Нормализованная копия для SUBNET + allCidrs.push('9.9.9.0/24'); + // Явный /32 для SLASH32 + allCidrs.push('1.2.0.1/32'); + // /22 + allCidrs.push('1.2.4.0/22'); + + for (const c of allCidrs) { + await pool.query( + `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'api-test-cleanup' + WHERE value_cidr = $1 AND deleted_at IS NULL`, [c] + ); + } +} + +// ── Основной тест-раннер ────────────────────────────────────────────────────── + +(async () => { + console.log('\n══════════════════════════════════════════════════════'); + console.log(' API тесты /api/v1/* (Bearer JWT, реальная БД)'); + console.log('══════════════════════════════════════════════════════\n'); + + // ── Запускаем сервер ───────────────────────────────────────────────────────── + let app, auth; + try { + const mod = require('../server'); + app = await mod.start(); + // Инициализируем auth отдельно, чтобы выпустить токены + auth = await require('../src/auth').initAuth(); + } catch (e) { + console.error('FATAL: не удалось запустить сервер:', e.message); + await pool.end(); + process.exit(1); + } + + const req = supertest(app); + + // ── Токены для трёх пользователей ─────────────────────────────────────────── + const { MOCK_USERS } = require('../src/config'); + const uAdmin = MOCK_USERS.find(u => u.id === 'admin'); + const uUser = MOCK_USERS.find(u => u.id === 'test'); + const uUser2 = MOCK_USERS.find(u => u.id === 'client2'); + + const tokenAdmin = auth.issueMockToken({ clientId: uAdmin.clientId, companyId: uAdmin.companyId, companyName: uAdmin.companyName, email: uAdmin.email }); + const tokenUser = auth.issueMockToken({ clientId: uUser.clientId, companyId: uUser.companyId, companyName: uUser.companyName, email: uUser.email }); + const tokenUser2 = auth.issueMockToken({ clientId: uUser2.clientId, companyId: uUser2.companyId, companyName: uUser2.companyName, email: uUser2.email }); + + log(!!tokenAdmin && !!tokenUser && !!tokenUser2, 'setup: выпустили токены для admin, user, user2'); + + await cleanup(); + console.log('── Cleanup done ──\n'); + + // ══════════════════════════════════════════════════════════════════════════════ + // A. Auth — отсутствие / невалидный токен + // ══════════════════════════════════════════════════════════════════════════════ + console.log('── A. Auth ──'); + + { + const r = await req.get('/api/v1/entries'); + log(r.status === 401, 'A1: GET /entries без токена → 401', `status=${r.status}`); + log(!!json(r).error, 'A2: тело содержит error-поле'); + } + + { + const r = await req.get('/api/v1/entries').set('Authorization', 'Bearer broken.token.here'); + log(r.status === 401, 'A3: GET /entries с невалидным токеном → 401', `status=${r.status}`); + } + + { + const r = await req.get('/api/v1/entries').set('Authorization', 'Basic dXNlcjpwYXNz'); + log(r.status === 401, 'A4: Basic auth → 401 (ожидается Bearer)', `status=${r.status}`); + } + + { + const r = await req.post('/api/v1/entries').set(bearer(tokenUser)); + // Нет body — 400, но не 401 + log(r.status !== 401, 'A5: POST /entries с валидным токеном → не 401', `status=${r.status}`); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // B. Entries CRUD — базовый цикл + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── B. Entries CRUD ──'); + + // B1: GET — пустой список (до добавления) + { + const r = await req.get('/api/v1/entries').set(bearer(tokenUser)); + log(r.status === 200, 'B1: GET /entries → 200', `status=${r.status}`); + const b = json(r); + log(Array.isArray(b.entries), 'B2: тело содержит entries[]'); + log(typeof b.limit === 'number', 'B3: тело содержит limit (число)', `limit=${b.limit}`); + log(typeof b.used === 'number', 'B4: тело содержит used (число)', `used=${b.used}`); + log(b.used === b.entries.length, 'B5: used === entries.length'); + } + + // B6: POST — создать запись + let entryId; + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)) + .send({ value: C.A, comment: 'api-тест A' }); + log(r.status === 201, 'B6: POST /entries → 201', `status=${r.status} body=${JSON.stringify(json(r)).slice(0,120)}`); + const b = json(r); + if (r.status === 201 && b.entry) { + log(!!b.entry.id, 'B7: ответ содержит entry.id'); + log(b.entry.value_cidr === cidr(C.A), 'B8: entry.value_cidr верный', `got=${b.entry.value_cidr}`); + log(b.entry.comment === 'api-тест A', 'B9: entry.comment сохранён'); + log(b.wasNormalized === false, 'B10: wasNormalized=false для хост-адреса', `got=${b.wasNormalized}`); + entryId = b.entry.id; + } else { + log(false, 'B7: ответ содержит entry.id'); log(false, 'B8: entry.value_cidr верный'); + log(false, 'B9: entry.comment сохранён'); log(false, 'B10: wasNormalized=false'); + } + } + + // B11: GET — запись появилась в списке + { + const r = await req.get('/api/v1/entries').set(bearer(tokenUser)); + const b = json(r); + const found = b.entries?.find(e => e.id === entryId); + log(!!found, 'B11: созданная запись появилась в GET /entries'); + log(b.used >= 1, 'B12: used >= 1 после добавления', `used=${b.used}`); + } + + // B13: PATCH — обновить запись + if (entryId) { + const r = await req.patch('/api/v1/entries/' + entryId) + .set(bearer(tokenUser)) + .send({ value: C.B, comment: 'обновлено' }); + log(r.status === 200, 'B13: PATCH /entries/:id → 200', `status=${r.status}`); + const b = json(r); + log(b.entry?.value_cidr === cidr(C.B), 'B14: value_cidr обновлён', `got=${b.entry?.value_cidr}`); + log(b.entry?.comment === 'обновлено', 'B15: comment обновлён'); + } else { log(false,'B13: PATCH'); log(false,'B14: value_cidr'); log(false,'B15: comment'); } + + // B16: DELETE — удалить запись + { + const r = await req.delete('/api/v1/entries/' + entryId).set(bearer(tokenUser)); + log([200, 204].includes(r.status), 'B16: DELETE /entries/:id → 200/204', `status=${r.status}`); + } + + // B17: запись не видна после удаления + { + const r = await req.get('/api/v1/entries').set(bearer(tokenUser)); + const b = json(r); + const found = b.entries?.find(e => e.id === entryId); + log(!found, 'B17: удалённая запись не в списке GET /entries'); + } + + // B18: soft delete — deleted_at выставлен в БД + if (entryId) { + const row = await pool.query('SELECT deleted_at FROM whitelist_entries WHERE id = $1', [entryId]); + log(!!row.rows[0]?.deleted_at, 'B18: soft delete — deleted_at выставлен в БД'); + } + + // B19: DELETE несуществующей (уже удалена) → 404 + { + const r = await req.delete('/api/v1/entries/' + entryId).set(bearer(tokenUser)); + log(r.status === 404, 'B19: повторный DELETE → 404', `status=${r.status}`); + } + + // B20: PATCH несуществующей → 404 + { + const r = await req.patch('/api/v1/entries/' + entryId) + .set(bearer(tokenUser)) + .send({ value: C.C, comment: '' }); + log(r.status === 404, 'B20: PATCH удалённой → 404', `status=${r.status}`); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // C. Валидация + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── C. Валидация ──'); + + // C1: нет value → 400 + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)) + .send({ comment: 'нет ip' }); + log(r.status === 400, 'C1: POST без value → 400', `status=${r.status}`); + } + + // C2: пустой value → 400 или ошибка валидации + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)) + .send({ value: '', comment: '' }); + log([400, 409, 422].includes(r.status), 'C2: POST с value="" → не 201', `status=${r.status}`); + } + + // C3: текст вместо IP → 400/409 + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)) + .send({ value: 'not-an-ip', comment: '' }); + log([400, 409, 422].includes(r.status), 'C3: POST "not-an-ip" → не 201', `status=${r.status}`); + log(!!json(r).error, 'C4: тело содержит error'); + } + + // C5: октет > 255 + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)) + .send({ value: '300.0.0.1' }); + log([400, 409, 422].includes(r.status), 'C5: 300.0.0.1 → не 201', `status=${r.status}`); + } + + // C6: маска > 32 + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)) + .send({ value: '10.0.0.1/33' }); + log([400, 409, 422].includes(r.status), 'C6: /33 → не 201', `status=${r.status}`); + } + + // C7: маска < 0 (negative) + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)) + .send({ value: '10.0.0.1/-1' }); + log([400, 409, 422].includes(r.status), 'C7: /-1 → не 201', `status=${r.status}`); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // D. Нормализация CIDR + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── D. Нормализация ──'); + + // D1: 9.9.9.5/24 → 9.9.9.0/24 + let subnetEntryId; + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)) + .send({ value: C.SUBNET, comment: 'нормализация' }); + log(r.status === 201, 'D1: POST 9.9.9.5/24 → 201', `status=${r.status} body=${JSON.stringify(json(r)).slice(0,120)}`); + const b = json(r); + log(b.wasNormalized === true, 'D2: wasNormalized=true', `got=${b.wasNormalized}`); + log(b.entry?.value_cidr === '9.9.9.0/24', 'D3: нормализован → 9.9.9.0/24', `got=${b.entry?.value_cidr}`); + subnetEntryId = b.entry?.id; + } + + // D4: дубликат (сам себя) → 409 + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)) + .send({ value: C.SUBNET, comment: 'дубликат' }); + log(r.status === 409, 'D4: дубликат нормализованного → 409', `status=${r.status}`); + log(json(r).error?.includes('уже существует'), 'D5: error содержит "уже существует"'); + } + + // D6: пересечение — 9.9.9.100 ⊂ 9.9.9.0/24 → 409 + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)) + .send({ value: C.OVERLAP, comment: 'пересечение' }); + log(r.status === 409, 'D6: пересекающийся CIDR → 409', `status=${r.status}`); + log(json(r).error?.includes('Пересечение'), 'D7: error содержит "Пересечение"'); + } + + // Удаляем subnet для чистоты + if (subnetEntryId) { + await req.delete('/api/v1/entries/' + subnetEntryId).set(bearer(tokenUser)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // E. Изоляция между компаниями + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── E. Изоляция ──'); + + // Добавляем запись под user + let isoEntryId; + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)) + .send({ value: C.C, comment: 'isolation test' }); + isoEntryId = json(r).entry?.id; + log(r.status === 201 && !!isoEntryId, 'E1: user создал запись для теста изоляции'); + } + + // E2: user2 не видит записи user в GET /entries + { + const r = await req.get('/api/v1/entries').set(bearer(tokenUser2)); + const b = json(r); + const found = b.entries?.find(e => e.id === isoEntryId); + log(!found, 'E2: user2 не видит запись user в своём GET /entries'); + } + + // E3: user2 не может PATCH чужой записи → 404 (запись не найдена в его компании) + { + const r = await req.patch('/api/v1/entries/' + isoEntryId) + .set(bearer(tokenUser2)) + .send({ value: C.D, comment: 'IDOR' }); + log(r.status === 404, 'E3: user2 PATCH чужой записи → 404', `status=${r.status}`); + } + + // E4: user2 не может DELETE чужой записи → 404 + { + const r = await req.delete('/api/v1/entries/' + isoEntryId).set(bearer(tokenUser2)); + log(r.status === 404, 'E4: user2 DELETE чужой записи → 404', `status=${r.status}`); + } + + // Убедимся что запись user цела + { + const row = await pool.query('SELECT deleted_at FROM whitelist_entries WHERE id = $1', [isoEntryId]); + log(!row.rows[0]?.deleted_at, 'E5: запись user жива после атак user2'); + } + + // Чистим + await req.delete('/api/v1/entries/' + isoEntryId).set(bearer(tokenUser)); + + // ══════════════════════════════════════════════════════════════════════════════ + // F. Export + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── F. Export ──'); + + // Добавляем запись для export-теста + { + await req.post('/api/v1/entries').set(bearer(tokenUser)).send({ value: C.EXPORT, comment: 'export' }); + } + + // F1: GET /api/v1/entries/export → 200 text/plain + { + const r = await req.get('/api/v1/entries/export').set(bearer(tokenUser)); + log(r.status === 200, 'F1: GET /export → 200', `status=${r.status}`); + log(r.headers['content-type']?.includes('text/plain'), 'F2: Content-Type: text/plain'); + log(r.text.includes(cidr(C.EXPORT)), 'F3: экспорт содержит добавленный CIDR', `text=${r.text.slice(0,100)}`); + } + + // F4: user export не содержит записи user2 + { + await req.post('/api/v1/entries').set(bearer(tokenUser2)).send({ value: C.D, comment: 'u2-export' }); + const rUser = await req.get('/api/v1/entries/export').set(bearer(tokenUser)); + const rUser2 = await req.get('/api/v1/entries/export').set(bearer(tokenUser2)); + log(!rUser.text.includes(cidr(C.D)), 'F4: экспорт user не содержит CIDR user2'); + log(!rUser2.text.includes(cidr(C.EXPORT)), 'F5: экспорт user2 не содержит CIDR user'); + await req.delete('/api/v1/entries/' + ( + (await pool.query(`SELECT id FROM whitelist_entries WHERE value_cidr=$1 AND deleted_at IS NULL`, [cidr(C.D)])).rows[0]?.id + )).set(bearer(tokenUser2)); + } + + // F6: удалённый CIDR не появляется в export + { + const exportId = (await pool.query( + `SELECT id FROM whitelist_entries WHERE value_cidr=$1 AND deleted_at IS NULL`, [cidr(C.EXPORT)] + )).rows[0]?.id; + if (exportId) { + await req.delete('/api/v1/entries/' + exportId).set(bearer(tokenUser)); + const r = await req.get('/api/v1/entries/export').set(bearer(tokenUser)); + log(!r.text.includes(cidr(C.EXPORT)), 'F6: удалённый CIDR не в export'); + } else { + log(false, 'F6: не нашли запись для удаления'); + } + } + + // F7: export без токена → 401 + { + const r = await req.get('/api/v1/entries/export'); + log(r.status === 401, 'F7: export без токена → 401', `status=${r.status}`); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // G. Admin /companies + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── G. Admin /companies ──'); + + // G1: admin получает список компаний + let userCompanyId; + { + const r = await req.get('/api/v1/companies').set(bearer(tokenAdmin)); + log(r.status === 200, 'G1: GET /companies (admin) → 200', `status=${r.status}`); + const b = json(r); + log(Array.isArray(b.companies), 'G2: тело содержит companies[]'); + log(b.companies?.length >= 1, 'G3: хотя бы одна компания', `count=${b.companies?.length}`); + const comp = b.companies?.find(c => c.client_id === uUser.clientId); + log(!!comp, 'G4: компания user присутствует в списке', `clientId=${uUser.clientId}`); + userCompanyId = comp?.id; + } + + // G5: user не может получить список компаний → 403 + { + const r = await req.get('/api/v1/companies').set(bearer(tokenUser)); + log(r.status === 403, 'G5: GET /companies (user) → 403', `status=${r.status}`); + } + + // G6: установить лимит + { + const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit') + .set(bearer(tokenAdmin)) + .send({ limit: 50 }); + log(r.status === 200, 'G6: PATCH /companies/:id/limit → 200', `status=${r.status}`); + log(json(r).limit === 50, 'G7: лимит в ответе = 50', `got=${json(r).limit}`); + } + + // G8: проверить лимит применился + { + const r = await req.get('/api/v1/entries').set(bearer(tokenUser)); + log(json(r).limit === 50, 'G8: GET /entries возвращает обновлённый limit=50', `got=${json(r).limit}`); + } + + // G9: невалидный лимит — отрицательный → 400 + { + const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit') + .set(bearer(tokenAdmin)) + .send({ limit: -1 }); + log(r.status === 400, 'G9: limit=-1 → 400', `status=${r.status}`); + } + + // G10: невалидный лимит — строка → 400 + { + const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit') + .set(bearer(tokenAdmin)) + .send({ limit: 'abc' }); + log(r.status === 400, 'G10: limit="abc" → 400', `status=${r.status}`); + } + + // G11: user не может менять лимиты → 403 + { + const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit') + .set(bearer(tokenUser)) + .send({ limit: 5 }); + log(r.status === 403, 'G11: PATCH /limit (user) → 403', `status=${r.status}`); + } + + // Сбрасываем лимит обратно (NULL = дефолт) + await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]); + + // ══════════════════════════════════════════════════════════════════════════════ + // H. Admin /audit + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── H. Admin /audit ──'); + + // H1: admin получает журнал + { + const r = await req.get('/api/v1/audit').set(bearer(tokenAdmin)); + log(r.status === 200, 'H1: GET /audit (admin) → 200', `status=${r.status}`); + const b = json(r); + log(Array.isArray(b.rows), 'H2: тело содержит rows[]'); + log(Array.isArray(b.companies), 'H3: тело содержит companies[]'); + } + + // H4: admin фильтрует по компании + { + const r = await req.get('/api/v1/audit?company=' + userCompanyId).set(bearer(tokenAdmin)); + log(r.status === 200, 'H4: GET /audit?company= → 200', `status=${r.status}`); + const rows = json(r).rows || []; + const allForCompany = rows.every(row => row.company_id === userCompanyId); + log(allForCompany || rows.length === 0, 'H5: все записи аудита для указанной компании'); + } + + // H6: user не видит аудит → 403 + { + const r = await req.get('/api/v1/audit').set(bearer(tokenUser)); + log(r.status === 403, 'H6: GET /audit (user) → 403', `status=${r.status}`); + } + + // H7: операции записаны в audit_log + { + const rows = (await pool.query( + `SELECT action FROM audit_log WHERE company_id = $1 ORDER BY created_at DESC LIMIT 20`, [userCompanyId] + )).rows; + const actions = rows.map(r => r.action); + log(actions.some(a => /create/i.test(a)), 'H7: CREATE записан в audit_log', `actions=${JSON.stringify(actions.slice(0,5))}`); + log(actions.some(a => /delete/i.test(a)), 'H8: DELETE записан в audit_log'); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // I. Лимит — enforcement + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── I. Лимит enforcement ──'); + + // I1: лимит = 0 → любая вставка → 409 + { + await req.patch('/api/v1/companies/' + userCompanyId + '/limit') + .set(bearer(tokenAdmin)).send({ limit: 0 }); + + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: C.LIMIT, comment: 'лимит=0' }); + log(r.status === 409, 'I1: лимит=0, POST /entries → 409', `status=${r.status}`); + log(json(r).error?.includes('Лимит'), 'I2: error содержит "Лимит"'); + + await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]); + } + + // I3: лимит = currentCount → новая запись → 409 + { + // Считаем текущее количество + const cnt = (await pool.query( + `SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`, + [userCompanyId] + )).rows[0].c; + + await req.patch('/api/v1/companies/' + userCompanyId + '/limit') + .set(bearer(tokenAdmin)).send({ limit: cnt }); + + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: C.LIMIT, comment: 'лимит=count' }); + log(r.status === 409, 'I3: лимит=currentCount, POST → 409', `status=${r.status} cnt=${cnt}`); + + await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]); + } + + // I4: снижение лимита не удаляет существующие записи + { + // Добавим одну запись + const addR = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: C.E, comment: 'limit-shrink' }); + const eid = json(addR).entry?.id; + + // Ставим лимит = 0 (ниже существующего количества) + await req.patch('/api/v1/companies/' + userCompanyId + '/limit') + .set(bearer(tokenAdmin)).send({ limit: 0 }); + + // Запись всё ещё видна в списке + const listR = await req.get('/api/v1/entries').set(bearer(tokenUser)); + const found = json(listR).entries?.find(e => e.id === eid); + log(!!found, 'I4: снижение лимита не удаляет существующие записи'); + + // Сброс + чистка + await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]); + if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // J. Безопасность — XSS, длинный и пустой comment + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── J. Безопасность ──'); + + // J1: XSS в comment — принимается, но в API возвращается без исполнения + { + const xssPayload = ''; + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: C.XSS, comment: xssPayload }); + // API принимает (не валидирует comment на XSS — это задача шаблона) + if (r.status === 201) { + const b = json(r); + // В JSON-ответе скрипт не должен быть исполнен — это просто строка + log(b.entry?.comment === xssPayload, 'J1: XSS в comment сохранён как plain string'); + await req.delete('/api/v1/entries/' + b.entry.id).set(bearer(tokenUser)); + } else { + log(r.status !== 500, 'J1: XSS в comment → не 500', `status=${r.status}`); + } + } + + // J2: пустой comment → 201 (comment необязателен) + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: C.EMPTY_C, comment: '' }); + log(r.status === 201, 'J2: пустой comment → 201', `status=${r.status}`); + const eid = json(r).entry?.id; + if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser)); + } + + // J3: очень длинный comment → 400 (> 255 символов) + { + const longComment = 'X'.repeat(10000); + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: C.LONG_C, comment: longComment }); + log(r.status === 400, 'J3: длинный comment (10k) → 400', `status=${r.status}`); + log(json(r).error?.includes('длинный') || json(r).error?.includes('255'), 'J3b: error указывает на длину', `err=${json(r).error}`); + } + + // J4: null comment → не 500 + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: C.NULL_C, comment: null }); + log(r.status !== 500, 'J4: null comment → не 500', `status=${r.status}`); + const eid = json(r).entry?.id; + if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser)); + } + + // J5: SQL injection в value → 400 (валидатор отклоняет как невалидный IP) + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: "1.2.3.4'; DROP TABLE companies;--", comment: '' }); + log([400, 409, 422].includes(r.status), 'J5: SQL injection в value → не 201 и не 500', `status=${r.status}`); + } + + // J6: заблокированный диапазон → 400 (не 500) + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: C.BLOCKED, comment: 'blocked' }); + log(r.status === 400, 'J6: приватный IP (BLOCKED_RANGES) → 400', `status=${r.status}`); + log(json(r).error?.includes('запрещённым'), 'J7: error содержит "запрещённым"', `err=${json(r).error}`); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // K. Граничные значения CIDR + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── K. Граничные CIDR ──'); + + // K1: /32 — одиночный хост без маски + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: C.SLASH32, comment: '/32 без маски' }); + log(r.status === 201, 'K1: хост без /32 → 201', `status=${r.status}`); + log(json(r).entry?.value_cidr === C.SLASH32, 'K2: /32 сохранён как есть', `got=${json(r).entry?.value_cidr}`); + const eid = json(r).entry?.id; + if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser)); + } + + // K3: /22 — подсеть, должна приниматься + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: C.SLASH22, comment: '/22' }); + log([201, 409].includes(r.status), 'K3: /22 → принят или 409 (пересечение)', `status=${r.status}`); + const eid = json(r).entry?.id; + if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser)); + } + + // K4: 0.0.0.0/0 — маска /0 < /22 → 400 + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: C.SLASH0, comment: '0.0.0.0/0' }); + log(r.status === 400, 'K4: 0.0.0.0/0 (маска /0 < /22) → 400', `status=${r.status}`); + log(json(r).error?.includes('Маска'), 'K4b: error содержит "Маска"', `err=${json(r).error}`); + } + + // K5: IPv6 → отклоняется (только IPv4), 400 + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: '2001:db8::1', comment: 'ipv6' }); + log(r.status === 400, 'K5: IPv6 → 400', `status=${r.status}`); + log(json(r).error?.includes('IPv6'), 'K5b: error содержит "IPv6"', `err=${json(r).error}`); + } + + // K6: localhost (127.0.0.1) → заблокирован → 400 + { + const r = await req.post('/api/v1/entries') + .set(bearer(tokenUser)).send({ value: '127.0.0.1', comment: 'localhost' }); + log(r.status === 400, 'K6: 127.0.0.1 (заблокирован) → 400', `status=${r.status}`); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // L. Admin — CRUD по чужой компании (?company=) + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── L. Admin CRUD по чужой компании ──'); + + // L1: admin добавляет запись в компанию user + let adminAddedId; + { + const r = await req.post('/api/v1/entries?company=' + userCompanyId) + .set(bearer(tokenAdmin)) + .send({ value: C.ADMIN, comment: 'от admin' }); + log(r.status === 201, 'L1: admin POST /entries?company= → 201', `status=${r.status}`); + const b = json(r); + // Запись должна оказаться в компании user, не admin + adminAddedId = b.entry?.id; + if (adminAddedId) { + const row = await pool.query('SELECT company_id FROM whitelist_entries WHERE id = $1', [adminAddedId]); + log(row.rows[0]?.company_id === userCompanyId, 'L2: запись сохранена в компанию user', `company_id=${row.rows[0]?.company_id}`); + } else { + log(false, 'L2: нет adminAddedId'); + } + } + + // L3: admin видит запись через GET /entries?company= + { + const r = await req.get('/api/v1/entries?company=' + userCompanyId).set(bearer(tokenAdmin)); + log(r.status === 200, 'L3: admin GET /entries?company= → 200', `status=${r.status}`); + const found = json(r).entries?.find(e => e.id === adminAddedId); + log(!!found, 'L4: admin видит запись компании user', `adminAddedId=${adminAddedId}`); + } + + // L5: admin обновляет запись в компании user + { + const r = await req.patch('/api/v1/entries/' + adminAddedId + '?company=' + userCompanyId) + .set(bearer(tokenAdmin)) + .send({ value: C.G, comment: 'обновлено admin' }); + log(r.status === 200, 'L5: admin PATCH /entries/:id?company= → 200', `status=${r.status}`); + } + + // L6: admin удаляет запись из компании user + { + const r = await req.delete('/api/v1/entries/' + adminAddedId + '?company=' + userCompanyId) + .set(bearer(tokenAdmin)); + log([200, 204].includes(r.status), 'L6: admin DELETE /entries/:id?company= → 200/204', `status=${r.status}`); + } + + // L7: user не может использовать ?company=<чужой> → запись попадает в свою компанию + // Используем компанию user2 (создана в разделе F) + { + const user2Comp = (await pool.query( + `SELECT id FROM companies WHERE client_id = $1`, [uUser2.clientId] + )).rows[0]?.id; + + if (user2Comp) { + const cntBefore = (await pool.query( + `SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`, [user2Comp] + )).rows[0].c; + + const r = await req.post('/api/v1/entries?company=' + user2Comp) + .set(bearer(tokenUser)) + .send({ value: C.D, comment: 'IDOR через ?company' }); + + const cntAfter = (await pool.query( + `SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`, [user2Comp] + )).rows[0].c; + + log(cntBefore === cntAfter, 'L7: user не смог добавить в компанию user2 через ?company=', + `before=${cntBefore} after=${cntAfter}`); + + await pool.query( + `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'api-test-cleanup' + WHERE value_cidr = $1 AND company_id = $2 AND deleted_at IS NULL`, [cidr(C.D), user2Comp] + ); + } else { + log(false, 'L7: не нашли компанию user2'); + } + } + + // ══════════════════════════════════════════════════════════════════════════════ + // M. Admin ACL — user не вызывает admin-эндпоинты + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── M. Admin ACL ──'); + + { + const r = await req.get('/api/v1/companies').set(bearer(tokenUser)); + log(r.status === 403, 'M1: GET /companies (user) → 403', `status=${r.status}`); + } + + { + const r = await req.patch('/api/v1/companies/1/limit') + .set(bearer(tokenUser)).send({ limit: 100 }); + log(r.status === 403, 'M2: PATCH /limit (user) → 403', `status=${r.status}`); + } + + { + const r = await req.get('/api/v1/audit').set(bearer(tokenUser)); + log(r.status === 403, 'M3: GET /audit (user) → 403', `status=${r.status}`); + } + + { + const r = await req.get('/api/v1/companies').set(bearer(tokenUser2)); + log(r.status === 403, 'M4: GET /companies (user2) → 403', `status=${r.status}`); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // N. Race condition — параллельные вставки при лимите + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── N. Race condition ──'); + + { + const cnt = (await pool.query( + `SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`, + [userCompanyId] + )).rows[0].c; + + // Лимит = текущее количество + 1 → только одна из 5 параллельных вставок пройдёт + await req.patch('/api/v1/companies/' + userCompanyId + '/limit') + .set(bearer(tokenAdmin)).send({ limit: cnt + 1 }); + + const raceCidrs = [C.RACE1, C.RACE2, C.RACE3, C.RACE4, C.RACE5]; + const results = await Promise.all( + raceCidrs.map(c => req.post('/api/v1/entries').set(bearer(tokenUser)).send({ value: c, comment: 'race' })) + ); + + const ok201 = results.filter(r => r.status === 201).length; + const ok409 = results.filter(r => r.status === 409).length; + + log(ok201 <= 1, 'N1: race condition: не более 1 записи прошло', `201=${ok201} 409=${ok409}`); + log(ok201 + ok409 === 5, 'N2: все 5 запросов получили 201 или 409', `results=${results.map(r => r.status).join(',')}`); + + // Сброс лимита + await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]); + + // Чистка race-записей + for (const c of raceCidrs) { + await pool.query( + `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'api-test-cleanup' + WHERE value_cidr = $1 AND deleted_at IS NULL`, [cidr(c)] + ); + } + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Cleanup + // ══════════════════════════════════════════════════════════════════════════════ + console.log('\n── Cleanup ──'); + await cleanup(); + await pool.end(); + + // ── Итог ───────────────────────────────────────────────────────────────────── + console.log('\n══════════════════════════════════════════════════════'); + console.log(` ${pass} PASS, ${fail} FAIL`); + if (errors.length) { + console.log('\nПроваленные тесты:'); + errors.forEach(e => console.log(' ', e)); + } + console.log('══════════════════════════════════════════════════════'); + process.exit(fail > 0 ? 1 : 0); +})().catch(e => { + console.error('\nFATAL:', e.message, e.stack); + pool.end(); + process.exit(1); +});