Compare commits
12 commits
9a450d2a84
...
3483b71fcb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3483b71fcb | ||
|
|
a8d53fa47b | ||
|
|
12a63cd6cf | ||
|
|
3e8e83481c | ||
|
|
fac6a0976d | ||
|
|
bd37560ef5 | ||
|
|
98063595f8 | ||
|
|
75a41c56b8 | ||
|
|
3a54a1e5fa | ||
|
|
284529dabe | ||
|
|
ebb6e404e5 | ||
|
|
18d63ec867 |
15 changed files with 1743 additions and 26 deletions
|
|
@ -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")
|
||||
|
|
|
|||
145
backend/db.py
145
backend/db.py
|
|
@ -1,27 +1,34 @@
|
|||
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,
|
||||
is_blocked INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT DEFAULT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
18
deploy/baton.service
Normal 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
22
deploy/env.template
Normal 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
379
frontend/admin.html
Normal 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
333
frontend/admin.js
Normal 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, '&')
|
||||
.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 = '<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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
487
tests/test_baton_005.py
Normal 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
235
tests/test_baton_006.py
Normal 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}
|
||||
|
|
@ -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 _get_conn() as conn:
|
||||
async with conn.execute("PRAGMA synchronous") as cur:
|
||||
row = await cur.fetchone()
|
||||
await conn.close()
|
||||
# 1 == NORMAL
|
||||
assert row[0] == 1
|
||||
finally:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue