Compare commits

...

12 commits

Author SHA1 Message Date
Gros Frumos
9a450d2a84 fix: add /api/health alias endpoint
Adds GET /api/health as alias for /health — fixes frontend 404.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 07:18:56 +02:00
Gros Frumos
fd60863e9c kin: BATON-005 Сделать админку для заведения пользователей со сменой пароля, блокировкой и удалением пользователей. 2026-03-20 23:50:54 +02:00
Gros Frumos
989074673a Merge branch 'BATON-005-frontend_dev' 2026-03-20 23:44:58 +02:00
Gros Frumos
8607a9f981 kin: BATON-005-frontend_dev 2026-03-20 23:44:58 +02:00
Gros Frumos
e547e1ce09 Merge branch 'BATON-005-backend_dev' 2026-03-20 23:39:28 +02:00
Gros Frumos
cb95c9928f kin: BATON-005-backend_dev 2026-03-20 23:39:28 +02:00
Gros Frumos
5fcfc3a76b kin: BATON-006 не работает фронт: {'detail':'Not Found'} 2026-03-20 23:31:26 +02:00
Gros Frumos
68a1c90541 Merge branch 'BATON-006-frontend_dev' 2026-03-20 23:27:06 +02:00
Gros Frumos
3cd7db11e7 kin: BATON-006-frontend_dev 2026-03-20 23:27:06 +02:00
Gros Frumos
7db8b849e0 fix: исправить RuntimeError в aiosqlite — _get_conn как async context manager
`async with await _get_conn()` запускал тред дважды: первый раз внутри
`_get_conn` через `await aiosqlite.connect()`, второй раз в `__aenter__`
через `await self`. Преобразован в `@asynccontextmanager` с `yield` и
`finally: conn.close()`. Все вызывающие места обновлены. Тест
`test_init_db_synchronous` обновлён под новый API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:16:12 +02:00
Gros Frumos
1c383191cc security: заменить реальный BOT_TOKEN на плейсхолдер в env.template
Добавить пример CHAT_ID в комментарий.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:40:53 +02:00
Gros Frumos
b70d5990c8 deploy: подготовить артефакты для деплоя на baton.itafrika.com
- nginx/baton.conf: заменить <YOUR_DOMAIN> на baton.itafrika.com
- deploy/baton.service: добавить systemd-юнит для uvicorn (/opt/baton, port 8000)
- deploy/baton-keepalive.service: прописать реальный URL health-эндпоинта
- deploy/env.template: шаблон .env для сервера (без секретов)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:32:05 +02:00
15 changed files with 1743 additions and 26 deletions

View file

@ -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")

View file

@ -1,28 +1,35 @@
from __future__ import annotations
from typing import Optional
from contextlib import asynccontextmanager
from typing import AsyncGenerator, Optional
import aiosqlite
from backend import config
async def _get_conn() -> aiosqlite.Connection:
@asynccontextmanager
async def _get_conn() -> AsyncGenerator[aiosqlite.Connection, None]:
conn = await aiosqlite.connect(config.DB_PATH)
await conn.execute("PRAGMA journal_mode=WAL")
await conn.execute("PRAGMA busy_timeout=5000")
await conn.execute("PRAGMA synchronous=NORMAL")
conn.row_factory = aiosqlite.Row
return conn
try:
yield conn
finally:
await conn.close()
async def init_db() -> None:
async with await _get_conn() as conn:
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 (
@ -53,11 +60,21 @@ 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()
async def register_user(uuid: str, name: str) -> dict:
async with await _get_conn() as conn:
async with _get_conn() as conn:
await conn.execute(
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
(uuid, name),
@ -77,7 +94,7 @@ async def save_signal(
lon: Optional[float],
accuracy: Optional[float],
) -> int:
async with await _get_conn() as conn:
async with _get_conn() as conn:
async with conn.execute(
"""
INSERT INTO signals (user_uuid, timestamp, lat, lon, accuracy)
@ -91,7 +108,7 @@ async def save_signal(
async def get_user_name(uuid: str) -> Optional[str]:
async with await _get_conn() as conn:
async with _get_conn() as conn:
async with conn.execute(
"SELECT name FROM users WHERE uuid = ?", (uuid,)
) as cur:
@ -99,12 +116,124 @@ 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,
signal_ids: list[int],
) -> int:
async with await _get_conn() as conn:
async with _get_conn() as conn:
async with conn.execute(
"""
INSERT INTO telegram_batches (message_text, sent_at, signals_count, status)

View file

@ -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_hex>:<dk_hex>``
"""
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 минут
@ -96,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())}
@ -108,6 +124,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 +158,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,

View file

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

View file

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

View file

@ -5,6 +5,6 @@ Description=Baton keep-alive ping
[Service]
Type=oneshot
# Замените URL на реальный адрес вашего приложения
ExecStart=curl -sf https://your-app.example.com/health
ExecStart=curl -sf https://baton.itafrika.com/health
StandardOutput=null
StandardError=journal

18
deploy/baton.service Normal file
View file

@ -0,0 +1,18 @@
[Unit]
Description=Baton — Telegram bot FastAPI backend
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/baton
EnvironmentFile=/opt/baton/.env
ExecStart=/opt/baton/venv/bin/uvicorn backend.main:app --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

22
deploy/env.template Normal file
View file

@ -0,0 +1,22 @@
# /opt/baton/.env — заполнить перед деплоем
# ВНИМАНИЕ: этот файл НЕ для git, только шаблон для ручного создания на сервере
# Telegram Bot — получить токен через @BotFather
BOT_TOKEN=YOUR_BOT_TOKEN_HERE
# Chat ID для уведомлений (example: CHAT_ID=5190015988)
CHAT_ID=
# Webhook secret — случайная строка 32+ символа (сгенерировать: openssl rand -hex 32)
WEBHOOK_SECRET=
# Webhook URL
WEBHOOK_URL=https://baton.itafrika.com/api/webhook/telegram
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=

379
frontend/admin.html Normal file
View file

@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Baton — Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #000000;
--bg2: #0d0d0d;
--text: #ffffff;
--muted: #9ca3af;
--input-bg: #1a1a1a;
--border: #374151;
--border-focus: #6b7280;
--btn-bg: #374151;
--btn-hover: #4b5563;
--danger: #991b1b;
--danger-hover: #7f1d1d;
--warn: #78350f;
--warn-hover: #92400e;
--success-bg: #14532d;
--blocked-row: #1c1008;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
min-height: 100vh;
}
/* ===== Token screen ===== */
#screen-token {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
.login-card {
width: 100%;
max-width: 360px;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px 28px;
display: flex;
flex-direction: column;
gap: 16px;
}
.login-title {
font-size: 22px;
font-weight: 700;
text-align: center;
}
.login-subtitle {
font-size: 13px;
color: var(--muted);
text-align: center;
margin-top: -8px;
}
/* ===== Panel screen ===== */
#screen-panel { display: none; flex-direction: column; min-height: 100vh; }
#screen-panel.active { display: flex; }
.panel-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panel-title {
font-size: 18px;
font-weight: 700;
flex: 1;
}
.panel-body {
flex: 1;
overflow: auto;
padding: 20px;
}
/* ===== Table ===== */
.users-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 600px;
}
thead tr {
background: var(--bg2);
}
th {
text-align: left;
padding: 10px 12px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #1f2937;
vertical-align: middle;
}
tr.is-blocked td { background: var(--blocked-row); }
tr:hover td { background: #111827; }
tr.is-blocked:hover td { background: #231508; }
.col-id { width: 50px; color: var(--muted); }
.col-uuid { max-width: 120px; font-family: monospace; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-date { white-space: nowrap; color: var(--muted); font-size: 12px; }
.col-actions { white-space: nowrap; }
.empty-row td {
text-align: center;
color: var(--muted);
padding: 32px;
}
/* ===== Badges ===== */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.badge--active { background: #14532d; color: #4ade80; }
.badge--blocked { background: #7f1d1d; color: #fca5a5; }
/* ===== Inputs ===== */
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px 14px;
background: var(--input-bg);
border: 1.5px solid var(--border);
border-radius: 10px;
color: var(--text);
font-size: 15px;
outline: none;
transition: border-color 0.15s;
-webkit-appearance: none;
}
input::placeholder { color: var(--muted); }
input:focus { border-color: var(--border-focus); }
/* ===== Buttons ===== */
.btn {
padding: 10px 18px;
background: var(--btn-bg);
border: none;
border-radius: 10px;
color: var(--text);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.btn:hover:not(:disabled) { background: var(--btn-hover); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn--full { width: 100%; }
.btn--danger { background: var(--danger); }
.btn--danger:hover:not(:disabled) { background: var(--danger-hover); }
/* Small inline buttons */
.btn-sm {
padding: 4px 10px;
background: var(--btn-bg);
border: none;
border-radius: 6px;
color: var(--text);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
margin-right: 4px;
}
.btn-sm:last-child { margin-right: 0; }
.btn-sm:hover { background: var(--btn-hover); }
.btn-sm--danger { background: var(--danger); }
.btn-sm--danger:hover { background: var(--danger-hover); }
.btn-sm--warn { background: var(--warn); }
.btn-sm--warn:hover { background: var(--warn-hover); }
/* ===== Error / info messages ===== */
.msg-error {
color: #f87171;
font-size: 13px;
text-align: center;
}
.msg-info {
color: var(--muted);
font-size: 13px;
padding: 12px 0;
}
/* ===== Modals ===== */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 100;
}
.modal-backdrop[hidden] { display: none; }
.modal-box {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px 24px;
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-title {
font-size: 16px;
font-weight: 700;
}
.modal-subtitle {
font-size: 13px;
color: var(--muted);
margin-top: -6px;
}
.modal-actions {
display: flex;
gap: 10px;
}
.modal-actions .btn { flex: 1; }
/* ===== Label ===== */
.field-label {
font-size: 12px;
color: var(--muted);
margin-bottom: 4px;
}
.field { display: flex; flex-direction: column; gap: 4px; }
</style>
</head>
<body>
<!-- ===== Token screen ===== -->
<div id="screen-token">
<div class="login-card">
<h1 class="login-title">Baton Admin</h1>
<p class="login-subtitle">Введите токен для доступа</p>
<input type="password" id="token-input" placeholder="Admin token" autocomplete="current-password">
<button type="button" id="btn-login" class="btn btn--full">Войти</button>
<p id="login-error" class="msg-error" hidden></p>
</div>
</div>
<!-- ===== Admin panel screen ===== -->
<div id="screen-panel">
<header class="panel-header">
<h1 class="panel-title">Пользователи</h1>
<button type="button" id="btn-create" class="btn">+ Создать</button>
<button type="button" id="btn-logout" class="btn">Выйти</button>
</header>
<div class="panel-body">
<p id="panel-error" class="msg-error" hidden></p>
<div class="users-wrap">
<table id="users-table">
<thead>
<tr>
<th class="col-id">#</th>
<th>Имя</th>
<th class="col-uuid">UUID</th>
<th>Статус</th>
<th class="col-date">Создан</th>
<th class="col-actions">Действия</th>
</tr>
</thead>
<tbody id="users-tbody">
<tr class="empty-row"><td colspan="6">Загрузка…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ===== Modal: change password ===== -->
<div id="modal-password" class="modal-backdrop" hidden>
<div class="modal-box">
<h2 class="modal-title">Сменить пароль</h2>
<p id="modal-pw-subtitle" class="modal-subtitle"></p>
<input type="hidden" id="modal-pw-user-id">
<div class="field">
<div class="field-label">Новый пароль</div>
<input type="password" id="new-password" placeholder="Минимум 1 символ" autocomplete="new-password">
</div>
<p id="modal-pw-error" class="msg-error" hidden></p>
<div class="modal-actions">
<button type="button" id="btn-pw-cancel" class="btn">Отмена</button>
<button type="button" id="btn-pw-save" class="btn">Сохранить</button>
</div>
</div>
</div>
<!-- ===== Modal: create user ===== -->
<div id="modal-create" class="modal-backdrop" hidden>
<div class="modal-box">
<h2 class="modal-title">Создать пользователя</h2>
<div class="field">
<div class="field-label">UUID</div>
<input type="text" id="create-uuid" autocomplete="off" spellcheck="false">
</div>
<div class="field">
<div class="field-label">Имя</div>
<input type="text" id="create-name" placeholder="Имя пользователя" autocomplete="off">
</div>
<div class="field">
<div class="field-label">Пароль (необязательно)</div>
<input type="password" id="create-password" placeholder="Оставьте пустым если не нужен" autocomplete="new-password">
</div>
<p id="create-error" class="msg-error" hidden></p>
<div class="modal-actions">
<button type="button" id="btn-create-cancel" class="btn">Отмена</button>
<button type="button" id="btn-create-submit" class="btn">Создать</button>
</div>
</div>
</div>
<script src="/admin.js"></script>
</body>
</html>

333
frontend/admin.js Normal file
View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 = '<td colspan="6">Нет пользователей</td>';
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 = `
<td class="col-id">${u.id}</td>
<td>${_esc(u.name)}</td>
<td class="col-uuid" title="${_esc(u.uuid)}">${_esc(uuidShort)}</td>
<td>
<span class="badge ${u.is_blocked ? 'badge--blocked' : 'badge--active'}">
${u.is_blocked ? 'Заблокирован' : 'Активен'}
</span>
</td>
<td class="col-date">${_esc(date)}</td>
<td class="col-actions">
<button class="btn-sm"
data-action="password"
data-id="${u.id}"
data-name="${_esc(u.name)}">Пароль</button>
<button class="btn-sm ${u.is_blocked ? 'btn-sm--warn' : ''}"
data-action="block"
data-id="${u.id}"
data-blocked="${u.is_blocked ? '1' : '0'}">
${u.is_blocked ? 'Разблокировать' : 'Заблокировать'}
</button>
<button class="btn-sm btn-sm--danger"
data-action="delete"
data-id="${u.id}"
data-name="${_esc(u.name)}">Удалить</button>
</td>
`;
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);

View file

@ -31,7 +31,7 @@ log_format baton_secure '$remote_addr - $remote_user [$time_local] '
# ---------------------------------------------------------------------------
server {
listen 80;
server_name <YOUR_DOMAIN>;
server_name baton.itafrika.com;
return 301 https://$server_name$request_uri;
}
@ -41,10 +41,10 @@ server {
# ---------------------------------------------------------------------------
server {
listen 443 ssl;
server_name <YOUR_DOMAIN>;
server_name baton.itafrika.com;
ssl_certificate /etc/letsencrypt/live/<YOUR_DOMAIN>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<YOUR_DOMAIN>/privkey.pem;
ssl_certificate /etc/letsencrypt/live/baton.itafrika.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/baton.itafrika.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
@ -55,16 +55,45 @@ 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;
}
# 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;
try_files $uri /index.html;
}
}

View file

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

487
tests/test_baton_005.py Normal file
View file

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

235
tests/test_baton_006.py Normal file
View file

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

View file

@ -109,10 +109,9 @@ async def test_init_db_synchronous():
await db.init_db()
# Check synchronous on a new connection via _get_conn()
from backend.db import _get_conn
conn = await _get_conn()
async with conn.execute("PRAGMA synchronous") as cur:
row = await cur.fetchone()
await conn.close()
async with _get_conn() as conn:
async with conn.execute("PRAGMA synchronous") as cur:
row = await cur.fetchone()
# 1 == NORMAL
assert row[0] == 1
finally: