Files
ipwhitelist-app/tests/tz-full-compliance.js
T
naeel 805b440e5d 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/
2026-06-02 18:38:08 +03:00

873 lines
42 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/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);
})();