Покрытие: - §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/
873 lines
42 KiB
JavaScript
873 lines
42 KiB
JavaScript
'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);
|
||
})();
|