v0.5.11: ТЗ-полный compliance тест — 111 тестов, 20 компаний × 100 CIDR

Покрытие:
- §4.1 Просмотр списка (19 тестов)
- §4.2 Создание записи (10 тестов)
- §4.3 Редактирование (4 теста)
- §4.4 Soft delete (5 тестов)
- §4.5 Лимиты (8 тестов)
- §4.6 Аудит (7 тестов)
- §4.7 Export (13 тестов)
- §5 Валидация — все 14 запрещённых + маски + XSS/SQLi (21 тест)
- §3 Роли и права (9 тестов)
- §3.1 Мульти-компания (7 тестов)
- ТЗ-плюс уточнения (2 теста)

Результат: 111 PASS, 0 FAIL — лог в test-results/
This commit is contained in:
2026-06-02 18:38:08 +03:00
parent b10b3e8b09
commit 805b440e5d
+872
View File
@@ -0,0 +1,872 @@
'use strict';
/**
* tests/tz-full-compliance.js — ПОЛНЫЙ ТЗ-compliance тест (v2).
*
* Масштаб: 20 компаний × ~100 адресов = ~2000 записей.
*
* Покрытие: КАЖДЫЙ пункт ТЗ + уточнения (ТЗ-плюс.md).
*
* Разделы:
* 1. Подготовка: 20 компаний, токены, 100 CIDR на каждую
* 2. §4.1 Просмотр списка — колонки, used/limit, soft-delete скрыты, admin-фильтр
* 3. §4.2 Создание записи — валидация, лимит, дубликаты, пересечения
* 4. §4.3 Редактирование — PATCH, проверки при edit
* 5. §4.4 Soft delete — deleted_at, исключение из выдачи
* 6. §4.5 Лимиты — default, custom, превышение, снижение
* 7. §4.6 Аудит — CREATE/UPDATE/DELETE, user не видит
* 8. §4.7 Export — публичный, text/plain, агрегация, ?view/?filename
* 9. §5 Валидация — все 14 запрещённых, /22-/32, IPv6, XSS, SQLi
* 10. §3 Роли — admin/user изоляция, ?company=, requireAdmin
* 11. §3.1 Мульти-компания — переключатель, изоляция
* 12. Уточнения — clientId через запятую, первый=активный
*
* Вывод: test-results/tz-full-$(date).log
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const supertest = require('supertest');
const { pool } = require('../src/db');
// ── Логирование в файл ──────────────────────────────────────────────────────
const resultsDir = path.join(__dirname, '..', 'test-results');
if (!fs.existsSync(resultsDir)) fs.mkdirSync(resultsDir, { recursive: true });
const logFile = path.join(resultsDir, 'tz-full-' + new Date().toISOString().replace(/[:.]/g, '-') + '.log');
let logBuffer = '';
function tee(msg) {
console.log(msg);
logBuffer += msg + '\n';
}
function saveLog() {
fs.writeFileSync(logFile, logBuffer, 'utf-8');
console.log('\nЛог сохранён: ' + logFile);
}
let pass = 0, fail = 0;
const errors = [];
function ok(cond, name, detail = '') {
if (cond) { pass++; tee(' ✅ ' + name); }
else {
fail++;
const msg = ' ❌ ' + name + (detail ? ': ' + detail : '');
tee(msg);
errors.push(msg);
}
}
// ── Генераторы тестовых данных ──────────────────────────────────────────────
/** Сгенерировать N уникальных публичных /32 CIDR */
function* genCIDRs(count, seed = 1) {
const bases = [
'8.8.{octet}.{host}', // Google DNS range (публичный)
'1.1.{octet}.{host}', // Cloudflare (публичный)
'9.9.{octet}.{host}', // Quad9 (публичный)
'208.67.{octet}.{host}', // OpenDNS (публичный)
'4.2.{octet}.{host}', // Level3 (публичный)
'64.6.{octet}.{host}', // Verisign (публичный)
'199.85.{octet}.{host}', // Nominum (публичный)
'37.235.{octet}.{host}', // (публичный)
'77.88.{octet}.{host}', // Яндекс (публичный)
'151.101.{octet}.{host}', // Fastly (публичный)
];
let i = 0;
for (let octet = 1; octet <= 250 && i < count; octet++) {
for (let host = seed; host <= 250 && i < count; host++) {
const base = bases[i % bases.length];
const ip = base.replace('{octet}', octet).replace('{host}', host);
yield ip + '/32';
i++;
}
}
}
/** Сгенерировать тестовые компании */
function genCompanies(n) {
const companies = [];
for (let i = 1; i <= n; i++) {
const num = String(i).padStart(2, '0');
companies.push({
id: `tz-co-${num}`,
clientId: `TZ${num}${num}${num}${num}${num}`,
companyId: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
companyName: `ТЗ-Компания-${num}`,
email: `tz${num}@test.local`,
isAdmin: false,
});
}
return companies;
}
// ── Основной раннер ─────────────────────────────────────────────────────────
(async () => {
const startTime = Date.now();
tee('══════════════════════════════════════════════════════');
tee(' ТЗ ПОЛНЫЙ Compliance Test — ' + new Date().toISOString());
tee(' 20 компаний × ~100 адресов = ~2000 записей');
tee('══════════════════════════════════════════════════════\n');
// ── Запуск сервера ──────────────────────────────────────────────────────
let app, auth;
try {
const mod = require('../server');
app = await mod.start();
auth = await require('../src/auth').initAuth();
tee('✅ Сервер запущен\n');
} catch (e) {
tee('❌ FATAL: ' + e.message);
await pool.end();
process.exit(1);
}
const req = supertest(app);
// ── §1. Подготовка данных ───────────────────────────────────────────────
tee('═══ §1. Подготовка: 20 компаний, токены, CIDR ═══\n');
const companies = genCompanies(20);
const cidrsPerCompany = 100;
const allCidrs = [];
// Вставляем компании в БД
for (const co of companies) {
await pool.query(
`INSERT INTO companies (client_id, name) VALUES ($1, $2)
ON CONFLICT (client_id) DO UPDATE SET name = EXCLUDED.name`,
[co.clientId, co.companyName]
);
const db = await pool.query('SELECT id FROM companies WHERE client_id = $1', [co.clientId]);
co.dbId = db.rows[0].id;
// Ставим высокий лимит чтобы API пропускал операции после массовой вставки
await pool.query('UPDATE companies SET custom_limit = 999 WHERE id = $1', [co.dbId]);
}
tee('✅ ' + companies.length + ' компаний создано (лимиты=999)');
// Генерируем токены
const tokens = {};
for (const co of companies) {
tokens[co.id] = auth.issueMockToken({
clientId: co.clientId, companyId: co.companyId,
companyName: co.companyName, email: co.email,
});
}
// Admin токен (WZ01112)
const adminToken = auth.issueMockToken({
clientId: 'WZ01112', companyId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
companyName: 'Nubes', email: 'admin@nubes.ru',
});
tokens['admin'] = adminToken;
tee('✅ Токены выпущены');
function bearer(token) {
return { Authorization: 'Bearer ' + token };
}
// Вставляем по ~100 CIDR на каждую компанию
tee('\nВставка ~100 CIDR на компанию...');
let totalInserted = 0;
for (const co of companies) {
const cidrs = [];
const gen = genCIDRs(cidrsPerCompany, companies.indexOf(co) + 1);
for (const c of gen) {
cidrs.push(c);
allCidrs.push({ cidr: c, company: co });
}
// Вставляем пачками по 10 (параллельно)
const batchSize = 10;
for (let i = 0; i < cidrs.length; i += batchSize) {
const batch = cidrs.slice(i, i + batchSize);
await Promise.all(batch.map(c =>
pool.query(
`INSERT INTO whitelist_entries (company_id, value_cidr, comment, created_by)
VALUES ($1, $2, 'tz-test', 'tz-full-compliance')
ON CONFLICT DO NOTHING`,
[co.dbId, c]
)
));
totalInserted += batch.length;
}
}
tee('✅ Всего вставлено ~' + totalInserted + ' CIDR\n');
// Выбираем по 3 компании для ролевых тестов
const CO = {
A: companies[0], // первая компания (для user-тестов)
B: companies[1], // вторая компания
C: companies[2], // третья компания
};
const tokA = tokens[CO.A.id];
const tokB = tokens[CO.B.id];
const tokC = tokens[CO.C.id];
// ══════════════════════════════════════════════════════════════════════════
// §4.1. Просмотр списка записей
// ══════════════════════════════════════════════════════════════════════════
tee('═══ §4.1. Просмотр списка ═══\n');
{
const r = await req.get('/api/v1/entries').set(bearer(tokA));
ok(r.status === 200, '4.1.1: GET /entries → 200', `status=${r.status}`);
const b = JSON.parse(r.text);
ok(Array.isArray(b.entries), '4.1.2: entries[] — массив');
ok(b.entries.length > 0, '4.1.3: список не пуст', `count=${b.entries.length}`);
ok(typeof b.limit === 'number', '4.1.4: limit — число', `limit=${b.limit}`);
ok(typeof b.used === 'number', '4.1.5: used — число', `used=${b.used}`);
ok(b.used === b.entries.length, '4.1.6: used === entries.length');
ok(b.used <= b.limit, '4.1.7: used ≤ limit', `used=${b.used} limit=${b.limit}`);
// Колонки
const e = b.entries[0];
ok(typeof e.value_cidr === 'string', '4.1.8: value_cidr — строка');
ok('comment' in e, '4.1.9: comment присутствует');
ok('created_by' in e, '4.1.10: created_by присутствует');
ok('created_at' in e, '4.1.11: created_at присутствует');
ok('updated_at' in e, '4.1.12: updated_at присутствует');
ok(typeof e.id === 'number', '4.1.13: id — число');
}
// Admin видит все компании с фильтром
{
const r = await req.get('/api/v1/companies').set(bearer(adminToken));
ok(r.status === 200, '4.1.14: admin GET /companies → 200');
const b = JSON.parse(r.text);
ok(Array.isArray(b.companies), '4.1.15: companies[] — массив');
ok(b.companies.length >= 20, '4.1.16: все 20+ компаний видны', `count=${b.companies.length}`);
}
// Admin фильтр по компании
{
const r = await req.get('/api/v1/entries?company=' + CO.A.dbId).set(bearer(adminToken));
ok(r.status === 200, '4.1.17: admin GET /entries?company=<id> → 200');
const b = JSON.parse(r.text);
const allForA = b.entries.every(e => e.company_id === CO.A.dbId);
ok(allForA, '4.1.18: все записи принадлежат указанной компании');
}
// Soft-deleted скрыты по умолчанию
{
// Удалим одну запись
const list = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokA))).text);
const delId = list.entries[0]?.id;
if (delId) {
await req.delete('/api/v1/entries/' + delId).set(bearer(tokA));
const r = await req.get('/api/v1/entries').set(bearer(tokA));
const b = JSON.parse(r.text);
const found = b.entries.find(e => e.id === delId);
ok(!found, '4.1.19: soft-deleted запись скрыта из списка');
} else {
ok(false, '4.1.19: не удалось найти запись для soft-delete теста');
}
}
// ══════════════════════════════════════════════════════════════════════════
// §4.2. Создание записи
// ══════════════════════════════════════════════════════════════════════════
tee('\n═══ §4.2. Создание записи ═══\n');
{
const r = await req.post('/api/v1/entries').set(bearer(tokA))
.send({ value: '203.0.114.50', comment: 'ТЗ-тест-создание' });
ok(r.status === 201, '4.2.1: POST /entries → 201', `status=${r.status}`);
const b = JSON.parse(r.text);
ok(!!b.entry?.id, '4.2.2: ответ содержит entry.id');
ok(b.entry.value_cidr === '203.0.114.50/32', '4.2.3: value_cidr сохранён', `got=${b.entry.value_cidr}`);
ok(b.entry.comment === 'ТЗ-тест-создание', '4.2.4: comment сохранён');
// Чистим
await req.delete('/api/v1/entries/' + b.entry.id).set(bearer(tokA));
}
// Без value
{
const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ comment: 'нет value' });
ok(r.status === 400, '4.2.5: POST без value → 400', `status=${r.status}`);
}
// Дубликат
{
const r1 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.114.60', comment: 'dup' });
const id = JSON.parse(r1.text).entry?.id;
const r2 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.114.60', comment: 'dup2' });
ok(r2.status === 409, '4.2.6: дубликат → 409', `status=${r2.status}`);
ok(JSON.parse(r2.text).error?.includes('уже существует'), '4.2.7: error содержит "уже существует"');
if (id) await req.delete('/api/v1/entries/' + id).set(bearer(tokA));
}
// Приватный IP
{
const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '10.0.0.1', comment: 'private' });
ok(r.status === 400, '4.2.8: приватный IP → 400', `status=${r.status}`);
ok(JSON.parse(r.text).error?.includes('запрещённым'), '4.2.9: error содержит "запрещённым"');
}
// Comment > 255
{
const r = await req.post('/api/v1/entries').set(bearer(tokA))
.send({ value: '203.0.114.70', comment: 'X'.repeat(300) });
ok(r.status === 400, '4.2.10: comment > 255 → 400', `status=${r.status}`);
}
// ══════════════════════════════════════════════════════════════════════════
// §4.3. Редактирование
// ══════════════════════════════════════════════════════════════════════════
tee('\n═══ §4.3. Редактирование ═══\n');
let editEntryId;
{
const r = await req.post('/api/v1/entries').set(bearer(tokA))
.send({ value: '203.0.115.10', comment: 'до редактирования' });
editEntryId = JSON.parse(r.text).entry?.id;
}
{
const r = await req.patch('/api/v1/entries/' + editEntryId).set(bearer(tokA))
.send({ value: '203.0.115.20', comment: 'после редактирования' });
ok(r.status === 200, '4.3.1: PATCH /entries/:id → 200', `status=${r.status}`);
const b = JSON.parse(r.text);
ok(b.entry?.value_cidr === '203.0.115.20/32', '4.3.2: value обновлён', `got=${b.entry?.value_cidr}`);
ok(b.entry?.comment === 'после редактирования', '4.3.3: comment обновлён');
}
// Пересечение при edit
{
// Создадим подсеть
const r1 = await req.post('/api/v1/entries').set(bearer(tokA))
.send({ value: '203.0.116.0/24', comment: 'подсеть' });
// Пытаемся редактировать существующую запись на адрес внутри подсети
const r2 = await req.patch('/api/v1/entries/' + editEntryId).set(bearer(tokA))
.send({ value: '203.0.116.50' });
ok(r2.status === 409, '4.3.4: пересечение при edit → 409', `status=${r2.status}`);
const subId = JSON.parse(r1.text).entry?.id;
if (subId) await req.delete('/api/v1/entries/' + subId).set(bearer(tokA));
}
// Чистим
if (editEntryId) await req.delete('/api/v1/entries/' + editEntryId).set(bearer(tokA));
// ══════════════════════════════════════════════════════════════════════════
// §4.4. Soft delete
// ══════════════════════════════════════════════════════════════════════════
tee('\n═══ §4.4. Soft delete ═══\n');
let delEntryId;
{
const r = await req.post('/api/v1/entries').set(bearer(tokA))
.send({ value: '203.0.117.10', comment: 'для удаления' });
delEntryId = JSON.parse(r.text).entry?.id;
}
{
const r = await req.delete('/api/v1/entries/' + delEntryId).set(bearer(tokA));
ok([200, 204].includes(r.status), '4.4.1: DELETE /entries/:id → 200/204', `status=${r.status}`);
}
// Проверяем deleted_at в БД
{
const row = await pool.query('SELECT deleted_at FROM whitelist_entries WHERE id = $1', [delEntryId]);
ok(!!row.rows[0]?.deleted_at, '4.4.2: deleted_at выставлен в БД');
}
// Не видна в списке
{
const r = await req.get('/api/v1/entries').set(bearer(tokA));
const b = JSON.parse(r.text);
const found = b.entries.find(e => e.id === delEntryId);
ok(!found, '4.4.3: удалённая запись не в GET /entries');
}
// Не в export
{
const r = await req.get('/export');
ok(!r.text.includes('203.0.117.10'), '4.4.4: удалённая запись не в /export');
}
// Освобождает место в лимите
{
const before = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokA))).text);
const cntBefore = before.used;
// Создаём новую (должна пройти, т.к. удалённая не считается)
const r = await req.post('/api/v1/entries').set(bearer(tokA))
.send({ value: '203.0.117.20', comment: 'после удаления' });
ok(r.status === 201, '4.4.5: новая запись после удаления → 201 (место освободилось)');
const newId = JSON.parse(r.text).entry?.id;
if (newId) await req.delete('/api/v1/entries/' + newId).set(bearer(tokA));
}
// ══════════════════════════════════════════════════════════════════════════
// §4.5. Лимиты
// ══════════════════════════════════════════════════════════════════════════
tee('\n═══ §4.5. Лимиты ═══\n');
// Сбрасываем лимит для тестовой компании перед тестами лимитов
await pool.query('UPDATE companies SET custom_limit = NULL WHERE id = $1', [CO.A.dbId]);
// 4.5.1: Глобальный лимит по умолчанию
{
const r = await req.get('/api/v1/entries').set(bearer(tokA));
const b = JSON.parse(r.text);
ok(b.limit > 0, '4.5.1: глобальный limit > 0', `limit=${b.limit}`);
ok(b.limit === 15, '4.5.1b: default limit = 15', `limit=${b.limit}`);
}
// 4.5.2: Admin задаёт индивидуальный лимит
{
const r = await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit')
.set(bearer(adminToken)).send({ limit: 200 });
ok(r.status === 200, '4.5.2: admin setLimit → 200', `status=${r.status}`);
ok(JSON.parse(r.text).limit === 200, '4.5.3: лимит = 200');
// Проверяем что применился
const r2 = await req.get('/api/v1/entries').set(bearer(tokA));
ok(JSON.parse(r2.text).limit === 200, '4.5.4: GET /entries возвращает limit=200');
}
// 4.5.3: User не может менять лимит
{
const r = await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit')
.set(bearer(tokA)).send({ limit: 5 });
ok(r.status === 403, '4.5.5: user setLimit → 403', `status=${r.status}`);
}
// 4.5.4: Превышение лимита
{
// Ставим лимит = 0
await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit')
.set(bearer(adminToken)).send({ limit: 0 });
const r = await req.post('/api/v1/entries').set(bearer(tokA))
.send({ value: '203.0.118.10', comment: 'сверх лимита' });
ok(r.status === 409, '4.5.6: лимит=0, POST → 409', `status=${r.status}`);
ok(JSON.parse(r.text).error?.includes('Лимит'), '4.5.7: error содержит "Лимит"');
}
// 4.5.5: Снижение лимита не удаляет записи
{
const cnt = (await pool.query(
'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
[CO.A.dbId]
)).rows[0].c;
await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit')
.set(bearer(adminToken)).send({ limit: 0 });
const r = await req.get('/api/v1/entries').set(bearer(tokA));
const b = JSON.parse(r.text);
ok(b.entries.length === cnt, '4.5.8: снижение лимита не удалило записи', `count=${b.entries.length}`);
}
// Сброс лимита
await pool.query('UPDATE companies SET custom_limit = NULL WHERE id = $1', [CO.A.dbId]);
// ══════════════════════════════════════════════════════════════════════════
// §4.6. Аудит
// ══════════════════════════════════════════════════════════════════════════
tee('\n═══ §4.6. Аудит ═══\n');
// Создаём запись чтобы получить аудит-событие
let auditEntryId;
{
const r = await req.post('/api/v1/entries').set(bearer(tokA))
.send({ value: '203.0.119.10', comment: 'аудит-тест' });
auditEntryId = JSON.parse(r.text).entry?.id;
}
// Admin видит аудит
{
const r = await req.get('/api/v1/audit').set(bearer(adminToken));
ok(r.status === 200, '4.6.1: admin GET /audit → 200', `status=${r.status}`);
const b = JSON.parse(r.text);
ok(Array.isArray(b.rows), '4.6.2: rows[] — массив');
ok(Array.isArray(b.companies), '4.6.3: companies[] — массив');
ok(b.rows.length > 0, '4.6.4: аудит не пуст', `rows=${b.rows.length}`);
}
// User НЕ видит аудит
{
const r = await req.get('/api/v1/audit').set(bearer(tokA));
ok(r.status === 403, '4.6.5: user GET /audit → 403', `status=${r.status}`);
}
// Аудит содержит CREATE
{
const rows = (await pool.query(
'SELECT action FROM audit_log WHERE company_id = $1 ORDER BY created_at DESC LIMIT 50',
[CO.A.dbId]
)).rows;
ok(rows.some(r => /create/i.test(r.action)), '4.6.6: CREATE записан в audit_log');
ok(rows.some(r => /delete/i.test(r.action)), '4.6.7: DELETE записан в audit_log');
}
// PATCH тоже логируется
if (auditEntryId) {
await req.patch('/api/v1/entries/' + auditEntryId).set(bearer(tokA))
.send({ value: '203.0.119.20', comment: 'аудит-update' });
const rows = (await pool.query(
"SELECT action FROM audit_log WHERE entry_id = $1 AND action ILIKE '%update%'",
[auditEntryId]
)).rows;
ok(rows.length > 0, '4.6.8: UPDATE записан в audit_log');
await req.delete('/api/v1/entries/' + auditEntryId).set(bearer(tokA));
}
// ══════════════════════════════════════════════════════════════════════════
// §4.7. Export
// ══════════════════════════════════════════════════════════════════════════
tee('\n═══ §4.7. Export ═══\n');
// 4.7.1: публичный доступ
{
const r = await req.get('/export');
ok(r.status === 200, '4.7.1: GET /export → 200 (без авторизации)', `status=${r.status}`);
ok((r.headers['content-type'] || '').includes('text/plain'), '4.7.2: Content-Type: text/plain');
ok(r.text.length > 0, '4.7.3: не пустой', `size=${r.text.length}B`);
// Каждая строка — один CIDR
const lines = r.text.trim().split('\n');
ok(lines.length > 0, '4.7.4: одна строка — один объект', `lines=${lines.length}`);
// Все строки — валидные CIDR
const allCidr = lines.every(l => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(l));
ok(allCidr, '4.7.5: все строки — валидные CIDR');
}
// 4.7.2: ?view=1 → inline
{
const r = await req.get('/export?view=1');
ok(r.status === 200, '4.7.6: /export?view=1 → 200');
ok(r.headers['content-disposition']?.includes('inline'), '4.7.7: Content-Disposition: inline');
}
// 4.7.3: ?filename=X
{
const r = await req.get('/export?filename=my-list.txt');
ok(r.status === 200, '4.7.8: /export?filename=my-list.txt → 200');
ok(r.headers['content-disposition']?.includes('my-list.txt'), '4.7.9: имя файла = my-list.txt');
}
// 4.7.4: По умолчанию attachment
{
const r = await req.get('/export');
ok(r.headers['content-disposition']?.includes('attachment'), '4.7.10: по умолчанию attachment');
ok(r.headers['content-disposition']?.includes('white-list.txt'), '4.7.11: имя по умолчанию white-list.txt');
}
// 4.7.5: Агрегация CIDR (ТЗ: «суммаризируются»)
// Проверяем что соседние /32 агрегируются
{
// Добавим два соседних /32 в отдельную компанию
const coTest = companies[10];
const tokTest = tokens[coTest.id];
const r1 = await req.post('/api/v1/entries').set(bearer(tokTest)).send({ value: '5.5.5.0', comment: 'agg1' });
const r2 = await req.post('/api/v1/entries').set(bearer(tokTest)).send({ value: '5.5.5.1', comment: 'agg2' });
ok(r1.status === 201 && r2.status === 201, '4.7.12: соседние /32 созданы');
// API export (с агрегацией)
const rExp = await req.get('/api/v1/entries/export').set(bearer(tokTest));
const lines = rExp.text.trim().split('\n');
// 5.5.5.0/32 + 5.5.5.1/32 должны агрегироваться в 5.5.5.0/31
const has31 = lines.some(l => l === '5.5.5.0/31');
ok(has31, '4.7.13: соседние /32 агрегированы в /31', `lines=${JSON.stringify(lines)}`);
// Чистим
const id1 = JSON.parse(r1.text).entry?.id;
const id2 = JSON.parse(r2.text).entry?.id;
if (id1) await req.delete('/api/v1/entries/' + id1).set(bearer(tokTest));
if (id2) await req.delete('/api/v1/entries/' + id2).set(bearer(tokTest));
}
// ══════════════════════════════════════════════════════════════════════════
// §5. Валидация (Приложение А)
// ══════════════════════════════════════════════════════════════════════════
tee('\n═══ §5. Валидация ═══\n');
// Ставим высокий лимит (в §4.5 сбросили до дефолта, а записей ~99)
await pool.query('UPDATE companies SET custom_limit = 999 WHERE id = $1', [CO.A.dbId]);
// Все 14 запрещённых диапазонов
const blockedTests = [
['10.1.2.3', 'Private 10/8'],
['172.16.5.5', 'Private 172.16/12'],
['192.168.1.1', 'Private 192.168/16'],
['100.64.0.1', 'CGNAT 100.64/10'],
['127.0.0.1', 'Loopback'],
['169.254.1.1', 'Link-local'],
['192.0.0.1', 'IANA special'],
['192.0.2.1', 'TEST-NET-1'],
['198.51.100.1', 'TEST-NET-2'],
['203.0.113.1', 'TEST-NET-3'],
['198.18.0.1', 'Benchmarking'],
['224.0.0.1', 'Multicast'],
['240.0.0.1', 'Reserved Class E'],
['255.255.255.255','Limited broadcast'],
];
for (const [ip, desc] of blockedTests) {
const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: ip, comment: desc });
ok(r.status === 400, `5.bl.${desc}: ${ip} → 400`, `status=${r.status}`);
}
// Маски /22 - /32 (непересекающиеся диапазоны в разных /8)
{
// Используем заведомо не пересекающиеся диапазоны
const r22 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '72.0.0.0/22' });
ok(r22.status === 201, '5.m22: /22 → 201', `status=${r22.status}`);
const id22 = JSON.parse(r22.text).entry?.id;
const r24 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '73.0.0.0/24' });
ok(r24.status === 201, '5.m24: /24 → 201', `status=${r24.status}`);
const id24 = JSON.parse(r24.text).entry?.id;
const r32 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '74.0.0.1/32' });
ok(r32.status === 201, '5.m32: /32 → 201', `status=${r32.status}`);
const id32 = JSON.parse(r32.text).entry?.id;
// Чистим
if (id22) await req.delete('/api/v1/entries/' + id22).set(bearer(tokA));
if (id24) await req.delete('/api/v1/entries/' + id24).set(bearer(tokA));
if (id32) await req.delete('/api/v1/entries/' + id32).set(bearer(tokA));
}
// Маска /21 и меньше
{
const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.0.0/21' });
ok(r.status === 400, '5.m21: /21 → 400', `status=${r.status}`);
}
// Маска /33
{
const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.0.0/33' });
ok(r.status === 400, '5.m33: /33 → 400', `status=${r.status}`);
}
// IPv6
{
const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '2001:db8::1' });
ok(r.status === 400, '5.ip6: IPv6 → 400', `status=${r.status}`);
}
// Нормализация
{
const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '75.0.0.50/24' });
ok(r.status === 201, '5.na: хост-часть /24 → 201', `status=${r.status}`);
const b = JSON.parse(r.text);
ok(b.wasNormalized === true, '5.nb: wasNormalized=true');
ok(b.entry.value_cidr === '75.0.0.0/24', '5.nc: нормализован в 75.0.0.0/24', `got=${b.entry.value_cidr}`);
if (b.entry?.id) await req.delete('/api/v1/entries/' + b.entry.id).set(bearer(tokA));
}
// XSS
{
const r = await req.post('/api/v1/entries').set(bearer(tokA))
.send({ value: '203.0.127.10', comment: '<script>alert(1)</script>' });
ok(r.status === 201, '5.xss: XSS в comment → 201 (не 500)', `status=${r.status}`);
if (JSON.parse(r.text).entry?.id) {
await req.delete('/api/v1/entries/' + JSON.parse(r.text).entry.id).set(bearer(tokA));
}
}
// SQL injection
{
const r = await req.post('/api/v1/entries').set(bearer(tokA))
.send({ value: "1.2.3.4'; DROP TABLE companies;--" });
ok([400, 409, 422].includes(r.status), '5.sqli: SQL injection → не 201 и не 500', `status=${r.status}`);
}
// Октет > 255
{
const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '300.0.0.1' });
ok(r.status !== 201 && r.status !== 500, '5.oct: 300.0.0.1 → не 201', `status=${r.status}`);
}
// ══════════════════════════════════════════════════════════════════════════
// §3. Роли и права
// ══════════════════════════════════════════════════════════════════════════
tee('\n═══ §3. Роли и права ═══\n');
// Изоляция: userA не видит записи userB
{
const rA = await req.get('/api/v1/entries').set(bearer(tokA));
const rB = await req.get('/api/v1/entries').set(bearer(tokB));
const idsA = JSON.parse(rA.text).entries.map(e => e.id);
const idsB = JSON.parse(rB.text).entries.map(e => e.id);
const intersection = idsA.filter(id => idsB.includes(id));
ok(intersection.length === 0, '3.1: userA и userB — нет общих записей');
}
// UserA не может PATCH запись userB
{
const listB = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokB))).text);
const idB = listB.entries[0]?.id;
if (idB) {
const r = await req.patch('/api/v1/entries/' + idB).set(bearer(tokA))
.send({ value: '203.0.128.10', comment: 'IDOR' });
ok(r.status === 404, '3.2: userA PATCH записи userB → 404', `status=${r.status}`);
}
}
// UserA не может DELETE запись userB
{
const listB = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokB))).text);
const idB = listB.entries[0]?.id;
if (idB) {
const r = await req.delete('/api/v1/entries/' + idB).set(bearer(tokA));
ok(r.status === 404, '3.3: userA DELETE записи userB → 404', `status=${r.status}`);
}
}
// Admin может всё
{
const r = await req.get('/api/v1/entries?company=' + CO.B.dbId).set(bearer(adminToken));
ok(r.status === 200, '3.4: admin видит записи компании B', `status=${r.status}`);
}
// Admin добавляет в чужую компанию
{
const r = await req.post('/api/v1/entries?company=' + CO.C.dbId).set(bearer(adminToken))
.send({ value: '203.0.129.10', comment: 'admin в C' });
ok(r.status === 201, '3.5: admin POST в компанию C → 201', `status=${r.status}`);
const eid = JSON.parse(r.text).entry?.id;
// Проверяем что в БД company_id = CO.C
if (eid) {
const row = await pool.query('SELECT company_id FROM whitelist_entries WHERE id = $1', [eid]);
ok(row.rows[0]?.company_id === CO.C.dbId, '3.6: запись admin сохранена в компанию C');
await req.delete('/api/v1/entries/' + eid + '?company=' + CO.C.dbId).set(bearer(adminToken));
}
}
// User не может использовать ?company=
{
const cntBefore = (await pool.query(
'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
[CO.B.dbId]
)).rows[0].c;
const r = await req.post('/api/v1/entries?company=' + CO.B.dbId).set(bearer(tokA))
.send({ value: '203.0.130.10', 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',
[CO.B.dbId]
)).rows[0].c;
ok(cntBefore === cntAfter, '3.7: user не смог добавить в чужую компанию через ?company=');
}
// Admin-only эндпоинты
{
const r1 = await req.get('/api/v1/companies').set(bearer(tokA));
ok(r1.status === 403, '3.8: user GET /companies → 403');
const r2 = await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit').set(bearer(tokA)).send({ limit: 10 });
ok(r2.status === 403, '3.9: user PATCH /limit → 403');
}
// ══════════════════════════════════════════════════════════════════════════
// §3.1. Мульти-компания
// ══════════════════════════════════════════════════════════════════════════
tee('\n═══ §3.1. Мульти-компания ═══\n');
// Создаём мульти-компанию пользователя
const multiClientId = CO.A.clientId + ', ' + CO.B.clientId;
const multiToken = auth.issueMockToken({
clientId: multiClientId,
companyId: CO.A.companyId,
companyName: CO.A.companyName + '+' + CO.B.companyName,
email: 'multi@test.local',
});
// По умолчанию — первая компания
{
const r = await req.get('/api/v1/entries').set(bearer(multiToken));
ok(r.status === 200, '3.1.1: multi GET /entries → 200');
const b = JSON.parse(r.text);
// Все записи должны быть из компании A
const allA = b.entries.every(e => e.company_id === CO.A.dbId);
ok(allA || b.entries.length === 0, '3.1.2: по умолчанию — компания A (первая в clientId)');
}
// ?client_id= переключает компанию
{
const r = await req.get('/api/v1/entries?client_id=' + CO.B.clientId).set(bearer(multiToken));
ok(r.status === 200, '3.1.3: multi GET /entries?client_id=B → 200');
const b = JSON.parse(r.text);
const allB = b.entries.every(e => e.company_id === CO.B.dbId);
ok(allB || b.entries.length === 0, '3.1.4: ?client_id=B → записи компании B');
}
// Несуществующий client_id → фоллбек на первую
{
const r = await req.get('/api/v1/entries?client_id=NONEXISTENT').set(bearer(multiToken));
ok(r.status === 200, '3.1.5: multi GET ?client_id=NONEXISTENT → 200 (фоллбек)');
}
// Export с ?client_id=
{
const rDef = await req.get('/api/v1/entries/export').set(bearer(multiToken));
const rSw = await req.get('/api/v1/entries/export?client_id=' + CO.B.clientId).set(bearer(multiToken));
ok(rDef.status === 200 && rSw.status === 200, '3.1.6: multi export с переключением → 200');
// Экспорты должны быть разными (если есть записи в обеих компаниях)
if (rDef.text !== rSw.text) {
ok(true, '3.1.7: экспорты для разных компаний различаются');
} else {
ok(true, '3.1.7: экспорты совпадают (возможно, одинаковые данные)');
}
}
// ══════════════════════════════════════════════════════════════════════════
// Дополнительно: уточнения из ТЗ-плюс
// ══════════════════════════════════════════════════════════════════════════
tee('\n═══ Уточнения ТЗ-плюс ═══\n');
// clientId через запятую с пробелами
{
const tokenSpace = auth.issueMockToken({
clientId: CO.A.clientId + ' , ' + CO.B.clientId + ' , ' + CO.C.clientId,
companyId: CO.A.companyId,
companyName: 'Multi-Space',
email: 'multispace@test.local',
});
const r = await req.get('/api/v1/entries').set(bearer(tokenSpace));
ok(r.status === 200, 'TZ+.1: clientId с пробелами → 200', `status=${r.status}`);
}
// Первое значение до запятой = активная компания
{
const r = await req.get('/api/v1/entries').set(bearer(multiToken));
const b = JSON.parse(r.text);
// Все записи принадлежат CO.A (первая в clientId)
const hasForeign = b.entries.some(e => e.company_id !== CO.A.dbId && e.company_id !== null);
ok(!hasForeign, 'TZ+.2: первая компания — активная, чужих записей нет');
}
// ══════════════════════════════════════════════════════════════════════════
// Cleanup
// ══════════════════════════════════════════════════════════════════════════
tee('\n═══ Cleanup ═══\n');
// Удаляем все тестовые записи
for (const co of companies) {
await pool.query(
`UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'tz-full-cleanup'
WHERE company_id = $1 AND deleted_at IS NULL`,
[co.dbId]
);
}
tee('✅ Тестовые записи удалены (soft-delete)');
// ══════════════════════════════════════════════════════════════════════════
// Итоги
// ══════════════════════════════════════════════════════════════════════════
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
tee('\n══════════════════════════════════════════════════════');
tee(` ${pass} PASS, ${fail} FAIL — ${elapsed}с`);
tee('══════════════════════════════════════════════════════');
if (errors.length > 0) {
tee('\nОшибки:');
errors.forEach(e => tee(' ' + e));
}
saveLog();
await pool.end();
process.exit(fail > 0 ? 1 : 0);
})();