Files
ipwhitelist-app/tests/tz-compliance.js
T

323 lines
18 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');
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 }; }
(async () => {
console.log('\n══════════════════════════════════════════');
console.log(' ТЗ Compliance Check — v0.5.0');
console.log('══════════════════════════════════════════\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 === 'test');
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 });
const C = { A: '8.8.4.1', B: '8.8.4.2', C: '8.8.4.3', SUB: '9.9.9.0/24' };
function cidr(ip) { return ip.includes('/') ? ip : ip + '/32'; }
// ══════════════════════════════════════════════════
// 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
{
// Создаём вторую запись
await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: C.C, comment: 'for-overlap' });
// Пытаемся обновить вторую на пересекающуюся с первой
const r = await req.patch('/api/v1/entries/' + entryId).set(bearer(tokUser)).send({ value: '8.8.4.2/31', 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=$1 AND deleted_at IS NULL', [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: /exp публичный (временный обходной путь)
const r = await req.get('/exp');
log(r.status === 200, '4.7a: GET /exp → 200 (без авторизации)');
log((r.headers['content-type'] || '').includes('text/plain'), '4.7b: Content-Type text/plain');
// 4.7c: /exp содержит только активные записи (проверим через добавление)
const cr = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: C.A, comment: 'export-test' });
const r2 = await req.get('/exp');
log(r2.text.includes(cidr(C.A)), '4.7c: /exp содержит добавленный CIDR');
}
// ══════════════════════════════════════════════════
// 5. Валидация (Приложение А)
// ══════════════════════════════════════════════════
console.log('── ТЗ 5 + Приложение А: Валидация ──');
// 5a: Маски /22-/32
{
for (const m of [22, 24, 32]) {
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: `85.0.0.0/${m}`, comment: `mask-${m}` });
log(r.status === 201, `5.m${m}: /${m} → 201`, `status=${r.status}`);
}
for (const m of [21, 33]) {
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: `85.0.0.0/${m}`, comment: `bad-${m}` });
log(r.status === 400, `5.m${m}: /${m} → 400`, `status=${r.status}`);
}
}
// 5b: Нормализация host bits
{
const r = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: '9.9.9.5/24', comment: 'normalize' });
log(r.status === 201, '5.na: 9.9.9.5/24 → 201');
log(r.body?.entry?.value_cidr === '9.9.9.0/24', '5.nb: нормализован в 9.9.9.0/24', `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: '1.2.4.1', 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 ──');
for (const c of [C.A, C.B, C.C, C.SUB, '9.9.9.0/24', '85.0.0.0/22', '85.0.0.0/24', '85.0.0.0/32', '1.2.4.1']) {
await pool.query('UPDATE whitelist_entries SET deleted_at=NOW() WHERE value_cidr=$1 AND deleted_at IS NULL', [cidr(c)]);
}
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); });