feat: comprehensive API tests (104 pass) + fix: validation errors → 400, comment length limit
This commit is contained in:
@@ -10,6 +10,7 @@ function createEntriesRouter({ q }) {
|
|||||||
// Маппинг сообщений ошибок на HTTP-коды
|
// Маппинг сообщений ошибок на HTTP-коды
|
||||||
function handleQueryError(e, res) {
|
function handleQueryError(e, res) {
|
||||||
const msg = e.message || '';
|
const msg = e.message || '';
|
||||||
|
// 409: бизнес-конфликты (дубликат, пересечение, лимит)
|
||||||
if (
|
if (
|
||||||
msg.includes('Лимит исчерпан') ||
|
msg.includes('Лимит исчерпан') ||
|
||||||
msg.includes('Такой адрес уже существует') ||
|
msg.includes('Такой адрес уже существует') ||
|
||||||
@@ -17,9 +18,20 @@ function createEntriesRouter({ q }) {
|
|||||||
) {
|
) {
|
||||||
return res.status(409).json({ error: msg });
|
return res.status(409).json({ error: msg });
|
||||||
}
|
}
|
||||||
|
// 404: запись не найдена
|
||||||
if (msg.includes('Запись не найдена')) {
|
if (msg.includes('Запись не найдена')) {
|
||||||
return res.status(404).json({ error: msg });
|
return res.status(404).json({ error: msg });
|
||||||
}
|
}
|
||||||
|
// 400: ошибки валидации от validate() и blocked-ranges
|
||||||
|
if (
|
||||||
|
msg.includes('Некорректн') ||
|
||||||
|
msg.includes('Маска должна быть') ||
|
||||||
|
msg.includes('Пустое значение') ||
|
||||||
|
msg.includes('IPv6 не поддерживается') ||
|
||||||
|
msg.includes('пересекается с запрещённым')
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: msg });
|
||||||
|
}
|
||||||
return res.status(500).json({ error: msg });
|
return res.status(500).json({ error: msg });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +92,7 @@ function createEntriesRouter({ q }) {
|
|||||||
router.post('/', json, async (req, res) => {
|
router.post('/', json, async (req, res) => {
|
||||||
const { value, comment } = req.body || {};
|
const { value, comment } = req.body || {};
|
||||||
if (!value) return res.status(400).json({ error: 'value required' });
|
if (!value) return res.status(400).json({ error: 'value required' });
|
||||||
|
if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' });
|
||||||
try {
|
try {
|
||||||
const company = await resolveCompany(req);
|
const company = await resolveCompany(req);
|
||||||
const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email);
|
const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email);
|
||||||
@@ -94,6 +107,7 @@ function createEntriesRouter({ q }) {
|
|||||||
router.patch('/:id', json, async (req, res) => {
|
router.patch('/:id', json, async (req, res) => {
|
||||||
const { value, comment } = req.body || {};
|
const { value, comment } = req.body || {};
|
||||||
if (!value) return res.status(400).json({ error: 'value required' });
|
if (!value) return res.status(400).json({ error: 'value required' });
|
||||||
|
if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' });
|
||||||
try {
|
try {
|
||||||
const company = await resolveCompany(req);
|
const company = await resolveCompany(req);
|
||||||
const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email);
|
const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email);
|
||||||
|
|||||||
+903
@@ -0,0 +1,903 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tests/api.js — интеграционные тесты REST API /api/v1/* против реальной БД.
|
||||||
|
*
|
||||||
|
* Запуск: node tests/api.js
|
||||||
|
* Требует: .env с данными БД, DEV_MODE=true.
|
||||||
|
*
|
||||||
|
* В отличие от tests/integration.js — тестирует API-слой напрямую
|
||||||
|
* через Bearer JWT (без сессий, без CSRF, без HTML-форм).
|
||||||
|
*
|
||||||
|
* Разделы:
|
||||||
|
* A. Auth — no/invalid token, 401
|
||||||
|
* B. Entries CRUD — user: создать, читать, обновить, удалить
|
||||||
|
* C. Валидация — неверный CIDR, дубликат, пересечение, нет value
|
||||||
|
* D. Нормализация — 9.9.9.5/24 → 9.9.9.0/24
|
||||||
|
* E. Изоляция — user2 не видит/меняет записи user
|
||||||
|
* F. Export — text/plain, изоляция
|
||||||
|
* G. Admin /companies — список, лимит
|
||||||
|
* H. Admin /audit — журнал
|
||||||
|
* I. Лимит — enforcement через API
|
||||||
|
* J. Безопасность — XSS, длинный comment, пустой comment
|
||||||
|
* K. Граничные значения — /0, /32, /22, маска > 32, IPv6
|
||||||
|
* L. Admin — CRUD по чужой компании (?company=<id>)
|
||||||
|
* M. Admin ACL — user не может вызвать 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Хелперы ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function bearer(token) {
|
||||||
|
return { Authorization: 'Bearer ' + token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Получить body как JSON, независимо от Content-Type */
|
||||||
|
function json(res) {
|
||||||
|
if (typeof res.body === 'object' && res.body !== null) return res.body;
|
||||||
|
try { return JSON.parse(res.text); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Тестовые CIDR (публичные диапазоны, не попадающие в BLOCKED_RANGES) ────────
|
||||||
|
// BLOCKED_RANGES включает: 10/8, 172.16/12, 192.168/16, 100.64/10, 127/8,
|
||||||
|
// 169.254/16, 192.0.0/24, 192.0.2/24, 198.51.100/24, 203.0.113/24,
|
||||||
|
// 198.18/15, 224/4, 240/4, 255.255.255.255/32
|
||||||
|
const C = {
|
||||||
|
A: '8.8.4.1', // /32 хост (Google DNS range — публичный)
|
||||||
|
B: '8.8.4.2',
|
||||||
|
C: '8.8.4.3',
|
||||||
|
D: '8.8.4.4',
|
||||||
|
E: '8.8.4.5',
|
||||||
|
F: '8.8.4.6',
|
||||||
|
G: '8.8.4.7',
|
||||||
|
SUBNET: '9.9.9.5/24', // нормализуется в 9.9.9.0/24 (Quad9, публичный)
|
||||||
|
OVERLAP: '9.9.9.100', // пересекается с 9.9.9.0/24
|
||||||
|
ADMIN: '1.1.1.100', // admin добавляет в компанию user (Cloudflare range)
|
||||||
|
XSS: '1.1.1.10',
|
||||||
|
LIMIT: '1.1.1.20',
|
||||||
|
EXPORT: '1.1.1.30',
|
||||||
|
SLASH32: '1.2.0.1/32', // явный /32
|
||||||
|
SLASH22: '1.2.4.0/22', // /22 подсеть (не пересекается с SLASH32)
|
||||||
|
SLASH0: '0.0.0.0/0', // маска < /22 → ошибка валидации
|
||||||
|
EMPTY_C: '1.1.1.40',
|
||||||
|
LONG_C: '1.1.1.50',
|
||||||
|
NULL_C: '1.1.1.60',
|
||||||
|
BLOCKED: '192.168.1.1', // заблокированный приватный диапазон → 400
|
||||||
|
RACE1: '8.8.5.1',
|
||||||
|
RACE2: '8.8.5.2',
|
||||||
|
RACE3: '8.8.5.3',
|
||||||
|
RACE4: '8.8.5.4',
|
||||||
|
RACE5: '8.8.5.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
// CIDR как они хранятся в БД (с /32 для хостов)
|
||||||
|
function cidr(ip) {
|
||||||
|
return ip.includes('/') ? ip : ip + '/32';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нормализованный CIDR (host-биты обнулены)
|
||||||
|
function normCidr(ipCidr) {
|
||||||
|
// для 9.9.9.5/24 → 9.9.9.0/24
|
||||||
|
if (ipCidr === C.SUBNET) return '9.9.9.0/24';
|
||||||
|
return cidr(ipCidr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ───────────────────────────────────────────────────────────────────
|
||||||
|
async function cleanup() {
|
||||||
|
const allCidrs = Object.values(C)
|
||||||
|
.filter(c => !['0.0.0.0/0', '192.168.1.1'].includes(c)) // нельзя добавить → не нужно чистить
|
||||||
|
.map(c => c.includes('/') ? c : c + '/32');
|
||||||
|
// Нормализованная копия для SUBNET
|
||||||
|
allCidrs.push('9.9.9.0/24');
|
||||||
|
// Явный /32 для SLASH32
|
||||||
|
allCidrs.push('1.2.0.1/32');
|
||||||
|
// /22
|
||||||
|
allCidrs.push('1.2.4.0/22');
|
||||||
|
|
||||||
|
for (const c of allCidrs) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'api-test-cleanup'
|
||||||
|
WHERE value_cidr = $1 AND deleted_at IS NULL`, [c]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Основной тест-раннер ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\n══════════════════════════════════════════════════════');
|
||||||
|
console.log(' API тесты /api/v1/* (Bearer JWT, реальная БД)');
|
||||||
|
console.log('══════════════════════════════════════════════════════\n');
|
||||||
|
|
||||||
|
// ── Запускаем сервер ─────────────────────────────────────────────────────────
|
||||||
|
let app, auth;
|
||||||
|
try {
|
||||||
|
const mod = require('../server');
|
||||||
|
app = await mod.start();
|
||||||
|
// Инициализируем auth отдельно, чтобы выпустить токены
|
||||||
|
auth = await require('../src/auth').initAuth();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('FATAL: не удалось запустить сервер:', e.message);
|
||||||
|
await pool.end();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = supertest(app);
|
||||||
|
|
||||||
|
// ── Токены для трёх пользователей ───────────────────────────────────────────
|
||||||
|
const { MOCK_USERS } = require('../src/config');
|
||||||
|
const uAdmin = MOCK_USERS.find(u => u.id === 'admin');
|
||||||
|
const uUser = MOCK_USERS.find(u => u.id === 'test');
|
||||||
|
const uUser2 = MOCK_USERS.find(u => u.id === 'client2');
|
||||||
|
|
||||||
|
const tokenAdmin = auth.issueMockToken({ clientId: uAdmin.clientId, companyId: uAdmin.companyId, companyName: uAdmin.companyName, email: uAdmin.email });
|
||||||
|
const tokenUser = auth.issueMockToken({ clientId: uUser.clientId, companyId: uUser.companyId, companyName: uUser.companyName, email: uUser.email });
|
||||||
|
const tokenUser2 = auth.issueMockToken({ clientId: uUser2.clientId, companyId: uUser2.companyId, companyName: uUser2.companyName, email: uUser2.email });
|
||||||
|
|
||||||
|
log(!!tokenAdmin && !!tokenUser && !!tokenUser2, 'setup: выпустили токены для admin, user, user2');
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
|
console.log('── Cleanup done ──\n');
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// A. Auth — отсутствие / невалидный токен
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('── A. Auth ──');
|
||||||
|
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/entries');
|
||||||
|
log(r.status === 401, 'A1: GET /entries без токена → 401', `status=${r.status}`);
|
||||||
|
log(!!json(r).error, 'A2: тело содержит error-поле');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/entries').set('Authorization', 'Bearer broken.token.here');
|
||||||
|
log(r.status === 401, 'A3: GET /entries с невалидным токеном → 401', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/entries').set('Authorization', 'Basic dXNlcjpwYXNz');
|
||||||
|
log(r.status === 401, 'A4: Basic auth → 401 (ожидается Bearer)', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries').set(bearer(tokenUser));
|
||||||
|
// Нет body — 400, но не 401
|
||||||
|
log(r.status !== 401, 'A5: POST /entries с валидным токеном → не 401', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// B. Entries CRUD — базовый цикл
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── B. Entries CRUD ──');
|
||||||
|
|
||||||
|
// B1: GET — пустой список (до добавления)
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/entries').set(bearer(tokenUser));
|
||||||
|
log(r.status === 200, 'B1: GET /entries → 200', `status=${r.status}`);
|
||||||
|
const b = json(r);
|
||||||
|
log(Array.isArray(b.entries), 'B2: тело содержит entries[]');
|
||||||
|
log(typeof b.limit === 'number', 'B3: тело содержит limit (число)', `limit=${b.limit}`);
|
||||||
|
log(typeof b.used === 'number', 'B4: тело содержит used (число)', `used=${b.used}`);
|
||||||
|
log(b.used === b.entries.length, 'B5: used === entries.length');
|
||||||
|
}
|
||||||
|
|
||||||
|
// B6: POST — создать запись
|
||||||
|
let entryId;
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: C.A, comment: 'api-тест A' });
|
||||||
|
log(r.status === 201, 'B6: POST /entries → 201', `status=${r.status} body=${JSON.stringify(json(r)).slice(0,120)}`);
|
||||||
|
const b = json(r);
|
||||||
|
if (r.status === 201 && b.entry) {
|
||||||
|
log(!!b.entry.id, 'B7: ответ содержит entry.id');
|
||||||
|
log(b.entry.value_cidr === cidr(C.A), 'B8: entry.value_cidr верный', `got=${b.entry.value_cidr}`);
|
||||||
|
log(b.entry.comment === 'api-тест A', 'B9: entry.comment сохранён');
|
||||||
|
log(b.wasNormalized === false, 'B10: wasNormalized=false для хост-адреса', `got=${b.wasNormalized}`);
|
||||||
|
entryId = b.entry.id;
|
||||||
|
} else {
|
||||||
|
log(false, 'B7: ответ содержит entry.id'); log(false, 'B8: entry.value_cidr верный');
|
||||||
|
log(false, 'B9: entry.comment сохранён'); log(false, 'B10: wasNormalized=false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// B11: GET — запись появилась в списке
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/entries').set(bearer(tokenUser));
|
||||||
|
const b = json(r);
|
||||||
|
const found = b.entries?.find(e => e.id === entryId);
|
||||||
|
log(!!found, 'B11: созданная запись появилась в GET /entries');
|
||||||
|
log(b.used >= 1, 'B12: used >= 1 после добавления', `used=${b.used}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// B13: PATCH — обновить запись
|
||||||
|
if (entryId) {
|
||||||
|
const r = await req.patch('/api/v1/entries/' + entryId)
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: C.B, comment: 'обновлено' });
|
||||||
|
log(r.status === 200, 'B13: PATCH /entries/:id → 200', `status=${r.status}`);
|
||||||
|
const b = json(r);
|
||||||
|
log(b.entry?.value_cidr === cidr(C.B), 'B14: value_cidr обновлён', `got=${b.entry?.value_cidr}`);
|
||||||
|
log(b.entry?.comment === 'обновлено', 'B15: comment обновлён');
|
||||||
|
} else { log(false,'B13: PATCH'); log(false,'B14: value_cidr'); log(false,'B15: comment'); }
|
||||||
|
|
||||||
|
// B16: DELETE — удалить запись
|
||||||
|
{
|
||||||
|
const r = await req.delete('/api/v1/entries/' + entryId).set(bearer(tokenUser));
|
||||||
|
log([200, 204].includes(r.status), 'B16: DELETE /entries/:id → 200/204', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// B17: запись не видна после удаления
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/entries').set(bearer(tokenUser));
|
||||||
|
const b = json(r);
|
||||||
|
const found = b.entries?.find(e => e.id === entryId);
|
||||||
|
log(!found, 'B17: удалённая запись не в списке GET /entries');
|
||||||
|
}
|
||||||
|
|
||||||
|
// B18: soft delete — deleted_at выставлен в БД
|
||||||
|
if (entryId) {
|
||||||
|
const row = await pool.query('SELECT deleted_at FROM whitelist_entries WHERE id = $1', [entryId]);
|
||||||
|
log(!!row.rows[0]?.deleted_at, 'B18: soft delete — deleted_at выставлен в БД');
|
||||||
|
}
|
||||||
|
|
||||||
|
// B19: DELETE несуществующей (уже удалена) → 404
|
||||||
|
{
|
||||||
|
const r = await req.delete('/api/v1/entries/' + entryId).set(bearer(tokenUser));
|
||||||
|
log(r.status === 404, 'B19: повторный DELETE → 404', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// B20: PATCH несуществующей → 404
|
||||||
|
{
|
||||||
|
const r = await req.patch('/api/v1/entries/' + entryId)
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: C.C, comment: '' });
|
||||||
|
log(r.status === 404, 'B20: PATCH удалённой → 404', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// C. Валидация
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── C. Валидация ──');
|
||||||
|
|
||||||
|
// C1: нет value → 400
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ comment: 'нет ip' });
|
||||||
|
log(r.status === 400, 'C1: POST без value → 400', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C2: пустой value → 400 или ошибка валидации
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: '', comment: '' });
|
||||||
|
log([400, 409, 422].includes(r.status), 'C2: POST с value="" → не 201', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C3: текст вместо IP → 400/409
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: 'not-an-ip', comment: '' });
|
||||||
|
log([400, 409, 422].includes(r.status), 'C3: POST "not-an-ip" → не 201', `status=${r.status}`);
|
||||||
|
log(!!json(r).error, 'C4: тело содержит error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// C5: октет > 255
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: '300.0.0.1' });
|
||||||
|
log([400, 409, 422].includes(r.status), 'C5: 300.0.0.1 → не 201', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C6: маска > 32
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: '10.0.0.1/33' });
|
||||||
|
log([400, 409, 422].includes(r.status), 'C6: /33 → не 201', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C7: маска < 0 (negative)
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: '10.0.0.1/-1' });
|
||||||
|
log([400, 409, 422].includes(r.status), 'C7: /-1 → не 201', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// D. Нормализация CIDR
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── D. Нормализация ──');
|
||||||
|
|
||||||
|
// D1: 9.9.9.5/24 → 9.9.9.0/24
|
||||||
|
let subnetEntryId;
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: C.SUBNET, comment: 'нормализация' });
|
||||||
|
log(r.status === 201, 'D1: POST 9.9.9.5/24 → 201', `status=${r.status} body=${JSON.stringify(json(r)).slice(0,120)}`);
|
||||||
|
const b = json(r);
|
||||||
|
log(b.wasNormalized === true, 'D2: wasNormalized=true', `got=${b.wasNormalized}`);
|
||||||
|
log(b.entry?.value_cidr === '9.9.9.0/24', 'D3: нормализован → 9.9.9.0/24', `got=${b.entry?.value_cidr}`);
|
||||||
|
subnetEntryId = b.entry?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// D4: дубликат (сам себя) → 409
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: C.SUBNET, comment: 'дубликат' });
|
||||||
|
log(r.status === 409, 'D4: дубликат нормализованного → 409', `status=${r.status}`);
|
||||||
|
log(json(r).error?.includes('уже существует'), 'D5: error содержит "уже существует"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// D6: пересечение — 9.9.9.100 ⊂ 9.9.9.0/24 → 409
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: C.OVERLAP, comment: 'пересечение' });
|
||||||
|
log(r.status === 409, 'D6: пересекающийся CIDR → 409', `status=${r.status}`);
|
||||||
|
log(json(r).error?.includes('Пересечение'), 'D7: error содержит "Пересечение"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем subnet для чистоты
|
||||||
|
if (subnetEntryId) {
|
||||||
|
await req.delete('/api/v1/entries/' + subnetEntryId).set(bearer(tokenUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// E. Изоляция между компаниями
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── E. Изоляция ──');
|
||||||
|
|
||||||
|
// Добавляем запись под user
|
||||||
|
let isoEntryId;
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: C.C, comment: 'isolation test' });
|
||||||
|
isoEntryId = json(r).entry?.id;
|
||||||
|
log(r.status === 201 && !!isoEntryId, 'E1: user создал запись для теста изоляции');
|
||||||
|
}
|
||||||
|
|
||||||
|
// E2: user2 не видит записи user в GET /entries
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/entries').set(bearer(tokenUser2));
|
||||||
|
const b = json(r);
|
||||||
|
const found = b.entries?.find(e => e.id === isoEntryId);
|
||||||
|
log(!found, 'E2: user2 не видит запись user в своём GET /entries');
|
||||||
|
}
|
||||||
|
|
||||||
|
// E3: user2 не может PATCH чужой записи → 404 (запись не найдена в его компании)
|
||||||
|
{
|
||||||
|
const r = await req.patch('/api/v1/entries/' + isoEntryId)
|
||||||
|
.set(bearer(tokenUser2))
|
||||||
|
.send({ value: C.D, comment: 'IDOR' });
|
||||||
|
log(r.status === 404, 'E3: user2 PATCH чужой записи → 404', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// E4: user2 не может DELETE чужой записи → 404
|
||||||
|
{
|
||||||
|
const r = await req.delete('/api/v1/entries/' + isoEntryId).set(bearer(tokenUser2));
|
||||||
|
log(r.status === 404, 'E4: user2 DELETE чужой записи → 404', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Убедимся что запись user цела
|
||||||
|
{
|
||||||
|
const row = await pool.query('SELECT deleted_at FROM whitelist_entries WHERE id = $1', [isoEntryId]);
|
||||||
|
log(!row.rows[0]?.deleted_at, 'E5: запись user жива после атак user2');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Чистим
|
||||||
|
await req.delete('/api/v1/entries/' + isoEntryId).set(bearer(tokenUser));
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// F. Export
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── F. Export ──');
|
||||||
|
|
||||||
|
// Добавляем запись для export-теста
|
||||||
|
{
|
||||||
|
await req.post('/api/v1/entries').set(bearer(tokenUser)).send({ value: C.EXPORT, comment: 'export' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// F1: GET /api/v1/entries/export → 200 text/plain
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/entries/export').set(bearer(tokenUser));
|
||||||
|
log(r.status === 200, 'F1: GET /export → 200', `status=${r.status}`);
|
||||||
|
log(r.headers['content-type']?.includes('text/plain'), 'F2: Content-Type: text/plain');
|
||||||
|
log(r.text.includes(cidr(C.EXPORT)), 'F3: экспорт содержит добавленный CIDR', `text=${r.text.slice(0,100)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// F4: user export не содержит записи user2
|
||||||
|
{
|
||||||
|
await req.post('/api/v1/entries').set(bearer(tokenUser2)).send({ value: C.D, comment: 'u2-export' });
|
||||||
|
const rUser = await req.get('/api/v1/entries/export').set(bearer(tokenUser));
|
||||||
|
const rUser2 = await req.get('/api/v1/entries/export').set(bearer(tokenUser2));
|
||||||
|
log(!rUser.text.includes(cidr(C.D)), 'F4: экспорт user не содержит CIDR user2');
|
||||||
|
log(!rUser2.text.includes(cidr(C.EXPORT)), 'F5: экспорт user2 не содержит CIDR user');
|
||||||
|
await req.delete('/api/v1/entries/' + (
|
||||||
|
(await pool.query(`SELECT id FROM whitelist_entries WHERE value_cidr=$1 AND deleted_at IS NULL`, [cidr(C.D)])).rows[0]?.id
|
||||||
|
)).set(bearer(tokenUser2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// F6: удалённый CIDR не появляется в export
|
||||||
|
{
|
||||||
|
const exportId = (await pool.query(
|
||||||
|
`SELECT id FROM whitelist_entries WHERE value_cidr=$1 AND deleted_at IS NULL`, [cidr(C.EXPORT)]
|
||||||
|
)).rows[0]?.id;
|
||||||
|
if (exportId) {
|
||||||
|
await req.delete('/api/v1/entries/' + exportId).set(bearer(tokenUser));
|
||||||
|
const r = await req.get('/api/v1/entries/export').set(bearer(tokenUser));
|
||||||
|
log(!r.text.includes(cidr(C.EXPORT)), 'F6: удалённый CIDR не в export');
|
||||||
|
} else {
|
||||||
|
log(false, 'F6: не нашли запись для удаления');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// F7: export без токена → 401
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/entries/export');
|
||||||
|
log(r.status === 401, 'F7: export без токена → 401', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// G. Admin /companies
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── G. Admin /companies ──');
|
||||||
|
|
||||||
|
// G1: admin получает список компаний
|
||||||
|
let userCompanyId;
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/companies').set(bearer(tokenAdmin));
|
||||||
|
log(r.status === 200, 'G1: GET /companies (admin) → 200', `status=${r.status}`);
|
||||||
|
const b = json(r);
|
||||||
|
log(Array.isArray(b.companies), 'G2: тело содержит companies[]');
|
||||||
|
log(b.companies?.length >= 1, 'G3: хотя бы одна компания', `count=${b.companies?.length}`);
|
||||||
|
const comp = b.companies?.find(c => c.client_id === uUser.clientId);
|
||||||
|
log(!!comp, 'G4: компания user присутствует в списке', `clientId=${uUser.clientId}`);
|
||||||
|
userCompanyId = comp?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// G5: user не может получить список компаний → 403
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/companies').set(bearer(tokenUser));
|
||||||
|
log(r.status === 403, 'G5: GET /companies (user) → 403', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// G6: установить лимит
|
||||||
|
{
|
||||||
|
const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
|
||||||
|
.set(bearer(tokenAdmin))
|
||||||
|
.send({ limit: 50 });
|
||||||
|
log(r.status === 200, 'G6: PATCH /companies/:id/limit → 200', `status=${r.status}`);
|
||||||
|
log(json(r).limit === 50, 'G7: лимит в ответе = 50', `got=${json(r).limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// G8: проверить лимит применился
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/entries').set(bearer(tokenUser));
|
||||||
|
log(json(r).limit === 50, 'G8: GET /entries возвращает обновлённый limit=50', `got=${json(r).limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// G9: невалидный лимит — отрицательный → 400
|
||||||
|
{
|
||||||
|
const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
|
||||||
|
.set(bearer(tokenAdmin))
|
||||||
|
.send({ limit: -1 });
|
||||||
|
log(r.status === 400, 'G9: limit=-1 → 400', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// G10: невалидный лимит — строка → 400
|
||||||
|
{
|
||||||
|
const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
|
||||||
|
.set(bearer(tokenAdmin))
|
||||||
|
.send({ limit: 'abc' });
|
||||||
|
log(r.status === 400, 'G10: limit="abc" → 400', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// G11: user не может менять лимиты → 403
|
||||||
|
{
|
||||||
|
const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ limit: 5 });
|
||||||
|
log(r.status === 403, 'G11: PATCH /limit (user) → 403', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем лимит обратно (NULL = дефолт)
|
||||||
|
await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]);
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// H. Admin /audit
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── H. Admin /audit ──');
|
||||||
|
|
||||||
|
// H1: admin получает журнал
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/audit').set(bearer(tokenAdmin));
|
||||||
|
log(r.status === 200, 'H1: GET /audit (admin) → 200', `status=${r.status}`);
|
||||||
|
const b = json(r);
|
||||||
|
log(Array.isArray(b.rows), 'H2: тело содержит rows[]');
|
||||||
|
log(Array.isArray(b.companies), 'H3: тело содержит companies[]');
|
||||||
|
}
|
||||||
|
|
||||||
|
// H4: admin фильтрует по компании
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/audit?company=' + userCompanyId).set(bearer(tokenAdmin));
|
||||||
|
log(r.status === 200, 'H4: GET /audit?company=<id> → 200', `status=${r.status}`);
|
||||||
|
const rows = json(r).rows || [];
|
||||||
|
const allForCompany = rows.every(row => row.company_id === userCompanyId);
|
||||||
|
log(allForCompany || rows.length === 0, 'H5: все записи аудита для указанной компании');
|
||||||
|
}
|
||||||
|
|
||||||
|
// H6: user не видит аудит → 403
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/audit').set(bearer(tokenUser));
|
||||||
|
log(r.status === 403, 'H6: GET /audit (user) → 403', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// H7: операции записаны в audit_log
|
||||||
|
{
|
||||||
|
const rows = (await pool.query(
|
||||||
|
`SELECT action FROM audit_log WHERE company_id = $1 ORDER BY created_at DESC LIMIT 20`, [userCompanyId]
|
||||||
|
)).rows;
|
||||||
|
const actions = rows.map(r => r.action);
|
||||||
|
log(actions.some(a => /create/i.test(a)), 'H7: CREATE записан в audit_log', `actions=${JSON.stringify(actions.slice(0,5))}`);
|
||||||
|
log(actions.some(a => /delete/i.test(a)), 'H8: DELETE записан в audit_log');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// I. Лимит — enforcement
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── I. Лимит enforcement ──');
|
||||||
|
|
||||||
|
// I1: лимит = 0 → любая вставка → 409
|
||||||
|
{
|
||||||
|
await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
|
||||||
|
.set(bearer(tokenAdmin)).send({ limit: 0 });
|
||||||
|
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: C.LIMIT, comment: 'лимит=0' });
|
||||||
|
log(r.status === 409, 'I1: лимит=0, POST /entries → 409', `status=${r.status}`);
|
||||||
|
log(json(r).error?.includes('Лимит'), 'I2: error содержит "Лимит"');
|
||||||
|
|
||||||
|
await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// I3: лимит = currentCount → новая запись → 409
|
||||||
|
{
|
||||||
|
// Считаем текущее количество
|
||||||
|
const cnt = (await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[userCompanyId]
|
||||||
|
)).rows[0].c;
|
||||||
|
|
||||||
|
await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
|
||||||
|
.set(bearer(tokenAdmin)).send({ limit: cnt });
|
||||||
|
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: C.LIMIT, comment: 'лимит=count' });
|
||||||
|
log(r.status === 409, 'I3: лимит=currentCount, POST → 409', `status=${r.status} cnt=${cnt}`);
|
||||||
|
|
||||||
|
await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// I4: снижение лимита не удаляет существующие записи
|
||||||
|
{
|
||||||
|
// Добавим одну запись
|
||||||
|
const addR = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: C.E, comment: 'limit-shrink' });
|
||||||
|
const eid = json(addR).entry?.id;
|
||||||
|
|
||||||
|
// Ставим лимит = 0 (ниже существующего количества)
|
||||||
|
await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
|
||||||
|
.set(bearer(tokenAdmin)).send({ limit: 0 });
|
||||||
|
|
||||||
|
// Запись всё ещё видна в списке
|
||||||
|
const listR = await req.get('/api/v1/entries').set(bearer(tokenUser));
|
||||||
|
const found = json(listR).entries?.find(e => e.id === eid);
|
||||||
|
log(!!found, 'I4: снижение лимита не удаляет существующие записи');
|
||||||
|
|
||||||
|
// Сброс + чистка
|
||||||
|
await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]);
|
||||||
|
if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// J. Безопасность — XSS, длинный и пустой comment
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── J. Безопасность ──');
|
||||||
|
|
||||||
|
// J1: XSS в comment — принимается, но в API возвращается без исполнения
|
||||||
|
{
|
||||||
|
const xssPayload = '<script>alert(1)</script>';
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: C.XSS, comment: xssPayload });
|
||||||
|
// API принимает (не валидирует comment на XSS — это задача шаблона)
|
||||||
|
if (r.status === 201) {
|
||||||
|
const b = json(r);
|
||||||
|
// В JSON-ответе скрипт не должен быть исполнен — это просто строка
|
||||||
|
log(b.entry?.comment === xssPayload, 'J1: XSS в comment сохранён как plain string');
|
||||||
|
await req.delete('/api/v1/entries/' + b.entry.id).set(bearer(tokenUser));
|
||||||
|
} else {
|
||||||
|
log(r.status !== 500, 'J1: XSS в comment → не 500', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// J2: пустой comment → 201 (comment необязателен)
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: C.EMPTY_C, comment: '' });
|
||||||
|
log(r.status === 201, 'J2: пустой comment → 201', `status=${r.status}`);
|
||||||
|
const eid = json(r).entry?.id;
|
||||||
|
if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
// J3: очень длинный comment → 400 (> 255 символов)
|
||||||
|
{
|
||||||
|
const longComment = 'X'.repeat(10000);
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: C.LONG_C, comment: longComment });
|
||||||
|
log(r.status === 400, 'J3: длинный comment (10k) → 400', `status=${r.status}`);
|
||||||
|
log(json(r).error?.includes('длинный') || json(r).error?.includes('255'), 'J3b: error указывает на длину', `err=${json(r).error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// J4: null comment → не 500
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: C.NULL_C, comment: null });
|
||||||
|
log(r.status !== 500, 'J4: null comment → не 500', `status=${r.status}`);
|
||||||
|
const eid = json(r).entry?.id;
|
||||||
|
if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
// J5: SQL injection в value → 400 (валидатор отклоняет как невалидный IP)
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: "1.2.3.4'; DROP TABLE companies;--", comment: '' });
|
||||||
|
log([400, 409, 422].includes(r.status), 'J5: SQL injection в value → не 201 и не 500', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// J6: заблокированный диапазон → 400 (не 500)
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: C.BLOCKED, comment: 'blocked' });
|
||||||
|
log(r.status === 400, 'J6: приватный IP (BLOCKED_RANGES) → 400', `status=${r.status}`);
|
||||||
|
log(json(r).error?.includes('запрещённым'), 'J7: error содержит "запрещённым"', `err=${json(r).error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// K. Граничные значения CIDR
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── K. Граничные CIDR ──');
|
||||||
|
|
||||||
|
// K1: /32 — одиночный хост без маски
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: C.SLASH32, comment: '/32 без маски' });
|
||||||
|
log(r.status === 201, 'K1: хост без /32 → 201', `status=${r.status}`);
|
||||||
|
log(json(r).entry?.value_cidr === C.SLASH32, 'K2: /32 сохранён как есть', `got=${json(r).entry?.value_cidr}`);
|
||||||
|
const eid = json(r).entry?.id;
|
||||||
|
if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
// K3: /22 — подсеть, должна приниматься
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: C.SLASH22, comment: '/22' });
|
||||||
|
log([201, 409].includes(r.status), 'K3: /22 → принят или 409 (пересечение)', `status=${r.status}`);
|
||||||
|
const eid = json(r).entry?.id;
|
||||||
|
if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
// K4: 0.0.0.0/0 — маска /0 < /22 → 400
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: C.SLASH0, comment: '0.0.0.0/0' });
|
||||||
|
log(r.status === 400, 'K4: 0.0.0.0/0 (маска /0 < /22) → 400', `status=${r.status}`);
|
||||||
|
log(json(r).error?.includes('Маска'), 'K4b: error содержит "Маска"', `err=${json(r).error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// K5: IPv6 → отклоняется (только IPv4), 400
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: '2001:db8::1', comment: 'ipv6' });
|
||||||
|
log(r.status === 400, 'K5: IPv6 → 400', `status=${r.status}`);
|
||||||
|
log(json(r).error?.includes('IPv6'), 'K5b: error содержит "IPv6"', `err=${json(r).error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// K6: localhost (127.0.0.1) → заблокирован → 400
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries')
|
||||||
|
.set(bearer(tokenUser)).send({ value: '127.0.0.1', comment: 'localhost' });
|
||||||
|
log(r.status === 400, 'K6: 127.0.0.1 (заблокирован) → 400', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// L. Admin — CRUD по чужой компании (?company=<id>)
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── L. Admin CRUD по чужой компании ──');
|
||||||
|
|
||||||
|
// L1: admin добавляет запись в компанию user
|
||||||
|
let adminAddedId;
|
||||||
|
{
|
||||||
|
const r = await req.post('/api/v1/entries?company=' + userCompanyId)
|
||||||
|
.set(bearer(tokenAdmin))
|
||||||
|
.send({ value: C.ADMIN, comment: 'от admin' });
|
||||||
|
log(r.status === 201, 'L1: admin POST /entries?company=<id> → 201', `status=${r.status}`);
|
||||||
|
const b = json(r);
|
||||||
|
// Запись должна оказаться в компании user, не admin
|
||||||
|
adminAddedId = b.entry?.id;
|
||||||
|
if (adminAddedId) {
|
||||||
|
const row = await pool.query('SELECT company_id FROM whitelist_entries WHERE id = $1', [adminAddedId]);
|
||||||
|
log(row.rows[0]?.company_id === userCompanyId, 'L2: запись сохранена в компанию user', `company_id=${row.rows[0]?.company_id}`);
|
||||||
|
} else {
|
||||||
|
log(false, 'L2: нет adminAddedId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// L3: admin видит запись через GET /entries?company=<id>
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/entries?company=' + userCompanyId).set(bearer(tokenAdmin));
|
||||||
|
log(r.status === 200, 'L3: admin GET /entries?company=<id> → 200', `status=${r.status}`);
|
||||||
|
const found = json(r).entries?.find(e => e.id === adminAddedId);
|
||||||
|
log(!!found, 'L4: admin видит запись компании user', `adminAddedId=${adminAddedId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L5: admin обновляет запись в компании user
|
||||||
|
{
|
||||||
|
const r = await req.patch('/api/v1/entries/' + adminAddedId + '?company=' + userCompanyId)
|
||||||
|
.set(bearer(tokenAdmin))
|
||||||
|
.send({ value: C.G, comment: 'обновлено admin' });
|
||||||
|
log(r.status === 200, 'L5: admin PATCH /entries/:id?company=<id> → 200', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L6: admin удаляет запись из компании user
|
||||||
|
{
|
||||||
|
const r = await req.delete('/api/v1/entries/' + adminAddedId + '?company=' + userCompanyId)
|
||||||
|
.set(bearer(tokenAdmin));
|
||||||
|
log([200, 204].includes(r.status), 'L6: admin DELETE /entries/:id?company=<id> → 200/204', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L7: user не может использовать ?company=<чужой> → запись попадает в свою компанию
|
||||||
|
// Используем компанию user2 (создана в разделе F)
|
||||||
|
{
|
||||||
|
const user2Comp = (await pool.query(
|
||||||
|
`SELECT id FROM companies WHERE client_id = $1`, [uUser2.clientId]
|
||||||
|
)).rows[0]?.id;
|
||||||
|
|
||||||
|
if (user2Comp) {
|
||||||
|
const cntBefore = (await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`, [user2Comp]
|
||||||
|
)).rows[0].c;
|
||||||
|
|
||||||
|
const r = await req.post('/api/v1/entries?company=' + user2Comp)
|
||||||
|
.set(bearer(tokenUser))
|
||||||
|
.send({ value: C.D, 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`, [user2Comp]
|
||||||
|
)).rows[0].c;
|
||||||
|
|
||||||
|
log(cntBefore === cntAfter, 'L7: user не смог добавить в компанию user2 через ?company=',
|
||||||
|
`before=${cntBefore} after=${cntAfter}`);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'api-test-cleanup'
|
||||||
|
WHERE value_cidr = $1 AND company_id = $2 AND deleted_at IS NULL`, [cidr(C.D), user2Comp]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log(false, 'L7: не нашли компанию user2');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// M. Admin ACL — user не вызывает admin-эндпоинты
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── M. Admin ACL ──');
|
||||||
|
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/companies').set(bearer(tokenUser));
|
||||||
|
log(r.status === 403, 'M1: GET /companies (user) → 403', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const r = await req.patch('/api/v1/companies/1/limit')
|
||||||
|
.set(bearer(tokenUser)).send({ limit: 100 });
|
||||||
|
log(r.status === 403, 'M2: PATCH /limit (user) → 403', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/audit').set(bearer(tokenUser));
|
||||||
|
log(r.status === 403, 'M3: GET /audit (user) → 403', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const r = await req.get('/api/v1/companies').set(bearer(tokenUser2));
|
||||||
|
log(r.status === 403, 'M4: GET /companies (user2) → 403', `status=${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// N. Race condition — параллельные вставки при лимите
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
console.log('\n── N. Race condition ──');
|
||||||
|
|
||||||
|
{
|
||||||
|
const cnt = (await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[userCompanyId]
|
||||||
|
)).rows[0].c;
|
||||||
|
|
||||||
|
// Лимит = текущее количество + 1 → только одна из 5 параллельных вставок пройдёт
|
||||||
|
await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
|
||||||
|
.set(bearer(tokenAdmin)).send({ limit: cnt + 1 });
|
||||||
|
|
||||||
|
const raceCidrs = [C.RACE1, C.RACE2, C.RACE3, C.RACE4, C.RACE5];
|
||||||
|
const results = await Promise.all(
|
||||||
|
raceCidrs.map(c => req.post('/api/v1/entries').set(bearer(tokenUser)).send({ value: c, comment: 'race' }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const ok201 = results.filter(r => r.status === 201).length;
|
||||||
|
const ok409 = results.filter(r => r.status === 409).length;
|
||||||
|
|
||||||
|
log(ok201 <= 1, 'N1: race condition: не более 1 записи прошло', `201=${ok201} 409=${ok409}`);
|
||||||
|
log(ok201 + ok409 === 5, 'N2: все 5 запросов получили 201 или 409', `results=${results.map(r => r.status).join(',')}`);
|
||||||
|
|
||||||
|
// Сброс лимита
|
||||||
|
await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]);
|
||||||
|
|
||||||
|
// Чистка race-записей
|
||||||
|
for (const c of raceCidrs) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'api-test-cleanup'
|
||||||
|
WHERE value_cidr = $1 AND deleted_at IS NULL`, [cidr(c)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 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, e.stack);
|
||||||
|
pool.end();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user