chore: DB defaults for managed platform, reduce required env vars
- db.js: DB_HOST default postgresqlk8s-master, DB_NAME default ipwhitelist, DB_USER default super - server.js: fail-fast for DB_PASS in production (instead of IAM_API_URL) - After this change: only DB_PASS is required for mock mode
This commit is contained in:
@@ -28,6 +28,11 @@ docs/nubes-design-system.md
|
|||||||
docs/deploy-vm.md
|
docs/deploy-vm.md
|
||||||
docs/questions-devops.md
|
docs/questions-devops.md
|
||||||
|
|
||||||
|
# Локальный Keycloak (бинарники не коммитим)
|
||||||
|
keycloak/keycloak-*/
|
||||||
|
keycloak/data/
|
||||||
|
keycloak/*.tar.gz
|
||||||
|
|
||||||
# Промпты для внешних агентов
|
# Промпты для внешних агентов
|
||||||
prompt-*.txt
|
prompt-*.txt
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Локальный Keycloak для тестирования OIDC
|
||||||
|
|
||||||
|
Поднимается на ВМ `italo.kube5s.ru` (5.172.178.213), пока нет кредов от продакшен-KC.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Всё делается на ВМ:
|
||||||
|
cd ~/keycloak
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Доступ после запуска
|
||||||
|
|
||||||
|
| Что | URL |
|
||||||
|
|---|---|
|
||||||
|
| Keycloak admin | `https://italo.kube5s.ru:8443/admin` |
|
||||||
|
| Realm | `ipwhitelist` |
|
||||||
|
| OIDC endpoints | `https://italo.kube5s.ru:8443/realms/ipwhitelist/.well-known/openid-configuration` |
|
||||||
|
|
||||||
|
## Переменные для .env (ipwhitelist)
|
||||||
|
|
||||||
|
```
|
||||||
|
KC_BASE_URL=https://italo.kube5s.ru:8443/realms/ipwhitelist
|
||||||
|
KC_CLIENT_ID=ipwhitelist
|
||||||
|
KC_CLIENT_SECRET=<из Keycloak admin>
|
||||||
|
APP_URL=https://italo.kube5s.ru
|
||||||
|
DEV_MODE=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пользователи
|
||||||
|
|
||||||
|
Создаются через Keycloak admin UI после запуска.
|
||||||
@@ -70,8 +70,8 @@ async function start() {
|
|||||||
console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!');
|
console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!process.env.IAM_API_URL) {
|
if (!process.env.DB_PASS) {
|
||||||
console.error('FATAL: IAM_API_URL не задан в production!');
|
console.error('FATAL: DB_PASS не задан в production!');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ const { Pool } = require('pg');
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST || 'postgresqlk8s-master',
|
||||||
port: process.env.DB_PORT || 5432,
|
port: process.env.DB_PORT || 5432,
|
||||||
database: process.env.DB_NAME || 'postgres',
|
database: process.env.DB_NAME || 'ipwhitelist',
|
||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER || 'super',
|
||||||
password: process.env.DB_PASS,
|
password: process.env.DB_PASS,
|
||||||
ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
|
ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
|
||||||
max: 10,
|
max: 10,
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* tests/real-users-smoke.js — smoke test с реальными токенами IAM.
|
||||||
|
*
|
||||||
|
* ⛔ ТОЛЬКО ЧТЕНИЕ — никаких POST/PATCH/DELETE.
|
||||||
|
* ⛔ Все запросы к IAM API и ipwhitelist GET-эндпоинтам.
|
||||||
|
* ✅ Безопасно для прода/стейджинга — ничего не создаёт, не удаляет, не меняет.
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* cp tokens_all.txt ../nubes/tokens_all.txt
|
||||||
|
* node tests/real-users-smoke.js
|
||||||
|
*
|
||||||
|
* Переменные окружения:
|
||||||
|
* IAM_API_URL — по умолчанию https://auth-api.ngcloud.ru (prod)
|
||||||
|
* APP_URL — по умолчанию https://italo.kube5s.ru (тестовый стенд)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const IAM_API_URL = (process.env.IAM_API_URL || 'https://auth-api.ngcloud.ru').replace(/\/$/, '');
|
||||||
|
const APP_URL = (process.env.APP_URL || 'https://italo.kube5s.ru').replace(/\/$/, '');
|
||||||
|
|
||||||
|
// ── Парсинг файла с токенами ────────────────────────────────────────────────
|
||||||
|
function parseTokensFile(filePath) {
|
||||||
|
const text = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const accounts = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const l = lines[i].trim();
|
||||||
|
if (!l || l.startsWith('eyJ')) { i++; continue; }
|
||||||
|
const email = l.replace(/^=+\s*|\s*=+$/g, '').trim();
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !lines[i].trim()) i++;
|
||||||
|
const tokParts = [];
|
||||||
|
while (i < lines.length) {
|
||||||
|
const t = lines[i].trim();
|
||||||
|
if (!t || t.includes('===')) break;
|
||||||
|
tokParts.push(t);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const token = tokParts.join('');
|
||||||
|
if (token) accounts.push({ email, token });
|
||||||
|
}
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP helper ──────────────────────────────────────────────────────────────
|
||||||
|
function httpRequest(url, method = 'GET', headers = {}, body = null, timeout = 10000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const u = new URL(url);
|
||||||
|
const lib = u.protocol === 'https:' ? https : http;
|
||||||
|
const opts = {
|
||||||
|
hostname: u.hostname,
|
||||||
|
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: u.pathname + u.search,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
timeout,
|
||||||
|
};
|
||||||
|
const req = lib.request(opts, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', c => { data += c; });
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({ status: res.statusCode, headers: res.headers, body: data });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
||||||
|
if (body) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JWT decode ──────────────────────────────────────────────────────────────
|
||||||
|
function jwtPayload(token) {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Тесты ───────────────────────────────────────────────────────────────────
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
return Promise.resolve().then(fn).then(() => {
|
||||||
|
console.log(' ✅ ' + name);
|
||||||
|
passed++;
|
||||||
|
}).catch(e => {
|
||||||
|
console.log(' ❌ ' + name + ' — ' + e.message);
|
||||||
|
failed++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
// Путь к файлу с токенами
|
||||||
|
const tokFile = path.resolve(__dirname, '..', '..', 'nubes', 'tokens_all.txt');
|
||||||
|
let accounts;
|
||||||
|
try {
|
||||||
|
accounts = parseTokensFile(tokFile);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('❌ Не найден файл с токенами:', tokFile);
|
||||||
|
console.log(' cp /home/naeel/nubes/tokens_all.txt ../nubes/tokens_all.txt');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🧪 Smoke-тесты с реальными IAM токенами');
|
||||||
|
console.log(' IAM:', IAM_API_URL);
|
||||||
|
console.log(' App:', APP_URL);
|
||||||
|
console.log(' Учётных записей:', accounts.length);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
for (const acc of accounts) {
|
||||||
|
const tok = acc.token;
|
||||||
|
const email = acc.email;
|
||||||
|
const payload = jwtPayload(tok);
|
||||||
|
const label = `${email} (${payload?.ClientID || '?'})`;
|
||||||
|
|
||||||
|
console.log(`\n══════════ ${label} ══════════`);
|
||||||
|
|
||||||
|
// ── 1. IAM API: GET /auth/user ──────────────────────────────────────────
|
||||||
|
console.log(' ── IAM API ──');
|
||||||
|
|
||||||
|
await test('IAM API доступен', async () => {
|
||||||
|
const res = await httpRequest(`${IAM_API_URL}/api/v1/auth/user`, 'GET', {
|
||||||
|
'Authorization': 'Bearer ' + tok,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
});
|
||||||
|
if (res.status !== 200) throw new Error('HTTP ' + res.status + ': ' + res.body.slice(0, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
let iamUser = null;
|
||||||
|
await test('IAM API: profiles[] не пуст', async () => {
|
||||||
|
const res = await httpRequest(`${IAM_API_URL}/api/v1/auth/user`, 'GET', {
|
||||||
|
'Authorization': 'Bearer ' + tok,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
});
|
||||||
|
iamUser = JSON.parse(res.body);
|
||||||
|
if (!iamUser.profiles || iamUser.profiles.length === 0) throw new Error('profiles[] пуст');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('IAM API: ClientID совпадает с JWT', () => {
|
||||||
|
const jwtCid = payload.ClientID;
|
||||||
|
const iamCid = iamUser.userInfo?.clientID;
|
||||||
|
if (jwtCid !== iamCid) throw new Error(`JWT ClientID=${jwtCid} !== IAM userInfo.clientID=${iamCid}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('IAM API: profiles[0].client_id совпадает', () => {
|
||||||
|
const profileCid = iamUser.profiles[0].client_id;
|
||||||
|
const jwtCid = payload.ClientID;
|
||||||
|
if (profileCid !== jwtCid) throw new Error(`profile client_id=${profileCid} !== JWT ClientID=${jwtCid}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('IAM API: email совпадает', () => {
|
||||||
|
const iamEmail = iamUser.userInfo?.email || '';
|
||||||
|
if (iamEmail && iamEmail !== email) throw new Error(`IAM email=${iamEmail} !== ожидаемый ${email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('IAM API: isAdmin флаг есть в ответе', () => {
|
||||||
|
if (typeof iamUser.userInfo?.isAdmin !== 'boolean') throw new Error('isAdmin отсутствует или не boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. IAM API: JWKS endpoint ──────────────────────────────────────────
|
||||||
|
await test('IAM API: JWKS endpoint доступен и содержит ключи', async () => {
|
||||||
|
const res = await httpRequest(`${IAM_API_URL}/.well-known/openid-configuration`, 'GET', {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
});
|
||||||
|
if (res.status === 404) {
|
||||||
|
// Не у всех IAM есть well-known, пропускаем не фатально
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(res.body);
|
||||||
|
if (!data.jwks_uri) throw new Error('Нет jwks_uri');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3. ipwhitelist app: login + read-only endpoints ────────────────────
|
||||||
|
console.log(' ── ipwhitelist app ──');
|
||||||
|
|
||||||
|
// Получаем сессионную куку для следующих запросов
|
||||||
|
let sessionCookie = '';
|
||||||
|
|
||||||
|
// Логинимся и запоминаем куку
|
||||||
|
await test('POST /login-token + установка cookie', async () => {
|
||||||
|
const loginRes = await httpRequest(`${APP_URL}/login-token`, 'POST', {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
}, 'token=' + encodeURIComponent(tok) + '&returnTo=/');
|
||||||
|
if (loginRes.status !== 302) throw new Error('Ожидался 302, получен ' + loginRes.status);
|
||||||
|
const sc = loginRes.headers['set-cookie'];
|
||||||
|
if (!sc) throw new Error('Нет set-cookie');
|
||||||
|
sessionCookie = sc.map(c => c.split(';')[0]).join('; ');
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET / — для admin без ?company= ожидается 302 (редирект на ?company=<id>)
|
||||||
|
await test('GET /: страница загружается', async () => {
|
||||||
|
const res = await httpRequest(`${APP_URL}/`, 'GET', { 'Cookie': sessionCookie });
|
||||||
|
if (res.status === 200 || res.status === 302) return; // оба OK
|
||||||
|
throw new Error('HTTP ' + res.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /export — доступен всем аутентифицированным
|
||||||
|
await test('GET /export: txt-файл отдаётся', async () => {
|
||||||
|
const res = await httpRequest(`${APP_URL}/export`, 'GET', { 'Cookie': sessionCookie });
|
||||||
|
if (res.status !== 200) throw new Error('HTTP ' + res.status);
|
||||||
|
const ct = res.headers['content-type'] || '';
|
||||||
|
if (!ct.includes('text/plain')) throw new Error('Не text/plain: ' + ct);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/entries — API endpoint
|
||||||
|
await test('GET /api/v1/entries: записи читаются', async () => {
|
||||||
|
const res = await httpRequest(`${APP_URL}/api/v1/entries`, 'GET', {
|
||||||
|
'Authorization': 'Bearer ' + tok,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
});
|
||||||
|
if (res.status !== 200) throw new Error('HTTP ' + res.status);
|
||||||
|
const data = JSON.parse(res.body);
|
||||||
|
if (!Array.isArray(data.entries)) throw new Error('entries не массив');
|
||||||
|
if (typeof data.limit !== 'number') throw new Error('limit не число');
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/entries/export — API export
|
||||||
|
await test('GET /api/v1/entries/export: API export доступен', async () => {
|
||||||
|
const res = await httpRequest(`${APP_URL}/api/v1/entries/export`, 'GET', {
|
||||||
|
'Authorization': 'Bearer ' + tok,
|
||||||
|
'Accept': 'text/plain',
|
||||||
|
});
|
||||||
|
if (res.status !== 200) throw new Error('HTTP ' + res.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin-only тесты
|
||||||
|
if (payload.ClientID === 'WZ01112' || iamUser?.userInfo?.isAdmin) {
|
||||||
|
await test('GET /api/v1/companies: admin может читать (JSON)', async () => {
|
||||||
|
const res = await httpRequest(`${APP_URL}/api/v1/companies`, 'GET', {
|
||||||
|
'Authorization': 'Bearer ' + tok,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
});
|
||||||
|
if (res.status !== 200) throw new Error('HTTP ' + res.status);
|
||||||
|
const data = JSON.parse(res.body);
|
||||||
|
if (!Array.isArray(data.companies)) throw new Error('companies не массив');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('GET /admin: admin UI доступна', async () => {
|
||||||
|
const res = await httpRequest(`${APP_URL}/admin`, 'GET', { 'Cookie': sessionCookie });
|
||||||
|
if (res.status !== 200) throw new Error('HTTP ' + res.status);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Проверяем что не-admin не может получить /admin
|
||||||
|
await test('GET /admin: не-admin получает 302 или 403', async () => {
|
||||||
|
const res = await httpRequest(`${APP_URL}/admin`, 'GET', { 'Cookie': sessionCookie });
|
||||||
|
if (res.status === 200) throw new Error('Не-admin получил /admin без прав');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('GET /api/v1/companies: не-admin 403', async () => {
|
||||||
|
const res = await httpRequest(`${APP_URL}/api/v1/companies`, 'GET', {
|
||||||
|
'Authorization': 'Bearer ' + tok,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
});
|
||||||
|
if (res.status !== 403) throw new Error('Ожидался 403, получен ' + res.status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Итог ──────────────────────────────────────────────────────────────────
|
||||||
|
console.log(`\n══════════ ИТОГ ══════════`);
|
||||||
|
console.log(` ✅ Пройдено: ${passed}`);
|
||||||
|
console.log(` ❌ Провалено: ${failed}`);
|
||||||
|
console.log(` Всего: ${passed + failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(e => {
|
||||||
|
console.error('FATAL:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user