diff --git a/backend/config.py b/backend/config.py index 40159b0..af4d933 100644 --- a/backend/config.py +++ b/backend/config.py @@ -21,4 +21,3 @@ WEBHOOK_URL: str = _require("WEBHOOK_URL") WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true" FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000") APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping -ADMIN_TOKEN: str = _require("ADMIN_TOKEN") diff --git a/backend/db.py b/backend/db.py index e0aca18..e52a95f 100644 --- a/backend/db.py +++ b/backend/db.py @@ -24,12 +24,10 @@ async def init_db() -> None: async with _get_conn() as conn: await conn.executescript(""" CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - is_blocked INTEGER NOT NULL DEFAULT 0, - password_hash TEXT DEFAULT NULL, - created_at TEXT DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS signals ( @@ -60,16 +58,6 @@ 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() @@ -116,118 +104,6 @@ async def get_user_name(uuid: str) -> Optional[str]: return row["name"] if row else None -async def is_user_blocked(uuid: str) -> bool: - async with _get_conn() as conn: - async with conn.execute( - "SELECT is_blocked FROM users WHERE uuid = ?", (uuid,) - ) as cur: - row = await cur.fetchone() - return bool(row["is_blocked"]) if row else False - - -async def admin_list_users() -> list[dict]: - async with _get_conn() as conn: - async with conn.execute( - "SELECT id, uuid, name, is_blocked, created_at FROM users ORDER BY id" - ) as cur: - rows = await cur.fetchall() - return [ - { - "id": row["id"], - "uuid": row["uuid"], - "name": row["name"], - "is_blocked": bool(row["is_blocked"]), - "created_at": row["created_at"], - } - for row in rows - ] - - -async def admin_get_user_by_id(user_id: int) -> Optional[dict]: - async with _get_conn() as conn: - async with conn.execute( - "SELECT id, uuid, name, is_blocked, created_at FROM users WHERE id = ?", - (user_id,), - ) as cur: - row = await cur.fetchone() - if row is None: - return None - return { - "id": row["id"], - "uuid": row["uuid"], - "name": row["name"], - "is_blocked": bool(row["is_blocked"]), - "created_at": row["created_at"], - } - - -async def admin_create_user( - uuid: str, name: str, password_hash: Optional[str] = None -) -> Optional[dict]: - """Returns None if UUID already exists.""" - async with _get_conn() as conn: - try: - async with conn.execute( - "INSERT INTO users (uuid, name, password_hash) VALUES (?, ?, ?)", - (uuid, name, password_hash), - ) as cur: - new_id = cur.lastrowid - except Exception: - return None # UNIQUE constraint violation — UUID already exists - await conn.commit() - async with conn.execute( - "SELECT id, uuid, name, is_blocked, created_at FROM users WHERE id = ?", - (new_id,), - ) as cur: - row = await cur.fetchone() - return { - "id": row["id"], - "uuid": row["uuid"], - "name": row["name"], - "is_blocked": bool(row["is_blocked"]), - "created_at": row["created_at"], - } - - -async def admin_set_password(user_id: int, password_hash: str) -> bool: - async with _get_conn() as conn: - async with conn.execute( - "UPDATE users SET password_hash = ? WHERE id = ?", - (password_hash, user_id), - ) as cur: - changed = cur.rowcount > 0 - await conn.commit() - return changed - - -async def admin_set_blocked(user_id: int, is_blocked: bool) -> bool: - async with _get_conn() as conn: - async with conn.execute( - "UPDATE users SET is_blocked = ? WHERE id = ?", - (1 if is_blocked else 0, user_id), - ) as cur: - changed = cur.rowcount > 0 - await conn.commit() - return changed - - -async def admin_delete_user(user_id: int) -> bool: - async with _get_conn() as conn: - # Delete signals first (no FK cascade in SQLite by default) - async with conn.execute( - "DELETE FROM signals WHERE user_uuid = (SELECT uuid FROM users WHERE id = ?)", - (user_id,), - ): - pass - async with conn.execute( - "DELETE FROM users WHERE id = ?", - (user_id,), - ) as cur: - changed = cur.rowcount > 0 - await conn.commit() - return changed - - async def save_telegram_batch( message_text: str, signals_count: int, diff --git a/backend/main.py b/backend/main.py index 38207f0..025d69d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,25 +1,20 @@ 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, HTTPException, Request +from fastapi import Depends, FastAPI, 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_admin_token, verify_webhook_secret +from backend.middleware import rate_limit_register, verify_webhook_secret from backend.models import ( - AdminBlockRequest, - AdminCreateUserRequest, - AdminSetPasswordRequest, RegisterRequest, RegisterResponse, SignalRequest, @@ -29,16 +24,6 @@ from backend.models import ( logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) - -def _hash_password(password: str) -> str: - """Hash a password using PBKDF2-HMAC-SHA256 (stdlib, no external deps). - - Stored format: ``:`` - """ - salt = os.urandom(16) - dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000) - return f"{salt.hex()}:{dk.hex()}" - # aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004) _KEEPALIVE_INTERVAL = 600 # 10 минут @@ -111,7 +96,6 @@ app.add_middleware( @app.get("/health") -@app.get("/api/health") async def health() -> dict[str, Any]: return {"status": "ok", "timestamp": int(time.time())} @@ -124,9 +108,6 @@ 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 @@ -158,44 +139,6 @@ async def signal(body: SignalRequest) -> SignalResponse: return SignalResponse(status="ok", signal_id=signal_id) -@app.get("/admin/users", dependencies=[Depends(verify_admin_token)]) -async def admin_list_users() -> list[dict]: - return await db.admin_list_users() - - -@app.post("/admin/users", status_code=201, dependencies=[Depends(verify_admin_token)]) -async def admin_create_user(body: AdminCreateUserRequest) -> dict: - password_hash = _hash_password(body.password) if body.password else None - result = await db.admin_create_user(body.uuid, body.name, password_hash) - if result is None: - raise HTTPException(status_code=409, detail="User with this UUID already exists") - return result - - -@app.put("/admin/users/{user_id}/password", dependencies=[Depends(verify_admin_token)]) -async def admin_set_password(user_id: int, body: AdminSetPasswordRequest) -> dict: - changed = await db.admin_set_password(user_id, _hash_password(body.password)) - if not changed: - raise HTTPException(status_code=404, detail="User not found") - return {"ok": True} - - -@app.put("/admin/users/{user_id}/block", dependencies=[Depends(verify_admin_token)]) -async def admin_block_user(user_id: int, body: AdminBlockRequest) -> dict: - changed = await db.admin_set_blocked(user_id, body.is_blocked) - if not changed: - raise HTTPException(status_code=404, detail="User not found") - user = await db.admin_get_user_by_id(user_id) - return user # type: ignore[return-value] - - -@app.delete("/admin/users/{user_id}", status_code=204, dependencies=[Depends(verify_admin_token)]) -async def admin_delete_user(user_id: int) -> None: - deleted = await db.admin_delete_user(user_id) - if not deleted: - raise HTTPException(status_code=404, detail="User not found") - - @app.post("/api/webhook/telegram") async def webhook_telegram( request: Request, diff --git a/backend/middleware.py b/backend/middleware.py index a384c84..34d913e 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -2,15 +2,11 @@ from __future__ import annotations import secrets import time -from typing import Optional -from fastapi import Depends, Header, HTTPException, Request -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi import Header, HTTPException, Request from backend import config -_bearer = HTTPBearer(auto_error=False) - _RATE_LIMIT = 5 _RATE_WINDOW = 600 # 10 minutes @@ -24,15 +20,6 @@ async def verify_webhook_secret( raise HTTPException(status_code=403, detail="Forbidden") -async def verify_admin_token( - credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), -) -> None: - if credentials is None or not secrets.compare_digest( - credentials.credentials, config.ADMIN_TOKEN - ): - raise HTTPException(status_code=401, detail="Unauthorized") - - async def rate_limit_register(request: Request) -> None: counters = request.app.state.rate_counters client_ip = request.client.host if request.client else "unknown" diff --git a/backend/models.py b/backend/models.py index b89e884..68265de 100644 --- a/backend/models.py +++ b/backend/models.py @@ -29,17 +29,3 @@ class SignalRequest(BaseModel): class SignalResponse(BaseModel): status: str signal_id: int - - -class AdminCreateUserRequest(BaseModel): - uuid: str = Field(..., min_length=1) - name: str = Field(..., min_length=1, max_length=100) - password: Optional[str] = None - - -class AdminSetPasswordRequest(BaseModel): - password: str = Field(..., min_length=1) - - -class AdminBlockRequest(BaseModel): - is_blocked: bool diff --git a/deploy/env.template b/deploy/env.template index ec9d2ef..abc87f3 100644 --- a/deploy/env.template +++ b/deploy/env.template @@ -17,6 +17,3 @@ WEBHOOK_ENABLED=true FRONTEND_ORIGIN=https://baton.itafrika.com APP_URL=https://baton.itafrika.com DB_PATH=/opt/baton/baton.db - -# Admin API token — случайная строка 32+ символа (сгенерировать: openssl rand -hex 32) -ADMIN_TOKEN= diff --git a/frontend/admin.html b/frontend/admin.html deleted file mode 100644 index 8dab73f..0000000 --- a/frontend/admin.html +++ /dev/null @@ -1,379 +0,0 @@ - - - - - - Baton — Admin - - - - - -
- -
- - -
-
-

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

- - -
- -
- - -
- - - - - - - - - - - - - - -
#ИмяUUIDСтатусСозданДействия
Загрузка…
-
-
-
- - - - - - - - - - diff --git a/frontend/admin.js b/frontend/admin.js deleted file mode 100644 index 7e46b9e..0000000 --- a/frontend/admin.js +++ /dev/null @@ -1,333 +0,0 @@ -'use strict'; - -// ========== Token (sessionStorage — cleared on browser close) ========== - -function _getToken() { - return sessionStorage.getItem('baton_admin_token') || ''; -} - -function _saveToken(t) { - sessionStorage.setItem('baton_admin_token', t); -} - -function _clearToken() { - sessionStorage.removeItem('baton_admin_token'); -} - -// ========== API wrapper ========== - -async function _api(method, path, body) { - const opts = { - method, - headers: { 'Authorization': 'Bearer ' + _getToken() }, - }; - if (body !== undefined) { - opts.headers['Content-Type'] = 'application/json'; - opts.body = JSON.stringify(body); - } - - const res = await fetch(path, opts); - - if (res.status === 204) return null; - - const text = await res.text().catch(() => ''); - if (!res.ok) { - let detail = text; - try { detail = JSON.parse(text).detail || text; } catch (_) {} - throw new Error('HTTP ' + res.status + (detail ? ': ' + detail : '')); - } - - try { return JSON.parse(text); } catch (_) { return null; } -} - -// ========== UI helpers ========== - -function _esc(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function _setError(id, msg) { - const el = document.getElementById(id); - el.textContent = msg; - el.hidden = !msg; -} - -function _showPanel() { - document.getElementById('screen-token').style.display = 'none'; - document.getElementById('screen-panel').classList.add('active'); -} - -function _showTokenScreen() { - document.getElementById('screen-panel').classList.remove('active'); - document.getElementById('screen-token').style.display = ''; - document.getElementById('token-input').value = ''; -} - -// ========== Users table ========== - -function _renderTable(users) { - const tbody = document.getElementById('users-tbody'); - tbody.innerHTML = ''; - - if (!users.length) { - const tr = document.createElement('tr'); - tr.className = 'empty-row'; - tr.innerHTML = 'Нет пользователей'; - tbody.appendChild(tr); - return; - } - - users.forEach((u) => { - const tr = document.createElement('tr'); - if (u.is_blocked) tr.classList.add('is-blocked'); - - const date = u.created_at ? u.created_at.slice(0, 16).replace('T', ' ') : '—'; - const uuidShort = u.uuid ? u.uuid.slice(0, 8) + '…' : '—'; - - tr.innerHTML = ` - ${u.id} - ${_esc(u.name)} - ${_esc(uuidShort)} - - - ${u.is_blocked ? 'Заблокирован' : 'Активен'} - - - ${_esc(date)} - - - - - - `; - tbody.appendChild(tr); - }); -} - -// ========== Load users ========== - -async function _loadUsers() { - _setError('panel-error', ''); - try { - const users = await _api('GET', '/admin/users'); - _renderTable(users); - } catch (err) { - _setError('panel-error', err.message); - } -} - -// ========== Login / Logout ========== - -async function _handleLogin() { - const input = document.getElementById('token-input'); - const btn = document.getElementById('btn-login'); - const token = input.value.trim(); - if (!token) return; - - btn.disabled = true; - _setError('login-error', ''); - _saveToken(token); - - try { - const users = await _api('GET', '/admin/users'); - _renderTable(users); - _showPanel(); - } catch (err) { - _clearToken(); - const msg = err.message.includes('401') ? 'Неверный токен' : err.message; - _setError('login-error', msg); - btn.disabled = false; - } -} - -function _handleLogout() { - _clearToken(); - _showTokenScreen(); -} - -// ========== Table action dispatcher (event delegation) ========== - -async function _handleTableClick(e) { - const btn = e.target.closest('[data-action]'); - if (!btn) return; - - const { action, id, name, blocked } = btn.dataset; - - if (action === 'password') { - _openPasswordModal(id, name); - } else if (action === 'block') { - await _toggleBlock(id, blocked === '1'); - } else if (action === 'delete') { - await _handleDelete(id, name); - } -} - -// ========== Block / Unblock ========== - -async function _toggleBlock(userId, currentlyBlocked) { - _setError('panel-error', ''); - try { - await _api('PUT', `/admin/users/${userId}/block`, { is_blocked: !currentlyBlocked }); - await _loadUsers(); - } catch (err) { - _setError('panel-error', err.message); - } -} - -// ========== Delete ========== - -async function _handleDelete(userId, userName) { - if (!confirm(`Удалить пользователя "${userName}"?\n\nБудут удалены все его сигналы. Действие нельзя отменить.`)) return; - _setError('panel-error', ''); - try { - await _api('DELETE', `/admin/users/${userId}`); - await _loadUsers(); - } catch (err) { - _setError('panel-error', err.message); - } -} - -// ========== Password modal ========== - -function _openPasswordModal(userId, userName) { - document.getElementById('modal-pw-subtitle').textContent = `Пользователь: ${userName}`; - document.getElementById('modal-pw-user-id').value = userId; - document.getElementById('new-password').value = ''; - _setError('modal-pw-error', ''); - document.getElementById('btn-pw-save').disabled = false; - document.getElementById('modal-password').hidden = false; - document.getElementById('new-password').focus(); -} - -function _closePasswordModal() { - document.getElementById('modal-password').hidden = true; -} - -async function _handleSetPassword() { - const userId = document.getElementById('modal-pw-user-id').value; - const password = document.getElementById('new-password').value; - const btn = document.getElementById('btn-pw-save'); - - if (!password) { - _setError('modal-pw-error', 'Введите пароль'); - return; - } - - btn.disabled = true; - _setError('modal-pw-error', ''); - - try { - await _api('PUT', `/admin/users/${userId}/password`, { password }); - _closePasswordModal(); - } catch (err) { - _setError('modal-pw-error', err.message); - btn.disabled = false; - } -} - -// ========== Create user modal ========== - -function _openCreateModal() { - document.getElementById('create-uuid').value = crypto.randomUUID(); - document.getElementById('create-name').value = ''; - document.getElementById('create-password').value = ''; - _setError('create-error', ''); - document.getElementById('btn-create-submit').disabled = false; - document.getElementById('modal-create').hidden = false; - document.getElementById('create-name').focus(); -} - -function _closeCreateModal() { - document.getElementById('modal-create').hidden = true; -} - -async function _handleCreateUser() { - const uuid = document.getElementById('create-uuid').value.trim(); - const name = document.getElementById('create-name').value.trim(); - const password = document.getElementById('create-password').value; - const btn = document.getElementById('btn-create-submit'); - - if (!uuid || !name) { - _setError('create-error', 'UUID и имя обязательны'); - return; - } - - btn.disabled = true; - _setError('create-error', ''); - - const body = { uuid, name }; - if (password) body.password = password; - - try { - await _api('POST', '/admin/users', body); - _closeCreateModal(); - await _loadUsers(); - } catch (err) { - const msg = err.message.includes('409') ? 'Пользователь с таким UUID уже существует' : err.message; - _setError('create-error', msg); - btn.disabled = false; - } -} - -// ========== Init ========== - -function _init() { - // Login screen - document.getElementById('btn-login').addEventListener('click', _handleLogin); - document.getElementById('token-input').addEventListener('keydown', (e) => { - if (e.key === 'Enter') _handleLogin(); - }); - - // Panel - document.getElementById('btn-logout').addEventListener('click', _handleLogout); - document.getElementById('btn-create').addEventListener('click', _openCreateModal); - - // Table (event delegation) - document.getElementById('users-table').addEventListener('click', _handleTableClick); - - // Password modal - document.getElementById('btn-pw-cancel').addEventListener('click', _closePasswordModal); - document.getElementById('btn-pw-save').addEventListener('click', _handleSetPassword); - document.getElementById('new-password').addEventListener('keydown', (e) => { - if (e.key === 'Enter') _handleSetPassword(); - }); - document.getElementById('modal-password').addEventListener('click', (e) => { - if (e.target.id === 'modal-password') _closePasswordModal(); - }); - - // Create modal - document.getElementById('btn-create-cancel').addEventListener('click', _closeCreateModal); - document.getElementById('btn-create-submit').addEventListener('click', _handleCreateUser); - document.getElementById('create-password').addEventListener('keydown', (e) => { - if (e.key === 'Enter') _handleCreateUser(); - }); - document.getElementById('modal-create').addEventListener('click', (e) => { - if (e.target.id === 'modal-create') _closeCreateModal(); - }); - - // Auto-login if token is already saved in sessionStorage - if (_getToken()) { - _showPanel(); - _loadUsers().catch(() => { - _clearToken(); - _showTokenScreen(); - }); - } -} - -document.addEventListener('DOMContentLoaded', _init); diff --git a/nginx/baton.conf b/nginx/baton.conf index e148729..e1e1854 100644 --- a/nginx/baton.conf +++ b/nginx/baton.conf @@ -55,45 +55,16 @@ server { # Заголовки X-Telegram-Bot-Api-Secret-Token НЕ логируются — # они передаются только в proxy_pass и не попадают в access_log. - - # 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; - - 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; + 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; } } diff --git a/tests/conftest.py b/tests/conftest.py index 24b0ff3..2604da8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,6 @@ 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 diff --git a/tests/test_baton_005.py b/tests/test_baton_005.py deleted file mode 100644 index 1e43810..0000000 --- a/tests/test_baton_005.py +++ /dev/null @@ -1,487 +0,0 @@ -""" -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" diff --git a/tests/test_baton_006.py b/tests/test_baton_006.py deleted file mode 100644 index 72ec197..0000000 --- a/tests/test_baton_006.py +++ /dev/null @@ -1,235 +0,0 @@ -""" -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}