feat: comprehensive API tests (104 pass) + fix: validation errors → 400, comment length limit

This commit is contained in:
2026-05-30 19:44:37 +03:00
parent 7779bf416a
commit ed5b3c8367
2 changed files with 917 additions and 0 deletions
+14
View File
@@ -10,6 +10,7 @@ function createEntriesRouter({ q }) {
// Маппинг сообщений ошибок на HTTP-коды // Маппинг сообщений ошибок на HTTP-коды
function handleQueryError(e, res) { function handleQueryError(e, res) {
const msg = e.message || ''; const msg = e.message || '';
// 409: бизнес-конфликты (дубликат, пересечение, лимит)
if ( if (
msg.includes('Лимит исчерпан') || msg.includes('Лимит исчерпан') ||
msg.includes('Такой адрес уже существует') || msg.includes('Такой адрес уже существует') ||
@@ -17,9 +18,20 @@ function createEntriesRouter({ q }) {
) { ) {
return res.status(409).json({ error: msg }); return res.status(409).json({ error: msg });
} }
// 404: запись не найдена
if (msg.includes('Запись не найдена')) { if (msg.includes('Запись не найдена')) {
return res.status(404).json({ error: msg }); 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 }); return res.status(500).json({ error: msg });
} }
@@ -80,6 +92,7 @@ function createEntriesRouter({ q }) {
router.post('/', json, async (req, res) => { router.post('/', json, async (req, res) => {
const { value, comment } = req.body || {}; const { value, comment } = req.body || {};
if (!value) return res.status(400).json({ error: 'value required' }); if (!value) return res.status(400).json({ error: 'value required' });
if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' });
try { try {
const company = await resolveCompany(req); const company = await resolveCompany(req);
const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email); 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) => { router.patch('/:id', json, async (req, res) => {
const { value, comment } = req.body || {}; const { value, comment } = req.body || {};
if (!value) return res.status(400).json({ error: 'value required' }); if (!value) return res.status(400).json({ error: 'value required' });
if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' });
try { try {
const company = await resolveCompany(req); const company = await resolveCompany(req);
const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email); const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email);
+903
View File
@@ -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=<id>)
* 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=<id> → 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 = '<script>alert(1)</script>';
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=<id>)
// ══════════════════════════════════════════════════════════════════════════════
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=<id> → 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=<id>
{
const r = await req.get('/api/v1/entries?company=' + userCompanyId).set(bearer(tokenAdmin));
log(r.status === 200, 'L3: admin GET /entries?company=<id> → 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=<id> → 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=<id> → 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);
});