Files
ipwhitelist-app/tests/tz-compliance.js
T
naeel 15b458eb35 v0.5.11: зачистка мёртвого кода + новые интеграционные тесты
Удалено:
- src/routes/*.js (5 файлов) — старая монолитная архитектура
- views/dev-login.ejs — старый dev-логин
- tests/debug-session.js — тесты старой архитектуры

Добавлено:
- safeReturn в src/auth.js
- Новые интеграционные тесты tests/integration.js (67 тестов, UI+API)

Обновлено:
- tests/run-tests.js — убраны старые require, добавлены новые модули
- tests/tz-compliance.js — /exp → /api/v1/entries/export
- server.js — убран require('./src/routes/exp')
2026-06-02 18:04:33 +03:00

352 lines
19 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-compliance.js — проверка соответствия ТЗ через API
* Запуск: node tests/tz-compliance.js
* Требует: DEV_MODE=true, .env с БД
*/
require('dotenv').config();
const supertest = require('supertest');
const { pool } = require('../src/db');
const { deleteTestAudit, softDeleteByCIDR } = require('./helpers');
let pass = 0, fail = 0;
const errors = [];
function log(ok, name, detail = '') {
if (ok) { console.log(' ✅', name); pass++; }
else { const msg = ' ❌ ' + name + (detail ? ': ' + detail : ''); console.error(msg); errors.push(msg); fail++; }
}
function bearer(t) { return { Authorization: 'Bearer ' + t }; }
function cidr(ip) { return ip.includes('/') ? ip : ip + '/32'; }
(async () => {
console.log('\n══════════════════════════════════════════');
console.log(' ТЗ Compliance Check — v0.5.0');
console.log('══════════════════════════════════════════\n');
// ── Тестовые CIDR из «кривых» диапазонов ──
// Фиксированные — cleanup всегда работает, даже между разными прогонами.
// Проверка коллизий: если реальная запись существует, тест её не трогает.
const C = {
A: '37.19.199.1/32', B: '46.18.188.2/32', C: '79.133.177.3/32',
SUB: '91.210.166.4/32', // для нормализации host bits
M22: '176.120.0.0/22', M24: '185.50.0.0/24', M32: '212.8.155.7/32',
ADM: '37.19.199.8/32',
};
// /30 для overlap — C.A=37.19.199.1, /30 покрывает 37.19.199.0-3 → включает C.A
const overlapCIDR = C.A.replace(/\/32$/, '/30');
// Нормализация: 91.210.166.5/24 → 91.210.166.0/24
const subParts = C.SUB.replace(/\/32$/, '').split('.').map(Number);
const subNormalized = subParts.slice(0,3).join('.') + '.0/24';
const allTestCIDRs = [C.A, C.B, C.C, C.SUB, C.M22, C.M24, C.M32, C.ADM,
subNormalized, overlapCIDR];
console.log(' Тестовые CIDR:', C.A, C.B, C.C, '...');
console.log(' Тестовые CIDR:', C.A, C.B, C.C, '...');
// ── Pre-cleanup ──
await softDeleteByCIDR(allTestCIDRs);
console.log(' Pre-cleanup: тестовые CIDR очищены\n');
const mod = require('../server');
const app = await mod.start();
const auth = await require('../src/auth').initAuth();
const req = supertest(app);
const { MOCK_USERS } = require('../src/config');
const admin = MOCK_USERS.find(u => u.id === 'admin');
const user = MOCK_USERS.find(u => u.id === 'alfa');
const tokAdmin = auth.issueMockToken({ clientId: admin.clientId, companyId: admin.companyId, companyName: admin.companyName, email: admin.email });
const tokUser = auth.issueMockToken({ clientId: user.clientId, companyId: user.companyId, companyName: user.companyName, email: user.email });
// ══════════════════════════════════════════════════
// 4.1. Просмотр списка записей
// ══════════════════════════════════════════════════
console.log('── ТЗ 4.1: Просмотр списка ──');
{
const r = await req.get('/api/v1/entries').set(bearer(tokUser));
log(r.status === 200, '4.1a: GET /entries → 200');
const b = r.body || {};
log(Array.isArray(b.entries), '4.1b: entries[] — массив');
log(typeof b.limit === 'number', '4.1c: limit — число', `limit=${b.limit}`);
log(typeof b.used === 'number', '4.1d: used — число (X из N)', `used=${b.used}`);
log(b.used === b.entries.length, '4.1e: used === entries.length', `used=${b.used} len=${b.entries.length}`);
}
// 4.1f: Soft-deleted filter for admin
{
// Создаём и удаляем запись
const cr = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: C.A, comment: 'tz-del-test' });
const eid = cr.body?.entry?.id;
if (eid) {
await req.delete('/api/v1/entries/' + eid).set(bearer(tokUser));
// Admin: проверяем что GET /entries НЕ показывает удалённую запись
const ar = await req.get('/api/v1/entries?company=' + (await (async () => {
const c = await pool.query('SELECT id FROM companies WHERE client_id=$1', [user.clientId]);
return c.rows[0]?.id;
})())).set(bearer(tokAdmin));
const found = (ar.body?.entries || []).find(e => e.id === eid);
log(!found, '4.1f: soft-deleted скрыты от admin (по умолчанию)');
}
// Чистка
await pool.query('UPDATE whitelist_entries SET deleted_at=NOW() WHERE value_cidr=$1 AND deleted_at IS NULL', [cidr(C.A)]);
}
// ══════════════════════════════════════════════════
// 4.2. Создание записи
// ══════════════════════════════════════════════════
console.log('── ТЗ 4.2: Создание записи ──');
let entryId;
{
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: C.A, comment: 'tz-create' });
log(r.status === 201, '4.2a: POST /entries → 201', `status=${r.status}`);
const b = r.body || {};
log(!!b.entry?.id, '4.2b: ответ содержит entry.id');
log(b.entry?.value_cidr === cidr(C.A), '4.2c: value_cidr сохранён верно');
log(typeof b.wasNormalized === 'boolean', '4.2d: wasNormalized в ответе');
entryId = b.entry?.id;
}
// 4.2e: Проверка лимита
{
// Узнаём текущий лимит и использовано
const r = await req.get('/api/v1/entries').set(bearer(tokUser));
const limit = r.body?.limit || 15;
const used = r.body?.used || 1;
log(used <= limit, `4.2e: used(${used}) ≤ limit(${limit})`);
}
// 4.2f: Дубликат → 409
{
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: C.A, comment: 'dup' });
log(r.status === 409, '4.2f: дубликат → 409', `status=${r.status}`);
}
// 4.2g: Запрещённый диапазон → 400
{
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: '192.168.1.1', comment: 'private' });
log(r.status === 400, '4.2g: приватный IP → 400', `status=${r.status}`);
}
// 4.2h: Пустое значение → 400
{
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: '', comment: '' });
log(r.status === 400, '4.2h: пустое значение → 400', `status=${r.status}`);
}
// 4.2i: Comment > 255 → 400
{
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: C.B, comment: 'x'.repeat(256) });
log(r.status === 400, '4.2i: comment > 255 → 400', `status=${r.status}`);
}
// ══════════════════════════════════════════════════
// 4.3. Редактирование записи
// ══════════════════════════════════════════════════
console.log('── ТЗ 4.3: Редактирование ──');
{
const r = await req.patch('/api/v1/entries/' + entryId).set(bearer(tokUser)).send({ value: C.B, comment: 'updated' });
log(r.status === 200, '4.3a: PATCH /entries/:id → 200', `status=${r.status}`);
log(r.body?.entry?.value_cidr === cidr(C.B), '4.3b: value обновлён');
log(r.body?.entry?.comment === 'updated', '4.3c: comment обновлён');
}
// 4.3d: Редактирование с пересечением → 409
{
// Создаём две записи: entryA с C.A, entryB с C.C
const rA = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: C.A, comment: 'keep-A' });
const rB = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: C.C, comment: 'keep-C' });
const idB = rB.body?.entry?.id;
// Пытаемся обновить entryB на overlapCIDR (пересекается с C.A из entryA)
const r = await req.patch('/api/v1/entries/' + idB).set(bearer(tokUser)).send({ value: overlapCIDR, comment: '' });
log(r.status === 409, '4.3d: пересечение при edit → 409', `status=${r.status}`);
await pool.query('UPDATE whitelist_entries SET deleted_at=NOW() WHERE value_cidr IN ($1,$2) AND deleted_at IS NULL', [cidr(C.A), cidr(C.C)]);
}
// 4.3e: Чужой пользователь не может редактировать
{
const r = await req.patch('/api/v1/entries/' + entryId).set(bearer(tokUser + 'WRONG')).send({ value: C.A, comment: '' });
log(r.status === 401, '4.3e: невалидный токен → 401', `status=${r.status}`);
}
// ══════════════════════════════════════════════════
// 4.4. Удаление (soft delete)
// ══════════════════════════════════════════════════
console.log('── ТЗ 4.4: Soft delete ──');
{
const r = await req.delete('/api/v1/entries/' + entryId).set(bearer(tokUser));
log([200, 204].includes(r.status), '4.4a: DELETE /entries/:id → 200/204', `status=${r.status}`);
// Проверяем что запись не в списке
const r2 = await req.get('/api/v1/entries').set(bearer(tokUser));
const found = (r2.body?.entries || []).find(e => e.id === entryId);
log(!found, '4.4b: удалённая запись не видна в GET /entries');
// Проверяем что удаление освободило место в лимите
const r3 = await req.get('/api/v1/entries').set(bearer(tokUser));
log((r3.body?.used || 0) <= (r3.body?.limit || 15), '4.4c: used ≤ limit после удаления');
}
// ══════════════════════════════════════════════════
// 4.5. Лимиты
// ══════════════════════════════════════════════════
console.log('── ТЗ 4.5: Лимиты ──');
{
// 4.5a: Глобальный лимит по умолчанию = 15
const r = await req.get('/api/v1/entries').set(bearer(tokUser));
log(r.body?.limit > 0, '4.5a: limit > 0 (глобальный default)', `limit=${r.body?.limit}`);
// 4.5b: Admin может установить per-company limit
const comps = await req.get('/api/v1/companies').set(bearer(tokAdmin));
const userCo = (comps.body?.companies || []).find(c => c.client_id === user.clientId);
if (userCo) {
// Установить лимит 3
const r2 = await req.patch('/api/v1/companies/' + userCo.id + '/limit').set(bearer(tokAdmin)).send({ limit: 3 });
log(r2.status === 200, '4.5b: admin setLimit → 200', `status=${r2.status}`);
// Сбросить
await req.patch('/api/v1/companies/' + userCo.id + '/limit').set(bearer(tokAdmin)).send({ limit: null });
}
// 4.5c: Обычный юзер не может менять лимит
const r3 = await req.patch('/api/v1/companies/1/limit').set(bearer(tokUser)).send({ limit: 999 });
log(r3.status === 403, '4.5c: user setLimit → 403', `status=${r3.status}`);
}
// ══════════════════════════════════════════════════
// 4.6. Журнал аудита
// ══════════════════════════════════════════════════
console.log('── ТЗ 4.6: Журнал аудита ──');
{
const r = await req.get('/api/v1/audit').set(bearer(tokAdmin));
log(r.status === 200, '4.6a: GET /audit → 200 (admin)');
log(Array.isArray(r.body?.rows), '4.6b: rows[] — массив');
log(Array.isArray(r.body?.companies), '4.6c: companies[] — массив');
// Проверяем что CREATE попал в аудит
const createAudit = (r.body?.rows || []).find(a => a.action === 'CREATE');
log(!!createAudit, '4.6d: CREATE записан в audit_log', createAudit ? `entry_id=${createAudit.entry_id}` : '');
// User не может смотреть аудит
const r2 = await req.get('/api/v1/audit').set(bearer(tokUser));
log(r2.status === 403, '4.6e: user GET /audit → 403');
}
// ══════════════════════════════════════════════════
// 4.7. Внешняя выдача (export)
// ══════════════════════════════════════════════════
console.log('── ТЗ 4.7: Export ──');
{
// 4.7a: /api/v1/entries/export (требует Bearer token)
const cr = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: C.A, comment: 'export-test' });
const r = await req.get('/api/v1/entries/export').set(bearer(tokUser));
log(r.status === 200, '4.7a: GET /api/v1/entries/export → 200', `status=${r.status}`);
log((r.headers['content-type'] || '').includes('text/plain'), '4.7b: Content-Type text/plain');
log(r.text.includes(cidr(C.A)), '4.7c: export содержит добавленный CIDR');
}
// ══════════════════════════════════════════════════
// 5. Валидация (Приложение А)
// ══════════════════════════════════════════════════
console.log('── ТЗ 5 + Приложение А: Валидация ──');
// 5a: Маски /22-/32
{
for (const [m, cidrVal] of [[22, C.M22], [24, C.M24], [32, C.M32]]) {
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: cidrVal, comment: `mask-${m}` });
log(r.status === 201, `5.m${m}: /${m} → 201`, `status=${r.status}`);
}
for (const m of [21, 33]) {
const badCIDR = C.M22.replace(/\/22$/, `/${m}`);
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: badCIDR, comment: `bad-${m}` });
log(r.status === 400, `5.m${m}: /${m} → 400`, `status=${r.status}`);
}
}
// 5b: Нормализация host bits
{
const subParts = C.SUB.split('/')[0].split('.');
subParts[3] = '5'; // host=5, должен нормализоваться в .0
const unnormalized = subParts.join('.') + '/24';
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: unnormalized, comment: 'normalize' });
log(r.status === 201, '5.na: нормализация → 201');
const expectedNet = subParts.slice(0,3).join('.') + '.0/24';
log(r.body?.entry?.value_cidr === expectedNet, '5.nb: нормализован в ' + expectedNet, `got=${r.body?.entry?.value_cidr}`);
log(r.body?.wasNormalized === true, '5.nc: wasNormalized=true');
}
// 5c: Все 14 запрещённых диапазонов
{
const blocked = [
'10.0.0.1', '172.16.0.1', '192.168.0.1', '100.64.0.1',
'127.0.0.1', '169.254.0.1', '192.0.0.1', '192.0.2.1',
'198.51.100.1', '203.0.113.1', '198.18.0.1',
'224.0.0.1', '240.0.0.1', '255.255.255.255',
];
let allBlocked = true;
for (const ip of blocked) {
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: ip, comment: 'blocked' });
if (r.status !== 400) { allBlocked = false; break; }
}
log(allBlocked, '5.bl: все 14 запрещённых → 400');
}
// 5d: IPv6 → 400
{
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: '::1', comment: 'ipv6' });
log(r.status === 400, '5.ip6: IPv6 → 400');
}
// 5e: Дубликаты внутри компании → 409
// (уже проверено в 4.2f)
// ══════════════════════════════════════════════════
// 3. Роли и права
// ══════════════════════════════════════════════════
console.log('── ТЗ 3: Роли и права ──');
// 3a: Admin видит все компании
{
const r = await req.get('/api/v1/companies').set(bearer(tokAdmin));
const companies = r.body?.companies || [];
log(companies.length >= 1, '3.aa: admin видит все компании', `count=${companies.length}`);
}
// 3b: Admin может CRUD в чужой компании
{
const userCo = (await pool.query('SELECT id FROM companies WHERE client_id=$1', [user.clientId])).rows[0];
if (userCo) {
const r = await req.post('/api/v1/entries?company=' + userCo.id).set(bearer(tokAdmin)).send({ value: C.ADM, comment: 'admin-crud' });
log(r.status === 201, '3.ba: admin POST /entries?company= → 201', `status=${r.status}`);
const eid = r.body?.entry?.id;
if (eid) {
await req.delete('/api/v1/entries/' + eid + '?company=' + userCo.id).set(bearer(tokAdmin));
}
}
}
// 3c: Обычный юзер не видит чужие компании
{
const r = await req.get('/api/v1/companies').set(bearer(tokUser));
log(r.status === 403, '3.ca: user GET /companies → 403');
}
// ══════════════════════════════════════════════════
// CLEANUP
// ══════════════════════════════════════════════════
console.log('\n── Cleanup ──');
await softDeleteByCIDR(allTestCIDRs);
await deleteTestAudit();
await pool.end();
console.log('\n══════════════════════════════════════════');
console.log(` ${pass} PASS, ${fail} FAIL`);
if (errors.length) { console.log('\nПровалы:'); errors.forEach(e => console.log(' ', e)); }
console.log('══════════════════════════════════════════');
process.exit(fail > 0 ? 1 : 0);
})().catch(e => { console.error('FATAL:', e.message); pool.end(); process.exit(1); });