1020 lines
52 KiB
JavaScript
1020 lines
52 KiB
JavaScript
'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 === 'alfa');
|
||
const uUser2 = MOCK_USERS.find(u => u.id === 'beta');
|
||
|
||
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)]
|
||
);
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// O. Multi-company — токен с clientId через запятую
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
console.log('\n── O. Multi-company ──');
|
||
|
||
const uMulti = MOCK_USERS.find(u => u.id === 'multi');
|
||
const tokenMulti = uMulti
|
||
? auth.issueMockToken({ clientId: uMulti.clientId, companyId: uMulti.companyId, companyName: uMulti.companyName, email: uMulti.email })
|
||
: null;
|
||
|
||
if (!tokenMulti) {
|
||
log(false, 'O0: не найден multi-пользователь в MOCK_USERS');
|
||
} else {
|
||
log(!!tokenMulti, 'O0: выпущен токен multi (WZ01325, WZ02001)');
|
||
|
||
// O1: GET /entries без ?client_id= → видит записи первой компании (WZ01325)
|
||
// Сначала добавим запись в обе компании для проверки
|
||
let multiEntry1, multiEntry2;
|
||
|
||
// O1a: создаём запись в первой компании (WZ01325) через multi-токен
|
||
{
|
||
const r = await req.post('/api/v1/entries').set(bearer(tokenMulti))
|
||
.send({ value: '4.4.4.1', comment: 'multi-company-1' });
|
||
log(r.status === 201, 'O1a: multi POST → 201 (первая компания WZ01325)', `status=${r.status}`);
|
||
multiEntry1 = json(r).entry?.id;
|
||
}
|
||
|
||
// O1b: создаём запись во ВТОРОЙ компании (WZ02001) через ?client_id=
|
||
{
|
||
const r = await req.post('/api/v1/entries?client_id=WZ02001').set(bearer(tokenMulti))
|
||
.send({ value: '4.4.4.2', comment: 'multi-company-2' });
|
||
log(r.status === 201, 'O1b: multi POST ?client_id=WZ02001 → 201', `status=${r.status}`);
|
||
multiEntry2 = json(r).entry?.id;
|
||
}
|
||
|
||
// O2: GET без ?client_id= → только записи WZ01325 (не видит WZ02001)
|
||
{
|
||
const rDefault = await req.get('/api/v1/entries').set(bearer(tokenMulti));
|
||
const entries = json(rDefault).entries || [];
|
||
const hasE1 = entries.some(e => e.id === multiEntry1);
|
||
const hasE2 = entries.some(e => e.id === multiEntry2);
|
||
log(hasE1, 'O2a: GET default содержит запись WZ01325');
|
||
log(!hasE2, 'O2b: GET default НЕ содержит запись WZ02001');
|
||
}
|
||
|
||
// O3: GET ?client_id=WZ02001 → только записи WZ02001
|
||
{
|
||
const rSwitched = await req.get('/api/v1/entries?client_id=WZ02001').set(bearer(tokenMulti));
|
||
const entries = json(rSwitched).entries || [];
|
||
const hasE1 = entries.some(e => e.id === multiEntry1);
|
||
const hasE2 = entries.some(e => e.id === multiEntry2);
|
||
log(!hasE1, 'O3a: GET ?client_id=WZ02001 НЕ содержит запись WZ01325');
|
||
log(hasE2, 'O3b: GET ?client_id=WZ02001 содержит запись WZ02001');
|
||
}
|
||
|
||
// O4: PATCH через ?client_id= работает изолированно
|
||
{
|
||
const r = await req.patch('/api/v1/entries/' + multiEntry2 + '?client_id=WZ02001')
|
||
.set(bearer(tokenMulti))
|
||
.send({ value: '4.4.4.3', comment: 'patched-in-WZ02001' });
|
||
log(r.status === 200, 'O4: multi PATCH ?client_id=WZ02001 → 200', `status=${r.status}`);
|
||
}
|
||
|
||
// O5: DELETE через ?client_id=
|
||
{
|
||
const r = await req.delete('/api/v1/entries/' + multiEntry2 + '?client_id=WZ02001')
|
||
.set(bearer(tokenMulti));
|
||
log([200, 204].includes(r.status), 'O5: multi DELETE ?client_id=WZ02001 → 200/204', `status=${r.status}`);
|
||
}
|
||
|
||
// O6: экспорт с ?client_id=WZ02001 не содержит запись WZ01325
|
||
{
|
||
// Создаём запись в WZ02001 для проверки экспорта
|
||
const cr = await req.post('/api/v1/entries?client_id=WZ02001').set(bearer(tokenMulti))
|
||
.send({ value: '4.4.5.1', comment: 'export-multi' });
|
||
const exportEntryId = json(cr).entry?.id;
|
||
|
||
const rWZ02 = await req.get('/api/v1/entries/export?client_id=WZ02001').set(bearer(tokenMulti));
|
||
log(rWZ02.status === 200, 'O6a: export ?client_id=WZ02001 → 200', `status=${rWZ02.status}`);
|
||
log(rWZ02.text.includes('4.4.5.1'), 'O6b: export содержит CIDR WZ02001');
|
||
log(!rWZ02.text.includes('4.4.4.1'), 'O6c: export НЕ содержит CIDR WZ01325');
|
||
|
||
// O6d: экспорт без ?client_id= → только WZ01325
|
||
const rDefault = await req.get('/api/v1/entries/export').set(bearer(tokenMulti));
|
||
log(rDefault.text.includes('4.4.4.1'), 'O6d: export default содержит WZ01325');
|
||
log(!rDefault.text.includes('4.4.5.1'), 'O6e: export default НЕ содержит WZ02001');
|
||
|
||
// Чистка
|
||
if (exportEntryId) {
|
||
await req.delete('/api/v1/entries/' + exportEntryId + '?client_id=WZ02001').set(bearer(tokenMulti));
|
||
}
|
||
}
|
||
|
||
// O7: несуществующий ?client_id= → игнорируется, фоллбек на WZ01325 (безопасность)
|
||
{
|
||
const r = await req.get('/api/v1/entries?client_id=NONEXISTENT').set(bearer(tokenMulti));
|
||
log(r.status === 200, 'O7: GET ?client_id=NONEXISTENT → 200 (игнор, фоллбек)', `status=${r.status}`);
|
||
// NONEXISTENT не в allClientIds → фоллбек на WZ01325 → записи ЕСТЬ
|
||
log(json(r).entries?.length > 0, 'O7b: фоллбек на WZ01325 — записи есть');
|
||
}
|
||
|
||
// O8: admin передаёт ?client_id= → использует свою компанию (игнор)
|
||
{
|
||
const r = await req.get('/api/v1/entries?client_id=WZ77777').set(bearer(tokenAdmin));
|
||
log(r.status === 200, 'O8: admin GET ?client_id= → 200 (игнорирует)', `status=${r.status}`);
|
||
}
|
||
|
||
// Чистка записей multi
|
||
if (multiEntry1) {
|
||
await req.delete('/api/v1/entries/' + multiEntry1).set(bearer(tokenMulti));
|
||
}
|
||
if (multiEntry2) {
|
||
await req.delete('/api/v1/entries/' + multiEntry2 + '?client_id=WZ77777').set(bearer(tokenMulti));
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// 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);
|
||
});
|