diff --git a/tests/tz-full-compliance.js b/tests/tz-full-compliance.js new file mode 100644 index 0000000..52404be --- /dev/null +++ b/tests/tz-full-compliance.js @@ -0,0 +1,872 @@ +'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); +})();