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); }
|
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-scene,
|
||||||
.parallax-layer,
|
.parallax-layer,
|
||||||
.cloud-layer,
|
.cloud-layer,
|
||||||
@@ -753,64 +753,146 @@ button {
|
|||||||
animation: none !important;
|
animation: none !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Richer background */
|
||||||
.dashboard-page {
|
.dashboard-page {
|
||||||
background: linear-gradient(180deg, #a8d2ee 0%, #d8ecf9 100%) !important;
|
background:
|
||||||
}
|
radial-gradient(ellipse 80% 40% at 15% 0%, rgba(120,185,240,0.35) 0%, transparent 60%),
|
||||||
.dashboard-page .panel,
|
radial-gradient(ellipse 60% 50% at 90% 100%, rgba(90,160,230,0.22) 0%, transparent 60%),
|
||||||
.dashboard-page .tile {
|
linear-gradient(170deg, #deeffe 0%, #c8e4f8 45%, #b0d5f0 100%) !important;
|
||||||
backdrop-filter: none !important;
|
|
||||||
background: rgba(255, 255, 255, 0.9) !important;
|
|
||||||
border: 1px solid rgba(198, 218, 235, 0.9) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Header — keep dark, refine */
|
||||||
.dashboard-page .header {
|
.dashboard-page .header {
|
||||||
background: rgba(10, 25, 41, 0.92) !important;
|
background: rgba(8, 20, 38, 0.94) !important;
|
||||||
backdrop-filter: blur(8px) !important;
|
backdrop-filter: blur(12px) !important;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.08) !important;
|
border-bottom: 1px solid rgba(255,255,255,0.09) !important;
|
||||||
}
|
box-shadow: 0 1px 0 rgba(0,0,0,0.18) !important;
|
||||||
.dashboard-page .svc-credentials {
|
|
||||||
background: rgba(220, 238, 252, 0.9) !important;
|
|
||||||
border-color: rgba(180, 210, 235, 0.85) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Top info panel */
|
||||||
.dashboard-page .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%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.tile,
|
|
||||||
.tile:hover,
|
/* Admin intro banner */
|
||||||
.made-by,
|
.dashboard-page .admin-intro {
|
||||||
.made-by:hover,
|
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,
|
||||||
.category-chip.active,
|
.category-chip.active,
|
||||||
.btn-link,
|
.btn-link,
|
||||||
.btn-link.secondary,
|
.btn-link.secondary,
|
||||||
button {
|
button {
|
||||||
transition: none !important;
|
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 {
|
.service-grid {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 1400px) {
|
||||||
.service-grid {
|
.service-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@media (max-width: 1050px) {
|
@media (max-width: 1050px) {
|
||||||
.service-grid {
|
.service-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.service-grid {
|
.service-grid { grid-template-columns: 1fr; }
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== Login page redesign ========== */
|
/* ========== Login page redesign ========== */
|
||||||
@@ -832,7 +914,7 @@ button {
|
|||||||
|
|
||||||
/* LEFT PANEL */
|
/* LEFT PANEL */
|
||||||
.login-left {
|
.login-left {
|
||||||
flex: 0 0 33.333%;
|
flex: 0 0 25%;
|
||||||
background: linear-gradient(150deg, #050d1a 0%, #091829 55%, #0e2344 100%);
|
background: linear-gradient(150deg, #050d1a 0%, #091829 55%, #0e2344 100%);
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -918,6 +1000,29 @@ button {
|
|||||||
font-size: 0.9rem;
|
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 */
|
/* RIGHT PANEL */
|
||||||
.login-right {
|
.login-right {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<ul class="login-features">
|
<ul class="login-features">
|
||||||
<li class="login-feature">
|
<li class="login-feature">
|
||||||
<span class="login-feature-icon">🖥</span>
|
<span class="login-feature-icon">🖥</span>
|
||||||
<span>Рабочие столы через RDP</span>
|
<span>Доступ к рабочим столам ОС</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="login-feature">
|
<li class="login-feature">
|
||||||
<span class="login-feature-icon">🌐</span>
|
<span class="login-feature-icon">🌐</span>
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
<span>Защищённый контур</span>
|
<span>Защищённый контур</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<button type="button" class="login-distrib-btn" onclick="window.open('https://maps.4mont.ru','_blank')">Продукты нашей дистрибуции</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="login-right">
|
<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