Files
ipwhitelist-app/tests/api.js
T

1020 lines
52 KiB
JavaScript
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.
'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)]
);
}
}
// ══════════════════════════════════════════════════════════════════════════════
// O. Multi-company — токен с clientId через запятую
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── O. Multi-company ──');
const uMulti = MOCK_USERS.find(u => u.id === 'multi');
const tokenMulti = uMulti
? auth.issueMockToken({ clientId: uMulti.clientId, companyId: uMulti.companyId, companyName: uMulti.companyName, email: uMulti.email })
: null;
if (!tokenMulti) {
log(false, 'O0: не найден multi-пользователь в MOCK_USERS');
} else {
log(!!tokenMulti, 'O0: выпущен токен multi (WZ01325, WZ02001)');
// O1: GET /entries без ?client_id= → видит записи первой компании (WZ01325)
// Сначала добавим запись в обе компании для проверки
let multiEntry1, multiEntry2;
// O1a: создаём запись в первой компании (WZ01325) через multi-токен
{
const r = await req.post('/api/v1/entries').set(bearer(tokenMulti))
.send({ value: '4.4.4.1', comment: 'multi-company-1' });
log(r.status === 201, 'O1a: multi POST → 201 (первая компания WZ01325)', `status=${r.status}`);
multiEntry1 = json(r).entry?.id;
}
// O1b: создаём запись во ВТОРОЙ компании (WZ02001) через ?client_id=
{
const r = await req.post('/api/v1/entries?client_id=WZ02001').set(bearer(tokenMulti))
.send({ value: '4.4.4.2', comment: 'multi-company-2' });
log(r.status === 201, 'O1b: multi POST ?client_id=WZ02001 → 201', `status=${r.status}`);
multiEntry2 = json(r).entry?.id;
}
// O2: GET без ?client_id= → только записи WZ01325 (не видит WZ02001)
{
const rDefault = await req.get('/api/v1/entries').set(bearer(tokenMulti));
const entries = json(rDefault).entries || [];
const hasE1 = entries.some(e => e.id === multiEntry1);
const hasE2 = entries.some(e => e.id === multiEntry2);
log(hasE1, 'O2a: GET default содержит запись WZ01325');
log(!hasE2, 'O2b: GET default НЕ содержит запись WZ02001');
}
// O3: GET ?client_id=WZ02001 → только записи WZ02001
{
const rSwitched = await req.get('/api/v1/entries?client_id=WZ02001').set(bearer(tokenMulti));
const entries = json(rSwitched).entries || [];
const hasE1 = entries.some(e => e.id === multiEntry1);
const hasE2 = entries.some(e => e.id === multiEntry2);
log(!hasE1, 'O3a: GET ?client_id=WZ02001 НЕ содержит запись WZ01325');
log(hasE2, 'O3b: GET ?client_id=WZ02001 содержит запись WZ02001');
}
// O4: PATCH через ?client_id= работает изолированно
{
const r = await req.patch('/api/v1/entries/' + multiEntry2 + '?client_id=WZ02001')
.set(bearer(tokenMulti))
.send({ value: '4.4.4.3', comment: 'patched-in-WZ02001' });
log(r.status === 200, 'O4: multi PATCH ?client_id=WZ02001 → 200', `status=${r.status}`);
}
// O5: DELETE через ?client_id=
{
const r = await req.delete('/api/v1/entries/' + multiEntry2 + '?client_id=WZ02001')
.set(bearer(tokenMulti));
log([200, 204].includes(r.status), 'O5: multi DELETE ?client_id=WZ02001 → 200/204', `status=${r.status}`);
}
// O6: экспорт с ?client_id=WZ02001 не содержит запись WZ01325
{
// Создаём запись в WZ02001 для проверки экспорта
const cr = await req.post('/api/v1/entries?client_id=WZ02001').set(bearer(tokenMulti))
.send({ value: '4.4.5.1', comment: 'export-multi' });
const exportEntryId = json(cr).entry?.id;
const rWZ02 = await req.get('/api/v1/entries/export?client_id=WZ02001').set(bearer(tokenMulti));
log(rWZ02.status === 200, 'O6a: export ?client_id=WZ02001 → 200', `status=${rWZ02.status}`);
log(rWZ02.text.includes('4.4.5.1'), 'O6b: export содержит CIDR WZ02001');
log(!rWZ02.text.includes('4.4.4.1'), 'O6c: export НЕ содержит CIDR WZ01325');
// O6d: экспорт без ?client_id= → только WZ01325
const rDefault = await req.get('/api/v1/entries/export').set(bearer(tokenMulti));
log(rDefault.text.includes('4.4.4.1'), 'O6d: export default содержит WZ01325');
log(!rDefault.text.includes('4.4.5.1'), 'O6e: export default НЕ содержит WZ02001');
// Чистка
if (exportEntryId) {
await req.delete('/api/v1/entries/' + exportEntryId + '?client_id=WZ02001').set(bearer(tokenMulti));
}
}
// O7: несуществующий ?client_id= → игнорируется, фоллбек на WZ01325 (безопасность)
{
const r = await req.get('/api/v1/entries?client_id=NONEXISTENT').set(bearer(tokenMulti));
log(r.status === 200, 'O7: GET ?client_id=NONEXISTENT → 200 (игнор, фоллбек)', `status=${r.status}`);
// NONEXISTENT не в allClientIds → фоллбек на WZ01325 → записи ЕСТЬ
log(json(r).entries?.length > 0, 'O7b: фоллбек на WZ01325 — записи есть');
}
// O8: admin передаёт ?client_id= → использует свою компанию (игнор)
{
const r = await req.get('/api/v1/entries?client_id=WZ02001').set(bearer(tokenAdmin));
log(r.status === 200, 'O8: admin GET ?client_id= → 200 (игнорирует)', `status=${r.status}`);
}
// Чистка записей multi
if (multiEntry1) {
await req.delete('/api/v1/entries/' + multiEntry1).set(bearer(tokenMulti));
}
if (multiEntry2) {
await req.delete('/api/v1/entries/' + multiEntry2 + '?client_id=WZ02001').set(bearer(tokenMulti));
}
}
// ══════════════════════════════════════════════════════════════════════════════
// 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);
});