From 3a54a1e5faeeb0771595a31ca89254b135cd8f80 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 23:27:06 +0200 Subject: [PATCH 1/6] kin: BATON-006-frontend_dev --- nginx/baton.conf | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/nginx/baton.conf b/nginx/baton.conf index e1e1854..c9d892a 100644 --- a/nginx/baton.conf +++ b/nginx/baton.conf @@ -55,16 +55,32 @@ server { # Заголовки X-Telegram-Bot-Api-Secret-Token НЕ логируются — # они передаются только в proxy_pass и не попадают в access_log. - location / { + + # API → FastAPI + location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # Таймауты для webhook-запросов от Telegram proxy_read_timeout 30s; proxy_send_timeout 30s; proxy_connect_timeout 5s; } + + # Health → FastAPI + location /health { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Статика фронтенда (SPA) + location / { + root /opt/baton/frontend; + try_files $uri /index.html; + } } From 98063595f8ba93f82a1709550f993e6bc88f017c Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 23:31:26 +0200 Subject: [PATCH 2/6] =?UTF-8?q?kin:=20BATON-006=20=D0=BD=D0=B5=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20=D1=84=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D1=82:=20{'detail':'Not=20Found'}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_baton_006.py | 235 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tests/test_baton_006.py diff --git a/tests/test_baton_006.py b/tests/test_baton_006.py new file mode 100644 index 0000000..72ec197 --- /dev/null +++ b/tests/test_baton_006.py @@ -0,0 +1,235 @@ +""" +Tests for BATON-006: не работает фронт — {"detail":"Not Found"} + +Acceptance criteria: +1. nginx/baton.conf содержит location /api/ (prefix match), проксирует на FastAPI. +2. nginx/baton.conf содержит location /health, проксирует на FastAPI. +3. nginx/baton.conf содержит location / с root и try_files (SPA-поведение). +4. GET / на FastAPI возвращает 404 (маршрут / не зарегистрирован в main.py — + статику должен отдавать nginx, а не FastAPI). +5. GET /health возвращает 200 (FastAPI-маршрут работает). +6. POST /api/register возвращает 200 (FastAPI-маршрут не сломан). +7. POST /api/signal возвращает 200 (FastAPI-маршрут не сломан). +8. POST /api/webhook/telegram возвращает 200 с корректным секретом. +""" +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("BOT_TOKEN", "test-bot-token") +os.environ.setdefault("CHAT_ID", "-1001234567890") +os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") +os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") +os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") + +import pytest + +from tests.conftest import make_app_client + +PROJECT_ROOT = Path(__file__).parent.parent +NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf" + +# --------------------------------------------------------------------------- +# Criterion 1 — location /api/ proxies to FastAPI +# --------------------------------------------------------------------------- + + +def test_nginx_conf_exists() -> None: + """nginx/baton.conf должен существовать в репозитории.""" + assert NGINX_CONF.is_file(), f"nginx/baton.conf не найден: {NGINX_CONF}" + + +def test_nginx_conf_has_api_location_block() -> None: + """nginx/baton.conf должен содержать location /api/ (prefix match).""" + content = NGINX_CONF.read_text(encoding="utf-8") + assert re.search(r"location\s+/api/", content), ( + "nginx/baton.conf не содержит блок location /api/" + ) + + +def test_nginx_conf_api_location_proxies_to_fastapi() -> None: + """Блок location /api/ должен делать proxy_pass на 127.0.0.1:8000.""" + content = NGINX_CONF.read_text(encoding="utf-8") + # Ищем блок api и proxy_pass внутри + api_block = re.search( + r"location\s+/api/\s*\{([^}]+)\}", content, re.DOTALL + ) + assert api_block is not None, "Блок location /api/ { ... } не найден" + assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", api_block.group(1)), ( + "Блок location /api/ не содержит proxy_pass http://127.0.0.1:8000" + ) + + +# --------------------------------------------------------------------------- +# Criterion 2 — location /health proxies to FastAPI +# --------------------------------------------------------------------------- + + +def test_nginx_conf_has_health_location_block() -> None: + """nginx/baton.conf должен содержать отдельный location /health.""" + content = NGINX_CONF.read_text(encoding="utf-8") + assert re.search(r"location\s+/health\b", content), ( + "nginx/baton.conf не содержит блок location /health" + ) + + +def test_nginx_conf_health_location_proxies_to_fastapi() -> None: + """Блок location /health должен делать proxy_pass на 127.0.0.1:8000.""" + content = NGINX_CONF.read_text(encoding="utf-8") + health_block = re.search( + r"location\s+/health\s*\{([^}]+)\}", content, re.DOTALL + ) + assert health_block is not None, "Блок location /health { ... } не найден" + assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", health_block.group(1)), ( + "Блок location /health не содержит proxy_pass http://127.0.0.1:8000" + ) + + +# --------------------------------------------------------------------------- +# Criterion 3 — location / serves static files (SPA) +# --------------------------------------------------------------------------- + + +def test_nginx_conf_root_location_has_root_directive() -> None: + """location / в nginx.conf должен содержать директиву root (статика).""" + content = NGINX_CONF.read_text(encoding="utf-8") + # Ищем последний блок location / (не /api/, не /health) + root_block = re.search( + r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL + ) + assert root_block is not None, "Блок location / { ... } не найден" + assert re.search(r"root\s+", root_block.group(1)), ( + "Блок location / не содержит директиву root — SPA статика не настроена" + ) + + +def test_nginx_conf_root_location_has_try_files_for_spa() -> None: + """location / должен содержать try_files с fallback на /index.html (SPA).""" + content = NGINX_CONF.read_text(encoding="utf-8") + root_block = re.search( + r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL + ) + assert root_block is not None, "Блок location / { ... } не найден" + assert re.search(r"try_files\s+\$uri\s+/index\.html", root_block.group(1)), ( + "Блок location / не содержит try_files $uri /index.html — " + "SPA-роутинг не работает" + ) + + +def test_nginx_conf_root_location_does_not_proxy_to_fastapi() -> None: + """location / НЕ должен делать proxy_pass на FastAPI (только статика).""" + content = NGINX_CONF.read_text(encoding="utf-8") + root_block = re.search( + r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL + ) + assert root_block is not None, "Блок location / { ... } не найден" + assert not re.search(r"proxy_pass", root_block.group(1)), ( + "Блок location / содержит proxy_pass — GET / будет проксирован в FastAPI, " + "что вернёт 404 {'detail':'Not Found'} (исходная ошибка BATON-006)" + ) + + +# --------------------------------------------------------------------------- +# Criterion 4 — FastAPI не имеет маршрута GET / +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fastapi_root_returns_404() -> None: + """GET / должен возвращать 404 от FastAPI — маршрут не зарегистрирован. + + Это ожидаемое поведение: статику отдаёт nginx (location / с root + try_files), + а не FastAPI. Регрессия: если когда-нибудь GET / начнёт возвращать 200 от FastAPI, + это нарушит архитектуру (FastAPI не должен отдавать статику). + """ + async with make_app_client() as client: + response = await client.get("/") + + assert response.status_code == 404, ( + f"GET / должен возвращать 404 от FastAPI (статику отдаёт nginx). " + f"Получено: {response.status_code}" + ) + + +# --------------------------------------------------------------------------- +# Criterion 5 — GET /health работает +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_health_endpoint_returns_200() -> None: + """GET /health должен возвращать 200 после изменений nginx-конфига.""" + async with make_app_client() as client: + response = await client.get("/health") + + assert response.status_code == 200 + assert response.json().get("status") == "ok" + + +# --------------------------------------------------------------------------- +# Criterion 6 — POST /api/register не сломан +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_api_register_not_broken_after_nginx_change() -> None: + """POST /api/register должен вернуть 200 — функция не сломана изменением nginx.""" + async with make_app_client() as client: + response = await client.post( + "/api/register", + json={"uuid": "baton-006-uuid-001", "name": "TestUser"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["user_id"] > 0 + assert data["uuid"] == "baton-006-uuid-001" + + +# --------------------------------------------------------------------------- +# Criterion 7 — POST /api/signal не сломан +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_api_signal_not_broken_after_nginx_change() -> None: + """POST /api/signal должен вернуть 200 — функция не сломана изменением nginx.""" + async with make_app_client() as client: + # Сначала регистрируем пользователя + await client.post( + "/api/register", + json={"uuid": "baton-006-uuid-002", "name": "SignalUser"}, + ) + # Отправляем сигнал + response = await client.post( + "/api/signal", + json={ + "user_id": "baton-006-uuid-002", + "timestamp": 1700000000000, + "geo": None, + }, + ) + + assert response.status_code == 200 + assert response.json().get("status") == "ok" + + +# --------------------------------------------------------------------------- +# Criterion 8 — POST /api/webhook/telegram не сломан +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_api_webhook_telegram_not_broken_after_nginx_change() -> None: + """POST /api/webhook/telegram с корректным секретом должен вернуть 200.""" + async with make_app_client() as client: + response = await client.post( + "/api/webhook/telegram", + json={"update_id": 200, "message": {"text": "hello"}}, + headers={"X-Telegram-Bot-Api-Secret-Token": "test-webhook-secret"}, + ) + + assert response.status_code == 200 + assert response.json() == {"ok": True} From bd37560ef56695e02f1584be43c6f287b0b3443e Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 23:39:28 +0200 Subject: [PATCH 3/6] kin: BATON-005-backend_dev --- backend/config.py | 1 + backend/db.py | 132 ++++++++++++++++++++++++++++++++++++++++-- backend/main.py | 60 ++++++++++++++++++- backend/middleware.py | 15 ++++- backend/models.py | 14 +++++ deploy/env.template | 3 + tests/conftest.py | 1 + 7 files changed, 219 insertions(+), 7 deletions(-) diff --git a/backend/config.py b/backend/config.py index af4d933..40159b0 100644 --- a/backend/config.py +++ b/backend/config.py @@ -21,3 +21,4 @@ WEBHOOK_URL: str = _require("WEBHOOK_URL") WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true" FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000") APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping +ADMIN_TOKEN: str = _require("ADMIN_TOKEN") diff --git a/backend/db.py b/backend/db.py index e52a95f..e0aca18 100644 --- a/backend/db.py +++ b/backend/db.py @@ -24,10 +24,12 @@ async def init_db() -> None: async with _get_conn() as conn: await conn.executescript(""" CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - created_at TEXT DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + is_blocked INTEGER NOT NULL DEFAULT 0, + password_hash TEXT DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS signals ( @@ -58,6 +60,16 @@ async def init_db() -> None: CREATE INDEX IF NOT EXISTS idx_batches_status ON telegram_batches(status); """) + # Migrations for existing databases (silently ignore if columns already exist) + for stmt in [ + "ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE users ADD COLUMN password_hash TEXT DEFAULT NULL", + ]: + try: + await conn.execute(stmt) + await conn.commit() + except Exception: + pass # Column already exists await conn.commit() @@ -104,6 +116,118 @@ async def get_user_name(uuid: str) -> Optional[str]: return row["name"] if row else None +async def is_user_blocked(uuid: str) -> bool: + async with _get_conn() as conn: + async with conn.execute( + "SELECT is_blocked FROM users WHERE uuid = ?", (uuid,) + ) as cur: + row = await cur.fetchone() + return bool(row["is_blocked"]) if row else False + + +async def admin_list_users() -> list[dict]: + async with _get_conn() as conn: + async with conn.execute( + "SELECT id, uuid, name, is_blocked, created_at FROM users ORDER BY id" + ) as cur: + rows = await cur.fetchall() + return [ + { + "id": row["id"], + "uuid": row["uuid"], + "name": row["name"], + "is_blocked": bool(row["is_blocked"]), + "created_at": row["created_at"], + } + for row in rows + ] + + +async def admin_get_user_by_id(user_id: int) -> Optional[dict]: + async with _get_conn() as conn: + async with conn.execute( + "SELECT id, uuid, name, is_blocked, created_at FROM users WHERE id = ?", + (user_id,), + ) as cur: + row = await cur.fetchone() + if row is None: + return None + return { + "id": row["id"], + "uuid": row["uuid"], + "name": row["name"], + "is_blocked": bool(row["is_blocked"]), + "created_at": row["created_at"], + } + + +async def admin_create_user( + uuid: str, name: str, password_hash: Optional[str] = None +) -> Optional[dict]: + """Returns None if UUID already exists.""" + async with _get_conn() as conn: + try: + async with conn.execute( + "INSERT INTO users (uuid, name, password_hash) VALUES (?, ?, ?)", + (uuid, name, password_hash), + ) as cur: + new_id = cur.lastrowid + except Exception: + return None # UNIQUE constraint violation — UUID already exists + await conn.commit() + async with conn.execute( + "SELECT id, uuid, name, is_blocked, created_at FROM users WHERE id = ?", + (new_id,), + ) as cur: + row = await cur.fetchone() + return { + "id": row["id"], + "uuid": row["uuid"], + "name": row["name"], + "is_blocked": bool(row["is_blocked"]), + "created_at": row["created_at"], + } + + +async def admin_set_password(user_id: int, password_hash: str) -> bool: + async with _get_conn() as conn: + async with conn.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + (password_hash, user_id), + ) as cur: + changed = cur.rowcount > 0 + await conn.commit() + return changed + + +async def admin_set_blocked(user_id: int, is_blocked: bool) -> bool: + async with _get_conn() as conn: + async with conn.execute( + "UPDATE users SET is_blocked = ? WHERE id = ?", + (1 if is_blocked else 0, user_id), + ) as cur: + changed = cur.rowcount > 0 + await conn.commit() + return changed + + +async def admin_delete_user(user_id: int) -> bool: + async with _get_conn() as conn: + # Delete signals first (no FK cascade in SQLite by default) + async with conn.execute( + "DELETE FROM signals WHERE user_uuid = (SELECT uuid FROM users WHERE id = ?)", + (user_id,), + ): + pass + async with conn.execute( + "DELETE FROM users WHERE id = ?", + (user_id,), + ) as cur: + changed = cur.rowcount > 0 + await conn.commit() + return changed + + async def save_telegram_batch( message_text: str, signals_count: int, diff --git a/backend/main.py b/backend/main.py index 025d69d..b7388cd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,20 +1,25 @@ from __future__ import annotations import asyncio +import hashlib import logging +import os import time from contextlib import asynccontextmanager from datetime import datetime, timezone from typing import Any import httpx -from fastapi import Depends, FastAPI, Request +from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from backend import config, db, telegram -from backend.middleware import rate_limit_register, verify_webhook_secret +from backend.middleware import rate_limit_register, verify_admin_token, verify_webhook_secret from backend.models import ( + AdminBlockRequest, + AdminCreateUserRequest, + AdminSetPasswordRequest, RegisterRequest, RegisterResponse, SignalRequest, @@ -24,6 +29,16 @@ from backend.models import ( logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) + +def _hash_password(password: str) -> str: + """Hash a password using PBKDF2-HMAC-SHA256 (stdlib, no external deps). + + Stored format: ``:`` + """ + salt = os.urandom(16) + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000) + return f"{salt.hex()}:{dk.hex()}" + # aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004) _KEEPALIVE_INTERVAL = 600 # 10 минут @@ -108,6 +123,9 @@ async def register(body: RegisterRequest, _: None = Depends(rate_limit_register) @app.post("/api/signal", response_model=SignalResponse) async def signal(body: SignalRequest) -> SignalResponse: + if await db.is_user_blocked(body.user_id): + raise HTTPException(status_code=403, detail="User is blocked") + geo = body.geo lat = geo.lat if geo else None lon = geo.lon if geo else None @@ -139,6 +157,44 @@ async def signal(body: SignalRequest) -> SignalResponse: return SignalResponse(status="ok", signal_id=signal_id) +@app.get("/admin/users", dependencies=[Depends(verify_admin_token)]) +async def admin_list_users() -> list[dict]: + return await db.admin_list_users() + + +@app.post("/admin/users", status_code=201, dependencies=[Depends(verify_admin_token)]) +async def admin_create_user(body: AdminCreateUserRequest) -> dict: + password_hash = _hash_password(body.password) if body.password else None + result = await db.admin_create_user(body.uuid, body.name, password_hash) + if result is None: + raise HTTPException(status_code=409, detail="User with this UUID already exists") + return result + + +@app.put("/admin/users/{user_id}/password", dependencies=[Depends(verify_admin_token)]) +async def admin_set_password(user_id: int, body: AdminSetPasswordRequest) -> dict: + changed = await db.admin_set_password(user_id, _hash_password(body.password)) + if not changed: + raise HTTPException(status_code=404, detail="User not found") + return {"ok": True} + + +@app.put("/admin/users/{user_id}/block", dependencies=[Depends(verify_admin_token)]) +async def admin_block_user(user_id: int, body: AdminBlockRequest) -> dict: + changed = await db.admin_set_blocked(user_id, body.is_blocked) + if not changed: + raise HTTPException(status_code=404, detail="User not found") + user = await db.admin_get_user_by_id(user_id) + return user # type: ignore[return-value] + + +@app.delete("/admin/users/{user_id}", status_code=204, dependencies=[Depends(verify_admin_token)]) +async def admin_delete_user(user_id: int) -> None: + deleted = await db.admin_delete_user(user_id) + if not deleted: + raise HTTPException(status_code=404, detail="User not found") + + @app.post("/api/webhook/telegram") async def webhook_telegram( request: Request, diff --git a/backend/middleware.py b/backend/middleware.py index 34d913e..a384c84 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -2,11 +2,15 @@ from __future__ import annotations import secrets import time +from typing import Optional -from fastapi import Header, HTTPException, Request +from fastapi import Depends, Header, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend import config +_bearer = HTTPBearer(auto_error=False) + _RATE_LIMIT = 5 _RATE_WINDOW = 600 # 10 minutes @@ -20,6 +24,15 @@ async def verify_webhook_secret( raise HTTPException(status_code=403, detail="Forbidden") +async def verify_admin_token( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), +) -> None: + if credentials is None or not secrets.compare_digest( + credentials.credentials, config.ADMIN_TOKEN + ): + raise HTTPException(status_code=401, detail="Unauthorized") + + async def rate_limit_register(request: Request) -> None: counters = request.app.state.rate_counters client_ip = request.client.host if request.client else "unknown" diff --git a/backend/models.py b/backend/models.py index 68265de..b89e884 100644 --- a/backend/models.py +++ b/backend/models.py @@ -29,3 +29,17 @@ class SignalRequest(BaseModel): class SignalResponse(BaseModel): status: str signal_id: int + + +class AdminCreateUserRequest(BaseModel): + uuid: str = Field(..., min_length=1) + name: str = Field(..., min_length=1, max_length=100) + password: Optional[str] = None + + +class AdminSetPasswordRequest(BaseModel): + password: str = Field(..., min_length=1) + + +class AdminBlockRequest(BaseModel): + is_blocked: bool diff --git a/deploy/env.template b/deploy/env.template index abc87f3..ec9d2ef 100644 --- a/deploy/env.template +++ b/deploy/env.template @@ -17,3 +17,6 @@ WEBHOOK_ENABLED=true FRONTEND_ORIGIN=https://baton.itafrika.com APP_URL=https://baton.itafrika.com DB_PATH=/opt/baton/baton.db + +# Admin API token — случайная строка 32+ символа (сгенерировать: openssl rand -hex 32) +ADMIN_TOKEN= diff --git a/tests/conftest.py b/tests/conftest.py index 2604da8..24b0ff3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ os.environ.setdefault("CHAT_ID", "-1001234567890") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") +os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") # ── 2. aiosqlite monkey-patch ──────────────────────────────────────────────── import aiosqlite From 3e8e83481c280f2671f67e511d6b0d92d62d55f2 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 23:44:58 +0200 Subject: [PATCH 4/6] kin: BATON-005-frontend_dev --- frontend/admin.html | 379 ++++++++++++++++++++++++++++++++++++++++++++ frontend/admin.js | 333 ++++++++++++++++++++++++++++++++++++++ nginx/baton.conf | 13 ++ 3 files changed, 725 insertions(+) create mode 100644 frontend/admin.html create mode 100644 frontend/admin.js diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..8dab73f --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,379 @@ + + + + + + Baton — Admin + + + + + +
+ +
+ + +
+
+

Пользователи

+ + +
+ +
+ + +
+ + + + + + + + + + + + + + +
#ИмяUUIDСтатусСозданДействия
Загрузка…
+
+
+
+ + + + + + + + + + diff --git a/frontend/admin.js b/frontend/admin.js new file mode 100644 index 0000000..7e46b9e --- /dev/null +++ b/frontend/admin.js @@ -0,0 +1,333 @@ +'use strict'; + +// ========== Token (sessionStorage — cleared on browser close) ========== + +function _getToken() { + return sessionStorage.getItem('baton_admin_token') || ''; +} + +function _saveToken(t) { + sessionStorage.setItem('baton_admin_token', t); +} + +function _clearToken() { + sessionStorage.removeItem('baton_admin_token'); +} + +// ========== API wrapper ========== + +async function _api(method, path, body) { + const opts = { + method, + headers: { 'Authorization': 'Bearer ' + _getToken() }, + }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + + const res = await fetch(path, opts); + + if (res.status === 204) return null; + + const text = await res.text().catch(() => ''); + if (!res.ok) { + let detail = text; + try { detail = JSON.parse(text).detail || text; } catch (_) {} + throw new Error('HTTP ' + res.status + (detail ? ': ' + detail : '')); + } + + try { return JSON.parse(text); } catch (_) { return null; } +} + +// ========== UI helpers ========== + +function _esc(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function _setError(id, msg) { + const el = document.getElementById(id); + el.textContent = msg; + el.hidden = !msg; +} + +function _showPanel() { + document.getElementById('screen-token').style.display = 'none'; + document.getElementById('screen-panel').classList.add('active'); +} + +function _showTokenScreen() { + document.getElementById('screen-panel').classList.remove('active'); + document.getElementById('screen-token').style.display = ''; + document.getElementById('token-input').value = ''; +} + +// ========== Users table ========== + +function _renderTable(users) { + const tbody = document.getElementById('users-tbody'); + tbody.innerHTML = ''; + + if (!users.length) { + const tr = document.createElement('tr'); + tr.className = 'empty-row'; + tr.innerHTML = 'Нет пользователей'; + tbody.appendChild(tr); + return; + } + + users.forEach((u) => { + const tr = document.createElement('tr'); + if (u.is_blocked) tr.classList.add('is-blocked'); + + const date = u.created_at ? u.created_at.slice(0, 16).replace('T', ' ') : '—'; + const uuidShort = u.uuid ? u.uuid.slice(0, 8) + '…' : '—'; + + tr.innerHTML = ` + ${u.id} + ${_esc(u.name)} + ${_esc(uuidShort)} + + + ${u.is_blocked ? 'Заблокирован' : 'Активен'} + + + ${_esc(date)} + + + + + + `; + tbody.appendChild(tr); + }); +} + +// ========== Load users ========== + +async function _loadUsers() { + _setError('panel-error', ''); + try { + const users = await _api('GET', '/admin/users'); + _renderTable(users); + } catch (err) { + _setError('panel-error', err.message); + } +} + +// ========== Login / Logout ========== + +async function _handleLogin() { + const input = document.getElementById('token-input'); + const btn = document.getElementById('btn-login'); + const token = input.value.trim(); + if (!token) return; + + btn.disabled = true; + _setError('login-error', ''); + _saveToken(token); + + try { + const users = await _api('GET', '/admin/users'); + _renderTable(users); + _showPanel(); + } catch (err) { + _clearToken(); + const msg = err.message.includes('401') ? 'Неверный токен' : err.message; + _setError('login-error', msg); + btn.disabled = false; + } +} + +function _handleLogout() { + _clearToken(); + _showTokenScreen(); +} + +// ========== Table action dispatcher (event delegation) ========== + +async function _handleTableClick(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + + const { action, id, name, blocked } = btn.dataset; + + if (action === 'password') { + _openPasswordModal(id, name); + } else if (action === 'block') { + await _toggleBlock(id, blocked === '1'); + } else if (action === 'delete') { + await _handleDelete(id, name); + } +} + +// ========== Block / Unblock ========== + +async function _toggleBlock(userId, currentlyBlocked) { + _setError('panel-error', ''); + try { + await _api('PUT', `/admin/users/${userId}/block`, { is_blocked: !currentlyBlocked }); + await _loadUsers(); + } catch (err) { + _setError('panel-error', err.message); + } +} + +// ========== Delete ========== + +async function _handleDelete(userId, userName) { + if (!confirm(`Удалить пользователя "${userName}"?\n\nБудут удалены все его сигналы. Действие нельзя отменить.`)) return; + _setError('panel-error', ''); + try { + await _api('DELETE', `/admin/users/${userId}`); + await _loadUsers(); + } catch (err) { + _setError('panel-error', err.message); + } +} + +// ========== Password modal ========== + +function _openPasswordModal(userId, userName) { + document.getElementById('modal-pw-subtitle').textContent = `Пользователь: ${userName}`; + document.getElementById('modal-pw-user-id').value = userId; + document.getElementById('new-password').value = ''; + _setError('modal-pw-error', ''); + document.getElementById('btn-pw-save').disabled = false; + document.getElementById('modal-password').hidden = false; + document.getElementById('new-password').focus(); +} + +function _closePasswordModal() { + document.getElementById('modal-password').hidden = true; +} + +async function _handleSetPassword() { + const userId = document.getElementById('modal-pw-user-id').value; + const password = document.getElementById('new-password').value; + const btn = document.getElementById('btn-pw-save'); + + if (!password) { + _setError('modal-pw-error', 'Введите пароль'); + return; + } + + btn.disabled = true; + _setError('modal-pw-error', ''); + + try { + await _api('PUT', `/admin/users/${userId}/password`, { password }); + _closePasswordModal(); + } catch (err) { + _setError('modal-pw-error', err.message); + btn.disabled = false; + } +} + +// ========== Create user modal ========== + +function _openCreateModal() { + document.getElementById('create-uuid').value = crypto.randomUUID(); + document.getElementById('create-name').value = ''; + document.getElementById('create-password').value = ''; + _setError('create-error', ''); + document.getElementById('btn-create-submit').disabled = false; + document.getElementById('modal-create').hidden = false; + document.getElementById('create-name').focus(); +} + +function _closeCreateModal() { + document.getElementById('modal-create').hidden = true; +} + +async function _handleCreateUser() { + const uuid = document.getElementById('create-uuid').value.trim(); + const name = document.getElementById('create-name').value.trim(); + const password = document.getElementById('create-password').value; + const btn = document.getElementById('btn-create-submit'); + + if (!uuid || !name) { + _setError('create-error', 'UUID и имя обязательны'); + return; + } + + btn.disabled = true; + _setError('create-error', ''); + + const body = { uuid, name }; + if (password) body.password = password; + + try { + await _api('POST', '/admin/users', body); + _closeCreateModal(); + await _loadUsers(); + } catch (err) { + const msg = err.message.includes('409') ? 'Пользователь с таким UUID уже существует' : err.message; + _setError('create-error', msg); + btn.disabled = false; + } +} + +// ========== Init ========== + +function _init() { + // Login screen + document.getElementById('btn-login').addEventListener('click', _handleLogin); + document.getElementById('token-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') _handleLogin(); + }); + + // Panel + document.getElementById('btn-logout').addEventListener('click', _handleLogout); + document.getElementById('btn-create').addEventListener('click', _openCreateModal); + + // Table (event delegation) + document.getElementById('users-table').addEventListener('click', _handleTableClick); + + // Password modal + document.getElementById('btn-pw-cancel').addEventListener('click', _closePasswordModal); + document.getElementById('btn-pw-save').addEventListener('click', _handleSetPassword); + document.getElementById('new-password').addEventListener('keydown', (e) => { + if (e.key === 'Enter') _handleSetPassword(); + }); + document.getElementById('modal-password').addEventListener('click', (e) => { + if (e.target.id === 'modal-password') _closePasswordModal(); + }); + + // Create modal + document.getElementById('btn-create-cancel').addEventListener('click', _closeCreateModal); + document.getElementById('btn-create-submit').addEventListener('click', _handleCreateUser); + document.getElementById('create-password').addEventListener('keydown', (e) => { + if (e.key === 'Enter') _handleCreateUser(); + }); + document.getElementById('modal-create').addEventListener('click', (e) => { + if (e.target.id === 'modal-create') _closeCreateModal(); + }); + + // Auto-login if token is already saved in sessionStorage + if (_getToken()) { + _showPanel(); + _loadUsers().catch(() => { + _clearToken(); + _showTokenScreen(); + }); + } +} + +document.addEventListener('DOMContentLoaded', _init); diff --git a/nginx/baton.conf b/nginx/baton.conf index c9d892a..e148729 100644 --- a/nginx/baton.conf +++ b/nginx/baton.conf @@ -78,6 +78,19 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # Admin API → FastAPI (UI-страница /admin.html раздаётся статикой ниже) + location /admin/users { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 30s; + proxy_send_timeout 30s; + proxy_connect_timeout 5s; + } + # Статика фронтенда (SPA) location / { root /opt/baton/frontend; From a8d53fa47bf4b41e35d123e1f748bffe7cf2afcd Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 23:50:54 +0200 Subject: [PATCH 5/6] =?UTF-8?q?kin:=20BATON-005=20=D0=A1=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D1=83?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B7=D0=B0=D0=B2=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D1=81=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=BE=D0=B9=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D1=8F,=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_baton_005.py | 487 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 tests/test_baton_005.py diff --git a/tests/test_baton_005.py b/tests/test_baton_005.py new file mode 100644 index 0000000..1e43810 --- /dev/null +++ b/tests/test_baton_005.py @@ -0,0 +1,487 @@ +""" +Tests for BATON-005: Admin panel — user creation, password change, block/unblock, delete. + +Acceptance criteria: +1. Создание пользователя — пользователь появляется в БД (GET /admin/users) +2. Смена пароля — endpoint возвращает ok, 404 для несуществующего пользователя +3. Блокировка — заблокированный пользователь не может отправить сигнал (403) +4. Разблокировка — восстанавливает доступ (сигнал снова проходит) +5. Удаление — пользователь исчезает из GET /admin/users, возвращается 204 +6. Защита: неавторизованный запрос к /admin/* возвращает 401 +7. Отсутствие регрессии с основным функционалом +""" +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("BOT_TOKEN", "test-bot-token") +os.environ.setdefault("CHAT_ID", "-1001234567890") +os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") +os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") +os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") +os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") + +import pytest + +from tests.conftest import make_app_client + +PROJECT_ROOT = Path(__file__).parent.parent +NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf" + +ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"} +WRONG_HEADERS = {"Authorization": "Bearer wrong-token"} + + +# --------------------------------------------------------------------------- +# Criterion 6 — Unauthorised requests to /admin/* return 401 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_admin_list_users_without_token_returns_401() -> None: + """GET /admin/users без Authorization header должен вернуть 401.""" + async with make_app_client() as client: + resp = await client.get("/admin/users") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_admin_list_users_wrong_token_returns_401() -> None: + """GET /admin/users с неверным токеном должен вернуть 401.""" + async with make_app_client() as client: + resp = await client.get("/admin/users", headers=WRONG_HEADERS) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_admin_create_user_without_token_returns_401() -> None: + """POST /admin/users без токена должен вернуть 401.""" + async with make_app_client() as client: + resp = await client.post( + "/admin/users", + json={"uuid": "unauth-uuid-001", "name": "Ghost"}, + ) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_admin_set_password_without_token_returns_401() -> None: + """PUT /admin/users/1/password без токена должен вернуть 401.""" + async with make_app_client() as client: + resp = await client.put( + "/admin/users/1/password", + json={"password": "newpass"}, + ) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_admin_block_user_without_token_returns_401() -> None: + """PUT /admin/users/1/block без токена должен вернуть 401.""" + async with make_app_client() as client: + resp = await client.put( + "/admin/users/1/block", + json={"is_blocked": True}, + ) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_admin_delete_user_without_token_returns_401() -> None: + """DELETE /admin/users/1 без токена должен вернуть 401.""" + async with make_app_client() as client: + resp = await client.delete("/admin/users/1") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Criterion 1 — Create user: appears in DB +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_admin_create_user_returns_201_with_user_data() -> None: + """POST /admin/users с валидными данными должен вернуть 201 с полями пользователя.""" + async with make_app_client() as client: + resp = await client.post( + "/admin/users", + json={"uuid": "create-uuid-001", "name": "Alice Admin"}, + headers=ADMIN_HEADERS, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["uuid"] == "create-uuid-001" + assert data["name"] == "Alice Admin" + assert data["id"] > 0 + assert data["is_blocked"] is False + + +@pytest.mark.asyncio +async def test_admin_create_user_appears_in_list() -> None: + """После POST /admin/users пользователь появляется в GET /admin/users.""" + async with make_app_client() as client: + await client.post( + "/admin/users", + json={"uuid": "create-uuid-002", "name": "Bob Admin"}, + headers=ADMIN_HEADERS, + ) + resp = await client.get("/admin/users", headers=ADMIN_HEADERS) + + assert resp.status_code == 200 + users = resp.json() + uuids = [u["uuid"] for u in users] + assert "create-uuid-002" in uuids + + +@pytest.mark.asyncio +async def test_admin_create_user_duplicate_uuid_returns_409() -> None: + """POST /admin/users с существующим UUID должен вернуть 409.""" + async with make_app_client() as client: + await client.post( + "/admin/users", + json={"uuid": "create-uuid-003", "name": "Carol"}, + headers=ADMIN_HEADERS, + ) + resp = await client.post( + "/admin/users", + json={"uuid": "create-uuid-003", "name": "Carol Duplicate"}, + headers=ADMIN_HEADERS, + ) + assert resp.status_code == 409 + + +@pytest.mark.asyncio +async def test_admin_list_users_returns_200_with_list() -> None: + """GET /admin/users с правильным токеном должен вернуть 200 со списком.""" + async with make_app_client() as client: + resp = await client.get("/admin/users", headers=ADMIN_HEADERS) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + +# --------------------------------------------------------------------------- +# Criterion 2 — Password change +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_admin_set_password_returns_ok() -> None: + """PUT /admin/users/{id}/password для существующего пользователя возвращает {"ok": True}.""" + async with make_app_client() as client: + create_resp = await client.post( + "/admin/users", + json={"uuid": "pass-uuid-001", "name": "PassUser"}, + headers=ADMIN_HEADERS, + ) + user_id = create_resp.json()["id"] + + resp = await client.put( + f"/admin/users/{user_id}/password", + json={"password": "newpassword123"}, + headers=ADMIN_HEADERS, + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + + +@pytest.mark.asyncio +async def test_admin_set_password_nonexistent_user_returns_404() -> None: + """PUT /admin/users/99999/password для несуществующего пользователя возвращает 404.""" + async with make_app_client() as client: + resp = await client.put( + "/admin/users/99999/password", + json={"password": "somepassword"}, + headers=ADMIN_HEADERS, + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_admin_set_password_user_still_accessible_after_change() -> None: + """Пользователь остаётся доступен в GET /admin/users после смены пароля.""" + async with make_app_client() as client: + create_resp = await client.post( + "/admin/users", + json={"uuid": "pass-uuid-002", "name": "PassUser2"}, + headers=ADMIN_HEADERS, + ) + user_id = create_resp.json()["id"] + + await client.put( + f"/admin/users/{user_id}/password", + json={"password": "updatedpass"}, + headers=ADMIN_HEADERS, + ) + + list_resp = await client.get("/admin/users", headers=ADMIN_HEADERS) + + uuids = [u["uuid"] for u in list_resp.json()] + assert "pass-uuid-002" in uuids + + +# --------------------------------------------------------------------------- +# Criterion 3 — Block user: blocked user cannot send signal +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_admin_block_user_returns_is_blocked_true() -> None: + """PUT /admin/users/{id}/block с is_blocked=true должен вернуть пользователя с is_blocked=True.""" + async with make_app_client() as client: + create_resp = await client.post( + "/admin/users", + json={"uuid": "block-uuid-001", "name": "BlockUser"}, + headers=ADMIN_HEADERS, + ) + user_id = create_resp.json()["id"] + + resp = await client.put( + f"/admin/users/{user_id}/block", + json={"is_blocked": True}, + headers=ADMIN_HEADERS, + ) + assert resp.status_code == 200 + assert resp.json()["is_blocked"] is True + + +@pytest.mark.asyncio +async def test_admin_block_user_prevents_signal() -> None: + """Заблокированный пользователь не может отправить сигнал — /api/signal возвращает 403.""" + async with make_app_client() as client: + create_resp = await client.post( + "/admin/users", + json={"uuid": "block-uuid-002", "name": "BlockSignalUser"}, + headers=ADMIN_HEADERS, + ) + user_id = create_resp.json()["id"] + user_uuid = create_resp.json()["uuid"] + + await client.put( + f"/admin/users/{user_id}/block", + json={"is_blocked": True}, + headers=ADMIN_HEADERS, + ) + + signal_resp = await client.post( + "/api/signal", + json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, + ) + assert signal_resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_admin_block_nonexistent_user_returns_404() -> None: + """PUT /admin/users/99999/block для несуществующего пользователя возвращает 404.""" + async with make_app_client() as client: + resp = await client.put( + "/admin/users/99999/block", + json={"is_blocked": True}, + headers=ADMIN_HEADERS, + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Criterion 4 — Unblock user: restores access +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_admin_unblock_user_returns_is_blocked_false() -> None: + """PUT /admin/users/{id}/block с is_blocked=false должен вернуть пользователя с is_blocked=False.""" + async with make_app_client() as client: + create_resp = await client.post( + "/admin/users", + json={"uuid": "unblock-uuid-001", "name": "UnblockUser"}, + headers=ADMIN_HEADERS, + ) + user_id = create_resp.json()["id"] + + await client.put( + f"/admin/users/{user_id}/block", + json={"is_blocked": True}, + headers=ADMIN_HEADERS, + ) + + resp = await client.put( + f"/admin/users/{user_id}/block", + json={"is_blocked": False}, + headers=ADMIN_HEADERS, + ) + assert resp.status_code == 200 + assert resp.json()["is_blocked"] is False + + +@pytest.mark.asyncio +async def test_admin_unblock_user_restores_signal_access() -> None: + """После разблокировки пользователь снова может отправить сигнал (200).""" + async with make_app_client() as client: + create_resp = await client.post( + "/admin/users", + json={"uuid": "unblock-uuid-002", "name": "UnblockSignalUser"}, + headers=ADMIN_HEADERS, + ) + user_id = create_resp.json()["id"] + user_uuid = create_resp.json()["uuid"] + + # Блокируем + await client.put( + f"/admin/users/{user_id}/block", + json={"is_blocked": True}, + headers=ADMIN_HEADERS, + ) + + # Разблокируем + await client.put( + f"/admin/users/{user_id}/block", + json={"is_blocked": False}, + headers=ADMIN_HEADERS, + ) + + # Сигнал должен пройти + signal_resp = await client.post( + "/api/signal", + json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, + ) + assert signal_resp.status_code == 200 + assert signal_resp.json()["status"] == "ok" + + +# --------------------------------------------------------------------------- +# Criterion 5 — Delete user: disappears from DB +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_admin_delete_user_returns_204() -> None: + """DELETE /admin/users/{id} для существующего пользователя возвращает 204.""" + async with make_app_client() as client: + create_resp = await client.post( + "/admin/users", + json={"uuid": "delete-uuid-001", "name": "DeleteUser"}, + headers=ADMIN_HEADERS, + ) + user_id = create_resp.json()["id"] + + resp = await client.delete( + f"/admin/users/{user_id}", + headers=ADMIN_HEADERS, + ) + assert resp.status_code == 204 + + +@pytest.mark.asyncio +async def test_admin_delete_user_disappears_from_list() -> None: + """После DELETE /admin/users/{id} пользователь отсутствует в GET /admin/users.""" + async with make_app_client() as client: + create_resp = await client.post( + "/admin/users", + json={"uuid": "delete-uuid-002", "name": "DeleteUser2"}, + headers=ADMIN_HEADERS, + ) + user_id = create_resp.json()["id"] + + await client.delete( + f"/admin/users/{user_id}", + headers=ADMIN_HEADERS, + ) + + list_resp = await client.get("/admin/users", headers=ADMIN_HEADERS) + + uuids = [u["uuid"] for u in list_resp.json()] + assert "delete-uuid-002" not in uuids + + +@pytest.mark.asyncio +async def test_admin_delete_nonexistent_user_returns_404() -> None: + """DELETE /admin/users/99999 для несуществующего пользователя возвращает 404.""" + async with make_app_client() as client: + resp = await client.delete( + "/admin/users/99999", + headers=ADMIN_HEADERS, + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# nginx config — location /admin/users block (BATON-006 fix) +# --------------------------------------------------------------------------- + + +def test_nginx_conf_has_admin_users_location_block() -> None: + """nginx/baton.conf должен содержать блок location /admin/users.""" + content = NGINX_CONF.read_text(encoding="utf-8") + assert re.search(r"location\s+/admin/users\b", content), ( + "nginx/baton.conf не содержит блок location /admin/users — " + "запросы к admin API будут попадать в location / и возвращать 404" + ) + + +def test_nginx_conf_admin_users_location_proxies_to_fastapi() -> None: + """Блок location /admin/users должен делать proxy_pass на 127.0.0.1:8000.""" + content = NGINX_CONF.read_text(encoding="utf-8") + admin_block = re.search( + r"location\s+/admin/users\s*\{([^}]+)\}", content, re.DOTALL + ) + assert admin_block is not None, "Блок location /admin/users { ... } не найден" + assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", admin_block.group(1)), ( + "Блок location /admin/users не содержит proxy_pass http://127.0.0.1:8000" + ) + + +def test_nginx_conf_admin_users_location_before_root_location() -> None: + """location /admin/users должен находиться в nginx.conf до location / для корректного prefix-matching.""" + content = NGINX_CONF.read_text(encoding="utf-8") + admin_pos = content.find("location /admin/users") + root_pos = re.search(r"location\s+/\s*\{", content) + assert admin_pos != -1, "Блок location /admin/users не найден" + assert root_pos is not None, "Блок location / не найден" + assert admin_pos < root_pos.start(), ( + "location /admin/users должен быть определён ДО location / в nginx.conf" + ) + + +# --------------------------------------------------------------------------- +# Criterion 7 — No regression with main functionality +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_register_not_broken_after_admin_operations() -> None: + """POST /api/register работает корректно после выполнения admin-операций.""" + async with make_app_client() as client: + # Admin операции + await client.post( + "/admin/users", + json={"uuid": "regress-admin-uuid-001", "name": "AdminCreated"}, + headers=ADMIN_HEADERS, + ) + + # Основной функционал + resp = await client.post( + "/api/register", + json={"uuid": "regress-user-uuid-001", "name": "RegularUser"}, + ) + assert resp.status_code == 200 + assert resp.json()["uuid"] == "regress-user-uuid-001" + + +@pytest.mark.asyncio +async def test_signal_from_unblocked_user_succeeds() -> None: + """Незаблокированный пользователь, созданный через admin API, может отправить сигнал.""" + async with make_app_client() as client: + create_resp = await client.post( + "/admin/users", + json={"uuid": "regress-signal-uuid-001", "name": "SignalUser"}, + headers=ADMIN_HEADERS, + ) + user_uuid = create_resp.json()["uuid"] + + signal_resp = await client.post( + "/api/signal", + json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None}, + ) + assert signal_resp.status_code == 200 + assert signal_resp.json()["status"] == "ok" From 3483b71fcb5f4f350c9e0b4d4603b6a6fc2092be Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 07:18:56 +0200 Subject: [PATCH 6/6] fix: add /api/health alias endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /api/health as alias for /health — fixes frontend 404. Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/main.py b/backend/main.py index b7388cd..38207f0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -111,6 +111,7 @@ app.add_middleware( @app.get("/health") +@app.get("/api/health") async def health() -> dict[str, Any]: return {"status": "ok", "timestamp": int(time.time())}