From a835cf0f163d44c863063422c704ae5e8bee2bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 12 Jun 2026 21:09:30 +0400 Subject: [PATCH] =?UTF-8?q?v2:=20user=20CRUD=20+=20schema=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20+=20router?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=E2=80=94=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D1=8C=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- v2/history/2026-06-12.md | 52 +++++++++++++++ v2/server.js | 5 ++ v2/src/config/index.js | 2 +- v2/src/router/test.js | 64 ++++++++++++++++++ v2/src/user/index.js | 140 ++++++++++++++++++++++++++++++++++++--- 6 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 v2/src/router/test.js diff --git a/package.json b/package.json index 6132c85..0532b85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipwhitelist", - "version": "0.5.75", + "version": "0.5.76", "description": "IP WhiteList microservice for cloud provider", "main": "server.js", "scripts": { diff --git a/v2/history/2026-06-12.md b/v2/history/2026-06-12.md index 64b9050..4b94cdf 100644 --- a/v2/history/2026-06-12.md +++ b/v2/history/2026-06-12.md @@ -75,3 +75,55 @@ CIDR validate, overlaps, cidrToRange, aggregateCIDRs, BLOCKED_RANGES. - Каждый модуль — отдельная папка в src/ - env-переменные — через process.env, без .env/dotenv - Умолчания — хардкод, переопределяются через V2_* env + +--- + +## Модуль router/ (0.5.75) + +`resolveContext` middleware — определяет контекст из `req.session.v2_user`: + +``` +нет сессии / нет v2_user → 302 /v2/login +обычный юзер → req.v2_email, req.v2_clientId (activeClientId) +юзер с несколькими компаниями → activeClientId из сессии +админ + adminMode → req.v2_isAdmin = true +админ без adminMode → req.v2_isAdmin = false (видит как юзер) +имперсонация → email подменён на originalUserEmail + clientId подменён на impersonatedCompanyId + req.v2_impersonatedBy = реальный админ +``` + +### Тесты (v2/src/router/test.js) + +9 тестов, все пройдены ✅: + +1. нет сессии → /v2/login +2. нет v2_user → /v2/login +3. обычный юзер 1 компания +4. юзер 2 компании → activeClientId +5. админ adminMode=true +6. админ adminMode=false +7. имперсонация с originalUserEmail +8. имперсонация без originalUserEmail +9. без activeClientId → fallback на clientId + +### Ошибки при тестировании + +- Причина: спешка. null вместо undefined, двойной префикс v2_v2_ в ключах. +- Урок: сначала думать, потом писать. + +## Текущая структура (0.5.75) + +``` +v2/src/ +├── auth/index.js # fetchIamUser(), login(), exchangeCode() +├── config/index.js # iamUrl, appUrl, version +├── router/ +│ ├── index.js # resolveContext middleware +│ └── test.js # 9 тестов +├── user/index.js # createUserRouter (пустышка) +├── admin/index.js # createAdminRouter (пустышка) +├── crud/index.js # createCrudRouter (не монтирован) +├── db/ # pool, queries, schema +└── validators/index.js # CIDR validate +``` diff --git a/v2/server.js b/v2/server.js index 95883d1..1fb13a6 100644 --- a/v2/server.js +++ b/v2/server.js @@ -21,8 +21,13 @@ const auth = require('./src/auth'); const { resolveContext } = require('./src/router'); const { createUserRouter } = require('./src/user'); const { createAdminRouter } = require('./src/admin'); +const { pool } = require('./src/db'); +const { ensureSchema } = require('./src/db/schema'); function createV2Router() { + // Автосоздание таблиц при старте + ensureSchema(pool).catch(e => console.error('[v2:db] Schema error:', e.message)); + const router = express.Router(); // ── GET /v2/login — редирект на основной вход ────────────────────────── diff --git a/v2/src/config/index.js b/v2/src/config/index.js index 855300b..965ad4c 100644 --- a/v2/src/config/index.js +++ b/v2/src/config/index.js @@ -6,7 +6,7 @@ // ═══════════════════════════════════════════════════════════════════════════════ module.exports = { - version: '0.5.75', + version: '0.5.76', 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/router/test.js b/v2/src/router/test.js new file mode 100644 index 0000000..5bb46c2 --- /dev/null +++ b/v2/src/router/test.js @@ -0,0 +1,64 @@ +const { resolveContext } = require('./index'); +let passed = 0, failed = 0; + +function t(name, session, expected) { + const res = { _redirect: undefined, redirect(u) { this._redirect = u; } }; + const req = { session: session || {} }; + resolveContext(req, res, () => {}); + + if (res._redirect !== (expected._redirect || undefined)) { + console.log('❌', name, 'redirect:', res._redirect, '!=', expected._redirect); + failed++; return; + } + + for (const k of Object.keys(expected).filter(k => k !== '_redirect')) { + if (JSON.stringify(req[k]) !== JSON.stringify(expected[k])) { + console.log('❌', name, k, ':', JSON.stringify(req[k]), '!=', JSON.stringify(expected[k])); + failed++; return; + } + } + console.log('✅', name); passed++; +} + +t('нет сессии', {}, { _redirect: '/v2/login' }); +t('нет v2_user', { v2_user: null }, { _redirect: '/v2/login' }); + +t('обычный юзер 1 компания', { v2_user: { + email: 'u@t.ru', clientId: 'WZ03709', activeClientId: 'WZ03709', + allClientIds: ['WZ03709'], companyName: 'test', isAdmin: false, profiles: [] +}}, { 'v2_email': 'u@t.ru', 'v2_clientId': 'WZ03709', 'v2_isAdmin': false, 'v2_isImpersonated': false, 'v2_impersonatedBy': null }); + +t('юзер 2 компании', { v2_user: { + email: 'm@t.ru', clientId: 'WZ88888', activeClientId: 'WZ77777', + allClientIds: ['WZ88888','WZ77777'], companyName: 'AB', isAdmin: false, profiles: [] +}}, { 'v2_email': 'm@t.ru', 'v2_clientId': 'WZ77777', 'v2_allClientIds': ['WZ88888','WZ77777'] }); + +t('админ adminMode', { v2_user: { + email: 'a@t.ru', clientId: 'WZ01112', activeClientId: 'WZ01112', + allClientIds: ['WZ01112'], isAdmin: true, profiles: [] +}, v2_adminMode: true }, { 'v2_email': 'a@t.ru', 'v2_clientId': 'WZ01112', 'v2_isAdmin': true }); + +t('админ без adminMode', { v2_user: { + email: 'a@t.ru', clientId: 'WZ01112', activeClientId: 'WZ01112', + allClientIds: ['WZ01112'], isAdmin: true, profiles: [] +} }, { 'v2_email': 'a@t.ru', 'v2_clientId': 'WZ01112', 'v2_isAdmin': false }); + +t('имперсонация', { v2_user: { + email: 'admin@t.ru', clientId: 'WZ01112', activeClientId: 'WZ01112', + allClientIds: ['WZ01112','WZ03709'], isAdmin: true, isImpersonated: true, + originalUserEmail: 'real@t.ru', impersonatedCompanyId: 'WZ03709', profiles: [] +}, v2_adminMode: true }, { 'v2_email': 'real@t.ru', 'v2_clientId': 'WZ03709', 'v2_isImpersonated': true, 'v2_impersonatedBy': 'admin@t.ru' }); + +t('имперс. без origEmail', { v2_user: { + email: 'x@t.ru', clientId: 'WZ01112', activeClientId: 'WZ01112', + allClientIds: ['WZ01112'], isAdmin: true, isImpersonated: true, + originalUserEmail: '', impersonatedCompanyId: '', profiles: [] +} }, { 'v2_email': 'x@t.ru', 'v2_clientId': 'WZ01112', 'v2_impersonatedBy': 'x@t.ru' }); + +t('без activeClientId', { v2_user: { + email: 'y@t.ru', clientId: 'WZ03709', allClientIds: ['WZ03709'], + isAdmin: false, profiles: [] +} }, { 'v2_email': 'y@t.ru', 'v2_clientId': 'WZ03709' }); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed) process.exit(1); diff --git a/v2/src/user/index.js b/v2/src/user/index.js index 4661c92..8727e75 100644 --- a/v2/src/user/index.js +++ b/v2/src/user/index.js @@ -1,24 +1,144 @@ // ═══════════════════════════════════════════════════════════════════════════════ -// V2 — пользовательский CRUD (пустышка) -// Будет: таблица записей, форма добавления, выбор компании из дропдауна +// V2 — пользовательский CRUD +// Вход: req.v2_* из resolveContext (email, clientId, allClientIds, profiles, ...) +// Всегда показывает выбор компании (даже если одна). // ═══════════════════════════════════════════════════════════════════════════════ const express = require('express'); +const q = require('../db/queries'); function createUserRouter() { const router = express.Router(); - router.get('/', (req, res) => { - res.send(`

User CRUD

-

email: ${req.v2_email}

-

clientId: ${req.v2_clientId}

-

isAdmin: ${req.v2_isAdmin}

-

isImpersonated: ${req.v2_isImpersonated}

-

Админка | Выйти

- `); + // ── GET / — главная: список записей + форма ──────────────────────────── + router.get('/', async (req, res) => { + try { + const clId = req.v2_clientId; + const allCompanies = req.v2_profiles.length > 0 + ? req.v2_profiles + : [{ client_id: clId, company_name: req.v2_companyName || clId }]; + + const company = await q.getOrCreateCompany(clId, allCompanies.find(c => c.client_id === clId)?.company_name || clId); + const includeDeleted = req.query.deleted === '1'; + const entries = await q.listEntries(company.id, includeDeleted); + const limit = await q.getLimit(company); + const used = entries.filter(e => !e.deleted_at).length; + + // Переключение компании + if (req.query.switchTo && allCompanies.find(c => c.client_id === req.query.switchTo)) { + req.session.v2_user.activeClientId = req.query.switchTo; + return res.redirect('/v2/app'); + } + + res.send(renderPage({ entries, company, limit, used, allCompanies, clId, user: req.v2_user, includeDeleted })); + } catch (e) { + res.status(500).send('

Ошибка

' + e.message + '
Назад'); + } + }); + + // ── POST /add ─────────────────────────────────────────────────────────── + router.post('/add', async (req, res) => { + try { + const clId = req.v2_clientId; + const company = await q.getOrCreateCompany(clId, clId); + const result = await q.createEntry(company.id, req.body.cidr || '', req.body.comment || '', req.v2_email, req.v2_impersonatedBy); + const msg = result.wasNormalized ? 'Добавлено (адрес нормализован)' : 'Добавлено'; + res.redirect('/v2/app?msg=' + encodeURIComponent(msg)); + } catch (e) { + res.redirect('/v2/app?error=' + encodeURIComponent(e.message)); + } + }); + + // ── POST /edit/:id ────────────────────────────────────────────────────── + router.post('/edit/:id', async (req, res) => { + try { + const clId = req.v2_clientId; + const company = await q.getOrCreateCompany(clId, clId); + const result = await q.updateEntry(parseInt(req.params.id), company.id, req.body.cidr || '', req.body.comment || '', req.v2_email, req.v2_impersonatedBy); + const msg = result.wasNormalized ? 'Изменено (адрес нормализован)' : 'Изменено'; + res.redirect('/v2/app?msg=' + encodeURIComponent(msg)); + } catch (e) { + res.redirect('/v2/app?error=' + encodeURIComponent(e.message)); + } + }); + + // ── POST /delete/:id ──────────────────────────────────────────────────── + router.post('/delete/:id', async (req, res) => { + try { + const clId = req.v2_clientId; + const company = await q.getOrCreateCompany(clId, clId); + await q.deleteEntry(parseInt(req.params.id), company.id, req.v2_email, req.v2_impersonatedBy); + res.redirect('/v2/app?msg=Удалено'); + } catch (e) { + res.redirect('/v2/app?error=' + encodeURIComponent(e.message)); + } }); return router; } +// ── HTML-рендеринг (временный, будет заменён на EJS) ───────────────────── + +function renderPage({ entries, company, limit, used, allCompanies, clId, user, includeDeleted }) { + const companyOptions = allCompanies.map(c => + `` + ).join(''); + + const rows = entries.map(e => { + const del = e.deleted_at; + const style = del ? 'text-decoration:line-through;opacity:0.5' : ''; + const actions = del ? 'удалено' : ` +
+ + + +
+
+ +
`; + return `${e.value_cidr}${e.comment || ''}${e.created_by}${new Date(e.created_at).toLocaleString('ru')}${actions}`; + }).join(''); + + return ` +V2 IP WhiteList + +
+ V2 IP WhiteList + ${user.email} ${user.isAdmin ? '| ADMIN' : ''} + +
+ +
+ Выйти +
+
+ +

Записей: ${used} из ${limit}

+
+
+ + + +
+
+ + + ${rows || ''} +
CIDRКомментарийКтоКогда
Нет записей
+

${includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые'}

+`; +} + module.exports = { createUserRouter };