From 3c061b6c4b6eba5d511e85c0e9ebc465800e848a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 13 Jun 2026 06:55:18 +0400 Subject: [PATCH] =?UTF-8?q?v2:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BB=D0=BE=D1=91=D0=B2=20?= =?UTF-8?q?=E2=80=94=20crud/index.js=20API-=D1=81=D0=BB=D0=BE=D0=B9,=20use?= =?UTF-8?q?r/=20=D0=B8=20test/=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20crud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- v2/history/2026-06-12.md | 210 +++++++++++++++++++++++++++++++++ v2/src/config/index.js | 2 +- v2/src/crud/index.js | 242 ++++++--------------------------------- v2/src/test/index.js | 22 ++-- v2/src/user/index.js | 29 ++--- 6 files changed, 267 insertions(+), 240 deletions(-) diff --git a/package.json b/package.json index df8b4f7..143fd8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipwhitelist", - "version": "0.5.89", + "version": "0.5.90", "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 2d268fb..fabca54 100644 --- a/v2/history/2026-06-12.md +++ b/v2/history/2026-06-12.md @@ -159,3 +159,213 @@ req.v2_profiles → req.profiles req.v2_companyName → req.companyName ``` Причина: в основном коде нет префиксов. + + +--- + +## Текущее состояние (0.5.89) + +### Структура v2/ + +``` +v2/ +├── server.js # createV2Router() — монтируется в server.js как /v2 +├── history/ +│ └── 2026-06-12.md # этот файл +└── src/ + ├── auth/index.js # fetchIamUser(), login(), exchangeCode(), buildAuthUrl() + ├── config/index.js # V2_* env, version, умолчания + ├── router/index.js # resolveContext middleware (email, clientId, isAdmin, impersonation) + ├── user/index.js # createUserRouter() — UI CRUD с выбором компании + ├── admin/index.js # createAdminRouter() — ПУСТЫШКА (email, clientId) + ├── crud/index.js # createCrudRouter() — старый, НЕ монтирован + ├── db/ + │ ├── index.js # pg pool + │ ├── queries.js # CRUD: getOrCreateCompany, listEntries, createEntry, updateEntry, deleteEntry, getAudit, getLimit, setLimit, getExportCIDRs, getAllCompanies + │ └── schema.js # ensureSchema(pool) — автосоздание v2_companies, v2_entries, v2_audit + ├── test/ + │ ├── index.js # createTestRouter() — /v2/test?action=... и GET /chaos + │ └── test.sh # 54 curl-теста + └── validators/index.js # validate(), overlaps(), cidrToRange(), aggregateCIDRs(), BLOCKED_RANGES +``` + +### Маршруты /v2 + +| Маршрут | Что | Авторизация | +|---------|-----|------------| +| `/v2/login` | Редирект на `/login?returnTo=/v2/app` | Нет | +| `/v2/iam` | IAM-данные из сессии | Да (сессия) | +| `/v2/app` | UI CRUD (createUserRouter) | resolveContext | +| `/v2/admin` | Админка (пустышка) | resolveContext | +| `/v2/test` | Тестовый API (`?action=...`) | Нет | +| `/v2/test/chaos` | Параллельный хаос-тест | Нет | +| `/v2/logout` | Редирект на `/logout` | Нет | + +### Деплой + +git push → Gitea → Nubes UI redeploy → `whitelist.nodejsk8s.services.ngcloud.ru` + +VM (italo.kube5s.ru) — НЕ используется для деплоя v2. + +--- + +## Тесты — полный список + +### 1. test.sh — 54 curl-теста (✅ все пройдены) + +**Запрещённые диапазоны (14 тестов):** +10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, 127.0.0.0/8, +169.254.0.0/16, 192.0.0.0/24, 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, +198.18.0.0/15, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32 + +**Нормализация и маски (5 тестов):** +- /22 работает, /21 отклонена, /33 отклонена, IPv6 отклонён, домен отклонён +- Нормализация хостовой части (13.0.0.5/24 → 13.0.0.0/24) +- wasNormalized=true + +**Лимиты (2 теста):** +- 16-я запись отклонена (лимит 15) +- Сообщение о лимите + +**CRUD полный цикл (10 тестов):** +- Добавление, добавление 2, добавление /32 +- Список — 3 записи +- Изменение CIDR, проверка в БД +- Soft delete, список после удаления, с удалёнными + +**Изоляция компаний (3 теста):** +- Добавление в компанию A/B, пересечение разрешено + +**Переключение компаний (5 тестов):** +- Добавление в активную, переключение, список, добавление в другую, обратно + +**Имперсонация — двойной аудит (6 тестов):** +- created_by = originalUserEmail +- audit: impersonated_by, user_email +- UPDATE: old→new записаны +- DELETE записан + +**Нагрузка (2 теста):** +- 50 добавлений (первые 15 ок, остальные лимит) +- БД: ровно 15 записей + +**Экспорт (1 тест):** +- Эндпоинт доступен + +### 2. Chaos-тест — параллельные пользователи (✅) + +`GET /v2/test/chaos` — 4 компании × 10 записей = 40 параллельных операций. + +Проверки: +- ok: 40, errors: 0 +- totalEntries: 40, totalAudit: 40 +- 4 компании по 10 записей, без дубликатов, в лимите + +Префиксы CIDR: 11, 12, 13, 14 (10.x заблокирован 10.0.0.0/8). + +### 3. Router — resolveContext (✅ 9 тестов) + +Файл: `v2/src/router/test.js` (или внутренние). + +1. нет сессии → /v2/login +2. нет user → /v2/login +3. обычный юзер 1 компания +4. юзер 2 компании → activeClientId +5. админ adminMode=true +6. админ adminMode=false +7. имперсонация с originalUserEmail +8. имперсонация без originalUserEmail +9. без activeClientId → fallback на clientId + +### Сводка + +| Группа | Кол-во | Статус | +|--------|--------|--------| +| test.sh | 54 | ✅ | +| Chaos | 1 | ✅ | +| Router | 9 | ✅ | +| **Всего** | **64** | **✅** | + +--- + +## НЕ протестировано + +| Модуль | Причина | +|--------|---------| +| **auth/** (login, exchangeCode, fetchIamUser) | Нужен `/v2/callback` в redirect URIs production KC | +| **admin/** | Пустышка — только email/clientId | +| **user/** UI (EJS) | Не тестировался UI-интерфейс | +| Интеграция через основную сессию | Не проверялся полный путь KC → IAM → CRUD | + +--- + +## Что дальше (план) + +1. Реализовать **admin/** — аудит, лимиты, все компании +2. Реализовать **export/** — выгрузка CIDR +3. Добавить `/v2/callback` в production KC → протестировать auth/ +4. EJS-шаблоны вместо inline HTML +5. Интеграция v2 модулей в основной код (убрать префиксы v2_) + +--- + +## Известные ошибки (исправлены) + +| Баг | Причина | Фикс | +|-----|---------|------| +| `10.x` CIDR → 45 ошибок в chaos | `10.0.0.0/8` в BLOCKED_RANGES | Префиксы 11–14 | +| auditCount=0, wZ10001Entries=0 | Проверяли только WZ10001 | Проверка всех 4 компаний | +| 105 ошибок из 150 | 3 задачи × 15 > лимит 15 | 1 задача × 10 на компанию | +| `/v2/test/chaos` → 302 /login | Роутер на `/test/api`, а не `/test` | Сменили mount на `/test` | + + +--- + +## Разделение слоёв (0.5.90+) — ПЛАН + +### Проблема + +Сейчас `user/index.js` знает про `db/queries.js` и `getOrCreateCompany`/`companyId`: +``` +user/index.js → db/queries.js → SQL +``` + +Фронтенд смешан с логикой БД. + +### Решение: три слоя + +``` +user/index.js (фронтенд, Express) → crud/index.js (API БД) → db/queries.js (SQL) +``` + +**crud/index.js** — чистые функции-агностики: +- Не знает про Express, req, res, сессии +- Принимает `clientId` (W-номер), сам резолвит `companyId` через `getOrCreateCompany` +- Вход: plain values, Выход: plain object или throw + +```js +list(clientId) → { entries, used, limit } +add(clientId, cidr, comment, email, impBy) → { entry, wasNormalized } +edit(entryId, clientId, cidr, comment, email, impBy) → { entry, wasNormalized } +remove(entryId, clientId, email, impBy) → void +``` + +**user/index.js** — только Express: +- Дёргает crud.*, рендерит HTML +- Не знает про companyId, getOrCreateCompany, SQL + +**db/queries.js** — без изменений (чистый SQL) + +### Что изменится + +| Файл | Было | Стало | +|------|------|-------| +| crud/index.js | старый, не рабочий | новый API-слой | +| user/index.js | require('../db/queries') | require('../crud') | +| test/index.js | require('../db/queries') | require('../crud') | + +### Преимущества + +1. **Тестирование**: crud тестируется без Express, user тестируется с моком crud +2. **Переиспользование**: admin, test, export — все через crud +3. **Независимость**: фронтенд не знает про deviceId, БД-схему, транзакции diff --git a/v2/src/config/index.js b/v2/src/config/index.js index 5c34159..93f1210 100644 --- a/v2/src/config/index.js +++ b/v2/src/config/index.js @@ -6,7 +6,7 @@ // ═══════════════════════════════════════════════════════════════════════════════ module.exports = { - version: '0.5.89', + version: '0.5.90', 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/crud/index.js b/v2/src/crud/index.js index 35b0445..93a21d4 100644 --- a/v2/src/crud/index.js +++ b/v2/src/crud/index.js @@ -1,217 +1,43 @@ // ═══════════════════════════════════════════════════════════════════════════════ -// V2 — CRUD-роутер (только для авторизованных) +// V2 — CRUD API-слой (чистые функции, без Express) // -// Вход: sessionUser из v2_ сессии (email, clientId, allClientIds, profiles, ...) -// Всегда показывает выбор компании (даже если одна). -// Переключение — смена activeClientId в сессии (без IAM). +// Принимает clientId (W-номер), сам резолвит companyId через getOrCreateCompany. +// Фронтенд (user/) и тесты (test/) дергают эти функции. +// +// Сигнатуры: +// list(clientId) → { entries, used, limit } +// add(clientId, cidr, comment, email, impBy) → { entry, wasNormalized } +// edit(entryId, clientId, cidr, comment, email, impBy) → { entry, wasNormalized } +// remove(entryId, clientId, email, impBy) → void // ═══════════════════════════════════════════════════════════════════════════════ -const express = require('express'); -const q = require('../db/queries'); -const config = require('../config'); -const { validate } = require('../validators'); +const q = require('../db/queries'); -function createCrudRouter() { - const router = express.Router(); - - // ── middleware: только для залогиненных ───────────────────────────────── - router.use((req, res, next) => { - if (!req.session.user) return res.redirect('/v2/login'); - next(); - }); - - // ── helpers ────────────────────────────────────────────────────────────── - function user(req) { return req.session.user; } - function token(req) { return req.session.token; } - - // activeClientId — текущая выбранная компания - function activeClientId(req) { - return req.session.user.activeClientId || req.session.user.clientId; - } - - // Список компаний для выпадающего списка - function companies(req) { - const u = user(req); - if (u.profiles && u.profiles.length > 0) return u.profiles; - // fallback: одна компания из clientId - return [{ client_id: u.clientId, company_name: u.companyName || u.clientId, is_active_profile: true }]; - } - - // ── GET /v2/app — главная страница ────────────────────────────────────── - router.get('/', async (req, res) => { - try { - const clId = activeClientId(req); - const allCompanies = companies(req); - const company = await q.getOrCreateCompany(clId, 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; - - const switchTo = req.query.switchTo; - if (switchTo && allCompanies.find(c => c.client_id === switchTo)) { - req.session.user.activeClientId = switchTo; - return res.redirect('/v2/app'); - } - - res.send(renderApp({ entries, company, limit, used, allCompanies, clId, user: user(req), includeDeleted })); - } catch (e) { - res.status(500).send('

Ошибка

' + e.message + '
Назад'); - } - }); - - // ── POST /v2/app/add ──────────────────────────────────────────────────── - router.post('/add', async (req, res) => { - try { - const clId = activeClientId(req); - const company = await q.getOrCreateCompany(clId, clId); - const u = user(req); - - const result = await q.createEntry( - company.id, - req.body.cidr || '', - req.body.comment || '', - u.email, - u.originalUserEmail || null // impersonated_by - ); - - const msg = result.wasNormalized ? 'Добавлено (адрес нормализован)' : 'Добавлено'; - res.redirect('/v2/app?msg=' + encodeURIComponent(msg)); - } catch (e) { - res.redirect('/v2/app?error=' + encodeURIComponent(e.message)); - } - }); - - // ── POST /v2/app/edit/:id ─────────────────────────────────────────────── - router.post('/edit/:id', async (req, res) => { - try { - const clId = activeClientId(req); - const company = await q.getOrCreateCompany(clId, clId); - const u = user(req); - - const result = await q.updateEntry( - parseInt(req.params.id), - company.id, - req.body.cidr || '', - req.body.comment || '', - u.email, - u.originalUserEmail || null - ); - - const msg = result.wasNormalized ? 'Изменено (адрес нормализован)' : 'Изменено'; - res.redirect('/v2/app?msg=' + encodeURIComponent(msg)); - } catch (e) { - res.redirect('/v2/app?error=' + encodeURIComponent(e.message)); - } - }); - - // ── POST /v2/app/delete/:id ───────────────────────────────────────────── - router.post('/delete/:id', async (req, res) => { - try { - const clId = activeClientId(req); - const company = await q.getOrCreateCompany(clId, clId); - const u = user(req); - - await q.deleteEntry( - parseInt(req.params.id), - company.id, - u.email, - u.originalUserEmail || null - ); - - res.redirect('/v2/app?msg=' + encodeURIComponent('Удалено')); - } catch (e) { - res.redirect('/v2/app?error=' + encodeURIComponent(e.message)); - } - }); - - return router; +async function resolve(clientId) { + return q.getOrCreateCompany(clientId, clientId); } -// ── HTML-рендеринг (временный, без EJS) ───────────────────────────────────── - -function renderApp({ entries, company, limit, used, allCompanies, clId, user, includeDeleted }) { - const msg = (s) => s ? `
${s}
` : ''; - const err = (s) => s ? `
${s}
` : ''; - - 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 v${config.version} - ${user.email} | ${user.fio || ''} ${user.isAdmin ? '| ADMIN' : ''} - -
- -
- Выйти -
-
- -${msg('')}${err('')} - - - -

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

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

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

- -`; +async function list(clientId, includeDeleted = false) { + const co = await resolve(clientId); + const entries = await q.listEntries(co.id, includeDeleted); + const limit = await q.getLimit(co); + const used = entries.filter(e => !e.deleted_at).length; + return { entries, used, limit }; } -module.exports = { createCrudRouter }; +async function add(clientId, cidr, comment, email, impBy) { + const co = await resolve(clientId); + return q.createEntry(co.id, cidr, comment, email, impBy); +} + +async function edit(entryId, clientId, cidr, comment, email, impBy) { + const co = await resolve(clientId); + return q.updateEntry(entryId, co.id, cidr, comment, email, impBy); +} + +async function remove(entryId, clientId, email, impBy) { + const co = await resolve(clientId); + return q.deleteEntry(entryId, co.id, email, impBy); +} + +module.exports = { list, add, edit, remove }; diff --git a/v2/src/test/index.js b/v2/src/test/index.js index 28ad3cf..f2f0e72 100644 --- a/v2/src/test/index.js +++ b/v2/src/test/index.js @@ -4,6 +4,7 @@ // ═══════════════════════════════════════════════════════════════════════════════ const express = require('express'); +const crud = require('../crud'); const q = require('../db/queries'); const { pool } = require('../db'); @@ -30,25 +31,25 @@ function createTestRouter() { 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 } }); + case 'list': { + const { entries, used, limit } = await crud.list(clientId, req.query.deleted === '1'); + return res.json({ ok: true, entries, count: entries.length, used, limit }); } // ── ADD ── case 'add': { - const r = await q.createEntry(company.id, req.query.cidr || '', req.query.comment || '', email, impBy); + const r = await crud.add(clientId, 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); + const r = await crud.edit(parseInt(req.query.id), clientId, 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); + await crud.remove(parseInt(req.query.id), clientId, email, impBy); return res.json({ ok: true }); // ── AUDIT ── @@ -112,10 +113,9 @@ function createTestRouter() { const impBy = idx === 3 ? 'real@nubes.ru' : null; const prefix = cidPrefix[idx]; tasks.push((async () => { - const co = await q.getOrCreateCompany(clientId, clientId); for (let j = 0; j < perCompany; j++) { try { - await q.createEntry(co.id, prefix + '.0.' + j + '.0/24', 'chaos', email, impBy); + await crud.add(clientId, prefix + '.0.' + j + '.0/24', 'chaos', email, impBy); ok++; } catch (e) { errors++; } } @@ -127,12 +127,10 @@ function createTestRouter() { const checks = []; let totalEntries = 0, totalAudit = 0, anyDupes = false, anyOverLimit = false; for (const clientId of cids) { - const co = await q.getOrCreateCompany(clientId, clientId); - const entries = await q.listEntries(co.id); + const { entries, limit } = await crud.list(clientId); const cidrs = entries.map(e => e.value_cidr); const dupes = cidrs.length !== new Set(cidrs).size; - const limit = await q.getLimit(co); - const audit = await q.getAudit(co.id); + const audit = await q.getAudit((await q.getOrCreateCompany(clientId, clientId)).id); totalEntries += entries.length; totalAudit += audit.length; if (dupes) anyDupes = true; diff --git a/v2/src/user/index.js b/v2/src/user/index.js index b1a6c45..f38b31e 100644 --- a/v2/src/user/index.js +++ b/v2/src/user/index.js @@ -1,11 +1,13 @@ // ═══════════════════════════════════════════════════════════════════════════════ -// V2 — пользовательский CRUD -// Вход: req.v2_* из resolveContext (email, clientId, allClientIds, profiles, ...) +// V2 — пользовательский CRUD (фронтенд, только Express) +// +// Вход: req.clientId, req.email, req.impersonatedBy из resolveContext. // Всегда показывает выбор компании (даже если одна). +// НЕ работает с БД напрямую — только через crud API-слой. // ═══════════════════════════════════════════════════════════════════════════════ const express = require('express'); -const q = require('../db/queries'); +const crud = require('../crud'); function createUserRouter() { const router = express.Router(); @@ -18,11 +20,8 @@ function createUserRouter() { ? req.profiles : [{ client_id: clId, company_name: req.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; + const { entries, used, limit } = await crud.list(clId, includeDeleted); // Переключение компании if (req.query.switchTo && allCompanies.find(c => c.client_id === req.query.switchTo)) { @@ -30,7 +29,7 @@ function createUserRouter() { return res.redirect('/v2/app'); } - res.send(renderPage({ entries, company, limit, used, allCompanies, clId, user: req.user, includeDeleted })); + res.send(renderPage({ entries, limit, used, allCompanies, clId, user: req.user, includeDeleted })); } catch (e) { res.status(500).send('

Ошибка

' + e.message + '
Назад'); } @@ -39,9 +38,7 @@ function createUserRouter() { // ── POST /add ─────────────────────────────────────────────────────────── router.post('/add', async (req, res) => { try { - const clId = req.clientId; - const company = await q.getOrCreateCompany(clId, clId); - const result = await q.createEntry(company.id, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy); + const result = await crud.add(req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy); const msg = result.wasNormalized ? 'Добавлено (адрес нормализован)' : 'Добавлено'; res.redirect('/v2/app?msg=' + encodeURIComponent(msg)); } catch (e) { @@ -52,9 +49,7 @@ function createUserRouter() { // ── POST /edit/:id ────────────────────────────────────────────────────── router.post('/edit/:id', async (req, res) => { try { - const clId = req.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.email, req.impersonatedBy); + const result = await crud.edit(parseInt(req.params.id), req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy); const msg = result.wasNormalized ? 'Изменено (адрес нормализован)' : 'Изменено'; res.redirect('/v2/app?msg=' + encodeURIComponent(msg)); } catch (e) { @@ -65,9 +60,7 @@ function createUserRouter() { // ── POST /delete/:id ──────────────────────────────────────────────────── router.post('/delete/:id', async (req, res) => { try { - const clId = req.clientId; - const company = await q.getOrCreateCompany(clId, clId); - await q.deleteEntry(parseInt(req.params.id), company.id, req.email, req.impersonatedBy); + await crud.remove(parseInt(req.params.id), req.clientId, req.email, req.impersonatedBy); res.redirect('/v2/app?msg=Удалено'); } catch (e) { res.redirect('/v2/app?error=' + encodeURIComponent(e.message)); @@ -79,7 +72,7 @@ function createUserRouter() { // ── HTML-рендеринг (временный, будет заменён на EJS) ───────────────────── -function renderPage({ entries, company, limit, used, allCompanies, clId, user, includeDeleted }) { +function renderPage({ entries, limit, used, allCompanies, clId, user, includeDeleted }) { const companyOptions = allCompanies.map(c => `` ).join('');