diff --git a/package.json b/package.json index 339c516..18702e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipwhitelist", - "version": "0.5.81", + "version": "0.5.82", "description": "IP WhiteList microservice for cloud provider", "main": "server.js", "scripts": { diff --git a/v2/server.js b/v2/server.js index d98b396..6ca4019 100644 --- a/v2/server.js +++ b/v2/server.js @@ -65,13 +65,11 @@ function createV2Router() { // ── GET /v2/ ───────────────────────────────────────────────────────────── router.get('/', (req, res) => res.redirect('/v2/app')); + // ── /v2/test/api — тестовый эндпоинт для CRUD + const { createTestRouter } = require('./src/test'); + router.use('/test/api', createTestRouter()); + // ── GET /v2/logout ─────────────────────────────────────────────────────── - // ── GET /v2/test/crud — тесты CRUD (временный) - router.get('/test/crud', async (req, res) => { - const { runCrudTests } = require('./src/user/test-crud'); - const result = await runCrudTests(); - res.send('
' + result.results.join('\n') + '' + result.passed + ' passed, ' + result.failed + ' failed из ' + result.total + '
'); - }); router.get('/logout', (req, res) => res.redirect('/logout')); diff --git a/v2/src/config/index.js b/v2/src/config/index.js index b2b81c5..d076f16 100644 --- a/v2/src/config/index.js +++ b/v2/src/config/index.js @@ -6,7 +6,7 @@ // ═══════════════════════════════════════════════════════════════════════════════ module.exports = { - version: '0.5.81', + version: '0.5.82', iamUrl: process.env.V2_IAM_API_URL || 'https://auth-api.ngcloud.ru', appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru', }; diff --git a/v2/src/test/index.js b/v2/src/test/index.js new file mode 100644 index 0000000..4705ec3 --- /dev/null +++ b/v2/src/test/index.js @@ -0,0 +1,86 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// V2 — тестовый эндпоинт для CRUD +// Принимает ?action=...&user=... → JSON-ответ +// ═══════════════════════════════════════════════════════════════════════════════ + +const express = require('express'); +const q = require('../db/queries'); +const { pool } = require('../db'); + +// Мок-юзеры +const USERS = { + u1: { email:'u1@t.ru', clientId:'WZ10001', activeClientId:'WZ10001', allClientIds:['WZ10001'], companyName:'Одна', isAdmin:false, profiles:[{client_id:'WZ10001',company_name:'Одна'}] }, + u2: { email:'u2@t.ru', clientId:'WZ20001', activeClientId:'WZ20002', allClientIds:['WZ20001','WZ20002'], companyName:'Вторая', isAdmin:false, profiles:[{client_id:'WZ20001',company_name:'Первая'},{client_id:'WZ20002',company_name:'Вторая'}] }, + imp:{ email:'admin@t.ru', clientId:'WZ01112', activeClientId:'WZ01112', allClientIds:['WZ01112','WZ03709'], companyName:'Nubes', isAdmin:true, isImpersonated:true, originalUserEmail:'real@t.ru', impersonatedCompanyId:'WZ03709', profiles:[] }, +}; + +function createTestRouter() { + const router = express.Router(); + + router.get('/', async (req, res) => { + try { + const action = req.query.action; + const user = USERS[req.query.user || 'u1']; + if (!user) return res.json({ ok: false, error: 'unknown user' }); + + const email = user.isImpersonated ? user.originalUserEmail : user.email; + const clientId = user.isImpersonated ? user.impersonatedCompanyId : user.activeClientId; + const company = await q.getOrCreateCompany(clientId, user.companyName || clientId); + const impBy = user.isImpersonated ? user.email : null; + + switch (action) { + // ── LIST ── + case 'list': + const entries = await q.listEntries(company.id, req.query.deleted === '1'); + return res.json({ ok: true, entries, count: entries.length, company: { id: company.id, client_id: company.client_id } }); + + // ── ADD ── + case 'add': { + const r = await q.createEntry(company.id, req.query.cidr || '', req.query.comment || '', email, impBy); + return res.json({ ok: true, entry: r.entry, wasNormalized: r.wasNormalized }); + } + + // ── EDIT ── + case 'edit': { + const r = await q.updateEntry(parseInt(req.query.id), company.id, req.query.cidr || '', req.query.comment || '', email, impBy); + return res.json({ ok: true, entry: r.entry, wasNormalized: r.wasNormalized }); + } + + // ── DELETE ── + case 'delete': + await q.deleteEntry(parseInt(req.query.id), company.id, email, impBy); + return res.json({ ok: true }); + + // ── AUDIT ── + case 'audit': { + const audit = await q.getAudit(company.id); + return res.json({ ok: true, audit, count: audit.length }); + } + + // ── LIMIT ── + case 'limit': + return res.json({ ok: true, limit: await q.getLimit(company) }); + + // ── CLEANUP ── + case 'cleanup': { + const cids = (req.query.cids || '').split(','); + for (const c of cids) { + await pool.query('DELETE FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{}); + await pool.query('DELETE FROM v2_audit WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{}); + await pool.query('DELETE FROM v2_companies WHERE client_id=$1',[c]).catch(()=>{}); + } + return res.json({ ok: true }); + } + + default: + return res.json({ ok: false, error: 'unknown action: ' + action }); + } + } catch (e) { + return res.json({ ok: false, error: e.message }); + } + }); + + return router; +} + +module.exports = { createTestRouter }; diff --git a/v2/src/test/test.sh b/v2/src/test/test.sh new file mode 100644 index 0000000..b21775b --- /dev/null +++ b/v2/src/test/test.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════════════════════════════════ +# CRUD-тесты через /v2/test/api +# Запуск: bash test.sh https://whitelist.nodejsk8s.services.ngcloud.ru +# ═══════════════════════════════════════════════════════════════════════════════ + +BASE="${1:-http://localhost:3000}/v2/test/api" +P=0 F=0 + +t() { local name="$1"; shift; if "$@"; then echo "✅ $name"; ((P++)); else echo "❌ $name"; ((F++)); fi; } +ok() { curl -sk "$BASE$1" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d.get('ok') else 1)"; } + +echo "=== 1. Одна компания ===" + +t "создание компании" ok "?action=list&user=u1" + +t "добавление" ok "?action=add&user=u1&cidr=1.2.3.0/24&comment=тест" + +t "дубликат" '!' 'ok "?action=add&user=u1&cidr=1.2.3.0/24"' + +t "пересечение" '!' 'ok "?action=add&user=u1&cidr=1.2.3.128/25"' + +t "запрещённый диапазон" '!' 'ok "?action=add&user=u1&cidr=10.0.0.0/24"' + +t "/32 работает" ok "?action=add&user=u1&cidr=5.5.5.5" + +ID=$(curl -sk "$BASE?action=list&user=u1" | python3 -c "import sys,json; print(json.load(sys.stdin)['entries'][0]['id'])") + +t "изменение" ok "?action=edit&user=u1&id=$ID&cidr=6.6.6.0/24&comment=изменено" + +t "удаление" ok "?action=delete&user=u1&id=$ID" + +t "аудит" ok "?action=audit&user=u1" + +echo "" +echo "=== 2. Две компании ===" + +t "добавление в активную" ok "?action=add&user=u2&cidr=7.7.7.0/24" + +LIST1=$(curl -sk "$BASE?action=list&user=u2" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['entries']))") +t "в активной есть запись" [ "$LIST1" -gt 0 ] + +echo "" +echo "=== 3. Имперсонация ===" + +t "добавление с impersonated_by" ok "?action=add&user=imp&cidr=8.8.8.0/24" + +AUDIT=$(curl -sk "$BASE?action=audit&user=imp" | python3 -c "import sys,json; a=json.load(sys.stdin)['audit']; print(a[0].get('impersonated_by','NONE'))") +t "impersonated_by в аудите" [ "$AUDIT" = "admin@t.ru" ] + +echo "" +echo "=== Чистка ===" +ok "?action=cleanup&cids=WZ10001,WZ20001,WZ20002,WZ01112,WZ03709" + +echo "" +echo "$P passed, $F failed" +[ "$F" -eq 0 ] || exit 1 diff --git a/v2/src/user/test-crud.js b/v2/src/user/test-crud.js deleted file mode 100644 index 265893e..0000000 --- a/v2/src/user/test-crud.js +++ /dev/null @@ -1,143 +0,0 @@ -// ═══════════════════════════════════════════════════════════════════════════════ -// V2 — встроенные тесты CRUD (запускаются на production-базе) -// ═══════════════════════════════════════════════════════════════════════════════ - -const q = require('../db/queries'); -const { pool } = require('../db'); - -let passed = 0, failed = 0; -const results = []; - -function t(name) { - return { - run: async (fn) => { - try { await fn(); passed++; results.push('✅ ' + name); } - catch (e) { failed++; results.push('❌ ' + name + ' — ' + e.message); } - } - }; -} - -async function runCrudTests() { - // Сброс - passed = 0; failed = 0; results.length = 0; - - const testCIDs = ['WZ10001', 'WZ20001', 'WZ20002', 'WZ01112', 'WZ03709']; - // Очистка тестовых данных - for (const cid of testCIDs) { - await pool.query(`DELETE FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)`, [cid]).catch(()=>{}); - await pool.query(`DELETE FROM v2_audit WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)`, [cid]).catch(()=>{}); - await pool.query(`DELETE FROM v2_companies WHERE client_id=$1`, [cid]).catch(()=>{}); - } - - // ── 1. Один юзер, одна компания ───────────────────────────────────────── - results.push('── 1. Одна компания ──'); - const c1 = { email: 'u1@t.ru', clientId: 'WZ10001', activeClientId: 'WZ10001', - allClientIds: ['WZ10001'], companyName: 'Одна', isAdmin: false, - profiles: [{ client_id: 'WZ10001', company_name: 'Одна', is_active_profile: true }] }; - - let entryId; - - await t('создание компании').run(async () => { - const co = await q.getOrCreateCompany('WZ10001', 'Одна'); - if (co.client_id !== 'WZ10001') throw new Error('не та компания'); - }); - - await t('добавление записи').run(async () => { - const co = await q.getOrCreateCompany('WZ10001', 'Одна'); - const r = await q.createEntry(co.id, '1.2.3.0/24', 'тест', c1.email, null); - if (!r.entry.value_cidr.includes('1.2.3.0')) throw new Error('CIDR не совпадает'); - entryId = r.entry.id; - }); - - await t('дубликат отклонён').run(async () => { - const co = await q.getOrCreateCompany('WZ10001', 'Одна'); - try { await q.createEntry(co.id, '1.2.3.0/24', '', c1.email, null); throw new Error('должен был упасть'); } - catch(e) { if (!e.message.includes('уже существует')) throw e; } - }); - - await t('пересечение отклонено').run(async () => { - const co = await q.getOrCreateCompany('WZ10001', 'Одна'); - try { await q.createEntry(co.id, '1.2.3.128/25', '', c1.email, null); throw new Error('должен был упасть'); } - catch(e) { if (!e.message.includes('Пересечение')) throw e; } - }); - - await t('запрещённый диапазон').run(async () => { - const co = await q.getOrCreateCompany('WZ10001', 'Одна'); - try { await q.createEntry(co.id, '10.0.0.0/24', '', c1.email, null); throw new Error('должен был упасть'); } - catch(e) { if (!e.message.includes('запрещённым')) throw e; } - }); - - await t('изменение записи').run(async () => { - const co = await q.getOrCreateCompany('WZ10001', 'Одна'); - const r = await q.updateEntry(entryId, co.id, '6.6.6.0/24', 'изменено', c1.email, null); - if (r.entry.value_cidr !== '6.6.6.0/24') throw new Error('не изменился'); - }); - - await t('soft delete').run(async () => { - const co = await q.getOrCreateCompany('WZ10001', 'Одна'); - await q.deleteEntry(entryId, co.id, c1.email, null); - const list = await q.listEntries(co.id, false); - if (list.length !== 0) throw new Error('запись не удалилась'); - const all = await q.listEntries(co.id, true); - if (all.length !== 1) throw new Error('soft delete: запись пропала'); - }); - - await t('аудит — записи созданы').run(async () => { - const co = await q.getOrCreateCompany('WZ10001', 'Одна'); - const audit = await q.getAudit(co.id); - if (audit.length < 3) throw new Error('аудит: ожидалось >=3, получили ' + audit.length); - }); - - // ── 2. Две компании ────────────────────────────────────────────────────── - results.push('── 2. Две компании ──'); - - await t('изолированность компаний').run(async () => { - const cA = await q.getOrCreateCompany('WZ20001', 'Первая'); - const cB = await q.getOrCreateCompany('WZ20002', 'Вторая'); - await q.createEntry(cA.id, '7.7.7.0/24', '', 'u2@t.ru', null); - await q.createEntry(cB.id, '8.8.8.0/24', '', 'u2@t.ru', null); - const listA = await q.listEntries(cA.id); - const listB = await q.listEntries(cB.id); - if (listA.length !== 1 || listB.length !== 1) throw new Error('изоляция нарушена'); - if (listA[0].value_cidr !== '7.7.7.0/24') throw new Error('чужая запись в компании A'); - }); - - await t('лимит').run(async () => { - const co = await q.getOrCreateCompany('WZ20001', 'Первая'); - // Заполняем до лимита (15) - const used = (await q.listEntries(co.id)).length; - const limit = await q.getLimit(co); - for (let i = used; i < limit; i++) { - await q.createEntry(co.id, `9.9.${i}.0/24`, '', 'u2@t.ru', null); - } - try { - await q.createEntry(co.id, '9.9.99.0/24', '', 'u2@t.ru', null); - throw new Error('должен был упасть по лимиту'); - } catch(e) { - if (!e.message.includes('Лимит')) throw e; - } - }); - - // ── 3. Имперсонация ───────────────────────────────────────────────────── - results.push('── 3. Имперсонация ──'); - - await t('impersonated_by в аудите').run(async () => { - const co = await q.getOrCreateCompany('WZ03709', 'ЧужаяКомпания'); - await q.createEntry(co.id, '10.10.10.0/24', '', 'real@nubes.ru', 'admin@nubes.ru'); - const audit = await q.getAudit(co.id); - const last = audit[0]; - if (last.impersonated_by !== 'admin@nubes.ru') throw new Error('impersonated_by=' + last.impersonated_by); - if (last.user_email !== 'real@nubes.ru') throw new Error('user_email=' + last.user_email); - }); - - // Чистка - for (const cid of testCIDs) { - await pool.query(`DELETE FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)`, [cid]).catch(()=>{}); - await pool.query(`DELETE FROM v2_audit WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)`, [cid]).catch(()=>{}); - await pool.query(`DELETE FROM v2_companies WHERE client_id=$1`, [cid]).catch(()=>{}); - } - - return { passed, failed, total: passed + failed, results }; -} - -module.exports = { runCrudTests }; diff --git a/v2/src/user/test.js b/v2/src/user/test.js deleted file mode 100644 index 97f84bf..0000000 --- a/v2/src/user/test.js +++ /dev/null @@ -1,155 +0,0 @@ -// ═══════════════════════════════════════════════════════════════════════════════ -// CRUD тесты — без внешних зависимостей (нет supertest) -// Использует http.request напрямую к Express-роутеру. -// ═══════════════════════════════════════════════════════════════════════════════ - -const http = require('http'); -const express = require('express'); -const { createUserRouter } = require('./index'); -const { resolveContext } = require('../router'); -const { pool } = require('../db'); -const { ensureSchema } = require('../db/schema'); - -const USERS = { - one: { email:'u1@t.ru', clientId:'WZ10001', activeClientId:'WZ10001', allClientIds:['WZ10001'], companyName:'Одна', isAdmin:false, isImpersonated:false, profiles:[{client_id:'WZ10001',company_name:'Одна',is_active_profile:true}] }, - two: { email:'u2@t.ru', clientId:'WZ20001', activeClientId:'WZ20002', allClientIds:['WZ20001','WZ20002'], companyName:'Вторая', isAdmin:false, isImpersonated:false, profiles:[{client_id:'WZ20001',company_name:'Первая'},{client_id:'WZ20002',company_name:'Вторая',is_active_profile:true}] }, - imp: { email:'admin@t.ru', clientId:'WZ01112', activeClientId:'WZ01112', allClientIds:['WZ01112','WZ03709'], companyName:'Nubes', isAdmin:true, isImpersonated:true, originalUserEmail:'real@t.ru', impersonatedCompanyId:'WZ03709', profiles:[] }, -}; - -let p = 0, f = 0; -function t(name, fn) { return fn().then(() => { console.log('✅', name); p++; }, e => { console.log('❌', name, e.message); f++; }); } - -function req(app, method, path, body) { - return new Promise((resolve, reject) => { - const server = app.listen(0, () => { - const port = server.address().port; - const opts = { hostname:'127.0.0.1', port, path, method, headers:{} }; - if (body) { opts.headers['Content-Type'] = 'application/x-www-form-urlencoded'; } - const r = http.request(opts, res => { - let d = ''; res.on('data', c => d += c); res.on('end', () => { server.close(); resolve({ status: res.statusCode, headers: res.headers, text: d }); }); - }); - r.on('error', e => { server.close(); reject(e); }); - if (body) r.write(new URLSearchParams(body).toString()); - r.end(); - }); - }); -} - -function makeApp(user) { - const app = express(); - app.use(express.urlencoded({ extended: true })); - app.use((req, res, next) => { req.session = { user }; next(); }); - app.use(resolveContext); - app.use(createUserRouter()); - return app; -} - -async function cleanup(cids) { - for (const c of cids) { - await pool.query('DELETE FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{}); - await pool.query('DELETE FROM v2_audit WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{}); - await pool.query('DELETE FROM v2_companies WHERE client_id=$1',[c]).catch(()=>{}); - } -} - -(async () => { - await ensureSchema(pool); - await cleanup(['WZ10001','WZ20001','WZ20002','WZ01112','WZ03709']); - - // ── 1. Одна компания ──────────────────────────────────────────────────── - console.log('\n=== 1. Одна компания ==='); - const a1 = makeApp(USERS.one); - - await t('пустой список', async () => { - const r = await req(a1, 'GET', '/'); - if (!r.text.includes('Нет записей')) throw new Error('ожидалась пустая таблица'); - }); - - await t('добавление', async () => { - const r = await req(a1, 'POST', '/add', { cidr: '1.2.3.0/24', comment: 'тест' }); - if (!r.headers.location.includes('msg=')) throw new Error('нет msg'); - }); - - await t('запись появилась', async () => { - const r = await req(a1, 'GET', '/'); - if (!r.text.includes('1.2.3.0/24')) throw new Error('запись не найдена'); - if (!r.text.includes('тест')) throw new Error('комментарий не найден'); - }); - - await t('дубликат отклонён', async () => { - const r = await req(a1, 'POST', '/add', { cidr: '1.2.3.0/24' }); - if (!r.headers.location.includes('error=')) throw new Error('дубликат должен быть отклонён'); - }); - - await t('пересечение отклонено', async () => { - const r = await req(a1, 'POST', '/add', { cidr: '1.2.3.128/25' }); - if (!r.headers.location.includes('error=')) throw new Error('пересечение должно быть отклонено'); - }); - - await t('/32 работает', async () => { - const r = await req(a1, 'POST', '/add', { cidr: '5.5.5.5' }); - if (!r.headers.location.includes('msg=')) throw new Error('/32 должен добавиться'); - }); - - await t('запрещённый диапазон', async () => { - const r = await req(a1, 'POST', '/add', { cidr: '10.0.0.0/24' }); - if (!r.headers.location.includes('error=')) throw new Error('10.0.0.0/24 запрещён'); - }); - - await t('изменение', async () => { - const rows = await pool.query("SELECT id FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id='WZ10001') AND deleted_at IS NULL LIMIT 1"); - const r = await req(a1, 'POST', '/edit/' + rows.rows[0].id, { cidr: '6.6.6.0/24', comment: 'изменено' }); - if (!r.headers.location.includes('msg=')) throw new Error('изменение не прошло'); - }); - - await t('удаление', async () => { - const rows = await pool.query("SELECT id FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id='WZ10001') AND deleted_at IS NULL LIMIT 1"); - const r = await req(a1, 'POST', '/delete/' + rows.rows[0].id); - if (!r.headers.location.includes('msg=')) throw new Error('удаление не прошло'); - }); - - await t('аудит', async () => { - const r = await pool.query("SELECT COUNT(*)::int as c FROM v2_audit WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id='WZ10001')"); - if (r.rows[0].c < 3) throw new Error('аудит: ' + r.rows[0].c + ' записей, ожидалось >=3'); - }); - - // ── 2. Две компании ────────────────────────────────────────────────────── - console.log('\n=== 2. Две компании ==='); - const a2 = makeApp(USERS.two); - - await t('активна вторая', async () => { - const r = await req(a2, 'GET', '/'); - if (!r.text.includes('Вторая')) throw new Error('должна быть Вторая'); - }); - - await t('добавление во вторую', async () => { - await req(a2, 'POST', '/add', { cidr: '7.7.7.0/24' }); - const r = await pool.query("SELECT value_cidr FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id='WZ20002') AND deleted_at IS NULL"); - if (!r.rows.find(rr => rr.value_cidr === '7.7.7.0/24')) throw new Error('не в той компании'); - }); - - await t('в первой пусто', async () => { - const r = await pool.query("SELECT COUNT(*)::int as c FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id='WZ20001') AND deleted_at IS NULL"); - if (r.rows[0].c !== 0) throw new Error('в первой не должно быть записей'); - }); - - // ── 3. Имперсонация ────────────────────────────────────────────────────── - console.log('\n=== 3. Имперсонация ==='); - const a3 = makeApp(USERS.imp); - - await t('created_by = originalUserEmail', async () => { - await req(a3, 'POST', '/add', { cidr: '8.8.8.0/24' }); - const r = await pool.query("SELECT created_by FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id='WZ03709') AND value_cidr='8.8.8.0/24'"); - if (r.rows[0].created_by !== 'real@t.ru') throw new Error('created_by=' + r.rows[0].created_by); - }); - - await t('impersonated_by в аудите', async () => { - const r = await pool.query("SELECT impersonated_by FROM v2_audit WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id='WZ03709') ORDER BY id DESC LIMIT 1"); - if (r.rows[0].impersonated_by !== 'admin@t.ru') throw new Error('impersonated_by=' + r.rows[0].impersonated_by); - }); - - console.log(`\n${p} passed, ${f} failed`); - await cleanup(['WZ10001','WZ20001','WZ20002','WZ01112','WZ03709']); - await pool.end(); - if (f) process.exit(1); -})().catch(e => { console.error(e); process.exit(1); });