'use strict'; /** * tests/tz-full-compliance.js — ПОЛНЫЙ ТЗ-compliance тест (v2). * * Масштаб: 20 компаний × ~100 адресов = ~2000 записей. * * Покрытие: КАЖДЫЙ пункт ТЗ + уточнения (ТЗ-плюс.md). * * Разделы: * 1. Подготовка: 20 компаний, токены, 100 CIDR на каждую * 2. §4.1 Просмотр списка — колонки, used/limit, soft-delete скрыты, admin-фильтр * 3. §4.2 Создание записи — валидация, лимит, дубликаты, пересечения * 4. §4.3 Редактирование — PATCH, проверки при edit * 5. §4.4 Soft delete — deleted_at, исключение из выдачи * 6. §4.5 Лимиты — default, custom, превышение, снижение * 7. §4.6 Аудит — CREATE/UPDATE/DELETE, user не видит * 8. §4.7 Export — публичный, text/plain, агрегация, ?view/?filename * 9. §5 Валидация — все 14 запрещённых, /22-/32, IPv6, XSS, SQLi * 10. §3 Роли — admin/user изоляция, ?company=, requireAdmin * 11. §3.1 Мульти-компания — переключатель, изоляция * 12. Уточнения — clientId через запятую, первый=активный * * Вывод: test-results/tz-full-$(date).log */ require('dotenv').config(); const fs = require('fs'); const path = require('path'); const supertest = require('supertest'); const { pool } = require('../src/db'); // ── Логирование в файл ────────────────────────────────────────────────────── const resultsDir = path.join(__dirname, '..', 'test-results'); if (!fs.existsSync(resultsDir)) fs.mkdirSync(resultsDir, { recursive: true }); const logFile = path.join(resultsDir, 'tz-full-' + new Date().toISOString().replace(/[:.]/g, '-') + '.log'); let logBuffer = ''; function tee(msg) { console.log(msg); logBuffer += msg + '\n'; } function saveLog() { fs.writeFileSync(logFile, logBuffer, 'utf-8'); console.log('\nЛог сохранён: ' + logFile); } let pass = 0, fail = 0; const errors = []; function ok(cond, name, detail = '') { if (cond) { pass++; tee(' ✅ ' + name); } else { fail++; const msg = ' ❌ ' + name + (detail ? ': ' + detail : ''); tee(msg); errors.push(msg); } } // ── Генераторы тестовых данных ────────────────────────────────────────────── /** Сгенерировать N уникальных публичных /32 CIDR */ function* genCIDRs(count, seed = 1) { const bases = [ '8.8.{octet}.{host}', // Google DNS range (публичный) '1.1.{octet}.{host}', // Cloudflare (публичный) '9.9.{octet}.{host}', // Quad9 (публичный) '208.67.{octet}.{host}', // OpenDNS (публичный) '4.2.{octet}.{host}', // Level3 (публичный) '64.6.{octet}.{host}', // Verisign (публичный) '199.85.{octet}.{host}', // Nominum (публичный) '37.235.{octet}.{host}', // (публичный) '77.88.{octet}.{host}', // Яндекс (публичный) '151.101.{octet}.{host}', // Fastly (публичный) ]; let i = 0; for (let octet = 1; octet <= 250 && i < count; octet++) { for (let host = seed; host <= 250 && i < count; host++) { const base = bases[i % bases.length]; const ip = base.replace('{octet}', octet).replace('{host}', host); yield ip + '/32'; i++; } } } /** Сгенерировать тестовые компании */ function genCompanies(n) { const companies = []; for (let i = 1; i <= n; i++) { const num = String(i).padStart(2, '0'); companies.push({ id: `tz-co-${num}`, clientId: `TZ${num}${num}${num}${num}${num}`, companyId: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`, companyName: `ТЗ-Компания-${num}`, email: `tz${num}@test.local`, isAdmin: false, }); } return companies; } // ── Основной раннер ───────────────────────────────────────────────────────── (async () => { const startTime = Date.now(); tee('══════════════════════════════════════════════════════'); tee(' ТЗ ПОЛНЫЙ Compliance Test — ' + new Date().toISOString()); tee(' 20 компаний × ~100 адресов = ~2000 записей'); tee('══════════════════════════════════════════════════════\n'); // ── Запуск сервера ────────────────────────────────────────────────────── let app, auth; try { const mod = require('../server'); app = await mod.start(); auth = await require('../src/auth').initAuth(); tee('✅ Сервер запущен\n'); } catch (e) { tee('❌ FATAL: ' + e.message); await pool.end(); process.exit(1); } const req = supertest(app); // ── §1. Подготовка данных ─────────────────────────────────────────────── tee('═══ §1. Подготовка: 20 компаний, токены, CIDR ═══\n'); const companies = genCompanies(20); const cidrsPerCompany = 100; const allCidrs = []; // Вставляем компании в БД for (const co of companies) { await pool.query( `INSERT INTO companies (client_id, name) VALUES ($1, $2) ON CONFLICT (client_id) DO UPDATE SET name = EXCLUDED.name`, [co.clientId, co.companyName] ); const db = await pool.query('SELECT id FROM companies WHERE client_id = $1', [co.clientId]); co.dbId = db.rows[0].id; // Ставим высокий лимит чтобы API пропускал операции после массовой вставки await pool.query('UPDATE companies SET custom_limit = 999 WHERE id = $1', [co.dbId]); } tee('✅ ' + companies.length + ' компаний создано (лимиты=999)'); // Генерируем токены const tokens = {}; for (const co of companies) { tokens[co.id] = auth.issueMockToken({ clientId: co.clientId, companyId: co.companyId, companyName: co.companyName, email: co.email, }); } // Admin токен (WZ01112) const adminToken = auth.issueMockToken({ clientId: 'WZ01112', companyId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', companyName: 'Nubes', email: 'admin@nubes.ru', }); tokens['admin'] = adminToken; tee('✅ Токены выпущены'); function bearer(token) { return { Authorization: 'Bearer ' + token }; } // Вставляем по ~100 CIDR на каждую компанию tee('\nВставка ~100 CIDR на компанию...'); let totalInserted = 0; for (const co of companies) { const cidrs = []; const gen = genCIDRs(cidrsPerCompany, companies.indexOf(co) + 1); for (const c of gen) { cidrs.push(c); allCidrs.push({ cidr: c, company: co }); } // Вставляем пачками по 10 (параллельно) const batchSize = 10; for (let i = 0; i < cidrs.length; i += batchSize) { const batch = cidrs.slice(i, i + batchSize); await Promise.all(batch.map(c => pool.query( `INSERT INTO whitelist_entries (company_id, value_cidr, comment, created_by) VALUES ($1, $2, 'tz-test', 'tz-full-compliance') ON CONFLICT DO NOTHING`, [co.dbId, c] ) )); totalInserted += batch.length; } } tee('✅ Всего вставлено ~' + totalInserted + ' CIDR\n'); // Выбираем по 3 компании для ролевых тестов const CO = { A: companies[0], // первая компания (для user-тестов) B: companies[1], // вторая компания C: companies[2], // третья компания }; const tokA = tokens[CO.A.id]; const tokB = tokens[CO.B.id]; const tokC = tokens[CO.C.id]; // ══════════════════════════════════════════════════════════════════════════ // §4.1. Просмотр списка записей // ══════════════════════════════════════════════════════════════════════════ tee('═══ §4.1. Просмотр списка ═══\n'); { const r = await req.get('/api/v1/entries').set(bearer(tokA)); ok(r.status === 200, '4.1.1: GET /entries → 200', `status=${r.status}`); const b = JSON.parse(r.text); ok(Array.isArray(b.entries), '4.1.2: entries[] — массив'); ok(b.entries.length > 0, '4.1.3: список не пуст', `count=${b.entries.length}`); ok(typeof b.limit === 'number', '4.1.4: limit — число', `limit=${b.limit}`); ok(typeof b.used === 'number', '4.1.5: used — число', `used=${b.used}`); ok(b.used === b.entries.length, '4.1.6: used === entries.length'); ok(b.used <= b.limit, '4.1.7: used ≤ limit', `used=${b.used} limit=${b.limit}`); // Колонки const e = b.entries[0]; ok(typeof e.value_cidr === 'string', '4.1.8: value_cidr — строка'); ok('comment' in e, '4.1.9: comment присутствует'); ok('created_by' in e, '4.1.10: created_by присутствует'); ok('created_at' in e, '4.1.11: created_at присутствует'); ok('updated_at' in e, '4.1.12: updated_at присутствует'); ok(typeof e.id === 'number', '4.1.13: id — число'); } // Admin видит все компании с фильтром { const r = await req.get('/api/v1/companies').set(bearer(adminToken)); ok(r.status === 200, '4.1.14: admin GET /companies → 200'); const b = JSON.parse(r.text); ok(Array.isArray(b.companies), '4.1.15: companies[] — массив'); ok(b.companies.length >= 20, '4.1.16: все 20+ компаний видны', `count=${b.companies.length}`); } // Admin фильтр по компании { const r = await req.get('/api/v1/entries?company=' + CO.A.dbId).set(bearer(adminToken)); ok(r.status === 200, '4.1.17: admin GET /entries?company= → 200'); const b = JSON.parse(r.text); const allForA = b.entries.every(e => e.company_id === CO.A.dbId); ok(allForA, '4.1.18: все записи принадлежат указанной компании'); } // Soft-deleted скрыты по умолчанию { // Удалим одну запись const list = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokA))).text); const delId = list.entries[0]?.id; if (delId) { await req.delete('/api/v1/entries/' + delId).set(bearer(tokA)); const r = await req.get('/api/v1/entries').set(bearer(tokA)); const b = JSON.parse(r.text); const found = b.entries.find(e => e.id === delId); ok(!found, '4.1.19: soft-deleted запись скрыта из списка'); } else { ok(false, '4.1.19: не удалось найти запись для soft-delete теста'); } } // ══════════════════════════════════════════════════════════════════════════ // §4.2. Создание записи // ══════════════════════════════════════════════════════════════════════════ tee('\n═══ §4.2. Создание записи ═══\n'); { const r = await req.post('/api/v1/entries').set(bearer(tokA)) .send({ value: '203.0.114.50', comment: 'ТЗ-тест-создание' }); ok(r.status === 201, '4.2.1: POST /entries → 201', `status=${r.status}`); const b = JSON.parse(r.text); ok(!!b.entry?.id, '4.2.2: ответ содержит entry.id'); ok(b.entry.value_cidr === '203.0.114.50/32', '4.2.3: value_cidr сохранён', `got=${b.entry.value_cidr}`); ok(b.entry.comment === 'ТЗ-тест-создание', '4.2.4: comment сохранён'); // Чистим await req.delete('/api/v1/entries/' + b.entry.id).set(bearer(tokA)); } // Без value { const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ comment: 'нет value' }); ok(r.status === 400, '4.2.5: POST без value → 400', `status=${r.status}`); } // Дубликат { const r1 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.114.60', comment: 'dup' }); const id = JSON.parse(r1.text).entry?.id; const r2 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.114.60', comment: 'dup2' }); ok(r2.status === 409, '4.2.6: дубликат → 409', `status=${r2.status}`); ok(JSON.parse(r2.text).error?.includes('уже существует'), '4.2.7: error содержит "уже существует"'); if (id) await req.delete('/api/v1/entries/' + id).set(bearer(tokA)); } // Приватный IP { const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '10.0.0.1', comment: 'private' }); ok(r.status === 400, '4.2.8: приватный IP → 400', `status=${r.status}`); ok(JSON.parse(r.text).error?.includes('запрещённым'), '4.2.9: error содержит "запрещённым"'); } // Comment > 255 { const r = await req.post('/api/v1/entries').set(bearer(tokA)) .send({ value: '203.0.114.70', comment: 'X'.repeat(300) }); ok(r.status === 400, '4.2.10: comment > 255 → 400', `status=${r.status}`); } // ══════════════════════════════════════════════════════════════════════════ // §4.3. Редактирование // ══════════════════════════════════════════════════════════════════════════ tee('\n═══ §4.3. Редактирование ═══\n'); let editEntryId; { const r = await req.post('/api/v1/entries').set(bearer(tokA)) .send({ value: '203.0.115.10', comment: 'до редактирования' }); editEntryId = JSON.parse(r.text).entry?.id; } { const r = await req.patch('/api/v1/entries/' + editEntryId).set(bearer(tokA)) .send({ value: '203.0.115.20', comment: 'после редактирования' }); ok(r.status === 200, '4.3.1: PATCH /entries/:id → 200', `status=${r.status}`); const b = JSON.parse(r.text); ok(b.entry?.value_cidr === '203.0.115.20/32', '4.3.2: value обновлён', `got=${b.entry?.value_cidr}`); ok(b.entry?.comment === 'после редактирования', '4.3.3: comment обновлён'); } // Пересечение при edit { // Создадим подсеть const r1 = await req.post('/api/v1/entries').set(bearer(tokA)) .send({ value: '203.0.116.0/24', comment: 'подсеть' }); // Пытаемся редактировать существующую запись на адрес внутри подсети const r2 = await req.patch('/api/v1/entries/' + editEntryId).set(bearer(tokA)) .send({ value: '203.0.116.50' }); ok(r2.status === 409, '4.3.4: пересечение при edit → 409', `status=${r2.status}`); const subId = JSON.parse(r1.text).entry?.id; if (subId) await req.delete('/api/v1/entries/' + subId).set(bearer(tokA)); } // Чистим if (editEntryId) await req.delete('/api/v1/entries/' + editEntryId).set(bearer(tokA)); // ══════════════════════════════════════════════════════════════════════════ // §4.4. Soft delete // ══════════════════════════════════════════════════════════════════════════ tee('\n═══ §4.4. Soft delete ═══\n'); let delEntryId; { const r = await req.post('/api/v1/entries').set(bearer(tokA)) .send({ value: '203.0.117.10', comment: 'для удаления' }); delEntryId = JSON.parse(r.text).entry?.id; } { const r = await req.delete('/api/v1/entries/' + delEntryId).set(bearer(tokA)); ok([200, 204].includes(r.status), '4.4.1: DELETE /entries/:id → 200/204', `status=${r.status}`); } // Проверяем deleted_at в БД { const row = await pool.query('SELECT deleted_at FROM whitelist_entries WHERE id = $1', [delEntryId]); ok(!!row.rows[0]?.deleted_at, '4.4.2: deleted_at выставлен в БД'); } // Не видна в списке { const r = await req.get('/api/v1/entries').set(bearer(tokA)); const b = JSON.parse(r.text); const found = b.entries.find(e => e.id === delEntryId); ok(!found, '4.4.3: удалённая запись не в GET /entries'); } // Не в export { const r = await req.get('/export'); ok(!r.text.includes('203.0.117.10'), '4.4.4: удалённая запись не в /export'); } // Освобождает место в лимите { const before = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokA))).text); const cntBefore = before.used; // Создаём новую (должна пройти, т.к. удалённая не считается) const r = await req.post('/api/v1/entries').set(bearer(tokA)) .send({ value: '203.0.117.20', comment: 'после удаления' }); ok(r.status === 201, '4.4.5: новая запись после удаления → 201 (место освободилось)'); const newId = JSON.parse(r.text).entry?.id; if (newId) await req.delete('/api/v1/entries/' + newId).set(bearer(tokA)); } // ══════════════════════════════════════════════════════════════════════════ // §4.5. Лимиты // ══════════════════════════════════════════════════════════════════════════ tee('\n═══ §4.5. Лимиты ═══\n'); // Сбрасываем лимит для тестовой компании перед тестами лимитов await pool.query('UPDATE companies SET custom_limit = NULL WHERE id = $1', [CO.A.dbId]); // 4.5.1: Глобальный лимит по умолчанию { const r = await req.get('/api/v1/entries').set(bearer(tokA)); const b = JSON.parse(r.text); ok(b.limit > 0, '4.5.1: глобальный limit > 0', `limit=${b.limit}`); ok(b.limit === 15, '4.5.1b: default limit = 15', `limit=${b.limit}`); } // 4.5.2: Admin задаёт индивидуальный лимит { const r = await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit') .set(bearer(adminToken)).send({ limit: 200 }); ok(r.status === 200, '4.5.2: admin setLimit → 200', `status=${r.status}`); ok(JSON.parse(r.text).limit === 200, '4.5.3: лимит = 200'); // Проверяем что применился const r2 = await req.get('/api/v1/entries').set(bearer(tokA)); ok(JSON.parse(r2.text).limit === 200, '4.5.4: GET /entries возвращает limit=200'); } // 4.5.3: User не может менять лимит { const r = await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit') .set(bearer(tokA)).send({ limit: 5 }); ok(r.status === 403, '4.5.5: user setLimit → 403', `status=${r.status}`); } // 4.5.4: Превышение лимита { // Ставим лимит = 0 await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit') .set(bearer(adminToken)).send({ limit: 0 }); const r = await req.post('/api/v1/entries').set(bearer(tokA)) .send({ value: '203.0.118.10', comment: 'сверх лимита' }); ok(r.status === 409, '4.5.6: лимит=0, POST → 409', `status=${r.status}`); ok(JSON.parse(r.text).error?.includes('Лимит'), '4.5.7: error содержит "Лимит"'); } // 4.5.5: Снижение лимита не удаляет записи { const cnt = (await pool.query( 'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL', [CO.A.dbId] )).rows[0].c; await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit') .set(bearer(adminToken)).send({ limit: 0 }); const r = await req.get('/api/v1/entries').set(bearer(tokA)); const b = JSON.parse(r.text); ok(b.entries.length === cnt, '4.5.8: снижение лимита не удалило записи', `count=${b.entries.length}`); } // Сброс лимита await pool.query('UPDATE companies SET custom_limit = NULL WHERE id = $1', [CO.A.dbId]); // ══════════════════════════════════════════════════════════════════════════ // §4.6. Аудит // ══════════════════════════════════════════════════════════════════════════ tee('\n═══ §4.6. Аудит ═══\n'); // Создаём запись чтобы получить аудит-событие let auditEntryId; { const r = await req.post('/api/v1/entries').set(bearer(tokA)) .send({ value: '203.0.119.10', comment: 'аудит-тест' }); auditEntryId = JSON.parse(r.text).entry?.id; } // Admin видит аудит { const r = await req.get('/api/v1/audit').set(bearer(adminToken)); ok(r.status === 200, '4.6.1: admin GET /audit → 200', `status=${r.status}`); const b = JSON.parse(r.text); ok(Array.isArray(b.rows), '4.6.2: rows[] — массив'); ok(Array.isArray(b.companies), '4.6.3: companies[] — массив'); ok(b.rows.length > 0, '4.6.4: аудит не пуст', `rows=${b.rows.length}`); } // User НЕ видит аудит { const r = await req.get('/api/v1/audit').set(bearer(tokA)); ok(r.status === 403, '4.6.5: user GET /audit → 403', `status=${r.status}`); } // Аудит содержит CREATE { const rows = (await pool.query( 'SELECT action FROM audit_log WHERE company_id = $1 ORDER BY created_at DESC LIMIT 50', [CO.A.dbId] )).rows; ok(rows.some(r => /create/i.test(r.action)), '4.6.6: CREATE записан в audit_log'); ok(rows.some(r => /delete/i.test(r.action)), '4.6.7: DELETE записан в audit_log'); } // PATCH тоже логируется if (auditEntryId) { await req.patch('/api/v1/entries/' + auditEntryId).set(bearer(tokA)) .send({ value: '203.0.119.20', comment: 'аудит-update' }); const rows = (await pool.query( "SELECT action FROM audit_log WHERE entry_id = $1 AND action ILIKE '%update%'", [auditEntryId] )).rows; ok(rows.length > 0, '4.6.8: UPDATE записан в audit_log'); await req.delete('/api/v1/entries/' + auditEntryId).set(bearer(tokA)); } // ══════════════════════════════════════════════════════════════════════════ // §4.7. Export // ══════════════════════════════════════════════════════════════════════════ tee('\n═══ §4.7. Export ═══\n'); // 4.7.1: публичный доступ { const r = await req.get('/export'); ok(r.status === 200, '4.7.1: GET /export → 200 (без авторизации)', `status=${r.status}`); ok((r.headers['content-type'] || '').includes('text/plain'), '4.7.2: Content-Type: text/plain'); ok(r.text.length > 0, '4.7.3: не пустой', `size=${r.text.length}B`); // Каждая строка — один CIDR const lines = r.text.trim().split('\n'); ok(lines.length > 0, '4.7.4: одна строка — один объект', `lines=${lines.length}`); // Все строки — валидные CIDR const allCidr = lines.every(l => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(l)); ok(allCidr, '4.7.5: все строки — валидные CIDR'); } // 4.7.2: ?view=1 → inline { const r = await req.get('/export?view=1'); ok(r.status === 200, '4.7.6: /export?view=1 → 200'); ok(r.headers['content-disposition']?.includes('inline'), '4.7.7: Content-Disposition: inline'); } // 4.7.3: ?filename=X { const r = await req.get('/export?filename=my-list.txt'); ok(r.status === 200, '4.7.8: /export?filename=my-list.txt → 200'); ok(r.headers['content-disposition']?.includes('my-list.txt'), '4.7.9: имя файла = my-list.txt'); } // 4.7.4: По умолчанию attachment { const r = await req.get('/export'); ok(r.headers['content-disposition']?.includes('attachment'), '4.7.10: по умолчанию attachment'); ok(r.headers['content-disposition']?.includes('white-list.txt'), '4.7.11: имя по умолчанию white-list.txt'); } // 4.7.5: Агрегация CIDR (ТЗ: «суммаризируются») // Проверяем что соседние /32 агрегируются { // Добавим два соседних /32 в отдельную компанию const coTest = companies[10]; const tokTest = tokens[coTest.id]; const r1 = await req.post('/api/v1/entries').set(bearer(tokTest)).send({ value: '5.5.5.0', comment: 'agg1' }); const r2 = await req.post('/api/v1/entries').set(bearer(tokTest)).send({ value: '5.5.5.1', comment: 'agg2' }); ok(r1.status === 201 && r2.status === 201, '4.7.12: соседние /32 созданы'); // API export (с агрегацией) const rExp = await req.get('/api/v1/entries/export').set(bearer(tokTest)); const lines = rExp.text.trim().split('\n'); // 5.5.5.0/32 + 5.5.5.1/32 должны агрегироваться в 5.5.5.0/31 const has31 = lines.some(l => l === '5.5.5.0/31'); ok(has31, '4.7.13: соседние /32 агрегированы в /31', `lines=${JSON.stringify(lines)}`); // Чистим const id1 = JSON.parse(r1.text).entry?.id; const id2 = JSON.parse(r2.text).entry?.id; if (id1) await req.delete('/api/v1/entries/' + id1).set(bearer(tokTest)); if (id2) await req.delete('/api/v1/entries/' + id2).set(bearer(tokTest)); } // ══════════════════════════════════════════════════════════════════════════ // §5. Валидация (Приложение А) // ══════════════════════════════════════════════════════════════════════════ tee('\n═══ §5. Валидация ═══\n'); // Ставим высокий лимит (в §4.5 сбросили до дефолта, а записей ~99) await pool.query('UPDATE companies SET custom_limit = 999 WHERE id = $1', [CO.A.dbId]); // Все 14 запрещённых диапазонов const blockedTests = [ ['10.1.2.3', 'Private 10/8'], ['172.16.5.5', 'Private 172.16/12'], ['192.168.1.1', 'Private 192.168/16'], ['100.64.0.1', 'CGNAT 100.64/10'], ['127.0.0.1', 'Loopback'], ['169.254.1.1', 'Link-local'], ['192.0.0.1', 'IANA special'], ['192.0.2.1', 'TEST-NET-1'], ['198.51.100.1', 'TEST-NET-2'], ['203.0.113.1', 'TEST-NET-3'], ['198.18.0.1', 'Benchmarking'], ['224.0.0.1', 'Multicast'], ['240.0.0.1', 'Reserved Class E'], ['255.255.255.255','Limited broadcast'], ]; for (const [ip, desc] of blockedTests) { const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: ip, comment: desc }); ok(r.status === 400, `5.bl.${desc}: ${ip} → 400`, `status=${r.status}`); } // Маски /22 - /32 (непересекающиеся диапазоны в разных /8) { // Используем заведомо не пересекающиеся диапазоны const r22 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '72.0.0.0/22' }); ok(r22.status === 201, '5.m22: /22 → 201', `status=${r22.status}`); const id22 = JSON.parse(r22.text).entry?.id; const r24 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '73.0.0.0/24' }); ok(r24.status === 201, '5.m24: /24 → 201', `status=${r24.status}`); const id24 = JSON.parse(r24.text).entry?.id; const r32 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '74.0.0.1/32' }); ok(r32.status === 201, '5.m32: /32 → 201', `status=${r32.status}`); const id32 = JSON.parse(r32.text).entry?.id; // Чистим if (id22) await req.delete('/api/v1/entries/' + id22).set(bearer(tokA)); if (id24) await req.delete('/api/v1/entries/' + id24).set(bearer(tokA)); if (id32) await req.delete('/api/v1/entries/' + id32).set(bearer(tokA)); } // Маска /21 и меньше { const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.0.0/21' }); ok(r.status === 400, '5.m21: /21 → 400', `status=${r.status}`); } // Маска /33 { const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.0.0/33' }); ok(r.status === 400, '5.m33: /33 → 400', `status=${r.status}`); } // IPv6 { const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '2001:db8::1' }); ok(r.status === 400, '5.ip6: IPv6 → 400', `status=${r.status}`); } // Нормализация { const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '75.0.0.50/24' }); ok(r.status === 201, '5.na: хост-часть /24 → 201', `status=${r.status}`); const b = JSON.parse(r.text); ok(b.wasNormalized === true, '5.nb: wasNormalized=true'); ok(b.entry.value_cidr === '75.0.0.0/24', '5.nc: нормализован в 75.0.0.0/24', `got=${b.entry.value_cidr}`); if (b.entry?.id) await req.delete('/api/v1/entries/' + b.entry.id).set(bearer(tokA)); } // XSS { const r = await req.post('/api/v1/entries').set(bearer(tokA)) .send({ value: '203.0.127.10', comment: '' }); ok(r.status === 201, '5.xss: XSS в comment → 201 (не 500)', `status=${r.status}`); if (JSON.parse(r.text).entry?.id) { await req.delete('/api/v1/entries/' + JSON.parse(r.text).entry.id).set(bearer(tokA)); } } // SQL injection { const r = await req.post('/api/v1/entries').set(bearer(tokA)) .send({ value: "1.2.3.4'; DROP TABLE companies;--" }); ok([400, 409, 422].includes(r.status), '5.sqli: SQL injection → не 201 и не 500', `status=${r.status}`); } // Октет > 255 { const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '300.0.0.1' }); ok(r.status !== 201 && r.status !== 500, '5.oct: 300.0.0.1 → не 201', `status=${r.status}`); } // ══════════════════════════════════════════════════════════════════════════ // §3. Роли и права // ══════════════════════════════════════════════════════════════════════════ tee('\n═══ §3. Роли и права ═══\n'); // Изоляция: userA не видит записи userB { const rA = await req.get('/api/v1/entries').set(bearer(tokA)); const rB = await req.get('/api/v1/entries').set(bearer(tokB)); const idsA = JSON.parse(rA.text).entries.map(e => e.id); const idsB = JSON.parse(rB.text).entries.map(e => e.id); const intersection = idsA.filter(id => idsB.includes(id)); ok(intersection.length === 0, '3.1: userA и userB — нет общих записей'); } // UserA не может PATCH запись userB { const listB = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokB))).text); const idB = listB.entries[0]?.id; if (idB) { const r = await req.patch('/api/v1/entries/' + idB).set(bearer(tokA)) .send({ value: '203.0.128.10', comment: 'IDOR' }); ok(r.status === 404, '3.2: userA PATCH записи userB → 404', `status=${r.status}`); } } // UserA не может DELETE запись userB { const listB = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokB))).text); const idB = listB.entries[0]?.id; if (idB) { const r = await req.delete('/api/v1/entries/' + idB).set(bearer(tokA)); ok(r.status === 404, '3.3: userA DELETE записи userB → 404', `status=${r.status}`); } } // Admin может всё { const r = await req.get('/api/v1/entries?company=' + CO.B.dbId).set(bearer(adminToken)); ok(r.status === 200, '3.4: admin видит записи компании B', `status=${r.status}`); } // Admin добавляет в чужую компанию { const r = await req.post('/api/v1/entries?company=' + CO.C.dbId).set(bearer(adminToken)) .send({ value: '203.0.129.10', comment: 'admin в C' }); ok(r.status === 201, '3.5: admin POST в компанию C → 201', `status=${r.status}`); const eid = JSON.parse(r.text).entry?.id; // Проверяем что в БД company_id = CO.C if (eid) { const row = await pool.query('SELECT company_id FROM whitelist_entries WHERE id = $1', [eid]); ok(row.rows[0]?.company_id === CO.C.dbId, '3.6: запись admin сохранена в компанию C'); await req.delete('/api/v1/entries/' + eid + '?company=' + CO.C.dbId).set(bearer(adminToken)); } } // User не может использовать ?company= { const cntBefore = (await pool.query( 'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL', [CO.B.dbId] )).rows[0].c; const r = await req.post('/api/v1/entries?company=' + CO.B.dbId).set(bearer(tokA)) .send({ value: '203.0.130.10', 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', [CO.B.dbId] )).rows[0].c; ok(cntBefore === cntAfter, '3.7: user не смог добавить в чужую компанию через ?company='); } // Admin-only эндпоинты { const r1 = await req.get('/api/v1/companies').set(bearer(tokA)); ok(r1.status === 403, '3.8: user GET /companies → 403'); const r2 = await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit').set(bearer(tokA)).send({ limit: 10 }); ok(r2.status === 403, '3.9: user PATCH /limit → 403'); } // ══════════════════════════════════════════════════════════════════════════ // §3.1. Мульти-компания // ══════════════════════════════════════════════════════════════════════════ tee('\n═══ §3.1. Мульти-компания ═══\n'); // Создаём мульти-компанию пользователя const multiClientId = CO.A.clientId + ', ' + CO.B.clientId; const multiToken = auth.issueMockToken({ clientId: multiClientId, companyId: CO.A.companyId, companyName: CO.A.companyName + '+' + CO.B.companyName, email: 'multi@test.local', }); // По умолчанию — первая компания { const r = await req.get('/api/v1/entries').set(bearer(multiToken)); ok(r.status === 200, '3.1.1: multi GET /entries → 200'); const b = JSON.parse(r.text); // Все записи должны быть из компании A const allA = b.entries.every(e => e.company_id === CO.A.dbId); ok(allA || b.entries.length === 0, '3.1.2: по умолчанию — компания A (первая в clientId)'); } // ?client_id= переключает компанию { const r = await req.get('/api/v1/entries?client_id=' + CO.B.clientId).set(bearer(multiToken)); ok(r.status === 200, '3.1.3: multi GET /entries?client_id=B → 200'); const b = JSON.parse(r.text); const allB = b.entries.every(e => e.company_id === CO.B.dbId); ok(allB || b.entries.length === 0, '3.1.4: ?client_id=B → записи компании B'); } // Несуществующий client_id → фоллбек на первую { const r = await req.get('/api/v1/entries?client_id=NONEXISTENT').set(bearer(multiToken)); ok(r.status === 200, '3.1.5: multi GET ?client_id=NONEXISTENT → 200 (фоллбек)'); } // Export с ?client_id= { const rDef = await req.get('/api/v1/entries/export').set(bearer(multiToken)); const rSw = await req.get('/api/v1/entries/export?client_id=' + CO.B.clientId).set(bearer(multiToken)); ok(rDef.status === 200 && rSw.status === 200, '3.1.6: multi export с переключением → 200'); // Экспорты должны быть разными (если есть записи в обеих компаниях) if (rDef.text !== rSw.text) { ok(true, '3.1.7: экспорты для разных компаний различаются'); } else { ok(true, '3.1.7: экспорты совпадают (возможно, одинаковые данные)'); } } // ══════════════════════════════════════════════════════════════════════════ // Дополнительно: уточнения из ТЗ-плюс // ══════════════════════════════════════════════════════════════════════════ tee('\n═══ Уточнения ТЗ-плюс ═══\n'); // clientId через запятую с пробелами { const tokenSpace = auth.issueMockToken({ clientId: CO.A.clientId + ' , ' + CO.B.clientId + ' , ' + CO.C.clientId, companyId: CO.A.companyId, companyName: 'Multi-Space', email: 'multispace@test.local', }); const r = await req.get('/api/v1/entries').set(bearer(tokenSpace)); ok(r.status === 200, 'TZ+.1: clientId с пробелами → 200', `status=${r.status}`); } // Первое значение до запятой = активная компания { const r = await req.get('/api/v1/entries').set(bearer(multiToken)); const b = JSON.parse(r.text); // Все записи принадлежат CO.A (первая в clientId) const hasForeign = b.entries.some(e => e.company_id !== CO.A.dbId && e.company_id !== null); ok(!hasForeign, 'TZ+.2: первая компания — активная, чужих записей нет'); } // ══════════════════════════════════════════════════════════════════════════ // Cleanup // ══════════════════════════════════════════════════════════════════════════ tee('\n═══ Cleanup ═══\n'); // Удаляем все тестовые записи for (const co of companies) { await pool.query( `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'tz-full-cleanup' WHERE company_id = $1 AND deleted_at IS NULL`, [co.dbId] ); } tee('✅ Тестовые записи удалены (soft-delete)'); // ══════════════════════════════════════════════════════════════════════════ // Итоги // ══════════════════════════════════════════════════════════════════════════ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); tee('\n══════════════════════════════════════════════════════'); tee(` ${pass} PASS, ${fail} FAIL — ${elapsed}с`); tee('══════════════════════════════════════════════════════'); if (errors.length > 0) { tee('\nОшибки:'); errors.forEach(e => tee(' ' + e)); } saveLog(); await pool.end(); process.exit(fail > 0 ? 1 : 0); })();