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:
@@ -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