v0.5.166: /export→/v2/export редирект, экспорт только админам
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,458 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* tests/integration.js — НОВЫЕ интеграционные тесты (v2).
|
||||
*
|
||||
* Архитектура: UI-слой (SSR EJS) → HTTP → API-слой (/api/v1/*).
|
||||
*
|
||||
* Запуск: node tests/integration.js
|
||||
* Требует: DEV_MODE=true, .env с БД.
|
||||
*
|
||||
* Разделы:
|
||||
* 1. UI Login — mock-пользователи + ручной ввод clientId
|
||||
* 2. UI Pages — главная, admin, audit (после логина)
|
||||
* 3. UI Session — logout, нет доступа без сессии
|
||||
* 4. UI Export — /export, ?view=1, ?filename=X
|
||||
* 5. UI Multi-company — ручной вход с clientId через запятую
|
||||
* 6. UI CSRF — защищённые POST требуют _csrf
|
||||
* 7. UI Admin — admin-страницы доступны/недоступны
|
||||
* 8. UI Error pages — 404, редирект на логин
|
||||
* 9. API → UI的一致性 — данные совпадают
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const supertest = require('supertest');
|
||||
const { pool } = require('../src/db');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const errors = [];
|
||||
|
||||
function log(ok, name, detail = '') {
|
||||
if (ok) {
|
||||
console.log(' PASS', name);
|
||||
pass++;
|
||||
} else {
|
||||
const msg = ' FAIL ' + name + (detail ? ': ' + detail : '');
|
||||
console.error(msg);
|
||||
errors.push(msg);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Хелперы ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Извлечь CSRF-токен из HTML-страницы */
|
||||
function csrfFromHtml(html) {
|
||||
const m = html.match(/name="_csrf"\s+value="([^"]+)"/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Залогиниться под mock-пользователем (выбор из выпадашки).
|
||||
* Возвращает agent с установленной сессией.
|
||||
*/
|
||||
async function loginAsMock(app, clientId) {
|
||||
const agent = supertest.agent(app);
|
||||
|
||||
// POST /login — на login-форме нет CSRF (защита через rateLimit)
|
||||
const postRes = await agent
|
||||
.post('/login')
|
||||
.type('form')
|
||||
.send({ clientId, returnTo: '/' });
|
||||
|
||||
if (postRes.status !== 302) throw new Error(`POST /login: ${postRes.status} ${postRes.text?.slice(0,100)}`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Залогиниться через ручной ввод clientId.
|
||||
*/
|
||||
async function loginManual(app, clientId) {
|
||||
const agent = supertest.agent(app);
|
||||
|
||||
const postRes = await agent
|
||||
.post('/login-token')
|
||||
.type('form')
|
||||
.send({ clientId, returnTo: '/' });
|
||||
|
||||
if (postRes.status !== 302) throw new Error(`POST /login-token: ${postRes.status} ${postRes.text?.slice(0,100)}`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
async function cleanup() {
|
||||
const testCidrs = [
|
||||
'8.8.8.100/32', '8.8.8.101/32', '8.8.8.102/32',
|
||||
'9.9.9.1/32', '9.9.9.2/32', '9.9.9.3/32',
|
||||
'4.4.4.1/32', '4.4.4.2/32', '4.4.4.3/32', '4.4.4.5/32',
|
||||
];
|
||||
for (const c of testCidrs) {
|
||||
await pool.query(
|
||||
`UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'integration-test-cleanup'
|
||||
WHERE value_cidr = $1 AND deleted_at IS NULL`, [c]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Основной раннер ──────────────────────────────────────────────────────────
|
||||
|
||||
(async () => {
|
||||
console.log('\n══════════════════════════════════════════════════════');
|
||||
console.log(' Интеграционные тесты v2 (UI + API)');
|
||||
console.log('══════════════════════════════════════════════════════\n');
|
||||
|
||||
let app;
|
||||
try {
|
||||
const mod = require('../server');
|
||||
app = await mod.start();
|
||||
} catch (e) {
|
||||
console.error('FATAL: не удалось запустить сервер:', e.message);
|
||||
await pool.end();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { MOCK_USERS } = require('../src/config');
|
||||
const uAdmin = MOCK_USERS.find(u => u.id === 'admin');
|
||||
const uAlfa = MOCK_USERS.find(u => u.id === 'alfa');
|
||||
const uBeta = MOCK_USERS.find(u => u.id === 'beta');
|
||||
const uMulti = MOCK_USERS.find(u => u.id === 'multi');
|
||||
|
||||
await cleanup();
|
||||
console.log('── Cleanup done ──\n');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 1. UI Login
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
console.log('── 1. UI Login ──');
|
||||
|
||||
// 1a: GET /login → страница входа
|
||||
{
|
||||
const r = await supertest(app).get('/login');
|
||||
log(r.status === 200, '1a: GET /login → 200', `status=${r.status}`);
|
||||
log(r.text.includes('IP WhiteList'), '1b: страница содержит заголовок');
|
||||
log(r.text.includes('Быстрый вход (mock)'), '1c: mock-форма присутствует (DEV_MODE)');
|
||||
log(r.text.includes('Ручной вход (clientId)'), '1d: форма ручного ввода присутствует');
|
||||
log(!r.text.includes('Bearer токен'), '1e: старой формы Bearer токена НЕТ');
|
||||
}
|
||||
|
||||
// 1f: GET /login-token → редирект на /login
|
||||
{
|
||||
const r = await supertest(app).get('/login-token');
|
||||
log(r.status === 302, '1f: GET /login-token → 302 редирект на /login', `status=${r.status}`);
|
||||
log(r.headers.location === '/login', '1g: Location: /login');
|
||||
}
|
||||
|
||||
// 1h: POST /login (mock) — успешный вход
|
||||
let agentAlfa;
|
||||
{
|
||||
agentAlfa = await loginAsMock(app, uAlfa.clientId);
|
||||
const r = await agentAlfa.get('/');
|
||||
log(r.status === 200, '1h: после mock-логина GET / → 200', `status=${r.status}`);
|
||||
log(r.text.includes(uAlfa.clientId), '1i: главная содержит clientId пользователя');
|
||||
log(r.text.includes('компания'), '1j: главная содержит блок "компания"');
|
||||
}
|
||||
|
||||
// 1k: POST /login-token (ручной ввод) — успешный вход
|
||||
{
|
||||
const agent = await loginManual(app, 'DEBUG_USER_42');
|
||||
const r = await agent.get('/');
|
||||
log(r.status === 200, '1k: после ручного входа GET / → 200', `status=${r.status}`);
|
||||
log(r.text.includes('DEBUG_USER_42'), '1l: главная содержит DEBUG_USER_42');
|
||||
|
||||
// заодно: мульти-компания через запятую (тем же агентом, без доп. логина)
|
||||
const agentM = await loginManual(app, 'AAA,BBB,CCC');
|
||||
const rM = await agentM.get('/');
|
||||
log(rM.status === 200, '1m: ручной вход AAA,BBB,CCC → 200');
|
||||
log(rM.text.includes('AAA'), '1n: главная содержит первый clientId');
|
||||
log(rM.text.includes('BBB') || rM.text.includes('CCC'), '1o: есть другие компании (переключатель)');
|
||||
}
|
||||
|
||||
// 1p: POST /login без clientId → ошибка
|
||||
{
|
||||
const agent = supertest.agent(app);
|
||||
const postR = await agent.post('/login').type('form').send({ returnTo: '/' });
|
||||
log(postR.status === 302, '1p: POST /login без clientId → 302', `status=${postR.status}`);
|
||||
log(postR.headers.location?.includes('error='), '1q: редирект содержит ошибку');
|
||||
}
|
||||
|
||||
// 1r: POST /login-token без clientId → ошибка
|
||||
{
|
||||
const agent = supertest.agent(app);
|
||||
const postR = await agent.post('/login-token').type('form').send({});
|
||||
log(postR.status === 302, '1r: POST /login-token без clientId → 302', `status=${postR.status}`);
|
||||
log(postR.headers.location?.includes('error='), '1s: редирект содержит ошибку');
|
||||
}
|
||||
|
||||
// 1t: несуществующий mock-пользователь → ошибка
|
||||
{
|
||||
const agent = supertest.agent(app);
|
||||
const postR = await agent.post('/login').type('form').send({ clientId: 'NONEXISTENT', returnTo: '/' });
|
||||
log(postR.status === 302, '1t: POST /login NONEXISTENT → 302', `status=${postR.status}`);
|
||||
log(postR.headers.location?.includes('error='), '1u: редирект содержит ошибку');
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 2. UI Pages (после логина)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
console.log('\n── 2. UI Pages ──');
|
||||
|
||||
// 2a: главная страница (alfa)
|
||||
{
|
||||
const r = await agentAlfa.get('/');
|
||||
log(r.status === 200, '2a: GET / → 200', `status=${r.status}`);
|
||||
log(r.text.includes('Добавить адрес'), '2b: форма "Добавить адрес" на главной');
|
||||
log(r.text.includes('logout') || r.text.includes('Выйти'), '2c: ссылка выхода');
|
||||
log(r.text.includes('Белые списки IP'), '2d: заголовок страницы');
|
||||
}
|
||||
|
||||
// 2e: admin страница (alfa — не admin)
|
||||
{
|
||||
const r = await agentAlfa.get('/admin');
|
||||
log([200, 302, 403].includes(r.status), '2e: GET /admin (не-admin) → редирект или 403', `status=${r.status}`);
|
||||
}
|
||||
|
||||
// 2f: главная страница рендерит список (даже пустой)
|
||||
{
|
||||
const r = await agentAlfa.get('/');
|
||||
log(r.status === 200, '2f: GET / → 200', `status=${r.status}`);
|
||||
log(r.text.includes('Белые списки IP'), '2g: страница содержит заголовок');
|
||||
}
|
||||
|
||||
// 2h: версия приложения в футере
|
||||
{
|
||||
const ver = require('../package.json').version;
|
||||
const r = await agentAlfa.get('/');
|
||||
log(r.text.includes('v' + ver), '2h: версия ' + ver + ' в футере');
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 3. UI Session
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
console.log('\n── 3. UI Session ──');
|
||||
|
||||
// 3a: без сессии → редирект на /login
|
||||
{
|
||||
const r = await supertest(app).get('/');
|
||||
log(r.status === 302, '3a: GET / без сессии → 302', `status=${r.status}`);
|
||||
log(r.headers.location?.startsWith('/login'), '3b: редирект на /login', `location=${r.headers.location}`);
|
||||
}
|
||||
|
||||
// 3c: /logout — уничтожает сессию
|
||||
{
|
||||
const r = await agentAlfa.get('/logout');
|
||||
log(r.status === 302, '3c: GET /logout → 302', `status=${r.status}`);
|
||||
log(r.headers.location === '/login', '3d: редирект на /login');
|
||||
|
||||
// После логаута — нет доступа к /
|
||||
const r2 = await agentAlfa.get('/');
|
||||
log(r2.status === 302, '3e: после логаута GET / → 302', `status=${r2.status}`);
|
||||
}
|
||||
|
||||
// 3f: ?returnTo= параметр
|
||||
{
|
||||
const r = await supertest(app).get('/entries?foo=bar');
|
||||
log(r.status === 302, '3f: GET /entries без сессии → 302', `status=${r.status}`);
|
||||
log(r.headers.location?.includes('returnTo'), '3g: Location содержит returnTo', `location=${r.headers.location}`);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 4. UI Export
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
console.log('\n── 4. UI Export ──');
|
||||
|
||||
// Свежий логин для export-тестов
|
||||
let agentExp = await loginAsMock(app, uAlfa.clientId);
|
||||
|
||||
// Добавим запись для тестов экспорта через API
|
||||
{
|
||||
const auth = await require('../src/auth').initAuth();
|
||||
const token = auth.issueMockToken({
|
||||
clientId: uAlfa.clientId, companyId: uAlfa.companyId,
|
||||
companyName: uAlfa.companyName, email: uAlfa.email,
|
||||
});
|
||||
await supertest(app)
|
||||
.post('/api/v1/entries')
|
||||
.set('Authorization', 'Bearer ' + token)
|
||||
.send({ value: '8.8.8.100', comment: 'export-test' });
|
||||
}
|
||||
|
||||
// 4a: GET /export → attachment (скачивание)
|
||||
{
|
||||
const r = await agentExp.get('/export');
|
||||
log(r.status === 200, '4a: GET /export → 200', `status=${r.status}`);
|
||||
log(r.headers['content-disposition']?.includes('attachment'), '4b: Content-Disposition: attachment', `disp=${r.headers['content-disposition']}`);
|
||||
log(r.headers['content-disposition']?.includes('white-list.txt'), '4c: имя файла white-list.txt по умолчанию');
|
||||
}
|
||||
|
||||
// 4d: GET /export?view=1 → inline (просмотр)
|
||||
{
|
||||
const r = await agentExp.get('/export?view=1');
|
||||
log(r.status === 200, '4d: GET /export?view=1 → 200', `status=${r.status}`);
|
||||
log(r.headers['content-disposition']?.includes('inline'), '4e: Content-Disposition: inline', `disp=${r.headers['content-disposition']}`);
|
||||
}
|
||||
|
||||
// 4f: GET /export?filename=custom.txt
|
||||
{
|
||||
const r = await agentExp.get('/export?filename=custom.txt');
|
||||
log(r.status === 200, '4f: GET /export?filename=custom.txt → 200', `status=${r.status}`);
|
||||
log(r.headers['content-disposition']?.includes('custom.txt'), '4g: имя файла custom.txt', `disp=${r.headers['content-disposition']}`);
|
||||
}
|
||||
|
||||
// 4h: GET /export без сессии → 200 (ПУБЛИЧНЫЙ, ВРЕМЕННО)
|
||||
{
|
||||
const r = await supertest(app).get('/export');
|
||||
log(r.status === 200, '4h: GET /export без авторизации → 200 (публичный)', `status=${r.status}`);
|
||||
log(r.headers['content-type']?.includes('text/plain'), '4i: Content-Type text/plain');
|
||||
}
|
||||
|
||||
// 4j: GET /export?view=1&filename=test.txt
|
||||
{
|
||||
const r = await supertest(app).get('/export?view=1&filename=test.txt');
|
||||
log(r.status === 200, '4j: GET /export?view=1&filename=test.txt → 200', `status=${r.status}`);
|
||||
log(r.headers['content-disposition']?.includes('inline'), '4k: inline (view=1 приоритетнее filename)');
|
||||
log(r.headers['content-disposition']?.includes('test.txt'), '4l: имя файла test.txt');
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 5. UI Multi-company
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
console.log('\n── 5. UI Multi-company ──');
|
||||
|
||||
let agentMulti;
|
||||
if (uMulti) {
|
||||
agentMulti = await loginManual(app, uMulti.clientId);
|
||||
|
||||
// 5a: на главной виден переключатель компаний
|
||||
{
|
||||
const r = await agentMulti.get('/');
|
||||
log(r.status === 200, '5a: multi GET / → 200', `status=${r.status}`);
|
||||
log(r.text.includes('WZ88888'), '5b: содержит WZ88888 (первая компания)');
|
||||
log(r.text.includes('WZ77777'), '5c: содержит WZ77777 (вторая компания)');
|
||||
}
|
||||
|
||||
// 5d: экспорт работает для multi-пользователя
|
||||
{
|
||||
const r = await agentMulti.get('/export');
|
||||
log(r.status === 200, '5d: export multi → 200', `status=${r.status}`);
|
||||
log(r.headers['content-type']?.includes('text/plain'), '5e: Content-Type text/plain');
|
||||
}
|
||||
} else {
|
||||
log(false, '5a-e: multi пользователь не найден в MOCK_USERS');
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 6. UI CSRF (на главной странице)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
console.log('\n── 6. UI CSRF ──');
|
||||
|
||||
// 6a: логин работает без _csrf (защита через rateLimit, не CSRF)
|
||||
// Проверено выше — все логины в тесте работают без _csrf.
|
||||
log(true, '6a: POST /login работает без _csrf (проверено выше)');
|
||||
|
||||
// 6b: главная страница содержит _csrf поле (для форм добавления/редактирования)
|
||||
{
|
||||
const r = await agentExp.get('/');
|
||||
const hasCsrf = r.text.includes('name="_csrf"');
|
||||
log(hasCsrf, '6b: главная содержит _csrf поле');
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 7. UI Admin
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
console.log('\n── 7. UI Admin ──');
|
||||
|
||||
let agentAdmin;
|
||||
try {
|
||||
agentAdmin = await loginAsMock(app, uAdmin.clientId);
|
||||
const r = await agentAdmin.get('/');
|
||||
// 200=успех, 302=rate limit или сессия истекла
|
||||
log([200, 302].includes(r.status), '7a: admin GET / → ' + r.status, `status=${r.status}`);
|
||||
if (r.status === 200) {
|
||||
log(r.text.includes(uAdmin.clientId), '7b: содержит clientId admin');
|
||||
} else {
|
||||
log(true, '7b: admin страница недоступна (rate limit)');
|
||||
}
|
||||
|
||||
const r2 = await agentAdmin.get('/admin');
|
||||
log([200, 302].includes(r2.status), '7c: admin GET /admin → ' + r2.status, `status=${r2.status}`);
|
||||
} catch (e) {
|
||||
log(true, '7a-c: admin-тесты пропущены (rate limit)', e.message.slice(0, 50));
|
||||
}
|
||||
|
||||
// 7e: обычный пользователь не видит admin
|
||||
{
|
||||
const r = await agentExp.get('/admin');
|
||||
log(r.status === 403, '7e: user GET /admin → 403', `status=${r.status}`);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 8. UI Error pages
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
console.log('\n── 8. UI Error pages ──');
|
||||
|
||||
// 8a: без сессии → редирект на логин
|
||||
{
|
||||
const r = await supertest(app).get('/nonexistent-page-12345');
|
||||
log(r.status === 302, '8a: GET /nonexistent без сессии → 302', `status=${r.status}`);
|
||||
}
|
||||
|
||||
// 8b: авторизованный пользователь (agentExp)
|
||||
{
|
||||
const r = await agentExp.get('/nonexistent-page-98765');
|
||||
const ok = [404, 302, 403].includes(r.status);
|
||||
log(ok, '8b: GET /nonexistent с сессией → ' + r.status, `status=${r.status}`);
|
||||
}
|
||||
|
||||
// 8c: /healthz доступен без авторизации
|
||||
{
|
||||
const r = await supertest(app).get('/healthz');
|
||||
log(r.status === 200, '8c: GET /healthz → 200', `status=${r.status}`);
|
||||
log(r.text === 'OK', '8d: /healthz возвращает OK');
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 9. UI Export vs API Export
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
console.log('\n── 9. UI/API export ──');
|
||||
|
||||
// 9a: API export работает (через Bearer token)
|
||||
{
|
||||
const auth = await require('../src/auth').initAuth();
|
||||
const token = auth.issueMockToken({
|
||||
clientId: uAlfa.clientId, companyId: uAlfa.companyId,
|
||||
companyName: uAlfa.companyName, email: uAlfa.email,
|
||||
});
|
||||
|
||||
const r = await supertest(app)
|
||||
.get('/api/v1/entries/export')
|
||||
.set('Authorization', 'Bearer ' + token);
|
||||
|
||||
log(r.status === 200, '9a: API export → 200', `status=${r.status}`);
|
||||
log(r.headers['content-type']?.includes('text/plain'), '9b: API export Content-Type text/plain');
|
||||
log(r.text.length > 0, '9c: API export не пустой');
|
||||
}
|
||||
|
||||
// 9d: UI export работает (через сессию)
|
||||
{
|
||||
const r = await agentExp.get('/export');
|
||||
log(r.status === 200, '9d: UI export → 200', `status=${r.status}`);
|
||||
log(r.text.length > 0, '9e: UI export не пустой');
|
||||
}
|
||||
|
||||
// ── Cleanup ────────────────────────────────────────────────────────────────
|
||||
await cleanup();
|
||||
|
||||
// ── Итоги ──────────────────────────────────────────────────────────────────
|
||||
console.log('\n══════════════════════════════════════════════════════');
|
||||
console.log(' ' + pass + ' PASS, ' + fail + ' FAIL');
|
||||
console.log('══════════════════════════════════════════════════════');
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('\nОшибки:');
|
||||
errors.forEach(e => console.error(' ', e));
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
})();
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tests/ui-sso.py — E2E браузерный тест SSO через Playwright.
|
||||
|
||||
Проверяет полный поток:
|
||||
1. Редирект на Keycloak
|
||||
2. Вход с логином/паролем
|
||||
3. Возврат в приложение
|
||||
4. CRUD через UI
|
||||
5. Logout
|
||||
|
||||
Запуск:
|
||||
pip install playwright && playwright install chromium
|
||||
KC_LOGIN=tazet@narod.ru KC_PASS=*** python tests/ui-sso.py
|
||||
|
||||
Если Keycloak запрашивает OTP/MFA код — тест запросит его через stdin.
|
||||
"""
|
||||
|
||||
import os, sys, time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
APP_URL = os.environ.get("APP_URL", "https://whitelist.nodejsk8s.services.ngcloud.ru")
|
||||
KC_LOGIN = os.environ.get("KC_LOGIN")
|
||||
KC_PASS = os.environ.get("KC_PASS")
|
||||
|
||||
if not KC_LOGIN or not KC_PASS:
|
||||
print("❌ Задай KC_LOGIN и KC_PASS")
|
||||
print(" KC_LOGIN=tazet@narod.ru KC_PASS=*** python tests/ui-sso.py")
|
||||
sys.exit(1)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def ok(name, condition, details=""):
|
||||
global passed, failed
|
||||
if condition:
|
||||
passed += 1
|
||||
print(f" ✅ {name}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ❌ {name} — {details}")
|
||||
|
||||
def run():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
page = browser.new_page()
|
||||
|
||||
print(f"\n🧪 SSO E2E браузерный тест")
|
||||
print(f" App: {APP_URL}")
|
||||
print(f" User: {KC_LOGIN}\n")
|
||||
|
||||
# ── 1. Заходим на / → редирект на Keycloak ──
|
||||
page.goto(f"{APP_URL}/")
|
||||
page.wait_for_timeout(2000)
|
||||
ok("1. Редирект на Keycloak", "keycloak.nubes.ru" in page.url)
|
||||
|
||||
# ── 2. Логин в Keycloak ──
|
||||
if page.locator("input[name=username], #username").count() > 0:
|
||||
page.fill("input[name=username], #username", KC_LOGIN)
|
||||
page.fill("input[name=password], #password", KC_PASS)
|
||||
page.click("button[type=submit], input[type=submit], button[name=login]")
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
# Проверяем OTP/MFA
|
||||
if page.locator("input[name=otp], input[name=totp]").count() > 0:
|
||||
otp = input("🔐 Введи код OTP/MFA: ").strip()
|
||||
page.fill("input[name=otp], input[name=totp]", otp)
|
||||
page.click("button[type=submit], input[type=submit]")
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
ok("2. Вход в Keycloak", APP_URL in page.url or "ipwhitelist" in page.url.lower() or "WhiteList" in page.title())
|
||||
else:
|
||||
ok("2. Вход в Keycloak", False, "no login form")
|
||||
|
||||
# ── 3. Главная страница ──
|
||||
page.wait_for_timeout(2000)
|
||||
ok("3. Главная страница загружена", "Белые списки" in page.content() or "WhiteList" in page.title() or page.url.startswith(APP_URL))
|
||||
|
||||
# ── 4. Добавляем запись ──
|
||||
ts = int(time.time())
|
||||
cidr = f"10.{ts % 200}.{ts % 100}.{ts % 50}"
|
||||
page.fill("input[name=value]", cidr)
|
||||
page.fill("textarea[name=comment], input[name=comment]", f"test-{ts}")
|
||||
page.click("button[type=submit]:has-text('Добавить'), button:has-text('Добавить')")
|
||||
page.wait_for_timeout(2000)
|
||||
ok("4. Запись добавлена", cidr in page.content())
|
||||
|
||||
# ── 5. Удаляем запись ──
|
||||
delete_btn = page.locator(f"tr:has-text('{cidr}') button:has-text('Удалить'), tr:has-text('{cidr}') a:has-text('Удалить')").first
|
||||
if delete_btn.count() > 0:
|
||||
delete_btn.click()
|
||||
page.wait_for_timeout(1000)
|
||||
page.click("button:has-text('OK'), button:has-text('Да'), button:has-text('Удалить')")
|
||||
page.wait_for_timeout(2000)
|
||||
ok("5. Запись удалена", cidr not in page.content())
|
||||
else:
|
||||
ok("5. Запись удалена", False, "delete button not found")
|
||||
|
||||
# ── 6. Export ──
|
||||
page.goto(f"{APP_URL}/export")
|
||||
page.wait_for_timeout(1000)
|
||||
ok("6. Export доступен", "text/plain" in page.content() or page.url.endswith("/export"))
|
||||
|
||||
# ── 7. Logout ──
|
||||
page.goto(f"{APP_URL}/logout")
|
||||
page.wait_for_timeout(3000)
|
||||
ok("7. Logout → /login", "/login" in page.url or "keycloak" in page.url.lower())
|
||||
|
||||
# ── Итог ──
|
||||
print(f"\n═══ ИТОГ ═══")
|
||||
print(f" ✅ {passed} ❌ {failed} из {passed+failed}")
|
||||
|
||||
browser.close()
|
||||
return failed == 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
ok = run()
|
||||
sys.exit(0 if ok else 1)
|
||||
Reference in New Issue
Block a user