v2: user CRUD + schema автозапуск + router тесты — модуль пользователя готов
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipwhitelist",
|
"name": "ipwhitelist",
|
||||||
"version": "0.5.75",
|
"version": "0.5.76",
|
||||||
"description": "IP WhiteList microservice for cloud provider",
|
"description": "IP WhiteList microservice for cloud provider",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -75,3 +75,55 @@ CIDR validate, overlaps, cidrToRange, aggregateCIDRs, BLOCKED_RANGES.
|
|||||||
- Каждый модуль — отдельная папка в src/
|
- Каждый модуль — отдельная папка в src/
|
||||||
- env-переменные — через process.env, без .env/dotenv
|
- env-переменные — через process.env, без .env/dotenv
|
||||||
- Умолчания — хардкод, переопределяются через V2_* env
|
- Умолчания — хардкод, переопределяются через 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
|
||||||
|
```
|
||||||
|
|||||||
@@ -21,8 +21,13 @@ const auth = require('./src/auth');
|
|||||||
const { resolveContext } = require('./src/router');
|
const { resolveContext } = require('./src/router');
|
||||||
const { createUserRouter } = require('./src/user');
|
const { createUserRouter } = require('./src/user');
|
||||||
const { createAdminRouter } = require('./src/admin');
|
const { createAdminRouter } = require('./src/admin');
|
||||||
|
const { pool } = require('./src/db');
|
||||||
|
const { ensureSchema } = require('./src/db/schema');
|
||||||
|
|
||||||
function createV2Router() {
|
function createV2Router() {
|
||||||
|
// Автосоздание таблиц при старте
|
||||||
|
ensureSchema(pool).catch(e => console.error('[v2:db] Schema error:', e.message));
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ── GET /v2/login — редирект на основной вход ──────────────────────────
|
// ── GET /v2/login — редирект на основной вход ──────────────────────────
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
version: '0.5.75',
|
version: '0.5.76',
|
||||||
iamUrl: process.env.V2_IAM_API_URL || 'https://auth-api.ngcloud.ru',
|
iamUrl: process.env.V2_IAM_API_URL || 'https://auth-api.ngcloud.ru',
|
||||||
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
|
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
+130
-10
@@ -1,24 +1,144 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// V2 — пользовательский CRUD (пустышка)
|
// V2 — пользовательский CRUD
|
||||||
// Будет: таблица записей, форма добавления, выбор компании из дропдауна
|
// Вход: req.v2_* из resolveContext (email, clientId, allClientIds, profiles, ...)
|
||||||
|
// Всегда показывает выбор компании (даже если одна).
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const q = require('../db/queries');
|
||||||
|
|
||||||
function createUserRouter() {
|
function createUserRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
// ── GET / — главная: список записей + форма ────────────────────────────
|
||||||
res.send(`<h2>User CRUD</h2>
|
router.get('/', async (req, res) => {
|
||||||
<p>email: ${req.v2_email}</p>
|
try {
|
||||||
<p>clientId: ${req.v2_clientId}</p>
|
const clId = req.v2_clientId;
|
||||||
<p>isAdmin: ${req.v2_isAdmin}</p>
|
const allCompanies = req.v2_profiles.length > 0
|
||||||
<p>isImpersonated: ${req.v2_isImpersonated}</p>
|
? req.v2_profiles
|
||||||
<p><a href="/v2/admin">Админка</a> | <a href="/v2/logout">Выйти</a></p>
|
: [{ 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('<h2>Ошибка</h2><pre>' + e.message + '</pre><a href="/v2/app">Назад</a>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── HTML-рендеринг (временный, будет заменён на EJS) ─────────────────────
|
||||||
|
|
||||||
|
function renderPage({ entries, company, limit, used, allCompanies, clId, user, includeDeleted }) {
|
||||||
|
const companyOptions = allCompanies.map(c =>
|
||||||
|
`<option value="${c.client_id}" ${c.client_id === clId ? 'selected' : ''}>${c.company_name || c.client_id} (${c.client_id})</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const rows = entries.map(e => {
|
||||||
|
const del = e.deleted_at;
|
||||||
|
const style = del ? 'text-decoration:line-through;opacity:0.5' : '';
|
||||||
|
const actions = del ? '<span style="color:#888">удалено</span>' : `
|
||||||
|
<form method="POST" action="/v2/app/edit/${e.id}" style="display:inline">
|
||||||
|
<input name="cidr" value="${e.value_cidr}" size="18">
|
||||||
|
<input name="comment" value="${e.comment || ''}" size="20">
|
||||||
|
<button>изменить</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/v2/app/delete/${e.id}" style="display:inline">
|
||||||
|
<button onclick="return confirm('Удалить?')">удалить</button>
|
||||||
|
</form>`;
|
||||||
|
return `<tr style="${style}"><td>${e.value_cidr}</td><td>${e.comment || ''}</td><td>${e.created_by}</td><td>${new Date(e.created_at).toLocaleString('ru')}</td><td>${actions}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>V2 IP WhiteList</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:sans-serif;max-width:960px;margin:20px auto;color:#222}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:10px 0}
|
||||||
|
th,td{border:1px solid #ccc;padding:6px;text-align:left}
|
||||||
|
th{background:#f5f5f5}
|
||||||
|
.bar{display:flex;justify-content:space-between;align-items:center;background:#eee;padding:10px;margin-bottom:10px}
|
||||||
|
button,input,select{padding:4px 8px}
|
||||||
|
.add-form{background:#f0f8ff;padding:10px;margin:10px 0}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="bar">
|
||||||
|
<span><strong>V2 IP WhiteList</strong></span>
|
||||||
|
<span>${user.email} ${user.isAdmin ? '| <a href="/v2/admin">ADMIN</a>' : ''}</span>
|
||||||
|
<span>
|
||||||
|
<form method="GET" action="/v2/app" style="display:inline">
|
||||||
|
<select name="switchTo" onchange="this.form.submit()">${companyOptions}</select>
|
||||||
|
</form>
|
||||||
|
<a href="/v2/logout">Выйти</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const p=new URLSearchParams(location.search);
|
||||||
|
if(p.get('msg')){document.body.insertAdjacentHTML('afterbegin','<div style="background:#4caf50;color:#fff;padding:8px">'+p.get('msg')+'</div>');history.replaceState(null,'','/v2/app')}
|
||||||
|
if(p.get('error')){document.body.insertAdjacentHTML('afterbegin','<div style="background:#f44336;color:#fff;padding:8px">'+p.get('error')+'</div>');history.replaceState(null,'','/v2/app')}
|
||||||
|
</script>
|
||||||
|
<p>Записей: <strong>${used}</strong> из ${limit}</p>
|
||||||
|
<div class="add-form">
|
||||||
|
<form method="POST" action="/v2/app/add">
|
||||||
|
<input name="cidr" placeholder="x.x.x.x/xx" size="18" required>
|
||||||
|
<input name="comment" placeholder="комментарий" size="30">
|
||||||
|
<button>Добавить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tr><th>CIDR</th><th>Комментарий</th><th>Кто</th><th>Когда</th><th></th></tr>
|
||||||
|
${rows || '<tr><td colspan="5">Нет записей</td></tr>'}
|
||||||
|
</table>
|
||||||
|
<p><a href="/v2/app?deleted=${includeDeleted ? '0' : '1'}">${includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые'}</a></p>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { createUserRouter };
|
module.exports = { createUserRouter };
|
||||||
|
|||||||
Reference in New Issue
Block a user