feat: integration tests CRUD 34/34 (supertest + real DB)

This commit is contained in:
2026-05-30 17:06:42 +03:00
parent 093b96b9ae
commit e97c36f19f
5 changed files with 742 additions and 4 deletions
+458
View File
@@ -0,0 +1,458 @@
'use strict';
/**
* tests/integration.js — интеграционные тесты CRUD против реальной БД.
*
* Запускать: node tests/integration.js
* Требует: .env с данными БД, DEV_MODE=true.
*
* Что тестируем:
* - Логин через /dev-login (получение сессии) от user и admin
* - GET / — страница загружается
* - POST /add — создание записи
* - POST /edit/:id — редактирование
* - POST /delete/:id — удаление
* - Изоляция: user не видит чужие записи через company_id
* - Лимит: создание сверх лимита → ошибка
* - Дубликат CIDR → ошибка
* - Невалидный CIDR → ошибка
* - Admin: может работать с любой компанией
*/
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(' PASS', name);
pass++;
} else {
const msg = ' FAIL ' + name + (detail ? ': ' + detail : '');
console.error(msg);
errors.push(msg);
fail++;
}
}
// ── Хелперы ──────────────────────────────────────────────────────────────────
/**
* Создаёт агент (сессию) и логинится под нужным пользователем.
* Возвращает agent с уже установленными cookies.
*/
async function loginAs(app, userId) {
const agent = supertest.agent(app);
// GET /dev-login → берём CSRF-токен из скрытого поля формы
const getRes = await agent.get('/dev-login');
if (getRes.status !== 200) throw new Error(`GET /dev-login: ${getRes.status}`);
const csrfMatch = getRes.text.match(/name="_csrf"\s+value="([^"]+)"/);
if (!csrfMatch) throw new Error('CSRF-токен не найден в форме /dev-login');
const csrfToken = csrfMatch[1];
// POST /dev-login — логинимся
const postRes = await agent
.post('/dev-login')
.type('form')
.send({ preset: userId, _csrf: csrfToken });
// Ожидаем редирект на /
if (postRes.status !== 302 && postRes.status !== 200) {
throw new Error(`POST /dev-login: ${postRes.status} ${postRes.text.slice(0, 200)}`);
}
return agent;
}
/**
* Получает CSRF-токен со страницы / (нужен для POST /add, /edit, /delete).
*/
async function getCsrfFromIndex(agent) {
const res = await agent.get('/');
if (res.status !== 200) throw new Error(`GET /: ${res.status}`);
const m = res.text.match(/name="_csrf"\s+value="([^"]+)"/);
if (!m) throw new Error('CSRF-токен не найден на главной странице');
return m[1];
}
/**
* Извлекает query-параметр из Location-заголовка редиректа.
*/
function getRedirectParam(res, param) {
const loc = res.headers.location || '';
try {
// Location может быть относительным — добавим базу
const url = new URL(loc, 'http://localhost');
return url.searchParams.get(param);
} catch {
return null;
}
}
// ── Тестовые данные ─────────────────────────────────────────────────────────
// Используем фиксированные публичные IP из диапазона TEST-NET, не в BLOCKED_RANGES.
// Cleanup удаляет их по company после тестов.
const TEST_CIDR_1 = '8.8.4.4'; // добавим, проверим дубликат и удалим
const TEST_CIDR_2 = '9.9.9.5/24'; // нормализуется в 9.9.9.0/24
const TEST_CIDR_OVERLAP = '9.9.9.100'; // пересекается с 9.9.9.0/24
const TEST_CIDR_ADMIN = '1.1.1.100'; // admin добавит в компанию user
const TEST_CLIENT_IDS = ['TEST-INTEG-001', 'TEST-INTEG-002'];
// ── Чистка тестовых данных ────────────────────────────────────────────────────
async function cleanup() {
// Удаляем конкретные тестовые CIDR из компаний WZ01325 и WZ01112
const testCidrs = ['8.8.4.4/32', '8.8.4.5/32', '9.9.9.0/24', '9.9.9.100/32', '1.1.1.100/32'];
for (const cidr of testCidrs) {
await pool.query(
`UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'test-cleanup'
WHERE value_cidr = $1 AND deleted_at IS NULL`, [cidr]
);
}
// Удаляем тестовые компании и все их записи (каскадно через транзакцию)
for (const cid of TEST_CLIENT_IDS) {
const comp = await pool.query('SELECT id FROM companies WHERE client_id = $1', [cid]);
if (comp.rows.length === 0) continue;
const compId = comp.rows[0].id;
await pool.query('DELETE FROM audit_log WHERE company_id = $1', [compId]);
await pool.query('DELETE FROM whitelist_entries WHERE company_id = $1', [compId]);
await pool.query('DELETE FROM companies WHERE id = $1', [compId]);
}
}
// ── Основной тест-раннер ──────────────────────────────────────────────────────
(async () => {
console.log('\n══════════════════════════════════════════════');
console.log(' Интеграционные тесты (реальная БД)');
console.log('══════════════════════════════════════════════\n');
// Запускаем сервер
let server, app;
try {
// server.js экспортирует app после start()
const mod = require('../server');
app = await mod.start();
} catch (e) {
console.error('FATAL: не удалось запустить сервер:', e.message);
await pool.end();
process.exit(1);
}
await cleanup();
console.log('── Cleanup done ──\n');
// ─────────────────────────────────────────────────────────────────────────────
// 1. Логин
// ─────────────────────────────────────────────────────────────────────────────
console.log('── 1. Логин ──');
let userAgent, adminAgent, user2Agent;
try {
userAgent = await loginAs(app, 'test');
log(true, 'login as user (test)');
} catch (e) { log(false, 'login as user (test)', e.message); }
try {
adminAgent = await loginAs(app, 'admin');
log(true, 'login as admin');
} catch (e) { log(false, 'login as admin', e.message); }
try {
user2Agent = await loginAs(app, 'client2');
log(true, 'login as user2 (client2)');
} catch (e) { log(false, 'login as user2 (client2)', e.message); }
if (!userAgent || !adminAgent) {
console.error('\nFATAL: не удалось залогиниться, прерываем тесты');
await cleanup();
await pool.end();
process.exit(1);
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. GET / — страница загружается
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 2. GET / ──');
{
const res = await userAgent.get('/');
log(res.status === 200, 'user: GET / → 200');
log(res.text.includes('Доверенные адреса'), 'user: страница содержит "Доверенные адреса"');
// company встречается в скрытых полях форм — это нормально
log(!res.text.includes('<select name="company"') || res.text.includes('isAdmin') === false, 'user: нет select переключателя компаний');
}
{
const res = await adminAgent.get('/');
log(res.status === 200, 'admin: GET / → 200');
log(res.text.includes('Доверенные адреса'), 'admin: страница загружена');
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. POST /add — создание записей от user
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 3. POST /add (user) ──');
let entryId; // сохраним для edit/delete
{
const csrf = await getCsrfFromIndex(userAgent);
const res = await userAgent.post('/add').type('form')
.send({ value: TEST_CIDR_1, comment: 'тест 1', _csrf: csrf });
log(res.status === 302, 'user: POST /add 10.10.10.1 → redirect');
const msg = getRedirectParam(res, 'message');
const err = getRedirectParam(res, 'error');
log(!err && (msg === 'Добавлено' || msg?.includes('нормализован')),
'user: /add → сообщение "Добавлено"', `msg=${msg} err=${err}`);
}
{
// Нормализация: 9.9.9.5/24 → 9.9.9.0/24
const csrf = await getCsrfFromIndex(userAgent);
const res = await userAgent.post('/add').type('form')
.send({ value: TEST_CIDR_2, comment: 'нормализация', _csrf: csrf });
log(res.status === 302, 'user: POST /add 10.20.30.5/24 → redirect');
const msg = getRedirectParam(res, 'message');
log(msg?.includes('нормализован'), 'user: /add → адрес нормализован', `msg=${msg}`);
}
// Сохраняем id добавленной записи для дальнейших тестов
// Читаем из БД напрямую — ищем TEST_CIDR_1 (8.8.4.4/32)
{
const r = await pool.query(
`SELECT id FROM whitelist_entries WHERE value_cidr = $1 AND deleted_at IS NULL`,
[TEST_CIDR_1 + '/32']
);
entryId = r.rows[0]?.id || null;
log(!!entryId, 'user: нашли entryId в странице', `entryId=${entryId}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. POST /add — дубликат → ошибка
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 4. Дубликат CIDR ──');
{
const csrf = await getCsrfFromIndex(userAgent);
const res = await userAgent.post('/add').type('form')
.send({ value: TEST_CIDR_1, comment: 'дубликат', _csrf: csrf });
const err = getRedirectParam(res, 'error');
log(res.status === 302 && err?.includes('уже существует'),
'user: дубликат → ошибка "уже существует"', `err=${err}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// 5. POST /add — невалидный CIDR → ошибка
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 5. Невалидный CIDR ──');
{
const csrf = await getCsrfFromIndex(userAgent);
const res = await userAgent.post('/add').type('form')
.send({ value: 'не-адрес', comment: '', _csrf: csrf });
const err = getRedirectParam(res, 'error');
log(res.status === 302 && !!err, 'user: невалидный CIDR → ошибка', `err=${err}`);
}
{
// Октеты > 255
const csrf = await getCsrfFromIndex(userAgent);
const res = await userAgent.post('/add').type('form')
.send({ value: '999.0.0.1', comment: '', _csrf: csrf });
const err = getRedirectParam(res, 'error');
log(res.status === 302 && !!err, 'user: октет > 255 → ошибка', `err=${err}`);
}
{
// Маска > 32
const csrf = await getCsrfFromIndex(userAgent);
const res = await userAgent.post('/add').type('form')
.send({ value: '10.0.0.1/33', comment: '', _csrf: csrf });
const err = getRedirectParam(res, 'error');
log(res.status === 302 && !!err, 'user: маска /33 → ошибка', `err=${err}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// 6. POST /edit/:id — редактирование
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 6. POST /edit (user) ──');
if (entryId) {
const csrf = await getCsrfFromIndex(userAgent);
const res = await userAgent.post(`/edit/${entryId}`).type('form')
.send({ value: '8.8.4.5', comment: 'обновлено', _csrf: csrf });
log(res.status === 302, `user: POST /edit/${entryId} → redirect`);
const msg = getRedirectParam(res, 'message');
const err = getRedirectParam(res, 'error');
log(!err && (msg === 'Сохранено' || msg?.includes('нормализован')),
'user: /edit → "Сохранено"', `msg=${msg} err=${err}`);
} else {
log(false, 'user: /edit — нет entryId, тест пропущен');
}
// ─────────────────────────────────────────────────────────────────────────────
// 7. Изоляция: user2 не может редактировать чужую запись
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 7. Изоляция между пользователями ──');
if (entryId && user2Agent) {
const csrf = await getCsrfFromIndex(user2Agent);
const res = await user2Agent.post(`/edit/${entryId}`).type('form')
.send({ value: '1.2.3.4', comment: 'чужая', _csrf: csrf });
const err = getRedirectParam(res, 'error');
log(res.status === 302 && !!err,
'user2: edit чужой записи → ошибка (изоляция)', `err=${err}`);
} else {
log(false, 'изоляция: нет entryId или user2Agent');
}
if (entryId && user2Agent) {
const csrf = await getCsrfFromIndex(user2Agent);
const res = await user2Agent.post(`/delete/${entryId}`).type('form')
.send({ _csrf: csrf });
const err = getRedirectParam(res, 'error');
log(res.status === 302 && !!err,
'user2: delete чужой записи → ошибка (изоляция)', `err=${err}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// 8. Пересечение CIDR → ошибка
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 8. Пересечение CIDR ──');
{
// 9.9.9.100 пересекается с 9.9.9.0/24 (уже добавлен)
const csrf = await getCsrfFromIndex(userAgent);
const res = await userAgent.post('/add').type('form')
.send({ value: TEST_CIDR_OVERLAP, comment: 'пересечение', _csrf: csrf });
const err = getRedirectParam(res, 'error');
log(res.status === 302 && err?.includes('Пересечение'),
'user: CIDR пересекается с существующим → ошибка', `err=${err}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// 9. Admin — работа с компанией пользователя
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 9. Admin CRUD по компании user ──');
// Получаем id компании user из БД
const userComp = await pool.query(
"SELECT id FROM companies WHERE client_id = 'WZ01325'"
);
const userCompId = userComp.rows[0]?.id;
log(!!userCompId, 'admin: нашли компанию user в БД', `id=${userCompId}`);
if (userCompId) {
// Admin добавляет запись в компанию user
const csrf = await getCsrfFromIndex(adminAgent);
const res = await adminAgent.post('/add').type('form')
.send({ value: TEST_CIDR_ADMIN, comment: 'от admin', company_id: userCompId, _csrf: csrf });
log(res.status === 302, 'admin: POST /add в чужую компанию → redirect');
const msg = getRedirectParam(res, 'message');
const err = getRedirectParam(res, 'error');
log(!err && (msg === 'Добавлено' || msg?.includes('нормализован')),
'admin: /add в компанию user → Добавлено', `msg=${msg} err=${err}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// 10. POST /delete/:id — удаление (user своей записи)
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 10. POST /delete (user) ──');
if (entryId) {
const csrf = await getCsrfFromIndex(userAgent);
const res = await userAgent.post(`/delete/${entryId}`).type('form')
.send({ _csrf: csrf });
log(res.status === 302, `user: POST /delete/${entryId} → redirect`);
const msg = getRedirectParam(res, 'message');
const err = getRedirectParam(res, 'error');
log(!err && msg === 'Запись удалена', 'user: /delete → "Запись удалена"', `msg=${msg} err=${err}`);
// Проверяем что мягко удалена (deleted_at выставлен)
const row = await pool.query(
'SELECT deleted_at FROM whitelist_entries WHERE id = $1', [entryId]
);
log(!!row.rows[0]?.deleted_at, 'user: deleted_at выставлен в БД (soft delete)');
}
// ─────────────────────────────────────────────────────────────────────────────
// 11. Повторное удаление удалённой записи → ошибка
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 11. Повторное удаление ──');
if (entryId) {
const csrf = await getCsrfFromIndex(userAgent);
const res = await userAgent.post(`/delete/${entryId}`).type('form')
.send({ _csrf: csrf });
const err = getRedirectParam(res, 'error');
log(res.status === 302 && !!err,
'user: повторное /delete удалённой записи → ошибка', `err=${err}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// 12. Неавторизованный доступ → редирект на /login
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 12. Неавторизованный доступ ──');
{
const res = await supertest(app).get('/');
log(res.status === 302 && res.headers.location?.includes('login'),
'анонимный: GET / → redirect /login', `loc=${res.headers.location}`);
}
{
const res = await supertest(app).post('/add').type('form').send({ value: '1.2.3.4' });
// CSRF middleware отдаёт 401 до auth.middleware — анонимный не проходит
log([302, 401, 403].includes(res.status),
'анонимный: POST /add → не 200 (302/401/403)', `status=${res.status}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// 13. /export — изоляция
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── 13. GET /export ──');
{
const res = await userAgent.get('/export');
log(res.status === 200, 'user: GET /export → 200');
// CIDR другой компании не должен попасть
const text = res.text;
// 172.16.0.1 добавил admin в компанию user — должен быть в экспорте
// но не должно быть CIDRов от WZ02001
log(!text.includes('WZ02001'), 'user: /export не содержит данных другой компании');
}
{
const res = await adminAgent.get('/export');
log(res.status === 200, 'admin: GET /export → 200 (все компании)');
}
// ─────────────────────────────────────────────────────────────────────────────
// Cleanup
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n── Cleanup ──');
await cleanup();
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('\nFATAL:', e.message);
pool.end();
process.exit(1);
});