Удалено:
- 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')
352 lines
19 KiB
JavaScript
352 lines
19 KiB
JavaScript
'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); });
|