login: left panel 1/4, distrib button, text tweaks, dashboard light theme polish
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# SQLite in-memory for tests — no PostgreSQL needed
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db")
|
||||
os.environ.setdefault("SIGNING_KEY", "test-signing-key-32chars-padding!!")
|
||||
os.environ.setdefault("ADMIN_USERNAME", "admin")
|
||||
os.environ.setdefault("ADMIN_PASSWORD", "testpass123")
|
||||
os.environ.setdefault("PUBLIC_HOST", "http://localhost")
|
||||
os.environ.setdefault("ENABLE_STARTUP_MAINTENANCE", "0")
|
||||
os.environ.setdefault("LOG_LEVEL", "ERROR")
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Patch docker before importing app modules
|
||||
_docker_mock = MagicMock()
|
||||
_docker_mock.containers.get.side_effect = Exception("no docker in tests")
|
||||
_docker_mock.containers.list.return_value = []
|
||||
_docker_mock.containers.run.return_value = MagicMock(id="test-container-id", status="running", name="test")
|
||||
|
||||
sys.modules.setdefault("docker", MagicMock())
|
||||
|
||||
with patch("docker.from_env", return_value=_docker_mock):
|
||||
with patch("runtime.ensure_schema_compatibility", lambda: None):
|
||||
from database import Base, get_db
|
||||
import main as app_module
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite:///./test.db",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Create admin user
|
||||
from auth import hash_password
|
||||
from models import User
|
||||
with TestingSessionLocal() as db:
|
||||
if not db.query(User).filter(User.username == "admin").first():
|
||||
import datetime as _dt
|
||||
db.add(User(
|
||||
username="admin",
|
||||
password_hash=hash_password("testpass123"),
|
||||
is_admin=True,
|
||||
active=True,
|
||||
expires_at=_dt.datetime(2099, 1, 1, tzinfo=_dt.timezone.utc),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def override_get_db():
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
app_module.app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client():
|
||||
with patch("runtime.docker_client", return_value=_docker_mock), \
|
||||
patch("runtime.ensure_schema_compatibility", lambda: None):
|
||||
with TestClient(app_module.app, raise_server_exceptions=False, base_url="https://testserver") as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _extract_csrf(client) -> str:
|
||||
"""GET / → берём CSRF из HTML и ставим куку вручную."""
|
||||
import re
|
||||
r = client.get("/", follow_redirects=True)
|
||||
assert r.status_code == 200
|
||||
m = re.search(r'name=["\']csrf_token["\'][^>]*value=["\']([^"\']+)["\']'
|
||||
r'|value=["\']([^"\']+)["\'][^>]*name=["\']csrf_token["\']', r.text)
|
||||
if not m:
|
||||
m = re.search(r'csrf_token["\']?\s*[=:]\s*["\']([^"\']{10,})["\']', r.text)
|
||||
assert m, f"csrf_token not found in HTML: {r.text[:500]}"
|
||||
csrf = m.group(1) or m.group(2)
|
||||
client.cookies.set("portal_csrf", csrf, domain="testserver")
|
||||
return csrf
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def auth_client(client):
|
||||
"""Client with admin session cookie."""
|
||||
csrf = _extract_csrf(client)
|
||||
r = client.post("/login", data={
|
||||
"username": "admin",
|
||||
"password": "testpass123",
|
||||
"csrf_token": csrf,
|
||||
}, follow_redirects=True)
|
||||
assert r.status_code == 200, f"login failed: {r.status_code} {r.text[:300]}"
|
||||
return client
|
||||
+140
-35
@@ -742,7 +742,7 @@ button {
|
||||
100% { transform: translate3d(-6%, 0, 0); }
|
||||
}
|
||||
|
||||
/* Effects disabled per request: no parallax, no animated clouds, no hover motion */
|
||||
/* ========== Dashboard refined light theme ========== */
|
||||
.parallax-scene,
|
||||
.parallax-layer,
|
||||
.cloud-layer,
|
||||
@@ -753,64 +753,146 @@ button {
|
||||
animation: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Richer background */
|
||||
.dashboard-page {
|
||||
background: linear-gradient(180deg, #a8d2ee 0%, #d8ecf9 100%) !important;
|
||||
}
|
||||
.dashboard-page .panel,
|
||||
.dashboard-page .tile {
|
||||
backdrop-filter: none !important;
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border: 1px solid rgba(198, 218, 235, 0.9) !important;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 40% at 15% 0%, rgba(120,185,240,0.35) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 50% at 90% 100%, rgba(90,160,230,0.22) 0%, transparent 60%),
|
||||
linear-gradient(170deg, #deeffe 0%, #c8e4f8 45%, #b0d5f0 100%) !important;
|
||||
}
|
||||
|
||||
/* Header — keep dark, refine */
|
||||
.dashboard-page .header {
|
||||
background: rgba(10, 25, 41, 0.92) !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08) !important;
|
||||
}
|
||||
.dashboard-page .svc-credentials {
|
||||
background: rgba(220, 238, 252, 0.9) !important;
|
||||
border-color: rgba(180, 210, 235, 0.85) !important;
|
||||
background: rgba(8, 20, 38, 0.94) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.09) !important;
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,0.18) !important;
|
||||
}
|
||||
|
||||
/* Top info panel */
|
||||
.dashboard-page .panel {
|
||||
backdrop-filter: none !important;
|
||||
background: rgba(255, 255, 255, 0.82) !important;
|
||||
border: 1px solid rgba(185, 215, 238, 0.75) !important;
|
||||
box-shadow: 0 4px 24px rgba(15, 64, 103, 0.1) !important;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tile,
|
||||
.tile:hover,
|
||||
.made-by,
|
||||
.made-by:hover,
|
||||
|
||||
/* Admin intro banner */
|
||||
.dashboard-page .admin-intro {
|
||||
background: linear-gradient(135deg, #e8f5ff 0%, #d8ecfa 100%);
|
||||
border: 1px solid rgba(160, 210, 240, 0.7);
|
||||
color: #0e3d5f;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Rules banner */
|
||||
.dashboard-page .rules-banner {
|
||||
background: linear-gradient(135deg, #f0f8ff 0%, #e4f2fd 100%);
|
||||
border: 1px solid rgba(160, 210, 240, 0.6);
|
||||
}
|
||||
.dashboard-page .rules-pill {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(180, 215, 238, 0.7);
|
||||
color: #1e4a6a;
|
||||
}
|
||||
|
||||
/* Category chips */
|
||||
.dashboard-page .category-chip {
|
||||
background: rgba(255, 255, 255, 0.75) !important;
|
||||
border: 1px solid rgba(160, 205, 235, 0.75) !important;
|
||||
color: #1a4a6e !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dashboard-page .category-chip.active {
|
||||
background: linear-gradient(135deg, #1675b4 0%, #0f5b94 100%) !important;
|
||||
border-color: transparent !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 2px 8px rgba(15, 91, 148, 0.35);
|
||||
}
|
||||
|
||||
/* Session notice */
|
||||
.dashboard-page .session-notice {
|
||||
background: #e4f3ff;
|
||||
border-color: #aad4f0;
|
||||
color: #174768;
|
||||
}
|
||||
|
||||
/* Service tiles — clean white cards with depth */
|
||||
.dashboard-page .tile {
|
||||
backdrop-filter: none !important;
|
||||
background: #ffffff !important;
|
||||
border: 1px solid rgba(185, 215, 238, 0.7) !important;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(15, 64, 103, 0.06),
|
||||
0 6px 20px rgba(15, 64, 103, 0.09) !important;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease !important;
|
||||
}
|
||||
|
||||
.dashboard-page .tile-wrap:hover .tile {
|
||||
transform: translateY(-3px) !important;
|
||||
border-color: rgba(42, 130, 210, 0.45) !important;
|
||||
box-shadow:
|
||||
0 2px 6px rgba(15, 64, 103, 0.08),
|
||||
0 12px 32px rgba(15, 64, 103, 0.16) !important;
|
||||
}
|
||||
|
||||
/* Icon box */
|
||||
.dashboard-page .tile-icon-box {
|
||||
background: #f2f8fd !important;
|
||||
border-color: rgba(185, 215, 238, 0.65) !important;
|
||||
}
|
||||
|
||||
/* Credentials block */
|
||||
.dashboard-page .svc-credentials {
|
||||
background: linear-gradient(135deg, #edf6ff 0%, #e2f0fb 100%) !important;
|
||||
border-color: rgba(180, 215, 240, 0.8) !important;
|
||||
}
|
||||
|
||||
/* Category badges on tile */
|
||||
.dashboard-page .service-cat-badge {
|
||||
background: #edf5fc;
|
||||
border-color: rgba(175, 210, 235, 0.8);
|
||||
color: #1e4a6e;
|
||||
}
|
||||
|
||||
/* Made by footer */
|
||||
.dashboard-page .made-by {
|
||||
color: rgba(20, 60, 95, 0.38) !important;
|
||||
text-shadow: 0 1px 4px rgba(255,255,255,0.5) !important;
|
||||
font-family: "Segoe UI", sans-serif !important;
|
||||
font-size: 0.82rem !important;
|
||||
font-weight: 500 !important;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.dashboard-page .made-by:hover {
|
||||
color: rgba(15, 91, 148, 0.65) !important;
|
||||
}
|
||||
|
||||
/* Suppress transitions on non-tile elements */
|
||||
.category-chip,
|
||||
.category-chip.active,
|
||||
.btn-link,
|
||||
.btn-link.secondary,
|
||||
button {
|
||||
transition: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.tile:hover {
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06) !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
/* 4-up desktop grid with adaptive breakpoints */
|
||||
/* 4-up desktop grid */
|
||||
.service-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.service-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.service-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 1050px) {
|
||||
.service-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.service-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.service-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.service-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ========== Login page redesign ========== */
|
||||
@@ -832,7 +914,7 @@ button {
|
||||
|
||||
/* LEFT PANEL */
|
||||
.login-left {
|
||||
flex: 0 0 33.333%;
|
||||
flex: 0 0 25%;
|
||||
background: linear-gradient(150deg, #050d1a 0%, #091829 55%, #0e2344 100%);
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -918,6 +1000,29 @@ button {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
.login-distrib-btn {
|
||||
margin-top: 2.5rem;
|
||||
padding: 0.78rem 1.5rem;
|
||||
border: 1px solid rgba(255,255,255,0.28);
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
color: rgba(220,238,255,0.9);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
letter-spacing: 0.03em;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
.login-distrib-btn:hover {
|
||||
background: rgba(255,255,255,0.13);
|
||||
border-color: rgba(255,255,255,0.45);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* RIGHT PANEL */
|
||||
.login-right {
|
||||
flex: 1;
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<ul class="login-features">
|
||||
<li class="login-feature">
|
||||
<span class="login-feature-icon">🖥</span>
|
||||
<span>Рабочие столы через RDP</span>
|
||||
<span>Доступ к рабочим столам ОС</span>
|
||||
</li>
|
||||
<li class="login-feature">
|
||||
<span class="login-feature-icon">🌐</span>
|
||||
@@ -50,6 +50,7 @@
|
||||
<span>Защищённый контур</span>
|
||||
</li>
|
||||
</ul>
|
||||
<button type="button" class="login-distrib-btn" onclick="window.open('https://maps.4mont.ru','_blank')">Продукты нашей дистрибуции</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="login-right">
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pytest==8.3.5
|
||||
httpx==0.28.1
|
||||
pytest-asyncio==0.24.0
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Smoke-tests: проверяем что все ключевые роуты не падают с NameError/ImportError.
|
||||
Не проверяем бизнес-логику — только что страницы отдают ответ.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Публичные страницы ──────────────────────────────────────────────────────
|
||||
|
||||
def test_index_anonymous(client):
|
||||
"""Главная без авторизации — либо страница сервисов, либо логин."""
|
||||
r = client.get("/", follow_redirects=True)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_login_form_on_index(client):
|
||||
"""Форма логина рендерится на /."""
|
||||
r = client.get("/", follow_redirects=True)
|
||||
assert r.status_code == 200
|
||||
assert "csrf" in r.text.lower() or "login" in r.text.lower() or "пароль" in r.text.lower()
|
||||
|
||||
|
||||
def _get_csrf(client):
|
||||
from conftest import _extract_csrf
|
||||
return _extract_csrf(client)
|
||||
|
||||
|
||||
def test_login_wrong_password(client):
|
||||
csrf = _get_csrf(client)
|
||||
r = client.post("/login", data={
|
||||
"username": "admin",
|
||||
"password": "wrongpass",
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert r.status_code in (200, 401)
|
||||
|
||||
|
||||
def test_login_csrf_fail(client):
|
||||
r = client.post("/login", data={
|
||||
"username": "admin",
|
||||
"password": "testpass123",
|
||||
"csrf_token": "bad-token",
|
||||
})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_login_no_such_method(client):
|
||||
r = client.get("/login")
|
||||
assert r.status_code in (200, 405) # только документируем поведение
|
||||
|
||||
|
||||
# ── Авторизованные страницы ─────────────────────────────────────────────────
|
||||
|
||||
def test_login_success(auth_client):
|
||||
r = auth_client.get("/", follow_redirects=True)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_admin_page(auth_client):
|
||||
r = auth_client.get("/admin")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_admin_requires_auth(client):
|
||||
from fastapi.testclient import TestClient
|
||||
import main as app_module
|
||||
fresh = TestClient(app_module.app, raise_server_exceptions=False)
|
||||
r = fresh.get("/admin", follow_redirects=False)
|
||||
assert r.status_code in (302, 303, 401, 403)
|
||||
|
||||
|
||||
# ── API роуты ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_api_services_list(auth_client):
|
||||
r = auth_client.get("/api/admin/services")
|
||||
assert r.status_code in (200, 404, 405)
|
||||
|
||||
|
||||
def test_api_users_list(auth_client):
|
||||
r = auth_client.get("/api/admin/users")
|
||||
assert r.status_code in (200, 404, 405)
|
||||
|
||||
|
||||
def test_api_categories(auth_client):
|
||||
r = auth_client.get("/api/admin/categories")
|
||||
assert r.status_code in (200, 404, 405)
|
||||
|
||||
|
||||
# ── Несуществующие роуты ────────────────────────────────────────────────────
|
||||
|
||||
def test_404(client):
|
||||
r = client.get("/this-does-not-exist-xyz")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_session_unknown(auth_client):
|
||||
r = auth_client.get("/s/00000000-0000-0000-0000-000000000000/")
|
||||
assert r.status_code in (200, 302, 303, 404)
|
||||
|
||||
|
||||
def test_go_unknown_slug(auth_client):
|
||||
r = auth_client.get("/go/nonexistent-service-slug", follow_redirects=False)
|
||||
assert r.status_code in (302, 303, 404)
|
||||
Reference in New Issue
Block a user