'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=) * 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= → 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 = ''; 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=) // ══════════════════════════════════════════════════════════════════════════════ 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= → 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= { const r = await req.get('/api/v1/entries?company=' + userCompanyId).set(bearer(tokenAdmin)); log(r.status === 200, 'L3: admin GET /entries?company= → 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= → 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= → 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=WZ02001').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=WZ02001').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); });