diff --git a/package.json b/package.json index 0417ec5..9d957d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipwhitelist", - "version": "0.5.167", + "version": "0.5.168", "description": "IP WhiteList microservice for cloud provider", "main": "server.js", "scripts": { diff --git a/v1_legacy/src/api/index.js b/v1_legacy/src/api/index.js deleted file mode 100644 index eb5a77d..0000000 --- a/v1_legacy/src/api/index.js +++ /dev/null @@ -1,41 +0,0 @@ -'use strict'; - -/** - * src/api/index.js — REST API v1. - * - * Монтируется в server.js ДО общего auth.middleware: - * app.use('/api/v1', createApiRouter({ auth, q })) - * - * Аутентификация: только Bearer JWT (без сессий, без CSRF). - * Ответы: JSON везде. - * Переиспользует: src/queries.js, src/auth.js (middleware для валидации токена). - * - * Эндпоинты: - * GET /api/v1/entries — список записей компании - * POST /api/v1/entries — создать запись - * PATCH /api/v1/entries/:id — обновить запись - * DELETE /api/v1/entries/:id — удалить запись - * GET /api/v1/companies — все компании (admin) - * PATCH /api/v1/companies/:id/limit — установить лимит (admin) - */ - -const express = require('express'); -const { requireBearer } = require('./middleware/bearerAuth'); -const { createEntriesRouter } = require('./routes/entries'); -const { createAdminRouter } = require('./routes/admin'); - -function createApiRouter({ auth, q }) { - const router = express.Router(); - - // Все /api/v1/* требуют Bearer — 401 JSON если нет токена - router.use(requireBearer); - // Валидация токена — Bearer-only (игнорирует сессию, всегда проверяет JWT) - router.use(auth.bearerMiddleware); - - router.use('/entries', createEntriesRouter({ q })); - router.use('/', createAdminRouter({ q })); - - return router; -} - -module.exports = { createApiRouter }; diff --git a/v1_legacy/src/api/routes/admin.js b/v1_legacy/src/api/routes/admin.js deleted file mode 100644 index 144522a..0000000 --- a/v1_legacy/src/api/routes/admin.js +++ /dev/null @@ -1,61 +0,0 @@ -'use strict'; - -const express = require('express'); - -// Отдельный requireAdmin для API — возвращает JSON вместо plain text -function apiRequireAdmin(req, res, next) { - if (!req.user || !req.user.isAdmin) { - return res.status(403).json({ error: 'Admin role required' }); - } - next(); -} - -function createAdminRouter({ q }) { - const router = express.Router(); - const json = express.json({ limit: '8kb' }); - - // GET /api/v1/companies — все компании (admin only) - router.get('/companies', apiRequireAdmin, async (req, res) => { - try { - const companies = await q.getAllCompanies(); - res.json({ companies }); - } catch (e) { - res.status(500).json({ error: e.message }); - } - }); - - // PATCH /api/v1/companies/:id/limit — установить лимит (admin only) - router.patch('/companies/:id/limit', apiRequireAdmin, json, async (req, res) => { - const rawLimit = (req.body || {}).limit; - const isReset = rawLimit === null || rawLimit === undefined; - const limit = isReset ? null : parseInt(rawLimit, 10); - if (!isReset && (isNaN(limit) || limit < 0)) { - return res.status(400).json({ error: 'limit must be non-negative integer or null to reset' }); - } - try { - await q.setLimit(req.params.id, limit); - res.json({ ok: true, limit }); - } catch (e) { - res.status(e.status || 500).json({ error: e.message }); - } - }); - - // GET /api/v1/audit — журнал действий (admin only) - // ?company= — фильтр по компании (опционально) - router.get('/audit', apiRequireAdmin, async (req, res) => { - try { - const companyId = req.query.company ? parseInt(req.query.company, 10) : null; - const [rows, companies] = await Promise.all([ - q.getAudit(companyId && Number.isFinite(companyId) ? companyId : null), - q.getAllCompanies(), - ]); - res.json({ rows, companies }); - } catch (e) { - res.status(500).json({ error: e.message }); - } - }); - - return router; -} - -module.exports = { createAdminRouter }; diff --git a/v1_legacy/src/api/routes/entries.js b/v1_legacy/src/api/routes/entries.js deleted file mode 100644 index 625f0e4..0000000 --- a/v1_legacy/src/api/routes/entries.js +++ /dev/null @@ -1,172 +0,0 @@ -'use strict'; - -const express = require('express'); -const { aggregateCIDRs } = require('../../validators'); - -function createEntriesRouter({ q }) { - const router = express.Router(); - const json = express.json({ limit: '32kb' }); - - // Маппинг сообщений ошибок на HTTP-коды - function handleQueryError(e, res) { - const msg = e.message || ''; - // 409: бизнес-конфликты (дубликат, пересечение, лимит) - if ( - msg.includes('Лимит исчерпан') || - msg.includes('Такой адрес уже существует') || - msg.includes('Пересечение с существующей записью') - ) { - return res.status(409).json({ error: msg }); - } - // 404: запись не найдена - if (msg.includes('Запись не найдена')) { - return res.status(404).json({ error: msg }); - } - // 400: ошибки валидации от validate() и blocked-ranges - if ( - msg.includes('Некорректн') || - msg.includes('Маска должна быть') || - msg.includes('Пустое значение') || - msg.includes('IPv6 не поддерживается') || - msg.includes('пересекается с запрещённым') - ) { - return res.status(400).json({ error: msg }); - } - return res.status(500).json({ error: msg }); - } - - // Определить компанию из запроса. - // Admin с ?company= → компания по ID. - // User с ?client_id= (мульти-компания) → компания по clientId. - // Иначе → компания пользователя (UPSERT). - async function resolveCompany(req) { - if (req.user.isAdmin && req.query.company) { - const id = parseInt(req.query.company, 10); - if (!Number.isFinite(id) || id <= 0) { - throw Object.assign(new Error('Invalid company id'), { status: 400 }); - } - const c = await q.getCompanyById(id); - if (!c) throw Object.assign(new Error('Company not found'), { status: 404 }); - return c; - } - // Мульти-компания: client_id разрешён только если есть в профилях (IAM) или allClientIds (JWT fallback) - const requestedId = (req.query.client_id || '').trim(); - const allowedIds = req.user.profiles && req.user.profiles.length - ? req.user.profiles.map(p => p.client_id) - : (req.user.allClientIds || []); - // Без верифицированного списка — client_id не принимаем - const effectiveClientId = (requestedId && allowedIds.length && allowedIds.includes(requestedId)) - ? requestedId - : req.user.clientId; - // companyName: для переключённой компании — находим в profiles или используем clientId - let effectiveName = effectiveClientId === req.user.clientId - ? req.user.companyName - : effectiveClientId; - if (req.user.profiles) { - const p = req.user.profiles.find(p => p.client_id === effectiveClientId); - if (p) effectiveName = p.company_name; - } - return q.getOrCreateCompany(effectiveClientId, effectiveName, req.user.email); - } - - // GET /api/v1/entries — список записей + лимит - // ?includeDeleted=true — показать soft-deleted (только admin) - router.get('/', async (req, res) => { - try { - const company = await resolveCompany(req); - const includeDeleted = req.user.isAdmin && req.query.includeDeleted === 'true'; - const [entries, limit] = await Promise.all([ - q.listEntries(company.id, includeDeleted), - q.getLimit(company), - ]); - res.json({ entries, limit, used: entries.length }); - } catch (e) { - res.status(e.status || 500).json({ error: e.message }); - } - }); - - // GET /api/v1/entries/export — агрегированный список CIDR - // По умолчанию JSON. ?format=txt → text/plain. - // Только для админа. - router.get('/export', async (req, res) => { - try { - let companyId = null; - if (!req.user.isAdmin) { - const requestedId = (req.query.client_id || '').trim(); - const effectiveClientId = (requestedId && req.user.allClientIds && req.user.allClientIds.includes(requestedId)) - ? requestedId - : req.user.clientId; - const company = await q.getOrCreateCompany(effectiveClientId, effectiveClientId); - companyId = company.id; - } else if (req.query.company) { - const id = parseInt(req.query.company, 10); - if (Number.isFinite(id) && id > 0) companyId = id; - } - const cidrs = await q.getExportCIDRs(companyId); - const aggregated = aggregateCIDRs(cidrs); - - const isText = req.query.format === 'txt'; - if (!isText) { - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - const fname = (req.query.filename || 'white-list.json').replace(/[^\w\-_. ]/g, '_'); - const disp = req.query.view === '1' ? 'inline' : 'attachment'; - res.setHeader('Content-Disposition', `${disp}; filename="${fname}"`); - return res.json({ cidrs: aggregated, count: aggregated.length }); - } - - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - const fname = (req.query.filename || 'white-list.txt').replace(/[^\w\-_. ]/g, '_'); - const disp = req.query.view === '1' ? 'inline' : 'attachment'; - res.setHeader('Content-Disposition', `${disp}; filename="${fname}"`); - res.send(aggregated.join('\n') + (aggregated.length ? '\n' : '')); - } catch (e) { - res.status(500).json({ error: e.message }); - } - }); - - // POST /api/v1/entries — создать запись - router.post('/', json, async (req, res) => { - const { value, comment } = req.body || {}; - if (!value) return res.status(400).json({ error: 'value required' }); - if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' }); - try { - const company = await resolveCompany(req); - const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email, req.user.originalUserEmail); - res.status(201).json({ entry, wasNormalized }); - } catch (e) { - if (e.status) return res.status(e.status).json({ error: e.message }); - handleQueryError(e, res); - } - }); - - // PATCH /api/v1/entries/:id — обновить запись - router.patch('/:id', json, async (req, res) => { - const { value, comment } = req.body || {}; - if (!value) return res.status(400).json({ error: 'value required' }); - if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' }); - try { - const company = await resolveCompany(req); - const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email, req.user.originalUserEmail); - res.json({ entry, wasNormalized }); - } catch (e) { - if (e.status) return res.status(e.status).json({ error: e.message }); - handleQueryError(e, res); - } - }); - - // DELETE /api/v1/entries/:id — удалить запись - router.delete('/:id', async (req, res) => { - try { - const company = await resolveCompany(req); - await q.deleteEntry(req.params.id, company.id, req.user.email, req.user.originalUserEmail); - res.status(204).send(); - } catch (e) { - if (e.status) return res.status(e.status).json({ error: e.message }); - handleQueryError(e, res); - } - }); - - return router; -} - -module.exports = { createEntriesRouter }; diff --git a/v1_legacy/tests/api.js b/v1_legacy/tests/api.js deleted file mode 100644 index 3980c79..0000000 --- a/v1_legacy/tests/api.js +++ /dev/null @@ -1,1019 +0,0 @@ -'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 === 'alfa'); - const uUser2 = MOCK_USERS.find(u => u.id === 'beta'); - - const tokenAdmin = auth.issueMockToken({ clientId: uAdmin.clientId, companyId: uAdmin.companyId, companyName: uAdmin.companyName, email: uAdmin.email }); - const tokenUser = auth.issueMockToken({ clientId: uUser.clientId, companyId: uUser.companyId, companyName: uUser.companyName, email: uUser.email }); - const tokenUser2 = auth.issueMockToken({ clientId: uUser2.clientId, companyId: uUser2.companyId, companyName: uUser2.companyName, email: uUser2.email }); - - log(!!tokenAdmin && !!tokenUser && !!tokenUser2, 'setup: выпустили токены для admin, user, user2'); - - await cleanup(); - console.log('── Cleanup done ──\n'); - - // ══════════════════════════════════════════════════════════════════════════════ - // A. Auth — отсутствие / невалидный токен - // ══════════════════════════════════════════════════════════════════════════════ - console.log('── A. Auth ──'); - - { - const r = await req.get('/api/v1/entries'); - log(r.status === 401, 'A1: GET /entries без токена → 401', `status=${r.status}`); - log(!!json(r).error, 'A2: тело содержит error-поле'); - } - - { - const r = await req.get('/api/v1/entries').set('Authorization', 'Bearer broken.token.here'); - log(r.status === 401, 'A3: GET /entries с невалидным токеном → 401', `status=${r.status}`); - } - - { - const r = await req.get('/api/v1/entries').set('Authorization', 'Basic dXNlcjpwYXNz'); - log(r.status === 401, 'A4: Basic auth → 401 (ожидается Bearer)', `status=${r.status}`); - } - - { - const r = await req.post('/api/v1/entries').set(bearer(tokenUser)); - // Нет body — 400, но не 401 - log(r.status !== 401, 'A5: POST /entries с валидным токеном → не 401', `status=${r.status}`); - } - - // ══════════════════════════════════════════════════════════════════════════════ - // B. Entries CRUD — базовый цикл - // ══════════════════════════════════════════════════════════════════════════════ - console.log('\n── B. Entries CRUD ──'); - - // B1: GET — пустой список (до добавления) - { - const r = await req.get('/api/v1/entries').set(bearer(tokenUser)); - log(r.status === 200, 'B1: GET /entries → 200', `status=${r.status}`); - const b = json(r); - log(Array.isArray(b.entries), 'B2: тело содержит entries[]'); - log(typeof b.limit === 'number', 'B3: тело содержит limit (число)', `limit=${b.limit}`); - log(typeof b.used === 'number', 'B4: тело содержит used (число)', `used=${b.used}`); - log(b.used === b.entries.length, 'B5: used === entries.length'); - } - - // B6: POST — создать запись - let entryId; - { - const r = await req.post('/api/v1/entries') - .set(bearer(tokenUser)) - .send({ value: C.A, comment: 'api-тест A' }); - log(r.status === 201, 'B6: POST /entries → 201', `status=${r.status} body=${JSON.stringify(json(r)).slice(0,120)}`); - const b = json(r); - if (r.status === 201 && b.entry) { - log(!!b.entry.id, 'B7: ответ содержит entry.id'); - log(b.entry.value_cidr === cidr(C.A), 'B8: entry.value_cidr верный', `got=${b.entry.value_cidr}`); - log(b.entry.comment === 'api-тест A', 'B9: entry.comment сохранён'); - log(b.wasNormalized === false, 'B10: wasNormalized=false для хост-адреса', `got=${b.wasNormalized}`); - entryId = b.entry.id; - } else { - log(false, 'B7: ответ содержит entry.id'); log(false, 'B8: entry.value_cidr верный'); - log(false, 'B9: entry.comment сохранён'); log(false, 'B10: wasNormalized=false'); - } - } - - // B11: GET — запись появилась в списке - { - const r = await req.get('/api/v1/entries').set(bearer(tokenUser)); - const b = json(r); - const found = b.entries?.find(e => e.id === entryId); - log(!!found, 'B11: созданная запись появилась в GET /entries'); - log(b.used >= 1, 'B12: used >= 1 после добавления', `used=${b.used}`); - } - - // B13: PATCH — обновить запись - if (entryId) { - const r = await req.patch('/api/v1/entries/' + entryId) - .set(bearer(tokenUser)) - .send({ value: C.B, comment: 'обновлено' }); - log(r.status === 200, 'B13: PATCH /entries/:id → 200', `status=${r.status}`); - const b = json(r); - log(b.entry?.value_cidr === cidr(C.B), 'B14: value_cidr обновлён', `got=${b.entry?.value_cidr}`); - log(b.entry?.comment === 'обновлено', 'B15: comment обновлён'); - } else { log(false,'B13: PATCH'); log(false,'B14: value_cidr'); log(false,'B15: comment'); } - - // B16: DELETE — удалить запись - { - const r = await req.delete('/api/v1/entries/' + entryId).set(bearer(tokenUser)); - log([200, 204].includes(r.status), 'B16: DELETE /entries/:id → 200/204', `status=${r.status}`); - } - - // B17: запись не видна после удаления - { - const r = await req.get('/api/v1/entries').set(bearer(tokenUser)); - const b = json(r); - const found = b.entries?.find(e => e.id === entryId); - log(!found, 'B17: удалённая запись не в списке GET /entries'); - } - - // B18: soft delete — deleted_at выставлен в БД - if (entryId) { - const row = await pool.query('SELECT deleted_at FROM whitelist_entries WHERE id = $1', [entryId]); - log(!!row.rows[0]?.deleted_at, 'B18: soft delete — deleted_at выставлен в БД'); - } - - // B19: DELETE несуществующей (уже удалена) → 404 - { - const r = await req.delete('/api/v1/entries/' + entryId).set(bearer(tokenUser)); - log(r.status === 404, 'B19: повторный DELETE → 404', `status=${r.status}`); - } - - // B20: PATCH несуществующей → 404 - { - const r = await req.patch('/api/v1/entries/' + entryId) - .set(bearer(tokenUser)) - .send({ value: C.C, comment: '' }); - log(r.status === 404, 'B20: PATCH удалённой → 404', `status=${r.status}`); - } - - // ══════════════════════════════════════════════════════════════════════════════ - // C. Валидация - // ══════════════════════════════════════════════════════════════════════════════ - console.log('\n── C. Валидация ──'); - - // C1: нет value → 400 - { - const r = await req.post('/api/v1/entries') - .set(bearer(tokenUser)) - .send({ comment: 'нет ip' }); - log(r.status === 400, 'C1: POST без value → 400', `status=${r.status}`); - } - - // C2: пустой value → 400 или ошибка валидации - { - const r = await req.post('/api/v1/entries') - .set(bearer(tokenUser)) - .send({ value: '', comment: '' }); - log([400, 409, 422].includes(r.status), 'C2: POST с value="" → не 201', `status=${r.status}`); - } - - // C3: текст вместо IP → 400/409 - { - const r = await req.post('/api/v1/entries') - .set(bearer(tokenUser)) - .send({ value: 'not-an-ip', comment: '' }); - log([400, 409, 422].includes(r.status), 'C3: POST "not-an-ip" → не 201', `status=${r.status}`); - log(!!json(r).error, 'C4: тело содержит error'); - } - - // C5: октет > 255 - { - const r = await req.post('/api/v1/entries') - .set(bearer(tokenUser)) - .send({ value: '300.0.0.1' }); - log([400, 409, 422].includes(r.status), 'C5: 300.0.0.1 → не 201', `status=${r.status}`); - } - - // C6: маска > 32 - { - const r = await req.post('/api/v1/entries') - .set(bearer(tokenUser)) - .send({ value: '10.0.0.1/33' }); - log([400, 409, 422].includes(r.status), 'C6: /33 → не 201', `status=${r.status}`); - } - - // C7: маска < 0 (negative) - { - const r = await req.post('/api/v1/entries') - .set(bearer(tokenUser)) - .send({ value: '10.0.0.1/-1' }); - log([400, 409, 422].includes(r.status), 'C7: /-1 → не 201', `status=${r.status}`); - } - - // ══════════════════════════════════════════════════════════════════════════════ - // D. Нормализация CIDR - // ══════════════════════════════════════════════════════════════════════════════ - console.log('\n── D. Нормализация ──'); - - // D1: 9.9.9.5/24 → 9.9.9.0/24 - let subnetEntryId; - { - const r = await req.post('/api/v1/entries') - .set(bearer(tokenUser)) - .send({ value: C.SUBNET, comment: 'нормализация' }); - log(r.status === 201, 'D1: POST 9.9.9.5/24 → 201', `status=${r.status} body=${JSON.stringify(json(r)).slice(0,120)}`); - const b = json(r); - log(b.wasNormalized === true, 'D2: wasNormalized=true', `got=${b.wasNormalized}`); - log(b.entry?.value_cidr === '9.9.9.0/24', 'D3: нормализован → 9.9.9.0/24', `got=${b.entry?.value_cidr}`); - subnetEntryId = b.entry?.id; - } - - // D4: дубликат (сам себя) → 409 - { - const r = await req.post('/api/v1/entries') - .set(bearer(tokenUser)) - .send({ value: C.SUBNET, comment: 'дубликат' }); - log(r.status === 409, 'D4: дубликат нормализованного → 409', `status=${r.status}`); - log(json(r).error?.includes('уже существует'), 'D5: error содержит "уже существует"'); - } - - // D6: пересечение — 9.9.9.100 ⊂ 9.9.9.0/24 → 409 - { - const r = await req.post('/api/v1/entries') - .set(bearer(tokenUser)) - .send({ value: C.OVERLAP, comment: 'пересечение' }); - log(r.status === 409, 'D6: пересекающийся CIDR → 409', `status=${r.status}`); - log(json(r).error?.includes('Пересечение'), 'D7: error содержит "Пересечение"'); - } - - // Удаляем subnet для чистоты - if (subnetEntryId) { - await req.delete('/api/v1/entries/' + subnetEntryId).set(bearer(tokenUser)); - } - - // ══════════════════════════════════════════════════════════════════════════════ - // E. Изоляция между компаниями - // ══════════════════════════════════════════════════════════════════════════════ - console.log('\n── E. Изоляция ──'); - - // Добавляем запись под user - let isoEntryId; - { - const r = await req.post('/api/v1/entries') - .set(bearer(tokenUser)) - .send({ value: C.C, comment: 'isolation test' }); - isoEntryId = json(r).entry?.id; - log(r.status === 201 && !!isoEntryId, 'E1: user создал запись для теста изоляции'); - } - - // E2: user2 не видит записи user в GET /entries - { - const r = await req.get('/api/v1/entries').set(bearer(tokenUser2)); - const b = json(r); - const found = b.entries?.find(e => e.id === isoEntryId); - log(!found, 'E2: user2 не видит запись user в своём GET /entries'); - } - - // E3: user2 не может PATCH чужой записи → 404 (запись не найдена в его компании) - { - const r = await req.patch('/api/v1/entries/' + isoEntryId) - .set(bearer(tokenUser2)) - .send({ value: C.D, comment: 'IDOR' }); - log(r.status === 404, 'E3: user2 PATCH чужой записи → 404', `status=${r.status}`); - } - - // E4: user2 не может DELETE чужой записи → 404 - { - const r = await req.delete('/api/v1/entries/' + isoEntryId).set(bearer(tokenUser2)); - log(r.status === 404, 'E4: user2 DELETE чужой записи → 404', `status=${r.status}`); - } - - // Убедимся что запись user цела - { - const row = await pool.query('SELECT deleted_at FROM whitelist_entries WHERE id = $1', [isoEntryId]); - log(!row.rows[0]?.deleted_at, 'E5: запись user жива после атак user2'); - } - - // Чистим - await req.delete('/api/v1/entries/' + isoEntryId).set(bearer(tokenUser)); - - // ══════════════════════════════════════════════════════════════════════════════ - // F. Export - // ══════════════════════════════════════════════════════════════════════════════ - console.log('\n── F. Export ──'); - - // Добавляем запись для export-теста - { - await req.post('/api/v1/entries').set(bearer(tokenUser)).send({ value: C.EXPORT, comment: 'export' }); - } - - // F1: GET /api/v1/entries/export → 200 text/plain - { - const r = await req.get('/api/v1/entries/export').set(bearer(tokenUser)); - log(r.status === 200, 'F1: GET /export → 200', `status=${r.status}`); - log(r.headers['content-type']?.includes('text/plain'), 'F2: Content-Type: text/plain'); - log(r.text.includes(cidr(C.EXPORT)), 'F3: экспорт содержит добавленный CIDR', `text=${r.text.slice(0,100)}`); - } - - // F4: user export не содержит записи user2 - { - await req.post('/api/v1/entries').set(bearer(tokenUser2)).send({ value: C.D, comment: 'u2-export' }); - const rUser = await req.get('/api/v1/entries/export').set(bearer(tokenUser)); - const rUser2 = await req.get('/api/v1/entries/export').set(bearer(tokenUser2)); - log(!rUser.text.includes(cidr(C.D)), 'F4: экспорт user не содержит CIDR user2'); - log(!rUser2.text.includes(cidr(C.EXPORT)), 'F5: экспорт user2 не содержит CIDR user'); - await req.delete('/api/v1/entries/' + ( - (await pool.query(`SELECT id FROM whitelist_entries WHERE value_cidr=$1 AND deleted_at IS NULL`, [cidr(C.D)])).rows[0]?.id - )).set(bearer(tokenUser2)); - } - - // F6: удалённый CIDR не появляется в export - { - const exportId = (await pool.query( - `SELECT id FROM whitelist_entries WHERE value_cidr=$1 AND deleted_at IS NULL`, [cidr(C.EXPORT)] - )).rows[0]?.id; - if (exportId) { - await req.delete('/api/v1/entries/' + exportId).set(bearer(tokenUser)); - const r = await req.get('/api/v1/entries/export').set(bearer(tokenUser)); - log(!r.text.includes(cidr(C.EXPORT)), 'F6: удалённый CIDR не в export'); - } else { - log(false, 'F6: не нашли запись для удаления'); - } - } - - // F7: export без токена → 401 - { - const r = await req.get('/api/v1/entries/export'); - log(r.status === 401, 'F7: export без токена → 401', `status=${r.status}`); - } - - // ══════════════════════════════════════════════════════════════════════════════ - // G. Admin /companies - // ══════════════════════════════════════════════════════════════════════════════ - console.log('\n── G. Admin /companies ──'); - - // G1: admin получает список компаний - let userCompanyId; - { - const r = await req.get('/api/v1/companies').set(bearer(tokenAdmin)); - log(r.status === 200, 'G1: GET /companies (admin) → 200', `status=${r.status}`); - const b = json(r); - log(Array.isArray(b.companies), 'G2: тело содержит companies[]'); - log(b.companies?.length >= 1, 'G3: хотя бы одна компания', `count=${b.companies?.length}`); - const comp = b.companies?.find(c => c.client_id === uUser.clientId); - log(!!comp, 'G4: компания user присутствует в списке', `clientId=${uUser.clientId}`); - userCompanyId = comp?.id; - } - - // G5: user не может получить список компаний → 403 - { - const r = await req.get('/api/v1/companies').set(bearer(tokenUser)); - log(r.status === 403, 'G5: GET /companies (user) → 403', `status=${r.status}`); - } - - // G6: установить лимит - { - const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit') - .set(bearer(tokenAdmin)) - .send({ limit: 50 }); - log(r.status === 200, 'G6: PATCH /companies/:id/limit → 200', `status=${r.status}`); - log(json(r).limit === 50, 'G7: лимит в ответе = 50', `got=${json(r).limit}`); - } - - // G8: проверить лимит применился - { - const r = await req.get('/api/v1/entries').set(bearer(tokenUser)); - log(json(r).limit === 50, 'G8: GET /entries возвращает обновлённый limit=50', `got=${json(r).limit}`); - } - - // G9: невалидный лимит — отрицательный → 400 - { - const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit') - .set(bearer(tokenAdmin)) - .send({ limit: -1 }); - log(r.status === 400, 'G9: limit=-1 → 400', `status=${r.status}`); - } - - // G10: невалидный лимит — строка → 400 - { - const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit') - .set(bearer(tokenAdmin)) - .send({ limit: 'abc' }); - log(r.status === 400, 'G10: limit="abc" → 400', `status=${r.status}`); - } - - // G11: user не может менять лимиты → 403 - { - const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit') - .set(bearer(tokenUser)) - .send({ limit: 5 }); - log(r.status === 403, 'G11: PATCH /limit (user) → 403', `status=${r.status}`); - } - - // Сбрасываем лимит обратно (NULL = дефолт) - await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]); - - // ══════════════════════════════════════════════════════════════════════════════ - // H. Admin /audit - // ══════════════════════════════════════════════════════════════════════════════ - console.log('\n── H. Admin /audit ──'); - - // H1: admin получает журнал - { - const r = await req.get('/api/v1/audit').set(bearer(tokenAdmin)); - log(r.status === 200, 'H1: GET /audit (admin) → 200', `status=${r.status}`); - const b = json(r); - log(Array.isArray(b.rows), 'H2: тело содержит rows[]'); - log(Array.isArray(b.companies), 'H3: тело содержит companies[]'); - } - - // H4: admin фильтрует по компании - { - const r = await req.get('/api/v1/audit?company=' + userCompanyId).set(bearer(tokenAdmin)); - log(r.status === 200, 'H4: GET /audit?company= → 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 (WZ88888, WZ77777)'); - - // O1: GET /entries без ?client_id= → видит записи первой компании (WZ88888) - // Сначала добавим запись в обе компании для проверки - let multiEntry1, multiEntry2; - - // O1a: создаём запись в первой компании (WZ88888) через 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 (первая компания WZ88888)', `status=${r.status}`); - multiEntry1 = json(r).entry?.id; - } - - // O1b: создаём запись во ВТОРОЙ компании (WZ77777) через ?client_id= - { - const r = await req.post('/api/v1/entries?client_id=WZ77777').set(bearer(tokenMulti)) - .send({ value: '4.4.4.2', comment: 'multi-company-2' }); - log(r.status === 201, 'O1b: multi POST ?client_id=WZ77777 → 201', `status=${r.status}`); - multiEntry2 = json(r).entry?.id; - } - - // O2: GET без ?client_id= → только записи WZ88888 (не видит WZ77777) - { - 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 содержит запись WZ88888'); - log(!hasE2, 'O2b: GET default НЕ содержит запись WZ77777'); - } - - // O3: GET ?client_id=WZ77777 → только записи WZ77777 - { - const rSwitched = await req.get('/api/v1/entries?client_id=WZ77777').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=WZ77777 НЕ содержит запись WZ88888'); - log(hasE2, 'O3b: GET ?client_id=WZ77777 содержит запись WZ77777'); - } - - // O4: PATCH через ?client_id= работает изолированно - { - const r = await req.patch('/api/v1/entries/' + multiEntry2 + '?client_id=WZ77777') - .set(bearer(tokenMulti)) - .send({ value: '4.4.4.3', comment: 'patched-in-WZ77777' }); - log(r.status === 200, 'O4: multi PATCH ?client_id=WZ77777 → 200', `status=${r.status}`); - } - - // O5: DELETE через ?client_id= - { - const r = await req.delete('/api/v1/entries/' + multiEntry2 + '?client_id=WZ77777') - .set(bearer(tokenMulti)); - log([200, 204].includes(r.status), 'O5: multi DELETE ?client_id=WZ77777 → 200/204', `status=${r.status}`); - } - - // O6: экспорт с ?client_id=WZ77777 не содержит запись WZ88888 - { - // Создаём запись в WZ77777 для проверки экспорта - const cr = await req.post('/api/v1/entries?client_id=WZ77777').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=WZ77777').set(bearer(tokenMulti)); - log(rWZ02.status === 200, 'O6a: export ?client_id=WZ77777 → 200', `status=${rWZ02.status}`); - log(rWZ02.text.includes('4.4.5.1'), 'O6b: export содержит CIDR WZ77777'); - log(!rWZ02.text.includes('4.4.4.1'), 'O6c: export НЕ содержит CIDR WZ88888'); - - // O6d: экспорт без ?client_id= → только WZ88888 - const rDefault = await req.get('/api/v1/entries/export').set(bearer(tokenMulti)); - log(rDefault.text.includes('4.4.4.1'), 'O6d: export default содержит WZ88888'); - log(!rDefault.text.includes('4.4.5.1'), 'O6e: export default НЕ содержит WZ77777'); - - // Чистка - if (exportEntryId) { - await req.delete('/api/v1/entries/' + exportEntryId + '?client_id=WZ77777').set(bearer(tokenMulti)); - } - } - - // O7: несуществующий ?client_id= → игнорируется, фоллбек на WZ88888 (безопасность) - { - 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 → фоллбек на WZ88888 → записи ЕСТЬ - log(json(r).entries?.length > 0, 'O7b: фоллбек на WZ88888 — записи есть'); - } - - // O8: admin передаёт ?client_id= → использует свою компанию (игнор) - { - const r = await req.get('/api/v1/entries?client_id=WZ77777').set(bearer(tokenAdmin)); - log(r.status === 200, 'O8: admin GET ?client_id= → 200 (игнорирует)', `status=${r.status}`); - } - - // Чистка записей multi - if (multiEntry1) { - await req.delete('/api/v1/entries/' + multiEntry1).set(bearer(tokenMulti)); - } - if (multiEntry2) { - await req.delete('/api/v1/entries/' + multiEntry2 + '?client_id=WZ77777').set(bearer(tokenMulti)); - } - } - - // ══════════════════════════════════════════════════════════════════════════════ - // Cleanup - // ══════════════════════════════════════════════════════════════════════════════ - console.log('\n── Cleanup ──'); - await cleanup(); - await pool.end(); - - // ── Итог ───────────────────────────────────────────────────────────────────── - console.log('\n══════════════════════════════════════════════════════'); - console.log(` ${pass} PASS, ${fail} FAIL`); - if (errors.length) { - console.log('\nПроваленные тесты:'); - errors.forEach(e => console.log(' ', e)); - } - console.log('══════════════════════════════════════════════════════'); - process.exit(fail > 0 ? 1 : 0); -})().catch(e => { - console.error('\nFATAL:', e.message, e.stack); - pool.end(); - process.exit(1); -}); diff --git a/v1_legacy/tests/integration.js b/v1_legacy/tests/integration.js deleted file mode 100644 index 2227209..0000000 --- a/v1_legacy/tests/integration.js +++ /dev/null @@ -1,458 +0,0 @@ -'use strict'; - -/** - * tests/integration.js — НОВЫЕ интеграционные тесты (v2). - * - * Архитектура: UI-слой (SSR EJS) → HTTP → API-слой (/api/v1/*). - * - * Запуск: node tests/integration.js - * Требует: DEV_MODE=true, .env с БД. - * - * Разделы: - * 1. UI Login — mock-пользователи + ручной ввод clientId - * 2. UI Pages — главная, admin, audit (после логина) - * 3. UI Session — logout, нет доступа без сессии - * 4. UI Export — /export, ?view=1, ?filename=X - * 5. UI Multi-company — ручной вход с clientId через запятую - * 6. UI CSRF — защищённые POST требуют _csrf - * 7. UI Admin — admin-страницы доступны/недоступны - * 8. UI Error pages — 404, редирект на логин - * 9. API → UI的一致性 — данные совпадают - */ - -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++; - } -} - -// ── Хелперы ────────────────────────────────────────────────────────────────── - -/** Извлечь CSRF-токен из HTML-страницы */ -function csrfFromHtml(html) { - const m = html.match(/name="_csrf"\s+value="([^"]+)"/); - return m ? m[1] : null; -} - -/** - * Залогиниться под mock-пользователем (выбор из выпадашки). - * Возвращает agent с установленной сессией. - */ -async function loginAsMock(app, clientId) { - const agent = supertest.agent(app); - - // POST /login — на login-форме нет CSRF (защита через rateLimit) - const postRes = await agent - .post('/login') - .type('form') - .send({ clientId, returnTo: '/' }); - - if (postRes.status !== 302) throw new Error(`POST /login: ${postRes.status} ${postRes.text?.slice(0,100)}`); - return agent; -} - -/** - * Залогиниться через ручной ввод clientId. - */ -async function loginManual(app, clientId) { - const agent = supertest.agent(app); - - const postRes = await agent - .post('/login-token') - .type('form') - .send({ clientId, returnTo: '/' }); - - if (postRes.status !== 302) throw new Error(`POST /login-token: ${postRes.status} ${postRes.text?.slice(0,100)}`); - return agent; -} - -// ── Cleanup ────────────────────────────────────────────────────────────────── -async function cleanup() { - const testCidrs = [ - '8.8.8.100/32', '8.8.8.101/32', '8.8.8.102/32', - '9.9.9.1/32', '9.9.9.2/32', '9.9.9.3/32', - '4.4.4.1/32', '4.4.4.2/32', '4.4.4.3/32', '4.4.4.5/32', - ]; - for (const c of testCidrs) { - await pool.query( - `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'integration-test-cleanup' - WHERE value_cidr = $1 AND deleted_at IS NULL`, [c] - ); - } -} - -// ── Основной раннер ────────────────────────────────────────────────────────── - -(async () => { - console.log('\n══════════════════════════════════════════════════════'); - console.log(' Интеграционные тесты v2 (UI + API)'); - console.log('══════════════════════════════════════════════════════\n'); - - let app; - try { - const mod = require('../server'); - app = await mod.start(); - } catch (e) { - console.error('FATAL: не удалось запустить сервер:', e.message); - await pool.end(); - process.exit(1); - } - - const { MOCK_USERS } = require('../src/config'); - const uAdmin = MOCK_USERS.find(u => u.id === 'admin'); - const uAlfa = MOCK_USERS.find(u => u.id === 'alfa'); - const uBeta = MOCK_USERS.find(u => u.id === 'beta'); - const uMulti = MOCK_USERS.find(u => u.id === 'multi'); - - await cleanup(); - console.log('── Cleanup done ──\n'); - - // ════════════════════════════════════════════════════════════════════════════ - // 1. UI Login - // ════════════════════════════════════════════════════════════════════════════ - console.log('── 1. UI Login ──'); - - // 1a: GET /login → страница входа - { - const r = await supertest(app).get('/login'); - log(r.status === 200, '1a: GET /login → 200', `status=${r.status}`); - log(r.text.includes('IP WhiteList'), '1b: страница содержит заголовок'); - log(r.text.includes('Быстрый вход (mock)'), '1c: mock-форма присутствует (DEV_MODE)'); - log(r.text.includes('Ручной вход (clientId)'), '1d: форма ручного ввода присутствует'); - log(!r.text.includes('Bearer токен'), '1e: старой формы Bearer токена НЕТ'); - } - - // 1f: GET /login-token → редирект на /login - { - const r = await supertest(app).get('/login-token'); - log(r.status === 302, '1f: GET /login-token → 302 редирект на /login', `status=${r.status}`); - log(r.headers.location === '/login', '1g: Location: /login'); - } - - // 1h: POST /login (mock) — успешный вход - let agentAlfa; - { - agentAlfa = await loginAsMock(app, uAlfa.clientId); - const r = await agentAlfa.get('/'); - log(r.status === 200, '1h: после mock-логина GET / → 200', `status=${r.status}`); - log(r.text.includes(uAlfa.clientId), '1i: главная содержит clientId пользователя'); - log(r.text.includes('компания'), '1j: главная содержит блок "компания"'); - } - - // 1k: POST /login-token (ручной ввод) — успешный вход - { - const agent = await loginManual(app, 'DEBUG_USER_42'); - const r = await agent.get('/'); - log(r.status === 200, '1k: после ручного входа GET / → 200', `status=${r.status}`); - log(r.text.includes('DEBUG_USER_42'), '1l: главная содержит DEBUG_USER_42'); - - // заодно: мульти-компания через запятую (тем же агентом, без доп. логина) - const agentM = await loginManual(app, 'AAA,BBB,CCC'); - const rM = await agentM.get('/'); - log(rM.status === 200, '1m: ручной вход AAA,BBB,CCC → 200'); - log(rM.text.includes('AAA'), '1n: главная содержит первый clientId'); - log(rM.text.includes('BBB') || rM.text.includes('CCC'), '1o: есть другие компании (переключатель)'); - } - - // 1p: POST /login без clientId → ошибка - { - const agent = supertest.agent(app); - const postR = await agent.post('/login').type('form').send({ returnTo: '/' }); - log(postR.status === 302, '1p: POST /login без clientId → 302', `status=${postR.status}`); - log(postR.headers.location?.includes('error='), '1q: редирект содержит ошибку'); - } - - // 1r: POST /login-token без clientId → ошибка - { - const agent = supertest.agent(app); - const postR = await agent.post('/login-token').type('form').send({}); - log(postR.status === 302, '1r: POST /login-token без clientId → 302', `status=${postR.status}`); - log(postR.headers.location?.includes('error='), '1s: редирект содержит ошибку'); - } - - // 1t: несуществующий mock-пользователь → ошибка - { - const agent = supertest.agent(app); - const postR = await agent.post('/login').type('form').send({ clientId: 'NONEXISTENT', returnTo: '/' }); - log(postR.status === 302, '1t: POST /login NONEXISTENT → 302', `status=${postR.status}`); - log(postR.headers.location?.includes('error='), '1u: редирект содержит ошибку'); - } - - // ════════════════════════════════════════════════════════════════════════════ - // 2. UI Pages (после логина) - // ════════════════════════════════════════════════════════════════════════════ - console.log('\n── 2. UI Pages ──'); - - // 2a: главная страница (alfa) - { - const r = await agentAlfa.get('/'); - log(r.status === 200, '2a: GET / → 200', `status=${r.status}`); - log(r.text.includes('Добавить адрес'), '2b: форма "Добавить адрес" на главной'); - log(r.text.includes('logout') || r.text.includes('Выйти'), '2c: ссылка выхода'); - log(r.text.includes('Белые списки IP'), '2d: заголовок страницы'); - } - - // 2e: admin страница (alfa — не admin) - { - const r = await agentAlfa.get('/admin'); - log([200, 302, 403].includes(r.status), '2e: GET /admin (не-admin) → редирект или 403', `status=${r.status}`); - } - - // 2f: главная страница рендерит список (даже пустой) - { - const r = await agentAlfa.get('/'); - log(r.status === 200, '2f: GET / → 200', `status=${r.status}`); - log(r.text.includes('Белые списки IP'), '2g: страница содержит заголовок'); - } - - // 2h: версия приложения в футере - { - const ver = require('../package.json').version; - const r = await agentAlfa.get('/'); - log(r.text.includes('v' + ver), '2h: версия ' + ver + ' в футере'); - } - - // ════════════════════════════════════════════════════════════════════════════ - // 3. UI Session - // ════════════════════════════════════════════════════════════════════════════ - console.log('\n── 3. UI Session ──'); - - // 3a: без сессии → редирект на /login - { - const r = await supertest(app).get('/'); - log(r.status === 302, '3a: GET / без сессии → 302', `status=${r.status}`); - log(r.headers.location?.startsWith('/login'), '3b: редирект на /login', `location=${r.headers.location}`); - } - - // 3c: /logout — уничтожает сессию - { - const r = await agentAlfa.get('/logout'); - log(r.status === 302, '3c: GET /logout → 302', `status=${r.status}`); - log(r.headers.location === '/login', '3d: редирект на /login'); - - // После логаута — нет доступа к / - const r2 = await agentAlfa.get('/'); - log(r2.status === 302, '3e: после логаута GET / → 302', `status=${r2.status}`); - } - - // 3f: ?returnTo= параметр - { - const r = await supertest(app).get('/entries?foo=bar'); - log(r.status === 302, '3f: GET /entries без сессии → 302', `status=${r.status}`); - log(r.headers.location?.includes('returnTo'), '3g: Location содержит returnTo', `location=${r.headers.location}`); - } - - // ════════════════════════════════════════════════════════════════════════════ - // 4. UI Export - // ════════════════════════════════════════════════════════════════════════════ - console.log('\n── 4. UI Export ──'); - - // Свежий логин для export-тестов - let agentExp = await loginAsMock(app, uAlfa.clientId); - - // Добавим запись для тестов экспорта через API - { - const auth = await require('../src/auth').initAuth(); - const token = auth.issueMockToken({ - clientId: uAlfa.clientId, companyId: uAlfa.companyId, - companyName: uAlfa.companyName, email: uAlfa.email, - }); - await supertest(app) - .post('/api/v1/entries') - .set('Authorization', 'Bearer ' + token) - .send({ value: '8.8.8.100', comment: 'export-test' }); - } - - // 4a: GET /export → attachment (скачивание) - { - const r = await agentExp.get('/export'); - log(r.status === 200, '4a: GET /export → 200', `status=${r.status}`); - log(r.headers['content-disposition']?.includes('attachment'), '4b: Content-Disposition: attachment', `disp=${r.headers['content-disposition']}`); - log(r.headers['content-disposition']?.includes('white-list.txt'), '4c: имя файла white-list.txt по умолчанию'); - } - - // 4d: GET /export?view=1 → inline (просмотр) - { - const r = await agentExp.get('/export?view=1'); - log(r.status === 200, '4d: GET /export?view=1 → 200', `status=${r.status}`); - log(r.headers['content-disposition']?.includes('inline'), '4e: Content-Disposition: inline', `disp=${r.headers['content-disposition']}`); - } - - // 4f: GET /export?filename=custom.txt - { - const r = await agentExp.get('/export?filename=custom.txt'); - log(r.status === 200, '4f: GET /export?filename=custom.txt → 200', `status=${r.status}`); - log(r.headers['content-disposition']?.includes('custom.txt'), '4g: имя файла custom.txt', `disp=${r.headers['content-disposition']}`); - } - - // 4h: GET /export без сессии → 200 (ПУБЛИЧНЫЙ, ВРЕМЕННО) - { - const r = await supertest(app).get('/export'); - log(r.status === 200, '4h: GET /export без авторизации → 200 (публичный)', `status=${r.status}`); - log(r.headers['content-type']?.includes('text/plain'), '4i: Content-Type text/plain'); - } - - // 4j: GET /export?view=1&filename=test.txt - { - const r = await supertest(app).get('/export?view=1&filename=test.txt'); - log(r.status === 200, '4j: GET /export?view=1&filename=test.txt → 200', `status=${r.status}`); - log(r.headers['content-disposition']?.includes('inline'), '4k: inline (view=1 приоритетнее filename)'); - log(r.headers['content-disposition']?.includes('test.txt'), '4l: имя файла test.txt'); - } - - // ════════════════════════════════════════════════════════════════════════════ - // 5. UI Multi-company - // ════════════════════════════════════════════════════════════════════════════ - console.log('\n── 5. UI Multi-company ──'); - - let agentMulti; - if (uMulti) { - agentMulti = await loginManual(app, uMulti.clientId); - - // 5a: на главной виден переключатель компаний - { - const r = await agentMulti.get('/'); - log(r.status === 200, '5a: multi GET / → 200', `status=${r.status}`); - log(r.text.includes('WZ88888'), '5b: содержит WZ88888 (первая компания)'); - log(r.text.includes('WZ77777'), '5c: содержит WZ77777 (вторая компания)'); - } - - // 5d: экспорт работает для multi-пользователя - { - const r = await agentMulti.get('/export'); - log(r.status === 200, '5d: export multi → 200', `status=${r.status}`); - log(r.headers['content-type']?.includes('text/plain'), '5e: Content-Type text/plain'); - } - } else { - log(false, '5a-e: multi пользователь не найден в MOCK_USERS'); - } - - // ════════════════════════════════════════════════════════════════════════════ - // 6. UI CSRF (на главной странице) - // ════════════════════════════════════════════════════════════════════════════ - console.log('\n── 6. UI CSRF ──'); - - // 6a: логин работает без _csrf (защита через rateLimit, не CSRF) - // Проверено выше — все логины в тесте работают без _csrf. - log(true, '6a: POST /login работает без _csrf (проверено выше)'); - - // 6b: главная страница содержит _csrf поле (для форм добавления/редактирования) - { - const r = await agentExp.get('/'); - const hasCsrf = r.text.includes('name="_csrf"'); - log(hasCsrf, '6b: главная содержит _csrf поле'); - } - - // ════════════════════════════════════════════════════════════════════════════ - // 7. UI Admin - // ════════════════════════════════════════════════════════════════════════════ - console.log('\n── 7. UI Admin ──'); - - let agentAdmin; - try { - agentAdmin = await loginAsMock(app, uAdmin.clientId); - const r = await agentAdmin.get('/'); - // 200=успех, 302=rate limit или сессия истекла - log([200, 302].includes(r.status), '7a: admin GET / → ' + r.status, `status=${r.status}`); - if (r.status === 200) { - log(r.text.includes(uAdmin.clientId), '7b: содержит clientId admin'); - } else { - log(true, '7b: admin страница недоступна (rate limit)'); - } - - const r2 = await agentAdmin.get('/admin'); - log([200, 302].includes(r2.status), '7c: admin GET /admin → ' + r2.status, `status=${r2.status}`); - } catch (e) { - log(true, '7a-c: admin-тесты пропущены (rate limit)', e.message.slice(0, 50)); - } - - // 7e: обычный пользователь не видит admin - { - const r = await agentExp.get('/admin'); - log(r.status === 403, '7e: user GET /admin → 403', `status=${r.status}`); - } - - // ════════════════════════════════════════════════════════════════════════════ - // 8. UI Error pages - // ════════════════════════════════════════════════════════════════════════════ - console.log('\n── 8. UI Error pages ──'); - - // 8a: без сессии → редирект на логин - { - const r = await supertest(app).get('/nonexistent-page-12345'); - log(r.status === 302, '8a: GET /nonexistent без сессии → 302', `status=${r.status}`); - } - - // 8b: авторизованный пользователь (agentExp) - { - const r = await agentExp.get('/nonexistent-page-98765'); - const ok = [404, 302, 403].includes(r.status); - log(ok, '8b: GET /nonexistent с сессией → ' + r.status, `status=${r.status}`); - } - - // 8c: /healthz доступен без авторизации - { - const r = await supertest(app).get('/healthz'); - log(r.status === 200, '8c: GET /healthz → 200', `status=${r.status}`); - log(r.text === 'OK', '8d: /healthz возвращает OK'); - } - - // ════════════════════════════════════════════════════════════════════════════ - // 9. UI Export vs API Export - // ════════════════════════════════════════════════════════════════════════════ - console.log('\n── 9. UI/API export ──'); - - // 9a: API export работает (через Bearer token) - { - const auth = await require('../src/auth').initAuth(); - const token = auth.issueMockToken({ - clientId: uAlfa.clientId, companyId: uAlfa.companyId, - companyName: uAlfa.companyName, email: uAlfa.email, - }); - - const r = await supertest(app) - .get('/api/v1/entries/export') - .set('Authorization', 'Bearer ' + token); - - log(r.status === 200, '9a: API export → 200', `status=${r.status}`); - log(r.headers['content-type']?.includes('text/plain'), '9b: API export Content-Type text/plain'); - log(r.text.length > 0, '9c: API export не пустой'); - } - - // 9d: UI export работает (через сессию) - { - const r = await agentExp.get('/export'); - log(r.status === 200, '9d: UI export → 200', `status=${r.status}`); - log(r.text.length > 0, '9e: UI export не пустой'); - } - - // ── Cleanup ──────────────────────────────────────────────────────────────── - await cleanup(); - - // ── Итоги ────────────────────────────────────────────────────────────────── - console.log('\n══════════════════════════════════════════════════════'); - console.log(' ' + pass + ' PASS, ' + fail + ' FAIL'); - console.log('══════════════════════════════════════════════════════'); - - if (errors.length > 0) { - console.error('\nОшибки:'); - errors.forEach(e => console.error(' ', e)); - } - - await pool.end(); - process.exit(fail > 0 ? 1 : 0); -})(); diff --git a/v1_legacy/tests/ui-sso.py b/v1_legacy/tests/ui-sso.py deleted file mode 100644 index 86fe794..0000000 --- a/v1_legacy/tests/ui-sso.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -""" -tests/ui-sso.py — E2E браузерный тест SSO через Playwright. - -Проверяет полный поток: - 1. Редирект на Keycloak - 2. Вход с логином/паролем - 3. Возврат в приложение - 4. CRUD через UI - 5. Logout - -Запуск: - pip install playwright && playwright install chromium - KC_LOGIN=tazet@narod.ru KC_PASS=*** python tests/ui-sso.py - -Если Keycloak запрашивает OTP/MFA код — тест запросит его через stdin. -""" - -import os, sys, time -from playwright.sync_api import sync_playwright - -APP_URL = os.environ.get("APP_URL", "https://whitelist.nodejsk8s.services.ngcloud.ru") -KC_LOGIN = os.environ.get("KC_LOGIN") -KC_PASS = os.environ.get("KC_PASS") - -if not KC_LOGIN or not KC_PASS: - print("❌ Задай KC_LOGIN и KC_PASS") - print(" KC_LOGIN=tazet@narod.ru KC_PASS=*** python tests/ui-sso.py") - sys.exit(1) - -passed = 0 -failed = 0 - -def ok(name, condition, details=""): - global passed, failed - if condition: - passed += 1 - print(f" ✅ {name}") - else: - failed += 1 - print(f" ❌ {name} — {details}") - -def run(): - with sync_playwright() as p: - browser = p.chromium.launch(headless=False) - page = browser.new_page() - - print(f"\n🧪 SSO E2E браузерный тест") - print(f" App: {APP_URL}") - print(f" User: {KC_LOGIN}\n") - - # ── 1. Заходим на / → редирект на Keycloak ── - page.goto(f"{APP_URL}/") - page.wait_for_timeout(2000) - ok("1. Редирект на Keycloak", "keycloak.nubes.ru" in page.url) - - # ── 2. Логин в Keycloak ── - if page.locator("input[name=username], #username").count() > 0: - page.fill("input[name=username], #username", KC_LOGIN) - page.fill("input[name=password], #password", KC_PASS) - page.click("button[type=submit], input[type=submit], button[name=login]") - page.wait_for_timeout(3000) - - # Проверяем OTP/MFA - if page.locator("input[name=otp], input[name=totp]").count() > 0: - otp = input("🔐 Введи код OTP/MFA: ").strip() - page.fill("input[name=otp], input[name=totp]", otp) - page.click("button[type=submit], input[type=submit]") - page.wait_for_timeout(3000) - - ok("2. Вход в Keycloak", APP_URL in page.url or "ipwhitelist" in page.url.lower() or "WhiteList" in page.title()) - else: - ok("2. Вход в Keycloak", False, "no login form") - - # ── 3. Главная страница ── - page.wait_for_timeout(2000) - ok("3. Главная страница загружена", "Белые списки" in page.content() or "WhiteList" in page.title() or page.url.startswith(APP_URL)) - - # ── 4. Добавляем запись ── - ts = int(time.time()) - cidr = f"10.{ts % 200}.{ts % 100}.{ts % 50}" - page.fill("input[name=value]", cidr) - page.fill("textarea[name=comment], input[name=comment]", f"test-{ts}") - page.click("button[type=submit]:has-text('Добавить'), button:has-text('Добавить')") - page.wait_for_timeout(2000) - ok("4. Запись добавлена", cidr in page.content()) - - # ── 5. Удаляем запись ── - delete_btn = page.locator(f"tr:has-text('{cidr}') button:has-text('Удалить'), tr:has-text('{cidr}') a:has-text('Удалить')").first - if delete_btn.count() > 0: - delete_btn.click() - page.wait_for_timeout(1000) - page.click("button:has-text('OK'), button:has-text('Да'), button:has-text('Удалить')") - page.wait_for_timeout(2000) - ok("5. Запись удалена", cidr not in page.content()) - else: - ok("5. Запись удалена", False, "delete button not found") - - # ── 6. Export ── - page.goto(f"{APP_URL}/export") - page.wait_for_timeout(1000) - ok("6. Export доступен", "text/plain" in page.content() or page.url.endswith("/export")) - - # ── 7. Logout ── - page.goto(f"{APP_URL}/logout") - page.wait_for_timeout(3000) - ok("7. Logout → /login", "/login" in page.url or "keycloak" in page.url.lower()) - - # ── Итог ── - print(f"\n═══ ИТОГ ═══") - print(f" ✅ {passed} ❌ {failed} из {passed+failed}") - - browser.close() - return failed == 0 - -if __name__ == "__main__": - ok = run() - sys.exit(0 if ok else 1) diff --git a/v1_legacy/ui/api-client.js b/v1_legacy/ui/api-client.js deleted file mode 100644 index 52f9c0a..0000000 --- a/v1_legacy/ui/api-client.js +++ /dev/null @@ -1,93 +0,0 @@ -'use strict'; - -/** - * ui/api-client.js — HTTP-клиент к /api/v1/*. - * - * UI-роуты не ходят напрямую в БД — только сюда. - * Токен берётся из req.session.token (установлен на /login). - * - * Все ошибки пробрасываются как Error с понятным message. - */ - -const http = require('http'); -const https = require('https'); - -const API_BASE = process.env.API_BASE || 'http://localhost:' + (process.env.PORT || 3000); - -/** - * Выполнить запрос к API. - * @param {string} method — GET, POST, PATCH, DELETE - * @param {string} path — /api/v1/entries - * @param {string} token — Bearer token - * @param {object} [body] — JSON body (для POST/PATCH) - * @returns {Promise<{status: number, data: any}>} - */ -function apiRequest(method, path, token, body, impHeaders) { - return new Promise((resolve, reject) => { - const url = new URL(API_BASE + path); - const lib = url.protocol === 'https:' ? https : http; - const bodyStr = body ? JSON.stringify(body) : null; - - const opts = { - hostname: url.hostname, - port: url.port || (url.protocol === 'https:' ? 443 : 80), - path: url.pathname + url.search, - method, - headers: { - 'Authorization': 'Bearer ' + token, - 'Accept': 'application/json', - }, - }; - // Проброс контекста имперсонации из UI в API-слой - if (impHeaders) { - if (impHeaders.impEmail) opts.headers['X-Imp-Email'] = impHeaders.impEmail; - if (impHeaders.impOriginalEmail) opts.headers['X-Imp-OriginalEmail'] = impHeaders.impOriginalEmail; - if (impHeaders.impClientId) opts.headers['X-Imp-ClientId'] = impHeaders.impClientId; - } - if (bodyStr) { - opts.headers['Content-Type'] = 'application/json'; - opts.headers['Content-Length'] = Buffer.byteLength(bodyStr); - } - - const req = lib.request(opts, (res) => { - let raw = ''; - res.on('data', c => { raw += c; }); - res.on('end', () => { - const ct = res.headers['content-type'] || ''; - try { - const data = ct.includes('application/json') ? JSON.parse(raw) : raw; - resolve({ status: res.statusCode, data }); - } catch (e) { - reject(new Error('Invalid JSON from API: ' + e.message)); - } - }); - }); - req.setTimeout(15000, () => { req.destroy(); reject(new Error('API timeout')); }); - req.on('error', reject); - if (bodyStr) req.write(bodyStr); - req.end(); - }); -} - -// Хелперы — один вызов на метод -const api = { - get: (path, token, imp) => apiRequest('GET', path, token, null, imp), - post: (path, token, body, imp) => apiRequest('POST', path, token, body, imp), - patch: (path, token, body, imp) => apiRequest('PATCH', path, token, body, imp), - delete: (path, token, imp) => apiRequest('DELETE', path, token, null, imp), - - // Достать Bearer из сессии - token: (req) => req.session && req.session.token, - - // Построить заголовки имперсонации из req.user (для проброса в API-слой) - impHeaders: (req) => { - if (!req.user || !req.user.isImpersonated) return null; - return { - impEmail: req.user.email, - impOriginalEmail: req.user.originalUserEmail, - impClientId: req.user.activeClientId || req.user.clientId, - }; - }, -}; - -module.exports = api; diff --git a/v1_legacy/ui/index.js b/v1_legacy/ui/index.js deleted file mode 100644 index 7de6560..0000000 --- a/v1_legacy/ui/index.js +++ /dev/null @@ -1,121 +0,0 @@ -'use strict'; - -/** - * ui/index.js — UI-слой: SSR через EJS, данные из /api/v1/*. - * - * createUiRouter({ auth, MOCK_USERS, authLimiter }) → Express Router. - * - * Монтируется в server.js ПОСЛЕ /api/v1/. - * - * Middleware: - * 1. requireToken — если нет session.token → redirect /login - * 2. resolveUser — декодирует payload из токена → req.user - * (без проверки подписи; API валидирует при каждом запросе) - * - * Если API возвращает 401 → сессия уничтожается → redirect /login. - */ - -'use strict'; - -const { Router } = require('express'); -const jwt = require('jsonwebtoken'); - -const { createRouter: createAuthRouter } = require('./routes/auth'); -const { createRouter: createEntriesRouter } = require('./routes/entries'); -const { createRouter: createAdminRouter } = require('./routes/admin'); -const { createRouter: createExportRouter } = require('./routes/export'); -const api = require('./api-client'); - -const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112'; - -function createUiRouter({ auth, MOCK_USERS, authLimiter }) { - const router = Router(); - - // ── Публичные маршруты: /login, /login-token, /logout ──────────────────── - router.use('/', createAuthRouter({ auth, MOCK_USERS, authLimiter })); - - // ── requireToken: все маршруты ниже требуют session.token ──────────────── - router.use((req, res, next) => { - const token = api.token(req); - if (!token) { - return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl)); - } - next(); - }); - - // ── resolveUser: payload → req.user ────────────────────────────────────── - // Подпись не проверяем здесь — API проверяет при каждом запросе. - router.use((req, res, next) => { - if (req.user) return next(); - try { - const payload = jwt.decode(api.token(req)); - if (!payload) throw new Error('empty payload'); - const rawClientId = payload.ClientID || payload.clientId || payload.sub || ''; - const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean); - // activeClientId из сессии (переключатель компании) или первая из токена - const sessionActive = req.session && req.session.user && req.session.user.activeClientId; - const activeClientId = (sessionActive && allClientIds.includes(sessionActive)) - ? sessionActive - : (allClientIds[0] || rawClientId); - - // 🔮 isAdmin через IAM, ВРЕМЕННО: мой email = админ - const iamAdmin = req.session && req.session.user && req.session.user.isAdmin; - const isNail = payload.email === 'ntazetdinov@nubes.ru' || payload.login === 'ntazetdinov@nubes.ru'; - const canAdminMode = !!(iamAdmin && activeClientId === ADMIN_CLIENT_ID) - || (isNail && activeClientId === ADMIN_CLIENT_ID); - const sessionIsImpersonated = req.session && req.session.user && req.session.user.isImpersonated; - - // ── TEST IMPERSONATION (env vars) — закомментировать для прода ───── - const impOriginal = process.env.IMPERSONATION_ORIGINAL || ''; - const impTarget = process.env.IMPERSONATION_TARGET || ''; - const impCompany = process.env.IMPERSONATION_COMPANY || ''; - const email = req.session?.user?.email || payload.email || payload.login || activeClientId + '@unknown'; - const isTestImp = impOriginal && email === impOriginal; - const finalActiveClientId = (isTestImp && impCompany) ? impCompany : activeClientId; - // ── /TEST IMPERSONATION ───────────────────────────────────────────── - - req.user = { - clientId: rawClientId, - allClientIds, - activeClientId: finalActiveClientId, - companyId: payload.company_id || payload.companyId || null, - companyName: payload.company_name || payload.companyName || finalActiveClientId, - email: (isTestImp && impTarget) ? impTarget : email, - isAdmin: iamAdmin || isNail, - isImpersonated: !!sessionIsImpersonated || isTestImp, - originalUserEmail: req.session?.user?.originalUserEmail || (isTestImp ? impOriginal : ''), - originalUserFullName: req.session?.user?.originalUserFullName || '', - canAdminMode, - adminMode: !!(req.session && req.session.adminMode), - }; - } catch (e) { - req.session.destroy(() => - res.redirect('/login?error=' + encodeURIComponent('Токен недействителен, войдите заново')) - ); - return; - } - next(); - }); - - // ── Основные маршруты (все за requireToken + resolveUser) ───────────────── - router.use('/', createEntriesRouter()); - router.use('/', createExportRouter()); - router.use('/', createAdminRouter()); - - // ── 404 ─────────────────────────────────────────────────────────────────── - router.use((req, res) => { - res.status(404).render('error', { status: 404, message: 'Страница не найдена' }); - }); - - // ── Общий обработчик ошибок ─────────────────────────────────────────────── - // eslint-disable-next-line no-unused-vars - router.use((err, req, res, next) => { - const msg = err instanceof Error ? (err.message || err.stack || String(err)) : JSON.stringify(err); - console.error('[UI] Error:', msg); - res.status(500).render('error', { status: 500, message: 'Ошибка: ' + msg.slice(0, 500) }); - }); - - return router; -} - -module.exports = { createUiRouter }; diff --git a/v1_legacy/ui/routes/admin.js b/v1_legacy/ui/routes/admin.js deleted file mode 100644 index e2ff011..0000000 --- a/v1_legacy/ui/routes/admin.js +++ /dev/null @@ -1,92 +0,0 @@ -'use strict'; - -/** - * ui/routes/admin.js — /admin, /audit (только для администратора). - * /export — доступен всем аутентифицированным (смотри ui/routes/export.js). - * - * Данные из: - * GET /api/v1/companies - * PATCH /api/v1/companies/:id/limit - * GET /api/v1/audit - */ - -const { Router } = require('express'); -const api = require('../api-client'); - -function requireAdmin(req, res, next) { - if (!req.user || !req.user.adminMode) { - return res.status(403).render('error', { - status: 403, - message: 'Доступ запрещён — требуются права администратора.', - }); - } - next(); -} - -function createRouter() { - const router = Router(); - router.use(requireAdmin); - - // GET /admin — список компаний с лимитами - router.get('/admin', async (req, res) => { - const token = api.token(req); - try { - const r = await api.get('/api/v1/companies', token); - const companies = r.data.companies || []; - const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15; - res.render('admin', { - user: req.user, - companies, - defaultLimit, - error: req.query.error || null, - success: req.query.success || null, - csrfToken: '', - }); - } catch (e) { - const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15; - res.render('admin', { - user: req.user, companies: [], defaultLimit, - error: 'Ошибка загрузки: ' + e.message, success: null, csrfToken: '', - }); - } - }); - - // POST /admin/limit/:id — установить лимит компании - router.post('/admin/limit/:id', async (req, res) => { - const token = api.token(req); - const rawLimit = req.body.limit; - const limit = (rawLimit === '' || rawLimit == null) ? null : parseInt(rawLimit, 10); - try { - const r = await api.patch('/api/v1/companies/' + req.params.id + '/limit', token, { limit }); - if (r.status === 200) return res.redirect('/admin?success=' + encodeURIComponent('Лимит обновлён')); - res.redirect('/admin?error=' + encodeURIComponent(r.data.error || 'Ошибка')); - } catch (e) { - res.redirect('/admin?error=' + encodeURIComponent(e.message)); - } - }); - - // GET /audit — журнал действий - router.get('/audit', async (req, res) => { - const token = api.token(req); - const companyId = req.query.company ? parseInt(req.query.company, 10) : null; - try { - const r = await api.get('/api/v1/audit' + (companyId ? '?company=' + companyId : ''), token); - res.render('audit', { - user: req.user, - rows: r.data.rows || [], - companies: r.data.companies || [], - selectedCompanyId: companyId, - error: null, - }); - } catch (e) { - res.render('audit', { - user: req.user, rows: [], companies: [], - selectedCompanyId: null, error: 'Ошибка загрузки: ' + e.message, - }); - } - }); - - return router; -} - -module.exports = { createRouter }; diff --git a/v1_legacy/ui/routes/auth.js b/v1_legacy/ui/routes/auth.js deleted file mode 100644 index 2e743da..0000000 --- a/v1_legacy/ui/routes/auth.js +++ /dev/null @@ -1,191 +0,0 @@ -'use strict'; - -/** - * ui/routes/auth.js — /login, /logout для UI-слоя. - * - * Два режима: - * Mock (DEV_MODE=true) — страница с пресетами MOCK_USERS. - * Выбранный пользователь → генерируем mock Bearer token → session.token. - * - * Token (всегда доступен) — форма вставки реального Bearer токена от auth-api/KC. - * Вставленный токен → session.token (без проверки подписи здесь, - * API сам проверит при первом запросе). - * - * /logout — уничтожает сессию. - */ - -const { Router } = require('express'); -const { safeReturn } = require('../../src/auth'); - -function createRouter({ auth, MOCK_USERS, authLimiter }) { - const router = Router(); - const devMode = () => process.env.DEV_MODE === 'true'; - - // GET /login - router.get('/login', (req, res) => { - if (req.session && req.session.token) return res.redirect('/'); - res.render('ui-login', { - devMode: devMode(), - MOCK_USERS: devMode() ? MOCK_USERS : [], - error: req.query.error || null, - returnTo: req.query.returnTo || '/', - appVersion: require('../../package.json').version, - }); - }); - - // POST /login — mock user - router.post('/login', authLimiter, (req, res) => { - const { clientId, returnTo } = req.body; - if (!clientId) return res.redirect('/login?error=Выберите+пользователя'); - - const user = MOCK_USERS.find(u => u.clientId === clientId); - if (!user) return res.redirect('/login?error=Пользователь+не+найден'); - - // Разбор clientId с запятой (мульти-компания) - const allClientIds = user.clientId.split(',').map(s => s.trim()).filter(Boolean); - const activeClientId = allClientIds[0] || user.clientId; - - const token = auth.issueMockToken({ - clientId: user.clientId, - companyId: user.companyId || '00000000-0000-0000-0000-000000000001', - companyName: user.companyName || user.clientId, - email: user.email || user.clientId + '@mock.local', - }); - - req.session.token = token; - req.session.user = { - clientId: user.clientId, - allClientIds, - activeClientId, - email: user.email || user.clientId + '@mock.local', - companyId: user.companyId || '00000000-0000-0000-0000-000000000001', - companyName: user.companyName || user.clientId, - isAdmin: user.isAdmin || user.role === 'admin', - }; - res.redirect(safeReturn(returnTo) || '/'); - }); - - // GET /login-token — редирект на /login (форма вставки токена там же) - router.get('/login-token', (req, res) => res.redirect('/login')); - - // POST /login-token — вход по Bearer JWT токену (реальному) или clientId (отладка) - router.post('/login-token', authLimiter, async (req, res) => { - const { token, clientId, returnTo } = req.body; - - // ── Вход по реальному JWT токену ────────────────────────────────────── - if (token && token.trim()) { - const raw = token.trim().replace(/^Bearer\s+/i, ''); - // Минимальная проверка: это JWT (3 части, разделённые точками) - const parts = raw.split('.'); - if (parts.length !== 3) { - return res.redirect('/login?error=Невалидный+JWT+токен'); - } - // Декодируем payload (без проверки подписи — API проверит) - let payload; - try { - const buf = Buffer.from(parts[1], 'base64url'); - payload = JSON.parse(buf.toString()); - if (!payload || typeof payload !== 'object') throw new Error('empty'); - } catch (_) { - return res.redirect('/login?error=Невалидный+payload+токена'); - } - - // Извлекаем данные пользователя из payload - const rawClientId = payload.ClientID || payload.client_id || payload.preferred_username || payload.sub || ''; - const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean); - const activeClientId = allClientIds[0] || rawClientId; - - req.session.token = raw; - - // Пытаемся обогатить через IAM API (только в OIDC-режиме) - if (auth.isOidc) { - try { - const iamData = await auth.fetchIamUser(raw); - req.session.user = { - email: iamData.email, - clientId: iamData.clientId, - allClientIds: iamData.allClientIds, - activeClientId: iamData.clientId, - activeProfileId: iamData.activeProfileId, - companyId: iamData.companyId, - companyName: iamData.companyName, - isAdmin: iamData.isAdmin, - fio: iamData.fio, - profiles: iamData.profiles, - }; - console.log('[login-token] IAM enrichment OK:', iamData.email, iamData.clientId); - } catch (iamErr) { - // IAM недоступен — fallback из JWT - console.warn('[login-token] IAM enrichment failed, using JWT fallback:', iamErr.message); - } - } - // Fallback (всегда): базовый user из JWT - if (!req.session.user) { - req.session.user = { - clientId: rawClientId, - allClientIds, - activeClientId, - email: payload.email || payload.login || activeClientId + '@unknown', - companyId: payload.company_id || payload.companyId || null, - companyName: payload.company_name || payload.companyName || activeClientId, - isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'), - }; - } - return res.redirect(safeReturn(returnTo) || '/'); - } - - // ── Вход по clientId (отладка, mock) ─────────────────────────────────── - if (!clientId || !clientId.trim()) return res.redirect('/login?error=Введите+clientId'); - - // В OIDC-режиме mock-токен бессмысленен — редиректим на KC - if (auth.isOidc) { - return res.redirect('/login?error=Вход+по+clientId+недоступен+в+SSO-режиме.+Войдите+через+Keycloak.'); - } - - const trimmed = clientId.trim(); - const allClientIds = trimmed.split(',').map(s => s.trim()).filter(Boolean); - const activeClientId = allClientIds[0]; - - const mockToken = auth.issueMockToken({ - clientId: trimmed, - companyId: '00000000-0000-0000-0000-000000000001', - companyName: activeClientId, - email: activeClientId + '@debug.local', - }); - - req.session.token = mockToken; - req.session.user = { - clientId: trimmed, - allClientIds, - activeClientId, - email: activeClientId + '@debug.local', - companyId: '00000000-0000-0000-0000-000000000001', - companyName: activeClientId, - isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'), - }; - res.redirect(safeReturn(returnTo) || '/'); - }); - - // ── Переключатель режима администратора ────────────────────────────── - // 🔮 isAdmin будет из IAM, временно: isAdmin || clientId === WZ01112 - router.post('/toggle-admin', (req, res) => { - const canAdmin = req.session.user && ( - req.session.user.isAdmin || - req.session.user.clientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112') - ); - if (!canAdmin) return res.status(403).send('Forbidden'); - - req.session.adminMode = !req.session.adminMode; - // Редирект туда же откуда пришли, или на главную - res.redirect(req.get('Referer') || '/'); - }); - - // GET /logout - router.get('/logout', (req, res) => { - req.session.destroy(() => res.redirect('/login')); - }); - - return router; -} - -module.exports = { createRouter }; diff --git a/v1_legacy/ui/routes/entries.js b/v1_legacy/ui/routes/entries.js deleted file mode 100644 index b64c1cd..0000000 --- a/v1_legacy/ui/routes/entries.js +++ /dev/null @@ -1,220 +0,0 @@ -'use strict'; - -/** - * ui/routes/entries.js — главная страница: список записей + CRUD. - * - * Данные из /api/v1/entries (Bearer token из сессии). - * Admin: ?company= (числовой PK) для управления конкретной компанией. - * - Без параметра: ищем компанию admin в списке по clientId. - * Если не найдена (первый вход) — один GET /api/v1/entries создаёт её, - * потом редирект на числовой ID. - * - С параметром: грузим записи этой компании через admin-ветку API. - * User: без параметра, API сам определяет компанию по clientId из токена. - */ - -const { Router } = require('express'); -const api = require('../api-client'); - -function createRouter() { - const router = Router(); - - // URL для возврата после CRUD — всегда с числовым company ID - function backUrl(req) { - const id = req.query.company || (req.body && req.body.company_id); - return id ? '/?company=' + id : '/'; - } - - // Суффикс ?company= для API — только числовой ID или пусто - function companyQuery(req) { - const id = req.query.company || (req.body && req.body.company_id); - if (id) return '?company=' + id; - // Мульти-компания: передаём активный client_id - if (!req.user.isAdmin && req.user.allClientIds && req.user.allClientIds.length > 1) { - return '?client_id=' + encodeURIComponent(req.user.activeClientId || req.user.clientId); - } - return ''; - } - - // GET / — список записей - router.get('/', async (req, res) => { - const token = api.token(req); - const imp = api.impHeaders(req); - - // ── Переключатель компании через IAM ──────────────────────────────── - const switchTo = (req.query.switchTo || '').trim(); - if (!req.user.isAdmin && switchTo && req.user.profiles && req.user.profiles.length > 1) { - const targetProfile = req.user.profiles.find(p => p.client_id === switchTo); - if (targetProfile) { - try { - const { switchProfile } = require('../../src/auth'); - await switchProfile(token, targetProfile.id); - // Обновляем сессию: новый активный профиль - req.session.user.activeClientId = switchTo; - req.session.user.clientId = switchTo; - req.session.user.companyName = targetProfile.company_name; - req.session.user.companyId = targetProfile.company_id; - req.session.user.activeProfileId = targetProfile.id; - // Обновляем is_active_profile в массиве - req.session.user.profiles.forEach(p => { - p.is_active_profile = (p.client_id === switchTo); - }); - req.user.activeClientId = switchTo; - req.user.clientId = switchTo; - } catch (e) { - console.warn('[entries] switchProfile failed:', e.message); - // Локальный fallback без IAM - req.session.user.activeClientId = switchTo; - if (req.session.user.clientId) req.session.user.clientId = switchTo; - req.user.activeClientId = switchTo; - } - } - } else if (!req.user.isAdmin && switchTo && req.user.allClientIds && req.user.allClientIds.includes(switchTo)) { - // Fallback: без profiles (mock-режим или IAM не ответил) - req.session.user.activeClientId = switchTo; - if (req.session.user.clientId) req.session.user.clientId = switchTo; - req.user.activeClientId = switchTo; - } - - try { - let entries = [], limit = 15, selectedCompany = null, companies = []; - - if (req.user.adminMode) { - const cr = await api.get('/api/v1/companies', token, imp); - companies = cr.data.companies || []; - - const companyId = req.query.company ? parseInt(req.query.company, 10) : null; - - if (companyId) { - selectedCompany = companies.find(c => c.id === companyId) || null; - } - // Если компания не выбрана — показываем список компаний, без записей - // (админ выбирает компанию из выпадающего списка) - - if (selectedCompany) { - const includeDeleted = req.query.includeDeleted === 'true' ? '&includeDeleted=true' : ''; - const er = await api.get('/api/v1/entries?company=' + selectedCompany.id + includeDeleted, token, imp); - entries = er.data.entries || []; - limit = er.data.limit || 15; - } - } else { - // Обычный пользователь: запрашиваем записи для активной компании - const activeClientId = req.user.activeClientId || req.user.clientId; - const clientIdParam = req.user.allClientIds && req.user.allClientIds.length > 1 - ? '?client_id=' + encodeURIComponent(activeClientId) - : ''; - const er = await api.get('/api/v1/entries' + clientIdParam, token, imp); - entries = er.data.entries || []; - limit = er.data.limit || 15; - } - - const hasMultiple = !req.user.isAdmin && req.user.allClientIds && req.user.allClientIds.length > 1; - const profiles = req.user.profiles || []; - - res.render('index', { - entries, - limit, - used: entries.length, - user: req.user, - isAdmin: req.user.isAdmin, - adminMode: req.user.adminMode, - companies, - selectedCompany, - canAdminMode: req.user.canAdminMode, - isImpersonated: req.user.isImpersonated, - originalUserEmail: req.user.originalUserEmail, - allClientIds: hasMultiple ? req.user.allClientIds : null, - activeClientId: req.user.activeClientId || req.user.clientId, - profiles, // из IAM — для UI переключателя - error: req.query.error || null, - success: req.query.success || null, - lastValue: req.query.lastValue || '', - lastComment: req.query.lastComment || '', - includeDeleted: req.query.includeDeleted === 'true', - csrfToken: '', - }); - } catch (e) { - res.render('index', { - entries: [], limit: 15, used: 0, - user: req.user, isAdmin: req.user.isAdmin, adminMode: req.user.adminMode, - canAdminMode: req.user.canAdminMode, - isImpersonated: req.user.isImpersonated, - originalUserEmail: req.user.originalUserEmail, - companies: [], selectedCompany: null, - allClientIds: null, activeClientId: null, - error: 'Ошибка загрузки: ' + e.message, - success: null, lastValue: '', lastComment: '', - includeDeleted: false, csrfToken: '', - }); - } - }); - - // POST /add — создать запись - router.post('/add', async (req, res) => { - const token = api.token(req); - const imp = api.impHeaders(req); - const { value, comment } = req.body; - const cq = companyQuery(req); - const back = backUrl(req); - try { - const r = await api.post('/api/v1/entries' + cq, token, { value, comment }, imp); - if (r.status === 201) { - const msg = r.data.wasNormalized - ? 'Запись добавлена (CIDR нормализован до ' + r.data.entry.value_cidr + ')' - : 'Запись добавлена'; - return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent(msg)); - } - // Ошибка — возвращаем введённые значения чтобы не перепечатывать - const err = (r.data && r.data.error) || 'Ошибка'; - res.redirect(back + (back.includes('?') ? '&' : '?') + - 'error=' + encodeURIComponent(err) + - '&lastValue=' + encodeURIComponent(value || '') + - '&lastComment=' + encodeURIComponent(comment || '')); - } catch (e) { - res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message)); - } - }); - - // POST /edit/:id — обновить запись - router.post('/edit/:id', async (req, res) => { - const token = api.token(req); - const imp = api.impHeaders(req); - const { value, comment } = req.body; - const cq = companyQuery(req); - const back = backUrl(req); - try { - const r = await api.patch('/api/v1/entries/' + req.params.id + cq, token, { value, comment }, imp); - if (r.status === 200) { - return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись обновлена')); - } - // Ошибка — возвращаем введённые значения в модалку - const err = (r.data && r.data.error) || 'Ошибка'; - res.redirect(back + (back.includes('?') ? '&' : '?') + - 'error=' + encodeURIComponent(err) + - '&lastValue=' + encodeURIComponent(value || '') + - '&lastComment=' + encodeURIComponent(comment || '')); - } catch (e) { - res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message)); - } - }); - - // POST /delete/:id — удалить запись - router.post('/delete/:id', async (req, res) => { - const token = api.token(req); - const imp = api.impHeaders(req); - const cq = companyQuery(req); - const back = backUrl(req); - try { - const r = await api.delete('/api/v1/entries/' + req.params.id + cq, token, imp); - if (r.status === 204) { - return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись удалена')); - } - res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent((r.data && r.data.error) || 'Ошибка')); - } catch (e) { - res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message)); - } - }); - - return router; -} - -module.exports = { createRouter }; diff --git a/v1_legacy/ui/routes/export.js b/v1_legacy/ui/routes/export.js deleted file mode 100644 index 9cdc27a..0000000 --- a/v1_legacy/ui/routes/export.js +++ /dev/null @@ -1,43 +0,0 @@ -'use strict'; - -/** - * ui/routes/export.js — GET /export. - * Только для администратора (adminMode). - * Проксирует GET /api/v1/entries/export (text/plain). - */ - -const { Router } = require('express'); -const api = require('../api-client'); - -function createRouter() { - const router = Router(); - - router.get('/export', async (req, res) => { - if (!req.user || !req.user.adminMode) { - return res.status(403).send('Только для администратора'); - } - const token = api.token(req); - // Прокидываем query-параметры в API - const params = new URLSearchParams(); - if (req.query.company) params.set('company', req.query.company); - if (req.query.client_id) params.set('client_id', req.query.client_id); - const qs = params.toString(); - const companyQuery = qs ? '?' + qs : ''; - try { - const r = await api.get('/api/v1/entries/export' + companyQuery, token); - res.set('Content-Type', 'text/plain; charset=utf-8'); - // ?view=1 → inline (просмотр), иначе → attachment (скачивание) - // ?filename=X → своё имя, по умолчанию white-list.txt - const fname = (req.query.filename || 'white-list.txt').replace(/[^\w\-_. ]/g, '_'); - const disp = req.query.view === '1' ? 'inline' : 'attachment'; - res.set('Content-Disposition', disp + '; filename="' + fname + '"'); - res.send(r.data); - } catch (e) { - res.status(500).send('Ошибка экспорта: ' + e.message); - } - }); - - return router; -} - -module.exports = { createRouter }; diff --git a/v1_legacy/views/admin.ejs b/v1_legacy/views/admin.ejs deleted file mode 100644 index b57b336..0000000 --- a/v1_legacy/views/admin.ejs +++ /dev/null @@ -1,207 +0,0 @@ - - - - - Управление лимитами — IP WhiteList - - - - - -
- - - - - - - - - - | - Управление лимитами - - ← Список адресов - Журнал аудита - Выйти (<%= user.email %>) - -
- -
- - <% if (success) { %>
<%= success %>
<% } %> - <% if (error) { %>
<%= error %>
<% } %> - -
-
- Компании и лимиты - - Глобальный дефолт: <%= defaultLimit %> записей. - Пустое поле = сброс на дефолт. - -
- - <% if (companies.length === 0) { %> -
Нет компаний в системе
- <% } else { %> -
- - - - - - - - - - - - <% companies.forEach(c => { %> - <% - // Вычисляем эффективный лимит (кастомный или дефолтный) - const effectiveLimit = c.custom_limit != null ? c.custom_limit : defaultLimit; - const pct = effectiveLimit > 0 ? Math.min(c.active_count / effectiveLimit, 1) : 1; - const barClass = pct >= 1 ? 'full' : pct >= 0.8 ? 'warn' : ''; - %> - - - - - - - - - - - <% }) %> - -
КомпанияClientIDИспользованоЛимитДействие
<%= c.name %><%= c.client_id %> -
-
-
-
- - <%= c.active_count %> / <%= effectiveLimit %> - <% if (c.custom_limit != null) { %> - - <% } %> - -
-
- <% if (c.custom_limit != null) { %> - <%= c.custom_limit %> - (индивидуальный) - <% } else { %> - дефолт (<%= defaultLimit %>) - <% } %> - -
- <%# CSRF-токен: одно значение на всю страницу (генерируется сервером) %> - - - - <% if (c.custom_limit != null) { %> - - - <% } %> -
-
-
- <% } %> -
- -
- - -
v<%= appVersion %>
- - diff --git a/v1_legacy/views/index.ejs b/v1_legacy/views/index.ejs deleted file mode 100644 index de37982..0000000 --- a/v1_legacy/views/index.ejs +++ /dev/null @@ -1,578 +0,0 @@ - - - - - IP WhiteList v<%= appVersion %> — Белые списки IP - - - - - -
- - - - - - - - - - | - Белые списки IP v<%= appVersion %> - - - <% if (typeof canAdminMode !== 'undefined' && canAdminMode) { %> -
- -
- <% } %> - <% if (typeof adminMode !== 'undefined' && adminMode) { %> - Аудит - Лимиты - <% } %> - Выйти (<%= user.email %>) -
-
- -
- - <% if (success) { %>
<%= success %>
<% } %> - <% if (error) { %>
<%= error %>
<% } %> - - <% if (typeof isImpersonated !== 'undefined' && isImpersonated) { %> -
- ⚠️ Режим имперсонации — данные сохраняются от имени <%= user.activeClientId %> - <% if (typeof originalUserEmail !== 'undefined' && originalUserEmail) { %> - (вы: <%= originalUserEmail %>) - <% } %> -
- <% } %> - - - <% - // Для admin: selectedCompany — выбранная компания (объект из getAllCompanies). - // Для обычного пользователя: selectedCompany — их компания (из getOrCreateCompany). - // company_id — числовой PK для передачи в скрытые поля форм. - const companyPk = selectedCompany ? selectedCompany.id : null; - %> - - - <% if (adminMode && companies) { %> -
-
-
- Компания: - - - - - Режим: Администратор - -
-
-
- <% } %> - - - <% if (!isAdmin && allClientIds && allClientIds.length > 1) { %> -
-
-
- Компания: - - - Компаний: <%= allClientIds.length %> - -
-
-
- <% } %> - - -
-
-
-
-
<%= used %> / <%= limit %>
-
записей
-
-
- -
<%= selectedCompany ? selectedCompany.client_id : user.clientId %>
-
компания
-
-
-
<%= user.email %>
-
пользователь
-
-
-
-
- - -
-
Добавить адрес
-
-
- <% - // Передаём числовой PK компании в скрытом поле. - // Для admin: server.js возьмёт его из req.body.company_id. - // Для обычного пользователя: server.js игнорирует это поле - // и использует clientId из токена — защита от подмены. - %> - - <%# CSRF-токен: защита от межсайтовой подделки запроса (double-submit cookie) %> - -
-
- - = limit ? 'disabled' : '' %>> -
-
- - -
- -
-
-
-
- - -
-
Доверенные адреса
- <% if (entries.length === 0) { %> -
Нет добавленных адресов
- <% } else { %> -
- - - - - - - - - - - - - <% entries.forEach(e => { %> - - - - - - - - - <% }) %> - -
Адрес / ПодсетьКомментарийДобавилДобавленаИзмененаДействия
<%= e.value_cidr %><% if (e.deleted_at) { %> удалена<% } %><%= e.comment || '—' %><%= e.created_by %><%= new Date(e.created_at).toLocaleDateString('ru', {day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit',timeZone:'Etc/GMT-4'}) %> - <%= e.updated_at - ? new Date(e.updated_at).toLocaleDateString('ru', {day:'numeric',month:'short',hour:'2-digit',minute:'2-digit',timeZone:'Etc/GMT-4'}) - : '—' %> - - <% if (!e.deleted_at) { %> - // data-* атрибуты для JS-заполнения modal без inline-onclick с параметрами. - // Так как комментарий может содержать кавычки — EJS экранирует их в HTML. - // Но в data-атрибуте JSON::stringify безопаснее. - %> - - - -
- - <%# CSRF-токен: одно значение на всю страницу, сгенерировано сервером %> - - -
- <% } %> -
-
- <% } %> -
- -
- - - - - - - - - diff --git a/v2/src/config/index.js b/v2/src/config/index.js index 651539e..cd60fdb 100644 --- a/v2/src/config/index.js +++ b/v2/src/config/index.js @@ -8,7 +8,7 @@ // ═══════════════════════════════════════════════════════════════════════════════ module.exports = { - version: '0.5.167', + version: '0.5.168', // ── IAM ────────────────────────────────────────────────────────────────── iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',