Compare commits
12 commits
3483b71fcb
...
9a450d2a84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a450d2a84 | ||
|
|
fd60863e9c | ||
|
|
989074673a | ||
|
|
8607a9f981 | ||
|
|
e547e1ce09 | ||
|
|
cb95c9928f | ||
|
|
5fcfc3a76b | ||
|
|
68a1c90541 | ||
|
|
3cd7db11e7 | ||
|
|
7db8b849e0 | ||
|
|
1c383191cc | ||
|
|
b70d5990c8 |
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"
|
WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true"
|
||||||
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
|
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||||
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
|
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
|
||||||
|
ADMIN_TOKEN: str = _require("ADMIN_TOKEN")
|
||||||
|
|
|
||||||
153
backend/db.py
153
backend/db.py
|
|
@ -1,28 +1,35 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import AsyncGenerator, Optional
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
from backend import config
|
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)
|
conn = await aiosqlite.connect(config.DB_PATH)
|
||||||
await conn.execute("PRAGMA journal_mode=WAL")
|
await conn.execute("PRAGMA journal_mode=WAL")
|
||||||
await conn.execute("PRAGMA busy_timeout=5000")
|
await conn.execute("PRAGMA busy_timeout=5000")
|
||||||
await conn.execute("PRAGMA synchronous=NORMAL")
|
await conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
conn.row_factory = aiosqlite.Row
|
conn.row_factory = aiosqlite.Row
|
||||||
return conn
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
async with await _get_conn() as conn:
|
async with _get_conn() as conn:
|
||||||
await conn.executescript("""
|
await conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
uuid TEXT UNIQUE NOT NULL,
|
uuid TEXT UNIQUE NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
is_blocked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
password_hash TEXT DEFAULT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS signals (
|
CREATE TABLE IF NOT EXISTS signals (
|
||||||
|
|
@ -53,11 +60,21 @@ async def init_db() -> None:
|
||||||
CREATE INDEX IF NOT EXISTS idx_batches_status
|
CREATE INDEX IF NOT EXISTS idx_batches_status
|
||||||
ON telegram_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()
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
async def register_user(uuid: str, name: str) -> dict:
|
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(
|
await conn.execute(
|
||||||
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
|
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
|
||||||
(uuid, name),
|
(uuid, name),
|
||||||
|
|
@ -77,7 +94,7 @@ async def save_signal(
|
||||||
lon: Optional[float],
|
lon: Optional[float],
|
||||||
accuracy: Optional[float],
|
accuracy: Optional[float],
|
||||||
) -> int:
|
) -> int:
|
||||||
async with await _get_conn() as conn:
|
async with _get_conn() as conn:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO signals (user_uuid, timestamp, lat, lon, accuracy)
|
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 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(
|
async with conn.execute(
|
||||||
"SELECT name FROM users WHERE uuid = ?", (uuid,)
|
"SELECT name FROM users WHERE uuid = ?", (uuid,)
|
||||||
) as cur:
|
) as cur:
|
||||||
|
|
@ -99,12 +116,124 @@ async def get_user_name(uuid: str) -> Optional[str]:
|
||||||
return row["name"] if row else None
|
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(
|
async def save_telegram_batch(
|
||||||
message_text: str,
|
message_text: str,
|
||||||
signals_count: int,
|
signals_count: int,
|
||||||
signal_ids: list[int],
|
signal_ids: list[int],
|
||||||
) -> int:
|
) -> int:
|
||||||
async with await _get_conn() as conn:
|
async with _get_conn() as conn:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO telegram_batches (message_text, sent_at, signals_count, status)
|
INSERT INTO telegram_batches (message_text, sent_at, signals_count, status)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Depends, FastAPI, Request
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from backend import config, db, telegram
|
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 (
|
from backend.models import (
|
||||||
|
AdminBlockRequest,
|
||||||
|
AdminCreateUserRequest,
|
||||||
|
AdminSetPasswordRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
SignalRequest,
|
SignalRequest,
|
||||||
|
|
@ -24,6 +29,16 @@ from backend.models import (
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
# aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004)
|
||||||
|
|
||||||
_KEEPALIVE_INTERVAL = 600 # 10 минут
|
_KEEPALIVE_INTERVAL = 600 # 10 минут
|
||||||
|
|
@ -96,6 +111,7 @@ app.add_middleware(
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
@app.get("/api/health")
|
||||||
async def health() -> dict[str, Any]:
|
async def health() -> dict[str, Any]:
|
||||||
return {"status": "ok", "timestamp": int(time.time())}
|
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)
|
@app.post("/api/signal", response_model=SignalResponse)
|
||||||
async def signal(body: SignalRequest) -> 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
|
geo = body.geo
|
||||||
lat = geo.lat if geo else None
|
lat = geo.lat if geo else None
|
||||||
lon = geo.lon 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)
|
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")
|
@app.post("/api/webhook/telegram")
|
||||||
async def webhook_telegram(
|
async def webhook_telegram(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
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
|
from backend import config
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
_RATE_LIMIT = 5
|
_RATE_LIMIT = 5
|
||||||
_RATE_WINDOW = 600 # 10 minutes
|
_RATE_WINDOW = 600 # 10 minutes
|
||||||
|
|
||||||
|
|
@ -20,6 +24,15 @@ async def verify_webhook_secret(
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
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:
|
async def rate_limit_register(request: Request) -> None:
|
||||||
counters = request.app.state.rate_counters
|
counters = request.app.state.rate_counters
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,17 @@ class SignalRequest(BaseModel):
|
||||||
class SignalResponse(BaseModel):
|
class SignalResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
signal_id: int
|
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]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
# Замените URL на реальный адрес вашего приложения
|
# Замените URL на реальный адрес вашего приложения
|
||||||
ExecStart=curl -sf https://your-app.example.com/health
|
ExecStart=curl -sf https://baton.itafrika.com/health
|
||||||
StandardOutput=null
|
StandardOutput=null
|
||||||
StandardError=journal
|
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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name <YOUR_DOMAIN>;
|
server_name baton.itafrika.com;
|
||||||
|
|
||||||
return 301 https://$server_name$request_uri;
|
return 301 https://$server_name$request_uri;
|
||||||
}
|
}
|
||||||
|
|
@ -41,10 +41,10 @@ server {
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name <YOUR_DOMAIN>;
|
server_name baton.itafrika.com;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/<YOUR_DOMAIN>/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/baton.itafrika.com/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/<YOUR_DOMAIN>/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/baton.itafrika.com/privkey.pem;
|
||||||
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_prefer_server_ciphers on;
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
@ -55,16 +55,45 @@ server {
|
||||||
|
|
||||||
# Заголовки X-Telegram-Bot-Api-Secret-Token НЕ логируются —
|
# Заголовки X-Telegram-Bot-Api-Secret-Token НЕ логируются —
|
||||||
# они передаются только в proxy_pass и не попадают в access_log.
|
# они передаются только в proxy_pass и не попадают в access_log.
|
||||||
location / {
|
|
||||||
|
# API → FastAPI
|
||||||
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
# Таймауты для webhook-запросов от Telegram
|
|
||||||
proxy_read_timeout 30s;
|
proxy_read_timeout 30s;
|
||||||
proxy_send_timeout 30s;
|
proxy_send_timeout 30s;
|
||||||
proxy_connect_timeout 5s;
|
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_SECRET", "test-webhook-secret")
|
||||||
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
||||||
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||||
|
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||||
|
|
||||||
# ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
|
# ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
|
||||||
import aiosqlite
|
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()
|
await db.init_db()
|
||||||
# Check synchronous on a new connection via _get_conn()
|
# Check synchronous on a new connection via _get_conn()
|
||||||
from backend.db import _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:
|
async with conn.execute("PRAGMA synchronous") as cur:
|
||||||
row = await cur.fetchone()
|
row = await cur.fetchone()
|
||||||
await conn.close()
|
|
||||||
# 1 == NORMAL
|
# 1 == NORMAL
|
||||||
assert row[0] == 1
|
assert row[0] == 1
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue