Compare commits
75 commits
BATON-007-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb89a90771 | ||
|
|
6e2503dc3f | ||
|
|
5da2a9a708 | ||
|
|
6617c85cd5 | ||
|
|
268fb62bf3 | ||
|
|
0562cb4e47 | ||
|
|
47b89ded8d | ||
|
|
04f7bd79e2 | ||
|
|
1adcabf3a6 | ||
|
|
baf05b6d84 | ||
|
|
6444b30d17 | ||
|
|
ea06309a6e | ||
|
|
e266b6506e | ||
|
|
86a41a3b35 | ||
|
|
40e1a9fa48 | ||
|
|
8c4c46ee92 | ||
|
|
5fe9a603f8 | ||
|
|
5fa3a35d27 | ||
|
|
debd7895f4 | ||
|
|
635991078c | ||
|
|
a0dc6a7b22 | ||
|
|
dd556e2f05 | ||
|
|
5401363ea9 | ||
|
|
c7661d7c1e | ||
|
|
fde7f57a7a | ||
|
|
35eef641fd | ||
|
|
283ff61dc5 | ||
|
|
6d5d84a882 | ||
|
|
257631436a | ||
|
|
b2fecc5993 | ||
|
|
36087c3d9e | ||
|
|
c838a775f7 | ||
|
|
33844a02ac | ||
|
|
2f6a84f08b | ||
|
|
370a2157b9 | ||
|
|
177a0d80dd | ||
|
|
85d156e9be | ||
|
|
2ab5e9ab54 | ||
|
|
42f4251184 | ||
|
|
4c9fec17de | ||
|
|
63be474cdc | ||
|
|
8896bc32f4 | ||
|
|
e21bcb1eb4 | ||
|
|
726bb0a82c | ||
|
|
a2b38ef815 | ||
|
|
cbc15eeedc | ||
|
|
6142770c0c | ||
|
|
4b37703335 | ||
|
|
99638fe22b | ||
|
|
4916b292c5 | ||
|
|
dbd1048a51 | ||
|
|
f17ee79edb | ||
|
|
fd4f32c1c3 | ||
|
|
5c9176fcd9 | ||
|
|
1b2aa501c6 | ||
|
|
4b7e59d78d | ||
|
|
097b7af949 | ||
|
|
205cc8037c | ||
|
|
2cf141f6ed | ||
|
|
7aae8c0f62 | ||
|
|
5d6695ecab | ||
|
|
9b8a5558a3 | ||
|
|
4ab2f04de6 | ||
|
|
9a450d2a84 | ||
|
|
fd60863e9c | ||
|
|
989074673a | ||
|
|
8607a9f981 | ||
|
|
e547e1ce09 | ||
|
|
cb95c9928f | ||
|
|
5fcfc3a76b | ||
|
|
68a1c90541 | ||
|
|
3cd7db11e7 | ||
|
|
7db8b849e0 | ||
|
|
1c383191cc | ||
|
|
b70d5990c8 |
55 changed files with 8452 additions and 491 deletions
16
.dockerignore
Normal file
16
.dockerignore
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
deploy/
|
||||||
|
frontend/
|
||||||
|
nginx/
|
||||||
|
*.md
|
||||||
|
.kin_worktrees/
|
||||||
|
PROGRESS.md
|
||||||
|
|
@ -11,3 +11,8 @@ DB_PATH=baton.db
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
FRONTEND_ORIGIN=https://yourdomain.com
|
FRONTEND_ORIGIN=https://yourdomain.com
|
||||||
|
|
||||||
|
# VAPID Push Notifications (generate with: python -c "from py_vapid import Vapid; v=Vapid(); v.generate_keys(); print(v.public_key, v.private_key)")
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_CLAIMS_EMAIL=
|
||||||
|
|
|
||||||
11
.pre-commit-config.yaml
Normal file
11
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
repos:
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: no-telegram-bot-token
|
||||||
|
name: Block Telegram bot tokens
|
||||||
|
# Matches tokens of format: 1234567890:AAFisjLS-yO_AmwqMjpBQgfV9qlHnexZlMs
|
||||||
|
# Pattern: 9-10 digits, colon, "AA", then 35 alphanumeric/dash/underscore chars
|
||||||
|
entry: '\d{9,10}:AA[A-Za-z0-9_-]{35}'
|
||||||
|
language: pygrep
|
||||||
|
types: [text]
|
||||||
|
exclude: '^\.pre-commit-config\.yaml$'
|
||||||
1
=2.0.0
Normal file
1
=2.0.0
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
(eval):1: command not found: pip
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/ backend/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
@ -21,3 +21,10 @@ 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")
|
||||||
|
ADMIN_CHAT_ID: str = _require("ADMIN_CHAT_ID")
|
||||||
|
VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "")
|
||||||
|
VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "")
|
||||||
|
VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "")
|
||||||
|
JWT_SECRET: str = os.getenv("JWT_SECRET", "")
|
||||||
|
JWT_TOKEN_EXPIRE_SECONDS: int = int(os.getenv("JWT_TOKEN_EXPIRE_SECONDS", "2592000")) # 30 days
|
||||||
|
|
|
||||||
352
backend/db.py
352
backend/db.py
|
|
@ -1,28 +1,37 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
import time
|
||||||
|
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,
|
||||||
|
api_key_hash TEXT DEFAULT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS signals (
|
CREATE TABLE IF NOT EXISTS signals (
|
||||||
|
|
@ -52,16 +61,67 @@ async def init_db() -> None:
|
||||||
ON signals(created_at);
|
ON signals(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_batches_status
|
CREATE INDEX IF NOT EXISTS idx_batches_status
|
||||||
ON telegram_batches(status);
|
ON telegram_batches(status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||||
|
ip TEXT NOT NULL PRIMARY KEY,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
window_start REAL NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS registrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
login TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
push_subscription TEXT DEFAULT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registrations_status
|
||||||
|
ON registrations(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registrations_email
|
||||||
|
ON registrations(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registrations_login
|
||||||
|
ON registrations(login);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ip_blocks (
|
||||||
|
ip TEXT NOT NULL PRIMARY KEY,
|
||||||
|
violation_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_blocked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
blocked_at TEXT DEFAULT NULL
|
||||||
|
);
|
||||||
""")
|
""")
|
||||||
|
# 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",
|
||||||
|
"ALTER TABLE users ADD COLUMN api_key_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, api_key_hash: Optional[str] = None) -> dict:
|
||||||
async with await _get_conn() as conn:
|
async with _get_conn() as conn:
|
||||||
await conn.execute(
|
if api_key_hash is not None:
|
||||||
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
|
await conn.execute(
|
||||||
(uuid, name),
|
"""
|
||||||
)
|
INSERT INTO users (uuid, name, api_key_hash) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(uuid) DO UPDATE SET api_key_hash = excluded.api_key_hash
|
||||||
|
""",
|
||||||
|
(uuid, name, api_key_hash),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
|
||||||
|
(uuid, name),
|
||||||
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
"SELECT id, uuid FROM users WHERE uuid = ?", (uuid,)
|
"SELECT id, uuid FROM users WHERE uuid = ?", (uuid,)
|
||||||
|
|
@ -70,6 +130,15 @@ async def register_user(uuid: str, name: str) -> dict:
|
||||||
return {"user_id": row["id"], "uuid": row["uuid"]}
|
return {"user_id": row["id"], "uuid": row["uuid"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_api_key_hash_by_uuid(uuid: str) -> Optional[str]:
|
||||||
|
async with _get_conn() as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT api_key_hash FROM users WHERE uuid = ?", (uuid,)
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return row["api_key_hash"] if row else None
|
||||||
|
|
||||||
|
|
||||||
async def save_signal(
|
async def save_signal(
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
timestamp: int,
|
timestamp: int,
|
||||||
|
|
@ -77,7 +146,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 +160,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 +168,228 @@ 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 rate_limit_increment(key: str, window: float) -> int:
|
||||||
|
"""Increment rate-limit counter for key within window. Returns current count.
|
||||||
|
|
||||||
|
Cleans up the stale record for this key before incrementing (TTL by window_start).
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
async with _get_conn() as conn:
|
||||||
|
# TTL cleanup: remove stale record for this key if window has expired
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM rate_limits WHERE ip = ? AND ? - window_start >= ?",
|
||||||
|
(key, now, window),
|
||||||
|
)
|
||||||
|
# Upsert: insert new record or increment existing
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rate_limits (ip, count, window_start)
|
||||||
|
VALUES (?, 1, ?)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET count = count + 1
|
||||||
|
""",
|
||||||
|
(key, now),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT count FROM rate_limits WHERE ip = ?", (key,)
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return row["count"] if row else 1
|
||||||
|
|
||||||
|
|
||||||
|
async def create_registration(
|
||||||
|
email: str,
|
||||||
|
login: str,
|
||||||
|
password_hash: str,
|
||||||
|
push_subscription: Optional[str] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Insert a new registration. Raises aiosqlite.IntegrityError on email/login conflict."""
|
||||||
|
async with _get_conn() as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO registrations (email, login, password_hash, push_subscription)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(email, login, password_hash, push_subscription),
|
||||||
|
) as cur:
|
||||||
|
reg_id = cur.lastrowid
|
||||||
|
await conn.commit()
|
||||||
|
return reg_id # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_registration(reg_id: int) -> Optional[dict]:
|
||||||
|
async with _get_conn() as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT id, email, login, status, push_subscription, created_at FROM registrations WHERE id = ?",
|
||||||
|
(reg_id,),
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"email": row["email"],
|
||||||
|
"login": row["login"],
|
||||||
|
"status": row["status"],
|
||||||
|
"push_subscription": row["push_subscription"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_registration_status(reg_id: int, status: str) -> bool:
|
||||||
|
"""Update registration status only if currently 'pending'. Returns False if already processed."""
|
||||||
|
async with _get_conn() as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"UPDATE registrations SET status = ? WHERE id = ? AND status = 'pending'",
|
||||||
|
(status, reg_id),
|
||||||
|
) as cur:
|
||||||
|
changed = cur.rowcount > 0
|
||||||
|
await conn.commit()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def get_registration_by_login_or_email(login_or_email: str) -> Optional[dict]:
|
||||||
|
async with _get_conn() as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, email, login, password_hash, status, push_subscription, created_at
|
||||||
|
FROM registrations
|
||||||
|
WHERE login = ? OR email = ?
|
||||||
|
""",
|
||||||
|
(login_or_email, login_or_email),
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"email": row["email"],
|
||||||
|
"login": row["login"],
|
||||||
|
"password_hash": row["password_hash"],
|
||||||
|
"status": row["status"],
|
||||||
|
"push_subscription": row["push_subscription"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -121,3 +406,36 @@ async def save_telegram_batch(
|
||||||
)
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
return batch_id
|
return batch_id
|
||||||
|
|
||||||
|
|
||||||
|
async def is_ip_blocked(ip: str) -> bool:
|
||||||
|
async with _get_conn() as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT is_blocked FROM ip_blocks WHERE ip = ?", (ip,)
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return bool(row["is_blocked"]) if row else False
|
||||||
|
|
||||||
|
|
||||||
|
async def record_ip_violation(ip: str) -> int:
|
||||||
|
"""Increment violation count for IP. Returns new count. Blocks IP at threshold."""
|
||||||
|
async with _get_conn() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ip_blocks (ip, violation_count) VALUES (?, 1)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET violation_count = violation_count + 1
|
||||||
|
""",
|
||||||
|
(ip,),
|
||||||
|
)
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT violation_count FROM ip_blocks WHERE ip = ?", (ip,)
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
count = row["violation_count"]
|
||||||
|
if count >= 5:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE ip_blocks SET is_blocked = 1, blocked_at = datetime('now') WHERE ip = ?",
|
||||||
|
(ip,),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return count
|
||||||
|
|
|
||||||
319
backend/main.py
319
backend/main.py
|
|
@ -1,29 +1,82 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
import time
|
import os
|
||||||
|
import secrets
|
||||||
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, Optional
|
||||||
|
|
||||||
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 fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from backend import config, db, telegram
|
from backend import config, db, push, telegram
|
||||||
from backend.middleware import rate_limit_register, verify_webhook_secret
|
from backend.middleware import (
|
||||||
|
_get_client_ip,
|
||||||
|
_verify_jwt_token,
|
||||||
|
check_ip_not_blocked,
|
||||||
|
create_auth_token,
|
||||||
|
rate_limit_auth_login,
|
||||||
|
rate_limit_auth_register,
|
||||||
|
rate_limit_register,
|
||||||
|
rate_limit_signal,
|
||||||
|
verify_admin_token,
|
||||||
|
verify_webhook_secret,
|
||||||
|
)
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
|
AdminBlockRequest,
|
||||||
|
AdminCreateUserRequest,
|
||||||
|
AdminSetPasswordRequest,
|
||||||
|
AuthLoginRequest,
|
||||||
|
AuthLoginResponse,
|
||||||
|
AuthRegisterRequest,
|
||||||
|
AuthRegisterResponse,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
SignalRequest,
|
SignalRequest,
|
||||||
SignalResponse,
|
SignalResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_api_key_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_api_key(key: str) -> str:
|
||||||
|
"""SHA-256 хэш для API-ключа (без соли — для быстрого сравнения)."""
|
||||||
|
return hashlib.sha256(key.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
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()}"
|
||||||
|
|
||||||
|
def _verify_password(password: str, stored_hash: str) -> bool:
|
||||||
|
"""Verify a password against a stored PBKDF2-HMAC-SHA256 hash (salt_hex:dk_hex)."""
|
||||||
|
try:
|
||||||
|
salt_hex, dk_hex = stored_hash.split(":", 1)
|
||||||
|
salt = bytes.fromhex(salt_hex)
|
||||||
|
expected_dk = bytes.fromhex(dk_hex)
|
||||||
|
actual_dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000)
|
||||||
|
return hmac.compare_digest(actual_dk, expected_dk)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# 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 минут
|
||||||
|
|
@ -45,10 +98,14 @@ async def _keep_alive_loop(app_url: str) -> None:
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
app.state.rate_counters = {}
|
|
||||||
await db.init_db()
|
await db.init_db()
|
||||||
logger.info("Database initialized")
|
logger.info("Database initialized")
|
||||||
|
|
||||||
|
if not await telegram.validate_bot_token():
|
||||||
|
logger.error(
|
||||||
|
"CRITICAL: BOT_TOKEN is invalid — Telegram delivery is broken. Update .env and restart."
|
||||||
|
)
|
||||||
|
|
||||||
if config.WEBHOOK_ENABLED:
|
if config.WEBHOOK_ENABLED:
|
||||||
await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET)
|
await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET)
|
||||||
logger.info("Webhook registered")
|
logger.info("Webhook registered")
|
||||||
|
|
@ -90,61 +147,277 @@ app = FastAPI(lifespan=lifespan)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[config.FRONTEND_ORIGIN],
|
allow_origins=[config.FRONTEND_ORIGIN],
|
||||||
allow_methods=["POST"],
|
allow_methods=["GET", "HEAD", "OPTIONS", "POST"],
|
||||||
allow_headers=["Content-Type"],
|
allow_headers=["Content-Type", "Authorization"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@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"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/vapid-public-key")
|
||||||
|
@app.get("/api/push/public-key")
|
||||||
|
async def vapid_public_key() -> dict[str, str]:
|
||||||
|
return {"vapid_public_key": config.VAPID_PUBLIC_KEY}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/register", response_model=RegisterResponse)
|
@app.post("/api/register", response_model=RegisterResponse)
|
||||||
async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse:
|
async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse:
|
||||||
result = await db.register_user(uuid=body.uuid, name=body.name)
|
api_key = secrets.token_hex(32)
|
||||||
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"])
|
result = await db.register_user(uuid=body.uuid, name=body.name, api_key_hash=_hash_api_key(api_key))
|
||||||
|
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"], api_key=api_key)
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_api_key_bearer),
|
||||||
|
_: None = Depends(rate_limit_signal),
|
||||||
|
) -> SignalResponse:
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
user_identifier: str = ""
|
||||||
|
user_name: str = ""
|
||||||
|
|
||||||
|
# Try JWT auth first (new registration flow)
|
||||||
|
jwt_payload = None
|
||||||
|
try:
|
||||||
|
jwt_payload = _verify_jwt_token(credentials.credentials)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if jwt_payload is not None:
|
||||||
|
reg_id = int(jwt_payload["sub"])
|
||||||
|
reg = await db.get_registration(reg_id)
|
||||||
|
if reg is None or reg["status"] != "approved":
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
user_identifier = reg["login"]
|
||||||
|
user_name = reg["login"]
|
||||||
|
else:
|
||||||
|
# Legacy api_key auth
|
||||||
|
if not body.user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
key_hash = _hash_api_key(credentials.credentials)
|
||||||
|
stored_hash = await db.get_api_key_hash_by_uuid(body.user_id)
|
||||||
|
if stored_hash is None or not secrets.compare_digest(key_hash, stored_hash):
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
if await db.is_user_blocked(body.user_id):
|
||||||
|
raise HTTPException(status_code=403, detail="User is blocked")
|
||||||
|
user_identifier = body.user_id
|
||||||
|
user_name = await db.get_user_name(body.user_id) or body.user_id[:8]
|
||||||
|
|
||||||
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
|
||||||
accuracy = geo.accuracy if geo else None
|
accuracy = geo.accuracy if geo else None
|
||||||
|
|
||||||
signal_id = await db.save_signal(
|
signal_id = await db.save_signal(
|
||||||
user_uuid=body.user_id,
|
user_uuid=user_identifier,
|
||||||
timestamp=body.timestamp,
|
timestamp=body.timestamp,
|
||||||
lat=lat,
|
lat=lat,
|
||||||
lon=lon,
|
lon=lon,
|
||||||
accuracy=accuracy,
|
accuracy=accuracy,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_name = await db.get_user_name(body.user_id)
|
|
||||||
ts = datetime.fromtimestamp(body.timestamp / 1000, tz=timezone.utc)
|
ts = datetime.fromtimestamp(body.timestamp / 1000, tz=timezone.utc)
|
||||||
name = user_name or body.user_id[:8]
|
|
||||||
geo_info = (
|
geo_info = (
|
||||||
f"📍 {lat}, {lon} (±{accuracy}м)"
|
f"📍 <a href=\"https://maps.google.com/maps?q={lat},{lon}\">{lat}, {lon}</a> (±{accuracy:.0f}м)"
|
||||||
if geo
|
if geo
|
||||||
else "Без геолокации"
|
else "Гео нету"
|
||||||
)
|
)
|
||||||
text = (
|
if body.is_test:
|
||||||
f"🚨 Сигнал от {name}\n"
|
text = (
|
||||||
f"⏰ {ts.strftime('%H:%M:%S')} UTC\n"
|
f"🧪 Тест от {user_name}\n"
|
||||||
f"{geo_info}"
|
f"⏰ {ts.strftime('%H:%M:%S')} UTC\n"
|
||||||
)
|
f"{geo_info}"
|
||||||
await telegram.send_message(text)
|
)
|
||||||
|
else:
|
||||||
|
text = (
|
||||||
|
f"🚨 Сигнал от {user_name}\n"
|
||||||
|
f"⏰ {ts.strftime('%H:%M:%S')} UTC\n"
|
||||||
|
f"{geo_info}"
|
||||||
|
)
|
||||||
|
asyncio.create_task(telegram.send_message(text))
|
||||||
|
|
||||||
return SignalResponse(status="ok", signal_id=signal_id)
|
return SignalResponse(status="ok", signal_id=signal_id)
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_EMAIL_DOMAIN = "tutlot.com"
|
||||||
|
_VIOLATION_BLOCK_THRESHOLD = 5
|
||||||
|
|
||||||
|
@app.post("/api/auth/register", response_model=AuthRegisterResponse, status_code=201)
|
||||||
|
async def auth_register(
|
||||||
|
request: Request,
|
||||||
|
body: AuthRegisterRequest,
|
||||||
|
_: None = Depends(rate_limit_auth_register),
|
||||||
|
__: None = Depends(check_ip_not_blocked),
|
||||||
|
) -> AuthRegisterResponse:
|
||||||
|
# Domain verification (server-side only)
|
||||||
|
email_str = str(body.email)
|
||||||
|
domain = email_str.rsplit("@", 1)[-1].lower() if "@" in email_str else ""
|
||||||
|
if domain != _ALLOWED_EMAIL_DOMAIN:
|
||||||
|
client_ip = _get_client_ip(request)
|
||||||
|
count = await db.record_ip_violation(client_ip)
|
||||||
|
logger.warning("Domain violation from %s (attempt %d): %s", client_ip, count, email_str)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Ваш IP отправлен компетентным службам и за вами уже выехали. Ожидайте.",
|
||||||
|
)
|
||||||
|
|
||||||
|
password_hash = _hash_password(body.password)
|
||||||
|
push_sub_json = (
|
||||||
|
body.push_subscription.model_dump_json() if body.push_subscription else None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reg_id = await db.create_registration(
|
||||||
|
email=email_str,
|
||||||
|
login=body.login,
|
||||||
|
password_hash=password_hash,
|
||||||
|
push_subscription=push_sub_json,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# aiosqlite.IntegrityError on email/login UNIQUE conflict
|
||||||
|
if "UNIQUE" in str(exc) or "unique" in str(exc).lower():
|
||||||
|
raise HTTPException(status_code=409, detail="Email или логин уже существует")
|
||||||
|
raise
|
||||||
|
reg = await db.get_registration(reg_id)
|
||||||
|
asyncio.create_task(
|
||||||
|
telegram.send_registration_notification(
|
||||||
|
reg_id=reg_id,
|
||||||
|
login=body.login,
|
||||||
|
email=email_str,
|
||||||
|
created_at=reg["created_at"] if reg else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return AuthRegisterResponse(status="pending", message="Заявка отправлена на рассмотрение")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/login", response_model=AuthLoginResponse)
|
||||||
|
async def auth_login(
|
||||||
|
body: AuthLoginRequest,
|
||||||
|
_: None = Depends(rate_limit_auth_login),
|
||||||
|
__: None = Depends(check_ip_not_blocked),
|
||||||
|
) -> AuthLoginResponse:
|
||||||
|
reg = await db.get_registration_by_login_or_email(body.login_or_email)
|
||||||
|
if reg is None or not _verify_password(body.password, reg["password_hash"]):
|
||||||
|
raise HTTPException(status_code=401, detail="Неверный логин или пароль")
|
||||||
|
if reg["status"] == "pending":
|
||||||
|
raise HTTPException(status_code=403, detail="Ваша заявка ожидает рассмотрения")
|
||||||
|
if reg["status"] == "rejected":
|
||||||
|
raise HTTPException(status_code=403, detail="Ваша заявка отклонена")
|
||||||
|
if reg["status"] != "approved":
|
||||||
|
raise HTTPException(status_code=403, detail="Доступ запрещён")
|
||||||
|
token = create_auth_token(reg["id"], reg["login"])
|
||||||
|
return AuthLoginResponse(token=token, login=reg["login"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_callback_query(cb: dict) -> None:
|
||||||
|
"""Process approve/reject callback from admin Telegram inline buttons."""
|
||||||
|
data = cb.get("data", "")
|
||||||
|
callback_query_id = cb.get("id", "")
|
||||||
|
message = cb.get("message", {})
|
||||||
|
chat_id = message.get("chat", {}).get("id")
|
||||||
|
message_id = message.get("message_id")
|
||||||
|
|
||||||
|
if ":" not in data:
|
||||||
|
return
|
||||||
|
action, reg_id_str = data.split(":", 1)
|
||||||
|
try:
|
||||||
|
reg_id = int(reg_id_str)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
reg = await db.get_registration(reg_id)
|
||||||
|
if reg is None:
|
||||||
|
await telegram.answer_callback_query(callback_query_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "approve":
|
||||||
|
updated = await db.update_registration_status(reg_id, "approved")
|
||||||
|
if not updated:
|
||||||
|
# Already processed (not pending) — ack the callback and stop
|
||||||
|
await telegram.answer_callback_query(callback_query_id)
|
||||||
|
return
|
||||||
|
if chat_id and message_id:
|
||||||
|
await telegram.edit_message_text(
|
||||||
|
chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен"
|
||||||
|
)
|
||||||
|
if reg["push_subscription"]:
|
||||||
|
asyncio.create_task(
|
||||||
|
push.send_push(
|
||||||
|
reg["push_subscription"],
|
||||||
|
"Baton",
|
||||||
|
"Ваша регистрация одобрена!",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif action == "reject":
|
||||||
|
updated = await db.update_registration_status(reg_id, "rejected")
|
||||||
|
if not updated:
|
||||||
|
await telegram.answer_callback_query(callback_query_id)
|
||||||
|
return
|
||||||
|
if chat_id and message_id:
|
||||||
|
await telegram.edit_message_text(
|
||||||
|
chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён"
|
||||||
|
)
|
||||||
|
|
||||||
|
await telegram.answer_callback_query(callback_query_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,
|
||||||
_: None = Depends(verify_webhook_secret),
|
_: None = Depends(verify_webhook_secret),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
update = await request.json()
|
update = await request.json()
|
||||||
|
|
||||||
|
# Handle inline button callback queries (approve/reject registration)
|
||||||
|
callback_query = update.get("callback_query")
|
||||||
|
if callback_query:
|
||||||
|
await _handle_callback_query(callback_query)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
message = update.get("message", {})
|
message = update.get("message", {})
|
||||||
text = message.get("text", "")
|
text = message.get("text", "")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,49 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
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, db
|
||||||
|
|
||||||
|
# JWT secret: stable across restarts if JWT_SECRET env var is set; random per-process otherwise
|
||||||
|
_JWT_SECRET: str = config.JWT_SECRET or secrets.token_hex(32)
|
||||||
|
_JWT_HEADER_B64: str = (
|
||||||
|
base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').rstrip(b"=").decode()
|
||||||
|
)
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
_RATE_LIMIT = 5
|
_RATE_LIMIT = 5
|
||||||
_RATE_WINDOW = 600 # 10 minutes
|
_RATE_WINDOW = 600 # 10 minutes
|
||||||
|
|
||||||
|
_SIGNAL_RATE_LIMIT = 10
|
||||||
|
_SIGNAL_RATE_WINDOW = 60 # 1 minute
|
||||||
|
|
||||||
|
_AUTH_REGISTER_RATE_LIMIT = 3
|
||||||
|
_AUTH_REGISTER_RATE_WINDOW = 600 # 10 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_ip(request: Request) -> str:
|
||||||
|
return (
|
||||||
|
request.headers.get("X-Real-IP")
|
||||||
|
or request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||||||
|
or (request.client.host if request.client else "unknown")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_ip_not_blocked(request: Request) -> None:
|
||||||
|
ip = _get_client_ip(request)
|
||||||
|
if await db.is_ip_blocked(ip):
|
||||||
|
raise HTTPException(status_code=403, detail="Доступ запрещён")
|
||||||
|
|
||||||
|
|
||||||
async def verify_webhook_secret(
|
async def verify_webhook_secret(
|
||||||
x_telegram_bot_api_secret_token: str = Header(default=""),
|
x_telegram_bot_api_secret_token: str = Header(default=""),
|
||||||
|
|
@ -20,15 +54,102 @@ 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
|
key = f"reg:{_get_client_ip(request)}"
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
count = await db.rate_limit_increment(key, _RATE_WINDOW)
|
||||||
now = time.time()
|
|
||||||
count, window_start = counters.get(client_ip, (0, now))
|
|
||||||
if now - window_start >= _RATE_WINDOW:
|
|
||||||
count = 0
|
|
||||||
window_start = now
|
|
||||||
count += 1
|
|
||||||
counters[client_ip] = (count, window_start)
|
|
||||||
if count > _RATE_LIMIT:
|
if count > _RATE_LIMIT:
|
||||||
raise HTTPException(status_code=429, detail="Too Many Requests")
|
raise HTTPException(status_code=429, detail="Too Many Requests")
|
||||||
|
|
||||||
|
|
||||||
|
async def rate_limit_signal(request: Request) -> None:
|
||||||
|
key = f"sig:{_get_client_ip(request)}"
|
||||||
|
count = await db.rate_limit_increment(key, _SIGNAL_RATE_WINDOW)
|
||||||
|
if count > _SIGNAL_RATE_LIMIT:
|
||||||
|
raise HTTPException(status_code=429, detail="Too Many Requests")
|
||||||
|
|
||||||
|
|
||||||
|
async def rate_limit_auth_register(request: Request) -> None:
|
||||||
|
key = f"authreg:{_get_client_ip(request)}"
|
||||||
|
count = await db.rate_limit_increment(key, _AUTH_REGISTER_RATE_WINDOW)
|
||||||
|
if count > _AUTH_REGISTER_RATE_LIMIT:
|
||||||
|
raise HTTPException(status_code=429, detail="Too Many Requests")
|
||||||
|
|
||||||
|
|
||||||
|
_AUTH_LOGIN_RATE_LIMIT = 5
|
||||||
|
_AUTH_LOGIN_RATE_WINDOW = 900 # 15 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_encode(data: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(s: str) -> bytes:
|
||||||
|
padding = 4 - len(s) % 4
|
||||||
|
if padding != 4:
|
||||||
|
s += "=" * padding
|
||||||
|
return base64.urlsafe_b64decode(s)
|
||||||
|
|
||||||
|
|
||||||
|
def create_auth_token(reg_id: int, login: str) -> str:
|
||||||
|
"""Create a signed HS256 JWT for an approved registration."""
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"sub": str(reg_id),
|
||||||
|
"login": login,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + config.JWT_TOKEN_EXPIRE_SECONDS,
|
||||||
|
}
|
||||||
|
payload_b64 = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||||
|
signing_input = f"{_JWT_HEADER_B64}.{payload_b64}"
|
||||||
|
sig = hmac.new(
|
||||||
|
_JWT_SECRET.encode(), signing_input.encode(), hashlib.sha256
|
||||||
|
).digest()
|
||||||
|
return f"{signing_input}.{_b64url_encode(sig)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_jwt_token(token: str) -> dict:
|
||||||
|
"""Verify token signature and expiry. Returns payload dict on success."""
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise ValueError("Invalid token format")
|
||||||
|
header_b64, payload_b64, sig_b64 = parts
|
||||||
|
signing_input = f"{header_b64}.{payload_b64}"
|
||||||
|
expected_sig = hmac.new(
|
||||||
|
_JWT_SECRET.encode(), signing_input.encode(), hashlib.sha256
|
||||||
|
).digest()
|
||||||
|
actual_sig = _b64url_decode(sig_b64)
|
||||||
|
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||||
|
raise ValueError("Invalid signature")
|
||||||
|
payload = json.loads(_b64url_decode(payload_b64))
|
||||||
|
if payload.get("exp", 0) < time.time():
|
||||||
|
raise ValueError("Token expired")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def rate_limit_auth_login(request: Request) -> None:
|
||||||
|
key = f"login:{_get_client_ip(request)}"
|
||||||
|
count = await db.rate_limit_increment(key, _AUTH_LOGIN_RATE_WINDOW)
|
||||||
|
if count > _AUTH_LOGIN_RATE_LIMIT:
|
||||||
|
raise HTTPException(status_code=429, detail="Too Many Requests")
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_auth_token(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer),
|
||||||
|
) -> dict:
|
||||||
|
"""Dependency for protected endpoints — verifies Bearer JWT, returns payload."""
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
try:
|
||||||
|
payload = _verify_jwt_token(credentials.credentials)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
return payload
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
uuid: str = Field(..., min_length=1)
|
uuid: str = Field(..., pattern=r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$')
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class RegisterResponse(BaseModel):
|
class RegisterResponse(BaseModel):
|
||||||
user_id: int
|
user_id: int
|
||||||
uuid: str
|
uuid: str
|
||||||
|
api_key: str
|
||||||
|
|
||||||
|
|
||||||
class GeoData(BaseModel):
|
class GeoData(BaseModel):
|
||||||
|
|
@ -21,11 +22,58 @@ class GeoData(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class SignalRequest(BaseModel):
|
class SignalRequest(BaseModel):
|
||||||
user_id: str = Field(..., min_length=1)
|
user_id: Optional[str] = None # UUID for legacy api_key auth; omit for JWT auth
|
||||||
timestamp: int = Field(..., gt=0)
|
timestamp: int = Field(..., gt=0)
|
||||||
geo: Optional[GeoData] = None
|
geo: Optional[GeoData] = None
|
||||||
|
is_test: bool = False
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscriptionKeys(BaseModel):
|
||||||
|
p256dh: str
|
||||||
|
auth: str
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscription(BaseModel):
|
||||||
|
endpoint: str
|
||||||
|
keys: PushSubscriptionKeys
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRegisterRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
login: str = Field(..., min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
push_subscription: Optional[PushSubscription] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRegisterResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLoginRequest(BaseModel):
|
||||||
|
login_or_email: str = Field(..., min_length=1, max_length=255)
|
||||||
|
password: str = Field(..., min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLoginResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
login: str
|
||||||
|
|
|
||||||
35
backend/push.py
Normal file
35
backend/push.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from backend import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_push(subscription_json: str, title: str, body: str) -> None:
|
||||||
|
"""Send a Web Push notification. Swallows all errors — never raises."""
|
||||||
|
if not config.VAPID_PRIVATE_KEY:
|
||||||
|
logger.warning("VAPID_PRIVATE_KEY not configured — push notification skipped")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import pywebpush # type: ignore[import]
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("pywebpush not installed — push notification skipped")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
subscription_info = json.loads(subscription_json)
|
||||||
|
data = json.dumps({"title": title, "body": body})
|
||||||
|
vapid_claims = {"sub": f"mailto:{config.VAPID_CLAIMS_EMAIL or 'admin@example.com'}"}
|
||||||
|
|
||||||
|
await asyncio.to_thread(
|
||||||
|
pywebpush.webpush,
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_private_key=config.VAPID_PRIVATE_KEY,
|
||||||
|
vapid_claims=vapid_claims,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Web Push failed: %s", exc)
|
||||||
|
|
@ -14,27 +14,123 @@ logger = logging.getLogger(__name__)
|
||||||
_TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}"
|
_TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}"
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_token(token: str) -> str:
|
||||||
|
"""Return a safe representation of the bot token for logging."""
|
||||||
|
if not token or len(token) < 4:
|
||||||
|
return "***REDACTED***"
|
||||||
|
return f"***{token[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_bot_token() -> bool:
|
||||||
|
"""Validate BOT_TOKEN by calling getMe. Logs ERROR if invalid. Never raises."""
|
||||||
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="getMe")
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(url)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
bot_name = resp.json().get("result", {}).get("username", "?")
|
||||||
|
logger.info("Telegram token valid, bot: @%s", bot_name)
|
||||||
|
return True
|
||||||
|
logger.error(
|
||||||
|
"BOT_TOKEN invalid — getMe returned %s: %s", resp.status_code, resp.text
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
# Do not log `exc` directly — it may contain the API URL with the token
|
||||||
|
# embedded (httpx includes request URL in some exception types/versions).
|
||||||
|
logger.error(
|
||||||
|
"BOT_TOKEN validation failed (network error): %s — token ends with %s",
|
||||||
|
type(exc).__name__,
|
||||||
|
_mask_token(config.BOT_TOKEN),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def send_message(text: str) -> None:
|
async def send_message(text: str) -> None:
|
||||||
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
while True:
|
for attempt in range(3):
|
||||||
resp = await client.post(url, json={"chat_id": config.CHAT_ID, "text": text})
|
resp = await client.post(url, json={"chat_id": config.CHAT_ID, "text": text, "parse_mode": "HTML"})
|
||||||
if resp.status_code == 429:
|
if resp.status_code == 429:
|
||||||
retry_after = resp.json().get("parameters", {}).get("retry_after", 30)
|
retry_after = resp.json().get("parameters", {}).get("retry_after", 30)
|
||||||
logger.warning("Telegram 429, sleeping %s sec", retry_after)
|
sleep = retry_after * (attempt + 1)
|
||||||
await asyncio.sleep(retry_after)
|
logger.warning("Telegram 429, sleeping %s sec (attempt %d)", sleep, attempt + 1)
|
||||||
|
await asyncio.sleep(sleep)
|
||||||
continue
|
continue
|
||||||
if resp.status_code >= 500:
|
if resp.status_code >= 500:
|
||||||
logger.error("Telegram 5xx: %s", resp.text)
|
logger.error("Telegram 5xx: %s", resp.text)
|
||||||
await asyncio.sleep(30)
|
await asyncio.sleep(30)
|
||||||
resp2 = await client.post(
|
continue
|
||||||
url, json={"chat_id": config.CHAT_ID, "text": text}
|
|
||||||
)
|
|
||||||
if resp2.status_code != 200:
|
|
||||||
logger.error("Telegram retry failed: %s", resp2.text)
|
|
||||||
elif resp.status_code != 200:
|
elif resp.status_code != 200:
|
||||||
logger.error("Telegram error %s: %s", resp.status_code, resp.text)
|
logger.error("Telegram error %s: %s", resp.status_code, resp.text)
|
||||||
break
|
break
|
||||||
|
else:
|
||||||
|
logger.error("Telegram send_message: all 3 attempts failed, message dropped")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_registration_notification(
|
||||||
|
reg_id: int, login: str, email: str, created_at: str
|
||||||
|
) -> None:
|
||||||
|
"""Send registration request notification to admin with approve/reject inline buttons.
|
||||||
|
Swallows all errors — never raises."""
|
||||||
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
|
||||||
|
text = (
|
||||||
|
f"📋 Новая заявка на регистрацию\n\n"
|
||||||
|
f"Login: {login}\nEmail: {email}\nДата: {created_at}"
|
||||||
|
)
|
||||||
|
reply_markup = {
|
||||||
|
"inline_keyboard": [[
|
||||||
|
{"text": "✅ Одобрить", "callback_data": f"approve:{reg_id}"},
|
||||||
|
{"text": "❌ Отклонить", "callback_data": f"reject:{reg_id}"},
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"chat_id": config.ADMIN_CHAT_ID,
|
||||||
|
"text": text,
|
||||||
|
"reply_markup": reply_markup,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
"send_registration_notification failed %s: %s",
|
||||||
|
resp.status_code,
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN
|
||||||
|
logger.error("send_registration_notification error: %s", type(exc).__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def answer_callback_query(callback_query_id: str) -> None:
|
||||||
|
"""Answer a Telegram callback query. Swallows all errors."""
|
||||||
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="answerCallbackQuery")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(url, json={"callback_query_id": callback_query_id})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error("answerCallbackQuery failed %s: %s", resp.status_code, resp.text)
|
||||||
|
except Exception as exc:
|
||||||
|
# Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN
|
||||||
|
logger.error("answerCallbackQuery error: %s", type(exc).__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> None:
|
||||||
|
"""Edit a Telegram message text. Swallows all errors."""
|
||||||
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="editMessageText")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url, json={"chat_id": chat_id, "message_id": message_id, "text": text}
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error("editMessageText failed %s: %s", resp.status_code, resp.text)
|
||||||
|
except Exception as exc:
|
||||||
|
# Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN
|
||||||
|
logger.error("editMessageText error: %s", type(exc).__name__)
|
||||||
|
|
||||||
|
|
||||||
async def set_webhook(url: str, secret: str) -> None:
|
async def set_webhook(url: str, secret: str) -> None:
|
||||||
|
|
@ -47,76 +143,3 @@ async def set_webhook(url: str, secret: str) -> None:
|
||||||
raise RuntimeError(f"setWebhook failed: {resp.text}")
|
raise RuntimeError(f"setWebhook failed: {resp.text}")
|
||||||
logger.info("Webhook registered: %s", url)
|
logger.info("Webhook registered: %s", url)
|
||||||
|
|
||||||
|
|
||||||
# v2.0 feature
|
|
||||||
class SignalAggregator:
|
|
||||||
def __init__(self, interval: int = 10) -> None:
|
|
||||||
self._interval = interval
|
|
||||||
self._buffer: list[dict] = []
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
self._stopped = False
|
|
||||||
|
|
||||||
async def add_signal(
|
|
||||||
self,
|
|
||||||
user_uuid: str,
|
|
||||||
user_name: Optional[str],
|
|
||||||
timestamp: int,
|
|
||||||
geo: Optional[dict],
|
|
||||||
signal_id: int,
|
|
||||||
) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
self._buffer.append(
|
|
||||||
{
|
|
||||||
"user_uuid": user_uuid,
|
|
||||||
"user_name": user_name,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"geo": geo,
|
|
||||||
"signal_id": signal_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def flush(self) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
if not self._buffer:
|
|
||||||
return
|
|
||||||
items = self._buffer[:]
|
|
||||||
self._buffer.clear()
|
|
||||||
|
|
||||||
signal_ids = [item["signal_id"] for item in items]
|
|
||||||
timestamps = [item["timestamp"] for item in items]
|
|
||||||
ts_start = datetime.fromtimestamp(min(timestamps) / 1000, tz=timezone.utc)
|
|
||||||
ts_end = datetime.fromtimestamp(max(timestamps) / 1000, tz=timezone.utc)
|
|
||||||
t_fmt = "%H:%M:%S"
|
|
||||||
|
|
||||||
names = []
|
|
||||||
for item in items:
|
|
||||||
name = item["user_name"]
|
|
||||||
label = name if name else item["user_uuid"][:8]
|
|
||||||
names.append(label)
|
|
||||||
|
|
||||||
geo_count = sum(1 for item in items if item["geo"])
|
|
||||||
n = len(items)
|
|
||||||
|
|
||||||
text = (
|
|
||||||
f"\U0001f6a8 Получено {n} сигнал{'ов' if n != 1 else ''} "
|
|
||||||
f"[{ts_start.strftime(t_fmt)}—{ts_end.strftime(t_fmt)}]\n"
|
|
||||||
f"Пользователи: {', '.join(names)}\n"
|
|
||||||
f"\U0001f4cd С геолокацией: {geo_count} из {n}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await send_message(text)
|
|
||||||
await db.save_telegram_batch(text, n, signal_ids)
|
|
||||||
# rate-limit: 1 msg/sec max (#1014)
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to flush aggregator batch")
|
|
||||||
|
|
||||||
async def run(self) -> None:
|
|
||||||
while not self._stopped:
|
|
||||||
await asyncio.sleep(self._interval)
|
|
||||||
if self._buffer:
|
|
||||||
await self.flush()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self._stopped = True
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
19
deploy/baton.service
Normal file
19
deploy/baton.service
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[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
|
||||||
|
ExecStartPre=/opt/baton/venv/bin/pip install -r /opt/baton/requirements.txt -q
|
||||||
|
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=
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DB_PATH: /data/baton.db
|
||||||
|
volumes:
|
||||||
|
- db_data:/data
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx/docker.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
300
docs/DESIGN_BATON008.md
Normal file
300
docs/DESIGN_BATON008.md
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
# DESIGN_BATON008 — Регистрационный flow с Telegram-апрувом
|
||||||
|
|
||||||
|
## Flow диаграмма
|
||||||
|
|
||||||
|
```
|
||||||
|
Пользователь Backend Telegram PWA / Service Worker
|
||||||
|
| | | |
|
||||||
|
|-- POST /api/auth/-------->| | |
|
||||||
|
| register | | |
|
||||||
|
| {email,login,pwd,push} | | |
|
||||||
|
| |-- validate input | |
|
||||||
|
| |-- hash password (PBKDF2) | |
|
||||||
|
| |-- INSERT registrations | |
|
||||||
|
| | (status=pending) | |
|
||||||
|
|<-- 201 {status:pending} --| | |
|
||||||
|
| |-- create_task ─────────────>| |
|
||||||
|
| | send_registration_ | |
|
||||||
|
| | notification() | |
|
||||||
|
| | | |
|
||||||
|
| | [Admin видит сообщение с кнопками] |
|
||||||
|
| | [✅ Одобрить / ❌ Отклонить] |
|
||||||
|
| | | |
|
||||||
|
| |<-- POST /api/webhook/ -----| (callback_query) |
|
||||||
|
| | telegram | |
|
||||||
|
| |-- parse callback_data | |
|
||||||
|
| |-- UPDATE registrations | |
|
||||||
|
| | SET status='approved' | |
|
||||||
|
| |-- answerCallbackQuery ─────>| |
|
||||||
|
| |-- editMessageText ─────────>| |
|
||||||
|
| |-- create_task | |
|
||||||
|
| | send_push() ─────────────────────────────────────>|
|
||||||
|
| | | [Push: "Одобрен!"] |
|
||||||
|
|<-- 200 {"ok": True} ------| | |
|
||||||
|
```
|
||||||
|
|
||||||
|
## API контракт
|
||||||
|
|
||||||
|
### POST /api/auth/register
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"login": "user_name",
|
||||||
|
"password": "securepass",
|
||||||
|
"push_subscription": {
|
||||||
|
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
|
||||||
|
"keys": {
|
||||||
|
"p256dh": "BNcR...",
|
||||||
|
"auth": "tBHI..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`push_subscription` — nullable. Если null, push при одобрении не отправляется.
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- `email`: формат email (Pydantic EmailStr или regex `[^@]+@[^@]+\.[^@]+`)
|
||||||
|
- `login`: 3–30 символов, `[a-zA-Z0-9_-]`
|
||||||
|
- `password`: минимум 8 символов
|
||||||
|
- `push_subscription`: nullable object
|
||||||
|
|
||||||
|
**Response 201:**
|
||||||
|
```json
|
||||||
|
{"status": "pending", "message": "Заявка отправлена на рассмотрение"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 409 (дубль email или login):**
|
||||||
|
```json
|
||||||
|
{"detail": "Пользователь с таким email или логином уже существует"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 429:** rate limit (через существующий `rate_limit_register` middleware)
|
||||||
|
|
||||||
|
**Response 422:** невалидные поля (Pydantic автоматически)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/webhook/telegram (расширение)
|
||||||
|
|
||||||
|
Существующий эндпоинт. Добавляется ветка обработки `callback_query`:
|
||||||
|
|
||||||
|
**Входящий update (approve):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"callback_query": {
|
||||||
|
"id": "123456789",
|
||||||
|
"data": "approve:42",
|
||||||
|
"message": {
|
||||||
|
"message_id": 777,
|
||||||
|
"chat": {"id": 5694335584}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Поведение при `approve:{id}`:**
|
||||||
|
1. `UPDATE registrations SET status='approved' WHERE id=?`
|
||||||
|
2. Fetch registration row (для получения login и push_subscription)
|
||||||
|
3. `answerCallbackQuery(callback_query_id)`
|
||||||
|
4. `editMessageText(chat_id, message_id, "✅ Пользователь {login} одобрен")`
|
||||||
|
5. Если `push_subscription IS NOT NULL` → `create_task(send_push(...))`
|
||||||
|
6. Вернуть `{"ok": True}`
|
||||||
|
|
||||||
|
**Поведение при `reject:{id}`:**
|
||||||
|
1. `UPDATE registrations SET status='rejected' WHERE id=?`
|
||||||
|
2. `answerCallbackQuery(callback_query_id)`
|
||||||
|
3. `editMessageText(chat_id, message_id, "❌ Пользователь {login} отклонён")`
|
||||||
|
4. Push НЕ отправляется
|
||||||
|
5. Вернуть `{"ok": True}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SQL миграция
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- В init_db(), добавить в executescript:
|
||||||
|
CREATE TABLE IF NOT EXISTS registrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
login TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
push_subscription TEXT DEFAULT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_registrations_email
|
||||||
|
ON registrations(email);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_registrations_login
|
||||||
|
ON registrations(login);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registrations_status
|
||||||
|
ON registrations(status);
|
||||||
|
```
|
||||||
|
|
||||||
|
Таблица создаётся через `CREATE TABLE IF NOT EXISTS` — backward compatible, не ломает существующие БД.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Список изменяемых файлов
|
||||||
|
|
||||||
|
| Файл | Тип изменения | Суть |
|
||||||
|
|------|--------------|------|
|
||||||
|
| `backend/db.py` | Modify | Добавить таблицу `registrations` в `init_db()` + 3 функции CRUD |
|
||||||
|
| `backend/config.py` | Modify | Добавить `ADMIN_CHAT_ID`, `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_CLAIMS_EMAIL` |
|
||||||
|
| `backend/models.py` | Modify | Добавить `PushKeys`, `PushSubscription`, `AuthRegisterRequest`, `AuthRegisterResponse` |
|
||||||
|
| `backend/telegram.py` | Modify | Добавить `send_registration_notification()`, `answer_callback_query()`, `edit_message_text()` |
|
||||||
|
| `backend/main.py` | Modify | Добавить `POST /api/auth/register` + callback_query ветку в webhook |
|
||||||
|
| `backend/push.py` | **New** | Отправка Web Push через pywebpush |
|
||||||
|
| `requirements.txt` | Modify | Добавить `pywebpush>=2.0.0` |
|
||||||
|
| `tests/test_baton_008.py` | **New** | Тесты для нового flow |
|
||||||
|
|
||||||
|
**НЕ трогать:** `backend/middleware.py`, `/api/register`, `users` таблица.
|
||||||
|
|
||||||
|
**Замечание:** CORS `allow_headers` уже содержит `Authorization` в `main.py:122` — изменение не требуется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Интеграционные точки с существующим кодом
|
||||||
|
|
||||||
|
### 1. `_hash_password()` в `main.py`
|
||||||
|
Функция уже существует (строки 41–48). Dev agent должен **переиспользовать её напрямую** в новом endpoint `POST /api/auth/register`, не дублируя логику.
|
||||||
|
|
||||||
|
### 2. `rate_limit_register` middleware
|
||||||
|
Существующий middleware из `backend/middleware.py` может быть подключён к новому endpoint как `Depends(rate_limit_register)` — тот же ключ `reg:{ip}`, та же логика.
|
||||||
|
|
||||||
|
### 3. `telegram.send_message()` — не модифицировать
|
||||||
|
Существующая функция использует `config.CHAT_ID` для SOS-сигналов. Для регистрационных уведомлений создаётся отдельная функция `send_registration_notification()`, которая использует `config.ADMIN_CHAT_ID`. Это разделяет два потока уведомлений.
|
||||||
|
|
||||||
|
### 4. Webhook handler (строки 223–242 в `main.py`)
|
||||||
|
Добавляется ветка в начало функции (до `message = update.get("message", {})`):
|
||||||
|
```python
|
||||||
|
callback_query = update.get("callback_query")
|
||||||
|
if callback_query:
|
||||||
|
asyncio.create_task(_handle_callback_query(callback_query))
|
||||||
|
return {"ok": True}
|
||||||
|
```
|
||||||
|
Существующая логика `/start` остаётся нетронутой.
|
||||||
|
|
||||||
|
### 5. `lifespan` в `main.py`
|
||||||
|
Никаких изменений — VAPID-ключи не требуют startup validation (unlike BOT_TOKEN), так как их инвалидация некритична для работы сервиса в целом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Спецификация новых компонентов
|
||||||
|
|
||||||
|
### `backend/db.py` — 3 новые функции
|
||||||
|
|
||||||
|
```
|
||||||
|
create_registration(email, login, password_hash, push_subscription) -> dict | None
|
||||||
|
INSERT INTO registrations ...
|
||||||
|
ON CONFLICT → raise aiosqlite.IntegrityError (caller catches → 409)
|
||||||
|
Returns: {"id", "email", "login", "created_at"}
|
||||||
|
|
||||||
|
get_registration_by_id(reg_id: int) -> dict | None
|
||||||
|
SELECT id, email, login, status, push_subscription FROM registrations WHERE id=?
|
||||||
|
|
||||||
|
update_registration_status(reg_id: int, status: str) -> dict | None
|
||||||
|
UPDATE registrations SET status=? WHERE id=?
|
||||||
|
Returns registration dict or None if not found
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/config.py` — новые переменные
|
||||||
|
|
||||||
|
```python
|
||||||
|
ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584")
|
||||||
|
VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "")
|
||||||
|
VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "")
|
||||||
|
VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "")
|
||||||
|
```
|
||||||
|
|
||||||
|
Все optional (не `_require`) — отсутствие VAPID только отключает Web Push, не ломает сервис.
|
||||||
|
|
||||||
|
### `backend/models.py` — новые Pydantic модели
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PushKeys(BaseModel):
|
||||||
|
p256dh: str
|
||||||
|
auth: str
|
||||||
|
|
||||||
|
class PushSubscription(BaseModel):
|
||||||
|
endpoint: str
|
||||||
|
keys: PushKeys
|
||||||
|
|
||||||
|
class AuthRegisterRequest(BaseModel):
|
||||||
|
email: str = Field(..., pattern=r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
||||||
|
login: str = Field(..., min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||||
|
password: str = Field(..., min_length=8)
|
||||||
|
push_subscription: Optional[PushSubscription] = None
|
||||||
|
|
||||||
|
class AuthRegisterResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/telegram.py` — 3 новые функции
|
||||||
|
|
||||||
|
```
|
||||||
|
send_registration_notification(login, email, reg_id, created_at) -> None
|
||||||
|
POST sendMessage с reply_markup=InlineKeyboardMarkup
|
||||||
|
chat_id = config.ADMIN_CHAT_ID
|
||||||
|
Swallows все ошибки (decision #1215)
|
||||||
|
|
||||||
|
answer_callback_query(callback_query_id: str, text: str = "") -> None
|
||||||
|
POST answerCallbackQuery
|
||||||
|
Swallows все ошибки
|
||||||
|
|
||||||
|
edit_message_text(chat_id: str | int, message_id: int, text: str) -> None
|
||||||
|
POST editMessageText
|
||||||
|
Swallows все ошибки
|
||||||
|
```
|
||||||
|
|
||||||
|
Все три используют тот же паттерн retry (3 попытки, 429/5xx) что и `send_message()`.
|
||||||
|
|
||||||
|
### `backend/push.py` — новый файл
|
||||||
|
|
||||||
|
```
|
||||||
|
send_push(subscription_json: str, title: str, body: str) -> None
|
||||||
|
Парсит subscription_json → dict
|
||||||
|
webpush(
|
||||||
|
subscription_info=subscription_dict,
|
||||||
|
data=json.dumps({"title": title, "body": body, "icon": "/icon-192.png"}),
|
||||||
|
vapid_private_key=config.VAPID_PRIVATE_KEY,
|
||||||
|
vapid_claims={"sub": f"mailto:{config.VAPID_CLAIMS_EMAIL}"}
|
||||||
|
)
|
||||||
|
Если VAPID_PRIVATE_KEY пустой → log warning, return (push disabled)
|
||||||
|
Swallows WebPushException и все прочие ошибки
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases и решения
|
||||||
|
|
||||||
|
| Кейс | Решение |
|
||||||
|
|------|---------|
|
||||||
|
| Email уже зарегистрирован | `IntegrityError` → HTTP 409 |
|
||||||
|
| Login уже занят | `IntegrityError` → HTTP 409 |
|
||||||
|
| Rejected пользователь пытается зарегистрироваться заново | 409 (статус не учитывается — оба поля UNIQUE) |
|
||||||
|
| push_subscription = null при approve | `if reg["push_subscription"]: send_push(...)` — skip gracefully |
|
||||||
|
| Истёкший/невалидный push endpoint | pywebpush raises → `logger.warning()` → swallow |
|
||||||
|
| Двойной клик Одобрить (admin кликает дважды) | UPDATE выполняется (idempotent), editMessageText может вернуть ошибку (уже отредактировано) → swallow |
|
||||||
|
| reg_id не существует в callback | `get_registration_by_id` returns None → log warning, answerCallbackQuery всё равно вызвать |
|
||||||
|
| VAPID ключи не настроены | Push не отправляется, log warning, сервис работает |
|
||||||
|
| Telegram недоступен при регистрации | Fire-and-forget + swallow — пользователь получает 201, уведомление теряется |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решения по open questions (из context_packet)
|
||||||
|
|
||||||
|
**VAPID ключи не сгенерированы:** Dev agent добавляет в README инструкцию по генерации:
|
||||||
|
```bash
|
||||||
|
python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); print(v.private_key, v.public_key)"
|
||||||
|
```
|
||||||
|
Ключи добавляются в `.env` вручную оператором перед деплоем.
|
||||||
|
|
||||||
|
**Повторный approve/reject:** Операция idempotent — UPDATE всегда выполняется без проверки текущего статуса. EditMessageText вернёт ошибку при повторном вызове — swallow.
|
||||||
|
|
||||||
|
**Service Worker:** Фронтенд вне скоупа этого тикета. Backend отправляет корректный Web Push payload — обработка на стороне клиента.
|
||||||
|
|
||||||
|
**Login после approve:** Механизм авторизации не входит в BATON-008. Регистрация — отдельный flow от аутентификации.
|
||||||
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);
|
||||||
360
frontend/app.js
360
frontend/app.js
|
|
@ -39,26 +39,26 @@ function _initStorage() {
|
||||||
|
|
||||||
// ========== User identity ==========
|
// ========== User identity ==========
|
||||||
|
|
||||||
function _getOrCreateUserId() {
|
|
||||||
let id = _storage.getItem('baton_user_id');
|
|
||||||
if (!id) {
|
|
||||||
id = crypto.randomUUID();
|
|
||||||
_storage.setItem('baton_user_id', id);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _isRegistered() {
|
function _isRegistered() {
|
||||||
return _storage.getItem('baton_registered') === '1';
|
return !!_storage.getItem('baton_auth_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getUserName() {
|
function _getUserName() {
|
||||||
return _storage.getItem('baton_user_name') || '';
|
return _storage.getItem('baton_login') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _saveRegistration(name) {
|
function _getAuthToken() {
|
||||||
_storage.setItem('baton_user_name', name);
|
return _storage.getItem('baton_auth_token') || '';
|
||||||
_storage.setItem('baton_registered', '1');
|
}
|
||||||
|
|
||||||
|
function _saveAuth(token, login) {
|
||||||
|
_storage.setItem('baton_auth_token', token);
|
||||||
|
_storage.setItem('baton_login', login);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearAuth() {
|
||||||
|
_storage.removeItem('baton_auth_token');
|
||||||
|
_storage.removeItem('baton_login');
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getInitials(name) {
|
function _getInitials(name) {
|
||||||
|
|
@ -87,6 +87,29 @@ function _setStatus(msg, cls) {
|
||||||
el.hidden = !msg;
|
el.hidden = !msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _setRegStatus(msg, cls) {
|
||||||
|
const el = document.getElementById('reg-status');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = 'reg-status' + (cls ? ' reg-status--' + cls : '');
|
||||||
|
el.hidden = !msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setLoginStatus(msg, cls) {
|
||||||
|
const el = document.getElementById('login-status');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = 'reg-status' + (cls ? ' reg-status--' + cls : '');
|
||||||
|
el.hidden = !msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showView(id) {
|
||||||
|
['view-login', 'view-register'].forEach((vid) => {
|
||||||
|
const el = document.getElementById(vid);
|
||||||
|
if (el) el.hidden = vid !== id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function _updateNetworkIndicator() {
|
function _updateNetworkIndicator() {
|
||||||
const el = document.getElementById('indicator-network');
|
const el = document.getElementById('indicator-network');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
@ -102,15 +125,17 @@ function _updateUserAvatar() {
|
||||||
|
|
||||||
// ========== API calls ==========
|
// ========== API calls ==========
|
||||||
|
|
||||||
async function _apiPost(path, body) {
|
async function _apiPost(path, body, extraHeaders) {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...extraHeaders },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '');
|
const text = await res.text().catch(() => '');
|
||||||
throw new Error('HTTP ' + res.status + (text ? ': ' + text : ''));
|
const err = new Error('HTTP ' + res.status + (text ? ': ' + text : ''));
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
@ -135,23 +160,38 @@ function _getGeo() {
|
||||||
|
|
||||||
// ========== Handlers ==========
|
// ========== Handlers ==========
|
||||||
|
|
||||||
async function _handleRegister() {
|
async function _handleLogin() {
|
||||||
const input = document.getElementById('name-input');
|
const loginInput = document.getElementById('login-input');
|
||||||
const btn = document.getElementById('btn-confirm');
|
const passInput = document.getElementById('login-password');
|
||||||
const name = input.value.trim();
|
const btn = document.getElementById('btn-login');
|
||||||
if (!name) return;
|
const login = loginInput.value.trim();
|
||||||
|
const password = passInput.value;
|
||||||
|
if (!login || !password) return;
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
_setStatus('', '');
|
_setLoginStatus('', '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uuid = _getOrCreateUserId();
|
const data = await _apiPost('/api/auth/login', {
|
||||||
await _apiPost('/api/register', { uuid, name });
|
login_or_email: login,
|
||||||
_saveRegistration(name);
|
password: password,
|
||||||
|
});
|
||||||
|
_saveAuth(data.token, data.login);
|
||||||
|
passInput.value = '';
|
||||||
_updateUserAvatar();
|
_updateUserAvatar();
|
||||||
_showMain();
|
_showMain();
|
||||||
} catch (_) {
|
} catch (err) {
|
||||||
_setStatus('Error. Please try again.', 'error');
|
let msg = 'Ошибка входа. Попробуйте ещё раз.';
|
||||||
|
if (err && err.message) {
|
||||||
|
const colonIdx = err.message.indexOf(': ');
|
||||||
|
if (colonIdx !== -1) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(err.message.slice(colonIdx + 2));
|
||||||
|
if (parsed.detail) msg = parsed.detail;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_setLoginStatus(msg, 'error');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -163,10 +203,43 @@ function _setSosState(state) {
|
||||||
btn.disabled = state === 'sending';
|
btn.disabled = state === 'sending';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _handleSignal() {
|
async function _handleTestSignal() {
|
||||||
// v1: no offline queue — show error and return (decision #1019)
|
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
_setStatus('No connection. Check your network and try again.', 'error');
|
_setStatus('Нет соединения.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = _getAuthToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
_setStatus('', '');
|
||||||
|
try {
|
||||||
|
const geo = await _getGeo();
|
||||||
|
const body = { timestamp: Date.now(), is_test: true };
|
||||||
|
if (geo) body.geo = geo;
|
||||||
|
await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token });
|
||||||
|
_setStatus('Тест отправлен', 'success');
|
||||||
|
setTimeout(() => _setStatus('', ''), 1500);
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.status === 401) {
|
||||||
|
_clearAuth();
|
||||||
|
_setStatus('Сессия истекла. Войдите заново.', 'error');
|
||||||
|
setTimeout(() => _showOnboarding(), 1500);
|
||||||
|
} else {
|
||||||
|
_setStatus('Ошибка отправки.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _handleSignal() {
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
_setStatus('Нет соединения. Проверьте сеть и попробуйте снова.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = _getAuthToken();
|
||||||
|
if (!token) {
|
||||||
|
_clearAuth();
|
||||||
|
_showOnboarding();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,21 +248,26 @@ async function _handleSignal() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const geo = await _getGeo();
|
const geo = await _getGeo();
|
||||||
const uuid = _getOrCreateUserId();
|
const body = { timestamp: Date.now() };
|
||||||
const body = { user_id: uuid, timestamp: Date.now() };
|
|
||||||
if (geo) body.geo = geo;
|
if (geo) body.geo = geo;
|
||||||
|
|
||||||
await _apiPost('/api/signal', body);
|
await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token });
|
||||||
|
|
||||||
_setSosState('success');
|
_setSosState('success');
|
||||||
_setStatus('Signal sent!', 'success');
|
_setStatus('Сигнал отправлен!', 'success');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
_setSosState('default');
|
_setSosState('default');
|
||||||
_setStatus('', '');
|
_setStatus('', '');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (_) {
|
} catch (err) {
|
||||||
_setSosState('default');
|
_setSosState('default');
|
||||||
_setStatus('Error sending. Try again.', 'error');
|
if (err && err.status === 401) {
|
||||||
|
_clearAuth();
|
||||||
|
_setStatus('Сессия истекла. Войдите заново.', 'error');
|
||||||
|
setTimeout(() => _showOnboarding(), 1500);
|
||||||
|
} else {
|
||||||
|
_setStatus('Ошибка отправки. Попробуйте ещё раз.', 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,17 +275,44 @@ async function _handleSignal() {
|
||||||
|
|
||||||
function _showOnboarding() {
|
function _showOnboarding() {
|
||||||
_showScreen('screen-onboarding');
|
_showScreen('screen-onboarding');
|
||||||
|
_showView('view-login');
|
||||||
|
|
||||||
const input = document.getElementById('name-input');
|
const loginInput = document.getElementById('login-input');
|
||||||
const btn = document.getElementById('btn-confirm');
|
const passInput = document.getElementById('login-password');
|
||||||
|
const btnLogin = document.getElementById('btn-login');
|
||||||
|
|
||||||
input.addEventListener('input', () => {
|
function _updateLoginBtn() {
|
||||||
btn.disabled = input.value.trim().length === 0;
|
btnLogin.disabled = !loginInput.value.trim() || !passInput.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginInput.addEventListener('input', _updateLoginBtn);
|
||||||
|
passInput.addEventListener('input', _updateLoginBtn);
|
||||||
|
passInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !btnLogin.disabled) _handleLogin();
|
||||||
});
|
});
|
||||||
input.addEventListener('keydown', (e) => {
|
btnLogin.addEventListener('click', _handleLogin);
|
||||||
if (e.key === 'Enter' && !btn.disabled) _handleRegister();
|
|
||||||
});
|
const btnToRegister = document.getElementById('btn-switch-to-register');
|
||||||
btn.addEventListener('click', _handleRegister);
|
if (btnToRegister) {
|
||||||
|
btnToRegister.addEventListener('click', () => {
|
||||||
|
_setRegStatus('', '');
|
||||||
|
_setLoginStatus('', '');
|
||||||
|
_showView('view-register');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnToLogin = document.getElementById('btn-switch-to-login');
|
||||||
|
if (btnToLogin) {
|
||||||
|
btnToLogin.addEventListener('click', () => {
|
||||||
|
_setLoginStatus('', '');
|
||||||
|
_showView('view-login');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnRegister = document.getElementById('btn-register');
|
||||||
|
if (btnRegister) {
|
||||||
|
btnRegister.addEventListener('click', _handleSignUp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showMain() {
|
function _showMain() {
|
||||||
|
|
@ -219,6 +324,20 @@ function _showMain() {
|
||||||
btn.addEventListener('click', _handleSignal);
|
btn.addEventListener('click', _handleSignal);
|
||||||
btn.dataset.listenerAttached = '1';
|
btn.dataset.listenerAttached = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avatar and network indicator → test signal (only on main screen)
|
||||||
|
const avatar = document.getElementById('user-avatar');
|
||||||
|
if (avatar && !avatar.dataset.testAttached) {
|
||||||
|
avatar.addEventListener('click', _handleTestSignal);
|
||||||
|
avatar.dataset.testAttached = '1';
|
||||||
|
avatar.style.cursor = 'pointer';
|
||||||
|
}
|
||||||
|
const indicator = document.getElementById('indicator-network');
|
||||||
|
if (indicator && !indicator.dataset.testAttached) {
|
||||||
|
indicator.addEventListener('click', _handleTestSignal);
|
||||||
|
indicator.dataset.testAttached = '1';
|
||||||
|
indicator.style.cursor = 'pointer';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Service Worker ==========
|
// ========== Service Worker ==========
|
||||||
|
|
@ -230,16 +349,150 @@ function _registerSW() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== VAPID / Push subscription ==========
|
||||||
|
|
||||||
|
async function _fetchVapidPublicKey() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/push/public-key');
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn('[baton] /api/push/public-key returned', res.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.vapid_public_key || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[baton] Failed to fetch VAPID public key:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _urlBase64ToUint8Array(base64String) {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const raw = atob(base64);
|
||||||
|
const output = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
output[i] = raw.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _initPushSubscription(vapidPublicKey) {
|
||||||
|
if (!vapidPublicKey) {
|
||||||
|
console.warn('[baton] VAPID public key not available — push subscription skipped');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const existing = await registration.pushManager.getSubscription();
|
||||||
|
if (existing) return;
|
||||||
|
const applicationServerKey = _urlBase64ToUint8Array(vapidPublicKey);
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey,
|
||||||
|
});
|
||||||
|
_storage.setItem('baton_push_subscription', JSON.stringify(subscription));
|
||||||
|
console.info('[baton] Push subscription created');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[baton] Push subscription failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Registration (account sign-up) ==========
|
||||||
|
|
||||||
|
async function _getPushSubscriptionForReg() {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
|
||||||
|
try {
|
||||||
|
const vapidKey = await _fetchVapidPublicKey();
|
||||||
|
if (!vapidKey) return null;
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const existing = await registration.pushManager.getSubscription();
|
||||||
|
if (existing) return existing.toJSON();
|
||||||
|
const applicationServerKey = _urlBase64ToUint8Array(vapidKey);
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey,
|
||||||
|
});
|
||||||
|
return subscription.toJSON();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[baton] Push subscription for registration failed:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _handleSignUp() {
|
||||||
|
const emailInput = document.getElementById('reg-email');
|
||||||
|
const loginInput = document.getElementById('reg-login');
|
||||||
|
const passwordInput = document.getElementById('reg-password');
|
||||||
|
const btn = document.getElementById('btn-register');
|
||||||
|
if (!emailInput || !loginInput || !passwordInput || !btn) return;
|
||||||
|
|
||||||
|
const email = emailInput.value.trim();
|
||||||
|
const login = loginInput.value.trim();
|
||||||
|
const password = passwordInput.value;
|
||||||
|
|
||||||
|
if (!email || !login || !password) {
|
||||||
|
_setRegStatus('Заполните все поля.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
_setRegStatus('Введите корректный email.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
const originalText = btn.textContent.trim();
|
||||||
|
btn.textContent = '...';
|
||||||
|
_setRegStatus('', '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const push_subscription = await _getPushSubscriptionForReg().catch(() => null);
|
||||||
|
await _apiPost('/api/auth/register', { email, login, password, push_subscription });
|
||||||
|
passwordInput.value = '';
|
||||||
|
_setRegStatus('Заявка отправлена. Ожидайте подтверждения администратора.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
let msg = 'Ошибка. Попробуйте ещё раз.';
|
||||||
|
if (err && err.message) {
|
||||||
|
const colonIdx = err.message.indexOf(': ');
|
||||||
|
if (colonIdx !== -1) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(err.message.slice(colonIdx + 2));
|
||||||
|
if (parsed.detail) msg = parsed.detail;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (err && err.status === 403 && msg !== 'Ошибка. Попробуйте ещё раз.') {
|
||||||
|
_showBlockScreen(msg);
|
||||||
|
} else {
|
||||||
|
_setRegStatus(msg, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showBlockScreen(msg) {
|
||||||
|
const screen = document.getElementById('screen-onboarding');
|
||||||
|
if (!screen) return;
|
||||||
|
screen.innerHTML =
|
||||||
|
'<div class="screen-content">' +
|
||||||
|
'<p class="block-message">' + msg + '</p>' +
|
||||||
|
'<button type="button" class="btn-confirm" id="btn-block-ok">OK</button>' +
|
||||||
|
'</div>';
|
||||||
|
document.getElementById('btn-block-ok').addEventListener('click', () => {
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Init ==========
|
// ========== Init ==========
|
||||||
|
|
||||||
function _init() {
|
function _init() {
|
||||||
_initStorage();
|
_initStorage();
|
||||||
|
|
||||||
// Pre-generate and persist UUID on first visit (per arch spec flow)
|
// Private mode graceful degradation (decision #1041)
|
||||||
_getOrCreateUserId();
|
|
||||||
|
|
||||||
// Private mode graceful degradation (decision #1041):
|
|
||||||
// show inline banner with explicit action guidance when localStorage is unavailable
|
|
||||||
if (_storageType !== 'local') {
|
if (_storageType !== 'local') {
|
||||||
const banner = document.getElementById('private-mode-banner');
|
const banner = document.getElementById('private-mode-banner');
|
||||||
if (banner) banner.hidden = false;
|
if (banner) banner.hidden = false;
|
||||||
|
|
@ -250,12 +503,17 @@ function _init() {
|
||||||
window.addEventListener('online', _updateNetworkIndicator);
|
window.addEventListener('online', _updateNetworkIndicator);
|
||||||
window.addEventListener('offline', _updateNetworkIndicator);
|
window.addEventListener('offline', _updateNetworkIndicator);
|
||||||
|
|
||||||
// Route to correct screen
|
// Route to correct screen based on JWT token presence
|
||||||
if (_isRegistered()) {
|
if (_isRegistered()) {
|
||||||
_showMain();
|
_showMain();
|
||||||
} else {
|
} else {
|
||||||
_showOnboarding();
|
_showOnboarding();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget: fetch VAPID key from API and subscribe to push (non-blocking)
|
||||||
|
_fetchVapidPublicKey().then(_initPushSubscription).catch((err) => {
|
||||||
|
console.warn('[baton] Push init error:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<meta name="description" content="Emergency signal button">
|
<meta name="description" content="Emergency signal button">
|
||||||
|
|
||||||
<!-- PWA meta tags -->
|
<!-- PWA meta tags -->
|
||||||
|
|
@ -36,23 +36,80 @@
|
||||||
|
|
||||||
<!-- Onboarding screen: shown on first visit (no UUID registered yet) -->
|
<!-- Onboarding screen: shown on first visit (no UUID registered yet) -->
|
||||||
<div id="screen-onboarding" class="screen" role="main" hidden>
|
<div id="screen-onboarding" class="screen" role="main" hidden>
|
||||||
<div class="screen-content">
|
|
||||||
|
<!-- View: login with credentials -->
|
||||||
|
<div class="screen-content" id="view-login">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name-input"
|
id="login-input"
|
||||||
class="name-input"
|
class="name-input"
|
||||||
placeholder="Your name"
|
placeholder="Логин или email"
|
||||||
maxlength="100"
|
maxlength="255"
|
||||||
autocomplete="name"
|
autocomplete="username"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="words"
|
autocapitalize="none"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
aria-label="Your name"
|
aria-label="Логин или email"
|
||||||
>
|
>
|
||||||
<button type="button" id="btn-confirm" class="btn-confirm" disabled>
|
<input
|
||||||
Confirm
|
type="password"
|
||||||
|
id="login-password"
|
||||||
|
class="name-input"
|
||||||
|
placeholder="Пароль"
|
||||||
|
autocomplete="current-password"
|
||||||
|
aria-label="Пароль"
|
||||||
|
>
|
||||||
|
<button type="button" id="btn-login" class="btn-confirm" disabled>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
<div id="login-status" class="reg-status" hidden></div>
|
||||||
|
<button type="button" id="btn-switch-to-register" class="btn-link">
|
||||||
|
Зарегистрироваться
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- View: account registration -->
|
||||||
|
<div class="screen-content" id="view-register" hidden>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="reg-email"
|
||||||
|
class="name-input"
|
||||||
|
placeholder="Email"
|
||||||
|
autocomplete="email"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-label="Email"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="reg-login"
|
||||||
|
class="name-input"
|
||||||
|
placeholder="Логин"
|
||||||
|
maxlength="64"
|
||||||
|
autocomplete="username"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-label="Логин"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="reg-password"
|
||||||
|
class="name-input"
|
||||||
|
placeholder="Пароль"
|
||||||
|
autocomplete="new-password"
|
||||||
|
aria-label="Пароль"
|
||||||
|
>
|
||||||
|
<button type="button" id="btn-register" class="btn-confirm">
|
||||||
|
Зарегистрироваться
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btn-switch-to-login" class="btn-link">
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
<div id="reg-status" class="reg-status" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main screen: SOS button -->
|
<!-- Main screen: SOS button -->
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,14 @@ html, body {
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
/* Use dynamic viewport height on mobile to account for browser chrome */
|
height: 100dvh;
|
||||||
min-height: 100dvh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Private mode banner (decision #1041) ===== */
|
/* ===== Private mode banner (decision #1041) ===== */
|
||||||
|
|
@ -59,6 +59,7 @@ body {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
|
padding-top: calc(env(safe-area-inset-top, 0px) + 16px);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,10 +149,8 @@ body {
|
||||||
/* ===== SOS button (min 60vmin × 60vmin per UX spec) ===== */
|
/* ===== SOS button (min 60vmin × 60vmin per UX spec) ===== */
|
||||||
|
|
||||||
.btn-sos {
|
.btn-sos {
|
||||||
width: 60vmin;
|
width: min(60vmin, 70vw, 300px);
|
||||||
height: 60vmin;
|
height: min(60vmin, 70vw, 300px);
|
||||||
min-width: 180px;
|
|
||||||
min-height: 180px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--sos);
|
background: var(--sos);
|
||||||
|
|
@ -198,3 +197,44 @@ body {
|
||||||
.status[hidden] { display: none; }
|
.status[hidden] { display: none; }
|
||||||
.status--error { color: #f87171; }
|
.status--error { color: #f87171; }
|
||||||
.status--success { color: #4ade80; }
|
.status--success { color: #4ade80; }
|
||||||
|
|
||||||
|
/* ===== Registration form ===== */
|
||||||
|
|
||||||
|
/* Override display:flex so [hidden] works on screen-content divs */
|
||||||
|
.screen-content[hidden] { display: none; }
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:active { color: var(--text); }
|
||||||
|
|
||||||
|
.reg-status {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reg-status[hidden] { display: none; }
|
||||||
|
.reg-status--error { color: #f87171; }
|
||||||
|
.reg-status--success { color: #4ade80; }
|
||||||
|
|
||||||
|
.block-message {
|
||||||
|
color: #f87171;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const CACHE_NAME = 'baton-v1';
|
const CACHE_NAME = 'baton-v4';
|
||||||
|
|
||||||
// App shell assets to precache
|
// App shell assets to precache
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
|
|
|
||||||
|
|
@ -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,63 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Security headers
|
||||||
|
# IMPORTANT: must be repeated in every location block that uses add_header,
|
||||||
|
# because nginx does not inherit parent add_header when child block defines its own.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always;
|
||||||
|
|
||||||
|
# Статика фронтенда (SPA)
|
||||||
|
location / {
|
||||||
|
root /opt/baton/frontend;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
expires 1h;
|
||||||
|
# Security headers repeated here because add_header in location blocks
|
||||||
|
# overrides parent-level add_header directives (nginx inheritance rule)
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always;
|
||||||
|
add_header Cache-Control "public" always;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
nginx/docker.conf
Normal file
61
nginx/docker.conf
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
map $request_uri $masked_uri {
|
||||||
|
default $request_uri;
|
||||||
|
"~^(/bot)[^/]+(/.*)$" "$1[REDACTED]$2";
|
||||||
|
}
|
||||||
|
|
||||||
|
log_format baton_secure '$remote_addr - $remote_user [$time_local] '
|
||||||
|
'"$request_method $masked_uri $server_protocol" '
|
||||||
|
'$status $body_bytes_sent '
|
||||||
|
'"$http_referer" "$http_user_agent"';
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/baton_access.log baton_secure;
|
||||||
|
error_log /var/log/nginx/baton_error.log warn;
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always;
|
||||||
|
|
||||||
|
# API + health + admin → backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://backend: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /admin/users {
|
||||||
|
proxy_pass http://backend: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend static
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public" always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,3 +4,5 @@ aiosqlite>=0.20.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
pydantic>=2.0
|
pydantic>=2.0
|
||||||
|
email-validator>=2.0.0
|
||||||
|
pywebpush>=2.0.0
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ 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")
|
||||||
|
os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
|
||||||
|
|
||||||
# ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
|
# ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
@ -68,16 +70,25 @@ def temp_db():
|
||||||
|
|
||||||
# ── 5. App client factory ────────────────────────────────────────────────────
|
# ── 5. App client factory ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def make_app_client():
|
def make_app_client(capture_send_requests: list | None = None):
|
||||||
"""
|
"""
|
||||||
Async context manager that:
|
Async context manager that:
|
||||||
1. Assigns a fresh temp-file DB path
|
1. Assigns a fresh temp-file DB path
|
||||||
2. Mocks Telegram setWebhook and sendMessage
|
2. Mocks Telegram setWebhook, sendMessage, answerCallbackQuery, editMessageText
|
||||||
3. Runs the FastAPI lifespan (startup → test → shutdown)
|
3. Runs the FastAPI lifespan (startup → test → shutdown)
|
||||||
4. Yields an httpx.AsyncClient wired to the app
|
4. Yields an httpx.AsyncClient wired to the app
|
||||||
|
|
||||||
|
Args:
|
||||||
|
capture_send_requests: if provided, each sendMessage request body (dict) is
|
||||||
|
appended to this list, enabling HTTP-level assertions on chat_id, text, etc.
|
||||||
"""
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
|
tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
|
||||||
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||||||
|
get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
|
||||||
|
answer_cb_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/answerCallbackQuery"
|
||||||
|
edit_msg_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/editMessageText"
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def _ctx():
|
async def _ctx():
|
||||||
|
|
@ -85,10 +96,28 @@ def make_app_client():
|
||||||
from backend.main import app
|
from backend.main import app
|
||||||
|
|
||||||
mock_router = respx.mock(assert_all_called=False)
|
mock_router = respx.mock(assert_all_called=False)
|
||||||
|
mock_router.get(get_me_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True, "result": {"username": "testbot"}})
|
||||||
|
)
|
||||||
mock_router.post(tg_set_url).mock(
|
mock_router.post(tg_set_url).mock(
|
||||||
return_value=httpx.Response(200, json={"ok": True, "result": True})
|
return_value=httpx.Response(200, json={"ok": True, "result": True})
|
||||||
)
|
)
|
||||||
mock_router.post(send_url).mock(
|
if capture_send_requests is not None:
|
||||||
|
def _capture_send(request: httpx.Request) -> httpx.Response:
|
||||||
|
try:
|
||||||
|
capture_send_requests.append(_json.loads(request.content))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return httpx.Response(200, json={"ok": True})
|
||||||
|
mock_router.post(send_url).mock(side_effect=_capture_send)
|
||||||
|
else:
|
||||||
|
mock_router.post(send_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
mock_router.post(answer_cb_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
mock_router.post(edit_msg_url).mock(
|
||||||
return_value=httpx.Response(200, json={"ok": True})
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ Acceptance criteria:
|
||||||
1. No asyncio task for the aggregator is created at lifespan startup.
|
1. No asyncio task for the aggregator is created at lifespan startup.
|
||||||
2. POST /api/signal calls telegram.send_message directly (no aggregator intermediary).
|
2. POST /api/signal calls telegram.send_message directly (no aggregator intermediary).
|
||||||
3. SignalAggregator class in telegram.py is preserved with '# v2.0 feature' marker.
|
3. SignalAggregator class in telegram.py is preserved with '# v2.0 feature' marker.
|
||||||
|
|
||||||
|
UUID notes: all UUIDs satisfy the UUID v4 pattern.
|
||||||
|
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
|
||||||
|
Tests that send signals register first and use the returned api_key.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -15,6 +19,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")
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
@ -25,6 +30,20 @@ from tests.conftest import make_app_client
|
||||||
|
|
||||||
_BACKEND_DIR = Path(__file__).parent.parent / "backend"
|
_BACKEND_DIR = Path(__file__).parent.parent / "backend"
|
||||||
|
|
||||||
|
# Valid UUID v4 constants
|
||||||
|
_UUID_S1 = "a0100001-0000-4000-8000-000000000001"
|
||||||
|
_UUID_S2 = "a0100002-0000-4000-8000-000000000002"
|
||||||
|
_UUID_S3 = "a0100003-0000-4000-8000-000000000003"
|
||||||
|
_UUID_S4 = "a0100004-0000-4000-8000-000000000004"
|
||||||
|
_UUID_S5 = "a0100005-0000-4000-8000-000000000005"
|
||||||
|
|
||||||
|
|
||||||
|
async def _register(client, uuid: str, name: str) -> str:
|
||||||
|
"""Register user and return api_key."""
|
||||||
|
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
|
||||||
|
assert r.status_code == 200
|
||||||
|
return r.json()["api_key"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Criterion 1 — No asyncio task for aggregator created at startup (static)
|
# Criterion 1 — No asyncio task for aggregator created at startup (static)
|
||||||
|
|
@ -72,11 +91,12 @@ def test_aggregator_instantiation_commented_out_in_main():
|
||||||
async def test_signal_calls_telegram_send_message_directly():
|
async def test_signal_calls_telegram_send_message_directly():
|
||||||
"""POST /api/signal must call telegram.send_message directly, not via aggregator (ADR-004)."""
|
"""POST /api/signal must call telegram.send_message directly, not via aggregator (ADR-004)."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
await client.post("/api/register", json={"uuid": "adr-uuid-s1", "name": "Tester"})
|
api_key = await _register(client, _UUID_S1, "Tester")
|
||||||
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
|
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={"user_id": "adr-uuid-s1", "timestamp": 1742478000000},
|
json={"user_id": _UUID_S1, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
mock_send.assert_called_once()
|
mock_send.assert_called_once()
|
||||||
|
|
@ -86,11 +106,12 @@ async def test_signal_calls_telegram_send_message_directly():
|
||||||
async def test_signal_message_contains_registered_username():
|
async def test_signal_message_contains_registered_username():
|
||||||
"""Message passed to send_message must include the registered user's name."""
|
"""Message passed to send_message must include the registered user's name."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
await client.post("/api/register", json={"uuid": "adr-uuid-s2", "name": "Alice"})
|
api_key = await _register(client, _UUID_S2, "Alice")
|
||||||
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
|
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={"user_id": "adr-uuid-s2", "timestamp": 1742478000000},
|
json={"user_id": _UUID_S2, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
text = mock_send.call_args[0][0]
|
text = mock_send.call_args[0][0]
|
||||||
assert "Alice" in text
|
assert "Alice" in text
|
||||||
|
|
@ -98,31 +119,33 @@ async def test_signal_message_contains_registered_username():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_signal_message_without_geo_contains_bez_geolocatsii():
|
async def test_signal_message_without_geo_contains_bez_geolocatsii():
|
||||||
"""When geo is None, message must contain 'Без геолокации'."""
|
"""When geo is None, message must contain 'Гео нету'."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
await client.post("/api/register", json={"uuid": "adr-uuid-s3", "name": "Bob"})
|
api_key = await _register(client, _UUID_S3, "Bob")
|
||||||
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
|
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={"user_id": "adr-uuid-s3", "timestamp": 1742478000000, "geo": None},
|
json={"user_id": _UUID_S3, "timestamp": 1742478000000, "geo": None},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
text = mock_send.call_args[0][0]
|
text = mock_send.call_args[0][0]
|
||||||
assert "Без геолокации" in text
|
assert "Гео нету" in text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_signal_message_with_geo_contains_coordinates():
|
async def test_signal_message_with_geo_contains_coordinates():
|
||||||
"""When geo is provided, message must contain lat and lon values."""
|
"""When geo is provided, message must contain lat and lon values."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
await client.post("/api/register", json={"uuid": "adr-uuid-s4", "name": "Charlie"})
|
api_key = await _register(client, _UUID_S4, "Charlie")
|
||||||
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
|
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={
|
json={
|
||||||
"user_id": "adr-uuid-s4",
|
"user_id": _UUID_S4,
|
||||||
"timestamp": 1742478000000,
|
"timestamp": 1742478000000,
|
||||||
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
|
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
|
||||||
},
|
},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
text = mock_send.call_args[0][0]
|
text = mock_send.call_args[0][0]
|
||||||
assert "55.7558" in text
|
assert "55.7558" in text
|
||||||
|
|
@ -133,49 +156,29 @@ async def test_signal_message_with_geo_contains_coordinates():
|
||||||
async def test_signal_message_contains_utc_marker():
|
async def test_signal_message_contains_utc_marker():
|
||||||
"""Message passed to send_message must contain 'UTC' timestamp marker."""
|
"""Message passed to send_message must contain 'UTC' timestamp marker."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
await client.post("/api/register", json={"uuid": "adr-uuid-s5", "name": "Dave"})
|
api_key = await _register(client, _UUID_S5, "Dave")
|
||||||
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
|
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={"user_id": "adr-uuid-s5", "timestamp": 1742478000000},
|
json={"user_id": _UUID_S5, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
text = mock_send.call_args[0][0]
|
text = mock_send.call_args[0][0]
|
||||||
assert "UTC" in text
|
assert "UTC" in text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_signal_unknown_user_message_uses_uuid_prefix():
|
|
||||||
"""When user is not registered, message uses first 8 chars of uuid as name."""
|
|
||||||
async with make_app_client() as client:
|
|
||||||
with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send:
|
|
||||||
await client.post(
|
|
||||||
"/api/signal",
|
|
||||||
json={"user_id": "unknown-uuid-xyz", "timestamp": 1742478000000},
|
|
||||||
)
|
|
||||||
text = mock_send.call_args[0][0]
|
|
||||||
assert "unknown-" in text # "unknown-uuid-xyz"[:8] == "unknown-"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Criterion 3 — SignalAggregator preserved with '# v2.0 feature' marker (static)
|
# Criterion 3 — SignalAggregator removed (BATON-BIZ-004: dead code cleanup)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_signal_aggregator_class_preserved_in_telegram():
|
def test_signal_aggregator_class_removed_from_telegram():
|
||||||
"""SignalAggregator class must still exist in telegram.py (ADR-004, reserved for v2)."""
|
"""SignalAggregator must be removed from telegram.py (BATON-BIZ-004)."""
|
||||||
source = (_BACKEND_DIR / "telegram.py").read_text()
|
source = (_BACKEND_DIR / "telegram.py").read_text()
|
||||||
assert "class SignalAggregator" in source
|
assert "class SignalAggregator" not in source
|
||||||
|
|
||||||
|
|
||||||
def test_signal_aggregator_has_v2_feature_comment():
|
def test_signal_aggregator_not_referenced_in_telegram():
|
||||||
"""The line immediately before 'class SignalAggregator' must contain '# v2.0 feature'."""
|
"""telegram.py must not reference SignalAggregator at all (BATON-BIZ-004)."""
|
||||||
lines = (_BACKEND_DIR / "telegram.py").read_text().splitlines()
|
source = (_BACKEND_DIR / "telegram.py").read_text()
|
||||||
class_line_idx = next(
|
assert "SignalAggregator" not in source
|
||||||
(i for i, line in enumerate(lines) if "class SignalAggregator" in line), None
|
|
||||||
)
|
|
||||||
assert class_line_idx is not None, "class SignalAggregator not found in telegram.py"
|
|
||||||
assert class_line_idx > 0, "SignalAggregator is on the first line — no preceding comment line"
|
|
||||||
preceding_line = lines[class_line_idx - 1]
|
|
||||||
assert "# v2.0 feature" in preceding_line, (
|
|
||||||
f"Expected '# v2.0 feature' on line before class SignalAggregator, got: {preceding_line!r}"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ Acceptance criteria:
|
||||||
5 requests pass (200), 6th returns 429; counter resets after the 10-minute window.
|
5 requests pass (200), 6th returns 429; counter resets after the 10-minute window.
|
||||||
2. Token comparison is timing-safe:
|
2. Token comparison is timing-safe:
|
||||||
secrets.compare_digest is used in middleware.py (no == / != for token comparison).
|
secrets.compare_digest is used in middleware.py (no == / != for token comparison).
|
||||||
|
|
||||||
|
UUID notes: RegisterRequest.uuid requires a valid UUID v4 pattern.
|
||||||
|
All UUID constants below satisfy this constraint.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -20,6 +23,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")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from tests.conftest import make_app_client
|
from tests.conftest import make_app_client
|
||||||
|
|
@ -38,6 +42,24 @@ _SAMPLE_UPDATE = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Valid UUID v4 constants for rate-limit tests
|
||||||
|
# Pattern: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}
|
||||||
|
_UUIDS_OK = [
|
||||||
|
f"d0{i:06d}-0000-4000-8000-000000000001"
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
_UUIDS_BLK = [
|
||||||
|
f"d1{i:06d}-0000-4000-8000-000000000001"
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
_UUIDS_EXP = [
|
||||||
|
f"d2{i:06d}-0000-4000-8000-000000000001"
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
_UUID_BLK_999 = "d1000999-0000-4000-8000-000000000001"
|
||||||
|
_UUID_EXP_BLK = "d2000999-0000-4000-8000-000000000001"
|
||||||
|
_UUID_EXP_AFTER = "d2001000-0000-4000-8000-000000000001"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Criterion 1 — Rate limiting: first 5 requests pass
|
# Criterion 1 — Rate limiting: first 5 requests pass
|
||||||
|
|
@ -51,7 +73,7 @@ async def test_register_rate_limit_allows_five_requests():
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": f"rl-ok-{i:03d}", "name": f"User{i}"},
|
json={"uuid": _UUIDS_OK[i], "name": f"User{i}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200, (
|
assert resp.status_code == 200, (
|
||||||
f"Request {i + 1}/5 unexpectedly returned {resp.status_code}"
|
f"Request {i + 1}/5 unexpectedly returned {resp.status_code}"
|
||||||
|
|
@ -70,11 +92,11 @@ async def test_register_rate_limit_blocks_sixth_request():
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": f"rl-blk-{i:03d}", "name": f"User{i}"},
|
json={"uuid": _UUIDS_BLK[i], "name": f"User{i}"},
|
||||||
)
|
)
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": "rl-blk-999", "name": "Attacker"},
|
json={"uuid": _UUID_BLK_999, "name": "Attacker"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 429
|
assert resp.status_code == 429
|
||||||
|
|
||||||
|
|
@ -94,13 +116,13 @@ async def test_register_rate_limit_resets_after_window_expires():
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": f"rl-exp-{i:03d}", "name": f"User{i}"},
|
json={"uuid": _UUIDS_EXP[i], "name": f"User{i}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify the 6th is blocked before window expiry
|
# Verify the 6th is blocked before window expiry
|
||||||
blocked = await client.post(
|
blocked = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": "rl-exp-blk", "name": "Attacker"},
|
json={"uuid": _UUID_EXP_BLK, "name": "Attacker"},
|
||||||
)
|
)
|
||||||
assert blocked.status_code == 429, (
|
assert blocked.status_code == 429, (
|
||||||
"Expected 429 after exhausting rate limit, got " + str(blocked.status_code)
|
"Expected 429 after exhausting rate limit, got " + str(blocked.status_code)
|
||||||
|
|
@ -110,7 +132,7 @@ async def test_register_rate_limit_resets_after_window_expires():
|
||||||
with patch("time.time", return_value=base_time + 601):
|
with patch("time.time", return_value=base_time + 601):
|
||||||
resp_after = await client.post(
|
resp_after = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": "rl-exp-after", "name": "Legit"},
|
json={"uuid": _UUID_EXP_AFTER, "name": "Legit"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert resp_after.status_code == 200, (
|
assert resp_after.status_code == 200, (
|
||||||
|
|
|
||||||
|
|
@ -222,9 +222,9 @@ def test_html_loads_app_js() -> None:
|
||||||
assert "/app.js" in _html()
|
assert "/app.js" in _html()
|
||||||
|
|
||||||
|
|
||||||
def test_html_has_name_input() -> None:
|
def test_html_has_login_input() -> None:
|
||||||
"""index.html must have name input field for onboarding."""
|
"""index.html must have login input field for onboarding."""
|
||||||
assert 'id="name-input"' in _html()
|
assert 'id="login-input"' in _html()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -316,31 +316,19 @@ def _app_js() -> str:
|
||||||
return (FRONTEND / "app.js").read_text(encoding="utf-8")
|
return (FRONTEND / "app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_app_uses_crypto_random_uuid() -> None:
|
def test_app_posts_to_auth_login() -> None:
|
||||||
"""app.js must generate UUID via crypto.randomUUID()."""
|
"""app.js must send POST to /api/auth/login during login."""
|
||||||
assert "crypto.randomUUID()" in _app_js()
|
assert "/api/auth/login" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
def test_app_posts_to_api_register() -> None:
|
def test_app_posts_to_auth_register() -> None:
|
||||||
"""app.js must send POST to /api/register during onboarding."""
|
"""app.js must send POST to /api/auth/register during registration."""
|
||||||
assert "/api/register" in _app_js()
|
assert "/api/auth/register" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
def test_app_register_sends_uuid() -> None:
|
def test_app_stores_auth_token() -> None:
|
||||||
"""app.js must include uuid in the /api/register request body."""
|
"""app.js must persist JWT token to storage."""
|
||||||
app = _app_js()
|
assert "baton_auth_token" in _app_js()
|
||||||
# The register call must include uuid in the payload
|
|
||||||
register_section = re.search(
|
|
||||||
r"_apiPost\(['\"]\/api\/register['\"].*?\)", app, re.DOTALL
|
|
||||||
)
|
|
||||||
assert register_section, "No _apiPost('/api/register') call found"
|
|
||||||
assert "uuid" in register_section.group(0), \
|
|
||||||
"uuid not included in /api/register call"
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_uuid_saved_to_storage() -> None:
|
|
||||||
"""app.js must persist UUID to storage (baton_user_id key)."""
|
|
||||||
assert "baton_user_id" in _app_js()
|
|
||||||
assert "setItem" in _app_js()
|
assert "setItem" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -434,16 +422,14 @@ def test_app_posts_to_api_signal() -> None:
|
||||||
assert "/api/signal" in _app_js()
|
assert "/api/signal" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
def test_app_signal_sends_user_id() -> None:
|
def test_app_signal_sends_auth_header() -> None:
|
||||||
"""app.js must include user_id (UUID) in the /api/signal request body."""
|
"""app.js must include Authorization Bearer header in /api/signal request."""
|
||||||
app = _app_js()
|
app = _app_js()
|
||||||
# The signal body may be built in a variable before passing to _apiPost
|
|
||||||
# Look for user_id key in the context around /api/signal
|
|
||||||
signal_area = re.search(
|
signal_area = re.search(
|
||||||
r"user_id.*?_apiPost\(['\"]\/api\/signal", app, re.DOTALL
|
r"_apiPost\(['\"]\/api\/signal['\"].*Authorization.*Bearer", app, re.DOTALL
|
||||||
)
|
)
|
||||||
assert signal_area, \
|
assert signal_area, \
|
||||||
"user_id must be set in the request body before calling _apiPost('/api/signal')"
|
"Authorization Bearer header must be set in _apiPost('/api/signal') call"
|
||||||
|
|
||||||
|
|
||||||
def test_app_sos_button_click_calls_handle_signal() -> None:
|
def test_app_sos_button_click_calls_handle_signal() -> None:
|
||||||
|
|
@ -456,15 +442,15 @@ def test_app_sos_button_click_calls_handle_signal() -> None:
|
||||||
"btn-sos must be connected to _handleSignal"
|
"btn-sos must be connected to _handleSignal"
|
||||||
|
|
||||||
|
|
||||||
def test_app_signal_uses_uuid_from_storage() -> None:
|
def test_app_signal_uses_token_from_storage() -> None:
|
||||||
"""app.js must retrieve UUID from storage (_getOrCreateUserId) before sending signal."""
|
"""app.js must retrieve auth token from storage before sending signal."""
|
||||||
app = _app_js()
|
app = _app_js()
|
||||||
handle_signal = re.search(
|
handle_signal = re.search(
|
||||||
r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL
|
r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL
|
||||||
)
|
)
|
||||||
assert handle_signal, "_handleSignal function not found"
|
assert handle_signal, "_handleSignal function not found"
|
||||||
assert "_getOrCreateUserId" in handle_signal.group(0), \
|
assert "_getAuthToken" in handle_signal.group(0), \
|
||||||
"_handleSignal must call _getOrCreateUserId() to get UUID"
|
"_handleSignal must call _getAuthToken() to get JWT token"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,14 @@ async def test_health_returns_status_ok():
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_health_returns_timestamp():
|
async def test_health_no_timestamp():
|
||||||
"""GET /health должен вернуть поле timestamp в JSON."""
|
"""GET /health не должен возвращать поле timestamp (устраняет time-based fingerprinting)."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
response = await client.get("/health")
|
response = await client.get("/health")
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "timestamp" in data
|
assert "timestamp" not in data
|
||||||
assert isinstance(data["timestamp"], int)
|
assert data == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
527
tests/test_baton_005.py
Normal file
527
tests/test_baton_005.py
Normal file
|
|
@ -0,0 +1,527 @@
|
||||||
|
"""
|
||||||
|
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. Отсутствие регрессии с основным функционалом
|
||||||
|
|
||||||
|
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
|
||||||
|
Tests 3 and 4 (block/unblock + signal) use /api/register to obtain an api_key,
|
||||||
|
then admin block/unblock the user by their DB id.
|
||||||
|
"""
|
||||||
|
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"}
|
||||||
|
|
||||||
|
# Valid UUID v4 for signal-related tests (registered via /api/register)
|
||||||
|
_UUID_BLOCK = "f0000001-0000-4000-8000-000000000001"
|
||||||
|
_UUID_UNBLOCK = "f0000002-0000-4000-8000-000000000002"
|
||||||
|
_UUID_SIG_OK = "f0000003-0000-4000-8000-000000000003"
|
||||||
|
|
||||||
|
# Valid UUID v4 for admin-only tests (POST /admin/users, no /api/register call)
|
||||||
|
_UUID_ADM_UNAUTH = "e0000000-0000-4000-8000-000000000000"
|
||||||
|
_UUID_ADM_CREATE_1 = "e0000001-0000-4000-8000-000000000001"
|
||||||
|
_UUID_ADM_CREATE_2 = "e0000002-0000-4000-8000-000000000002"
|
||||||
|
_UUID_ADM_CREATE_3 = "e0000003-0000-4000-8000-000000000003"
|
||||||
|
_UUID_ADM_PASS_1 = "e0000004-0000-4000-8000-000000000004"
|
||||||
|
_UUID_ADM_PASS_2 = "e0000005-0000-4000-8000-000000000005"
|
||||||
|
_UUID_ADM_BLOCK = "e0000006-0000-4000-8000-000000000006"
|
||||||
|
_UUID_ADM_UNBLOCK = "e0000007-0000-4000-8000-000000000007"
|
||||||
|
_UUID_ADM_DELETE_1 = "e0000008-0000-4000-8000-000000000008"
|
||||||
|
_UUID_ADM_DELETE_2 = "e0000009-0000-4000-8000-000000000009"
|
||||||
|
_UUID_ADM_REGRESS = "e000000a-0000-4000-8000-000000000010"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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": _UUID_ADM_UNAUTH, "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": _UUID_ADM_CREATE_1, "name": "Alice Admin"},
|
||||||
|
headers=ADMIN_HEADERS,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["uuid"] == _UUID_ADM_CREATE_1
|
||||||
|
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": _UUID_ADM_CREATE_2, "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 _UUID_ADM_CREATE_2 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": _UUID_ADM_CREATE_3, "name": "Carol"},
|
||||||
|
headers=ADMIN_HEADERS,
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/users",
|
||||||
|
json={"uuid": _UUID_ADM_CREATE_3, "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": _UUID_ADM_PASS_1, "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": _UUID_ADM_PASS_2, "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 _UUID_ADM_PASS_2 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": _UUID_ADM_BLOCK, "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:
|
||||||
|
# Регистрируем через /api/register чтобы получить api_key
|
||||||
|
reg_resp = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_BLOCK, "name": "BlockSignalUser"},
|
||||||
|
)
|
||||||
|
assert reg_resp.status_code == 200
|
||||||
|
api_key = reg_resp.json()["api_key"]
|
||||||
|
user_uuid = reg_resp.json()["uuid"]
|
||||||
|
|
||||||
|
# Находим ID пользователя
|
||||||
|
users_resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
|
||||||
|
user = next(u for u in users_resp.json() if u["uuid"] == user_uuid)
|
||||||
|
user_id = user["id"]
|
||||||
|
|
||||||
|
# Блокируем
|
||||||
|
await client.put(
|
||||||
|
f"/admin/users/{user_id}/block",
|
||||||
|
json={"is_blocked": True},
|
||||||
|
headers=ADMIN_HEADERS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Заблокированный пользователь должен получить 403
|
||||||
|
signal_resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
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": _UUID_ADM_UNBLOCK, "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:
|
||||||
|
# Регистрируем через /api/register чтобы получить api_key
|
||||||
|
reg_resp = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_UNBLOCK, "name": "UnblockSignalUser"},
|
||||||
|
)
|
||||||
|
assert reg_resp.status_code == 200
|
||||||
|
api_key = reg_resp.json()["api_key"]
|
||||||
|
user_uuid = reg_resp.json()["uuid"]
|
||||||
|
|
||||||
|
# Находим ID
|
||||||
|
users_resp = await client.get("/admin/users", headers=ADMIN_HEADERS)
|
||||||
|
user = next(u for u in users_resp.json() if u["uuid"] == user_uuid)
|
||||||
|
user_id = user["id"]
|
||||||
|
|
||||||
|
# Блокируем
|
||||||
|
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},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
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": _UUID_ADM_DELETE_1, "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": _UUID_ADM_DELETE_2, "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 _UUID_ADM_DELETE_2 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": _UUID_ADM_REGRESS, "name": "AdminCreated"},
|
||||||
|
headers=ADMIN_HEADERS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Основной функционал
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_SIG_OK, "name": "RegularUser"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["uuid"] == _UUID_SIG_OK
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_from_registered_unblocked_user_succeeds() -> None:
|
||||||
|
"""Зарегистрированный незаблокированный пользователь может отправить сигнал."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg_resp = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_SIG_OK, "name": "SignalUser"},
|
||||||
|
)
|
||||||
|
assert reg_resp.status_code == 200
|
||||||
|
api_key = reg_resp.json()["api_key"]
|
||||||
|
user_uuid = reg_resp.json()["uuid"]
|
||||||
|
|
||||||
|
signal_resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": user_uuid, "timestamp": 1700000000000, "geo": None},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
assert signal_resp.status_code == 200
|
||||||
|
assert signal_resp.json()["status"] == "ok"
|
||||||
244
tests/test_baton_006.py
Normal file
244
tests/test_baton_006.py
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
"""
|
||||||
|
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 с корректным секретом.
|
||||||
|
|
||||||
|
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
|
||||||
|
UUID constants satisfy the UUID v4 pattern.
|
||||||
|
"""
|
||||||
|
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"
|
||||||
|
|
||||||
|
# Valid UUID v4 constants
|
||||||
|
_UUID_REG = "e0000001-0000-4000-8000-000000000001"
|
||||||
|
_UUID_SIG = "e0000002-0000-4000-8000-000000000002"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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_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")
|
||||||
|
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": _UUID_REG, "name": "TestUser"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["user_id"] > 0
|
||||||
|
assert data["uuid"] == _UUID_REG
|
||||||
|
assert "api_key" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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:
|
||||||
|
reg_resp = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_SIG, "name": "SignalUser"},
|
||||||
|
)
|
||||||
|
assert reg_resp.status_code == 200
|
||||||
|
api_key = reg_resp.json()["api_key"]
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={
|
||||||
|
"user_id": _UUID_SIG,
|
||||||
|
"timestamp": 1700000000000,
|
||||||
|
"geo": None,
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
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}
|
||||||
381
tests/test_baton_007.py
Normal file
381
tests/test_baton_007.py
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-007: Verifying real Telegram delivery when a signal is sent.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. After pressing the button, a message physically appears in the Telegram group.
|
||||||
|
(verified: send_message is called with correct content containing user name)
|
||||||
|
2. journalctl -u baton does NOT throw ERROR during send.
|
||||||
|
(verified: no exception is raised when Telegram returns 200)
|
||||||
|
3. A repeated request is also delivered.
|
||||||
|
(verified: two consecutive signals each trigger send_message)
|
||||||
|
|
||||||
|
NOTE: These tests verify that send_message is called with correct parameters.
|
||||||
|
Physical delivery to an actual Telegram group is outside unit test scope.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
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 json
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
from tests.conftest import make_app_client, temp_db
|
||||||
|
|
||||||
|
# Valid UUID v4 constants — must not collide with UUIDs in other test files
|
||||||
|
_UUID_A = "d0000001-0000-4000-8000-000000000001"
|
||||||
|
_UUID_B = "d0000002-0000-4000-8000-000000000002"
|
||||||
|
_UUID_C = "d0000003-0000-4000-8000-000000000003"
|
||||||
|
_UUID_D = "d0000004-0000-4000-8000-000000000004"
|
||||||
|
_UUID_E = "d0000005-0000-4000-8000-000000000005"
|
||||||
|
_UUID_F = "d0000006-0000-4000-8000-000000000006"
|
||||||
|
|
||||||
|
|
||||||
|
async def _register(client: AsyncClient, uuid: str, name: str) -> str:
|
||||||
|
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
|
||||||
|
assert r.status_code == 200, f"Registration failed: {r.status_code} {r.text}"
|
||||||
|
return r.json()["api_key"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — send_message is called with text containing the user's name
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_send_message_called_with_user_name():
|
||||||
|
"""Criterion 1: send_message is invoked with text that includes the sender's name."""
|
||||||
|
sent_texts: list[str] = []
|
||||||
|
|
||||||
|
async def _capture(text: str) -> None:
|
||||||
|
sent_texts.append(text)
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
api_key = await _register(client, _UUID_A, "AliceBaton")
|
||||||
|
|
||||||
|
with patch("backend.telegram.send_message", side_effect=_capture):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_A, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0) # yield to event loop so background task runs
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(sent_texts) == 1, f"Expected 1 send_message call, got {len(sent_texts)}"
|
||||||
|
assert "AliceBaton" in sent_texts[0], (
|
||||||
|
f"Expected user name 'AliceBaton' in Telegram message, got: {sent_texts[0]!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_send_message_text_contains_signal_keyword():
|
||||||
|
"""Criterion 1: Telegram message text contains the word 'Сигнал'."""
|
||||||
|
sent_texts: list[str] = []
|
||||||
|
|
||||||
|
async def _capture(text: str) -> None:
|
||||||
|
sent_texts.append(text)
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
api_key = await _register(client, _UUID_B, "BobBaton")
|
||||||
|
|
||||||
|
with patch("backend.telegram.send_message", side_effect=_capture):
|
||||||
|
await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_B, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(sent_texts) == 1
|
||||||
|
assert "Сигнал" in sent_texts[0], (
|
||||||
|
f"Expected 'Сигнал' keyword in message, got: {sent_texts[0]!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_with_geo_send_message_contains_coordinates():
|
||||||
|
"""Criterion 1: when geo is provided, Telegram message includes lat/lon coordinates."""
|
||||||
|
sent_texts: list[str] = []
|
||||||
|
|
||||||
|
async def _capture(text: str) -> None:
|
||||||
|
sent_texts.append(text)
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
api_key = await _register(client, _UUID_C, "GeoUser")
|
||||||
|
|
||||||
|
with patch("backend.telegram.send_message", side_effect=_capture):
|
||||||
|
await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={
|
||||||
|
"user_id": _UUID_C,
|
||||||
|
"timestamp": 1742478000000,
|
||||||
|
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(sent_texts) == 1
|
||||||
|
assert "55.7558" in sent_texts[0], (
|
||||||
|
f"Expected lat '55.7558' in message, got: {sent_texts[0]!r}"
|
||||||
|
)
|
||||||
|
assert "37.6173" in sent_texts[0], (
|
||||||
|
f"Expected lon '37.6173' in message, got: {sent_texts[0]!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_without_geo_send_message_contains_no_geo_label():
|
||||||
|
"""Criterion 1: when geo is null, Telegram message contains 'Гео нету'."""
|
||||||
|
sent_texts: list[str] = []
|
||||||
|
|
||||||
|
async def _capture(text: str) -> None:
|
||||||
|
sent_texts.append(text)
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
api_key = await _register(client, _UUID_D, "NoGeoUser")
|
||||||
|
|
||||||
|
with patch("backend.telegram.send_message", side_effect=_capture):
|
||||||
|
await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_D, "timestamp": 1742478000000, "geo": None},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(sent_texts) == 1
|
||||||
|
assert "Гео нету" in sent_texts[0], (
|
||||||
|
f"Expected 'Гео нету' in message, got: {sent_texts[0]!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — No ERROR logged on successful send (service stays alive)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_send_message_no_error_on_200_response():
|
||||||
|
"""Criterion 2: send_message does not raise when Telegram returns 200."""
|
||||||
|
from backend import config as _cfg
|
||||||
|
from backend.telegram import send_message
|
||||||
|
|
||||||
|
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
|
||||||
|
|
||||||
|
# Must complete without exception
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.post(send_url).mock(return_value=httpx.Response(200, json={"ok": True}))
|
||||||
|
await send_message("Test signal delivery") # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_send_message_uses_configured_chat_id():
|
||||||
|
"""Criterion 2: send_message POSTs to Telegram with the configured CHAT_ID."""
|
||||||
|
from backend import config as _cfg
|
||||||
|
from backend.telegram import send_message
|
||||||
|
|
||||||
|
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
route = mock.post(send_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
await send_message("Delivery check")
|
||||||
|
|
||||||
|
assert route.called
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["chat_id"] == _cfg.CHAT_ID, (
|
||||||
|
f"Expected chat_id={_cfg.CHAT_ID!r}, got {body['chat_id']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 3 — Repeated requests are also delivered
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repeated_signals_each_trigger_send_message():
|
||||||
|
"""Criterion 3: two consecutive signals each cause a separate send_message call."""
|
||||||
|
sent_texts: list[str] = []
|
||||||
|
|
||||||
|
async def _capture(text: str) -> None:
|
||||||
|
sent_texts.append(text)
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
api_key = await _register(client, _UUID_E, "RepeatUser")
|
||||||
|
|
||||||
|
with patch("backend.telegram.send_message", side_effect=_capture):
|
||||||
|
r1 = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_E, "timestamp": 1742478000001},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
r2 = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_E, "timestamp": 1742478000002},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert len(sent_texts) == 2, (
|
||||||
|
f"Expected 2 send_message calls for 2 signals, got {len(sent_texts)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repeated_signals_produce_incrementing_signal_ids():
|
||||||
|
"""Criterion 3: repeated signals are each stored and return distinct incrementing signal_ids."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
api_key = await _register(client, _UUID_E, "RepeatUser2")
|
||||||
|
r1 = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_E, "timestamp": 1742478000001},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
r2 = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_E, "timestamp": 1742478000002},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["signal_id"] > r1.json()["signal_id"], (
|
||||||
|
"Second signal must have a higher signal_id than the first"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Director revision: regression #1214, #1226
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_uses_negative_chat_id_from_config():
|
||||||
|
"""Regression #1226: send_message must POST to Telegram with a negative chat_id.
|
||||||
|
|
||||||
|
Root cause of BATON-007: CHAT_ID=5190015988 (positive = user ID) was set in .env
|
||||||
|
instead of -5190015988 (negative = group ID). This test inspects the actual
|
||||||
|
chat_id value in the HTTP request body — not just call_count.
|
||||||
|
"""
|
||||||
|
from backend import config as _cfg
|
||||||
|
from backend.telegram import send_message
|
||||||
|
|
||||||
|
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
route = mock.post(send_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
await send_message("regression #1226")
|
||||||
|
|
||||||
|
assert route.called
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
chat_id = body["chat_id"]
|
||||||
|
assert chat_id == _cfg.CHAT_ID, (
|
||||||
|
f"Expected chat_id={_cfg.CHAT_ID!r}, got {chat_id!r}"
|
||||||
|
)
|
||||||
|
assert str(chat_id).startswith("-"), (
|
||||||
|
f"Regression #1226: chat_id must be negative (group ID), got: {chat_id!r}. "
|
||||||
|
"Positive chat_id is a user ID, not a Telegram group."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_4xx_does_not_trigger_retry_loop():
|
||||||
|
"""Regression #1214: on Telegram 4xx (wrong chat_id), retry loop must NOT run.
|
||||||
|
|
||||||
|
Only one HTTP call should be made. Retrying a 4xx is pointless — it will
|
||||||
|
keep failing. send_message must break immediately on any 4xx response.
|
||||||
|
"""
|
||||||
|
from backend import config as _cfg
|
||||||
|
from backend.telegram import send_message
|
||||||
|
|
||||||
|
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
route = mock.post(send_url).mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
400,
|
||||||
|
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await send_message("retry test #1214")
|
||||||
|
|
||||||
|
assert route.call_count == 1, (
|
||||||
|
f"Regression #1214: expected exactly 1 HTTP call on 4xx, got {route.call_count}. "
|
||||||
|
"send_message must break immediately on client errors — no retry loop."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_endpoint_returns_200_on_telegram_4xx(caplog):
|
||||||
|
"""Regression: /api/signal must return 200 even when Telegram Bot API returns 4xx.
|
||||||
|
|
||||||
|
When CHAT_ID is wrong (or any Telegram 4xx), the error must be logged by
|
||||||
|
send_message but the /api/signal endpoint must still return 200 — the signal
|
||||||
|
was saved to DB, only the Telegram notification failed.
|
||||||
|
"""
|
||||||
|
from backend import config as _cfg
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
|
||||||
|
tg_set_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/setWebhook"
|
||||||
|
get_me_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/getMe"
|
||||||
|
|
||||||
|
with temp_db():
|
||||||
|
with respx.mock(assert_all_called=False) as mock_tg:
|
||||||
|
mock_tg.get(get_me_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True, "result": {"username": "testbot"}})
|
||||||
|
)
|
||||||
|
mock_tg.post(tg_set_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True, "result": True})
|
||||||
|
)
|
||||||
|
mock_tg.post(send_url).mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
400,
|
||||||
|
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||||
|
reg = await client.post("/api/register", json={"uuid": _UUID_F, "name": "Tg4xxUser"})
|
||||||
|
assert reg.status_code == 200, f"Register failed: {reg.text}"
|
||||||
|
api_key = reg.json()["api_key"]
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_F, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, (
|
||||||
|
f"Expected /api/signal to return 200 even when Telegram returns 4xx, got {resp.status_code}"
|
||||||
|
)
|
||||||
|
assert any("400" in r.message for r in caplog.records), (
|
||||||
|
"Expected ERROR log containing '400' when Telegram returns 4xx. "
|
||||||
|
"Error must be logged, not silently swallowed."
|
||||||
|
)
|
||||||
888
tests/test_baton_008.py
Normal file
888
tests/test_baton_008.py
Normal file
|
|
@ -0,0 +1,888 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-008: Registration flow with Telegram admin approval.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. POST /api/auth/register returns 201 with status='pending' on valid input
|
||||||
|
2. POST /api/auth/register returns 409 on email or login conflict
|
||||||
|
3. POST /api/auth/register returns 422 on invalid email/login/password
|
||||||
|
4. Telegram notification is fire-and-forget — 201 is returned even if Telegram fails
|
||||||
|
5. Webhook callback_query approve → db status='approved', push task fired if subscription present
|
||||||
|
6. Webhook callback_query reject → db status='rejected'
|
||||||
|
7. Webhook callback_query with unknown reg_id → returns {"ok": True} gracefully
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
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")
|
||||||
|
os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
_WEBHOOK_SECRET = "test-webhook-secret"
|
||||||
|
_WEBHOOK_HEADERS = {"X-Telegram-Bot-Api-Secret-Token": _WEBHOOK_SECRET}
|
||||||
|
|
||||||
|
_VALID_PAYLOAD = {
|
||||||
|
"email": "user@tutlot.com",
|
||||||
|
"login": "testuser",
|
||||||
|
"password": "strongpassword123",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Happy path — 201
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_returns_201_pending():
|
||||||
|
"""Valid registration request returns 201 with status='pending'."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
|
||||||
|
assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}"
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "pending"
|
||||||
|
assert "message" in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_fire_and_forget_telegram_error_still_returns_201():
|
||||||
|
"""Telegram failure must not break 201 — fire-and-forget pattern."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch(
|
||||||
|
"backend.telegram.send_registration_notification",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("Telegram down"),
|
||||||
|
):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "other@tutlot.com", "login": "otheruser"},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert resp.status_code == 201, f"Telegram error must not break 201, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Conflict — 409
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_409_on_duplicate_email():
|
||||||
|
"""Duplicate email returns 409."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert r1.status_code == 201, f"First registration failed: {r1.text}"
|
||||||
|
|
||||||
|
r2 = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "login": "differentlogin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r2.status_code == 409, f"Expected 409 on duplicate email, got {r2.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_409_on_duplicate_login():
|
||||||
|
"""Duplicate login returns 409."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert r1.status_code == 201, f"First registration failed: {r1.text}"
|
||||||
|
|
||||||
|
r2 = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "different@tutlot.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r2.status_code == 409, f"Expected 409 on duplicate login, got {r2.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Validation — 422
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_422_invalid_email():
|
||||||
|
"""Invalid email format returns 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "not-an-email"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, f"Expected 422 on invalid email, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_422_short_login():
|
||||||
|
"""Login shorter than 3 chars returns 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "login": "ab"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, f"Expected 422 on short login, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_422_login_invalid_chars():
|
||||||
|
"""Login with spaces/special chars returns 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "login": "invalid login!"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, f"Expected 422 on login with spaces, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_422_short_password():
|
||||||
|
"""Password shorter than 8 chars returns 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "password": "short"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, f"Expected 422 on short password, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Telegram notification is sent to ADMIN_CHAT_ID
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_sends_notification_to_admin():
|
||||||
|
"""Registration triggers real HTTP sendMessage to ADMIN_CHAT_ID with correct login/email."""
|
||||||
|
from backend import config as _cfg
|
||||||
|
|
||||||
|
captured: list[dict] = []
|
||||||
|
async with make_app_client(capture_send_requests=captured) as client:
|
||||||
|
resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
admin_chat_id = str(_cfg.ADMIN_CHAT_ID)
|
||||||
|
admin_msgs = [r for r in captured if str(r.get("chat_id")) == admin_chat_id]
|
||||||
|
assert len(admin_msgs) >= 1, (
|
||||||
|
f"Expected sendMessage to ADMIN_CHAT_ID={admin_chat_id!r}, captured: {captured}"
|
||||||
|
)
|
||||||
|
text = admin_msgs[0].get("text", "")
|
||||||
|
assert _VALID_PAYLOAD["login"] in text, f"Expected login in text: {text!r}"
|
||||||
|
assert _VALID_PAYLOAD["email"] in text, f"Expected email in text: {text!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Webhook callback_query — approve
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_approve_updates_db_status():
|
||||||
|
"""approve callback updates registration status to 'approved' in DB."""
|
||||||
|
from backend import db as _db
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
# We need reg_id — get it from DB directly
|
||||||
|
reg_id = None
|
||||||
|
from tests.conftest import temp_db as _temp_db # noqa: F401 — already active
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
|
||||||
|
assert reg_id is not None, "Registration not found in DB"
|
||||||
|
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_001",
|
||||||
|
"data": f"approve:{reg_id}",
|
||||||
|
"message": {
|
||||||
|
"message_id": 42,
|
||||||
|
"chat": {"id": 5694335584},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/webhook/telegram",
|
||||||
|
json=cb_payload,
|
||||||
|
headers=_WEBHOOK_HEADERS,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
|
|
||||||
|
# Verify DB status updated
|
||||||
|
reg = await _db.get_registration(reg_id)
|
||||||
|
assert reg is not None
|
||||||
|
assert reg["status"] == "approved", f"Expected status='approved', got {reg['status']!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_approve_fires_push_when_subscription_present():
|
||||||
|
"""approve callback triggers send_push when push_subscription is set."""
|
||||||
|
push_sub = {
|
||||||
|
"endpoint": "https://fcm.googleapis.com/fcm/send/test",
|
||||||
|
"keys": {"p256dh": "BQABC", "auth": "xyz"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "push_subscription": push_sub},
|
||||||
|
)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
assert reg_id is not None
|
||||||
|
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_002",
|
||||||
|
"data": f"approve:{reg_id}",
|
||||||
|
"message": {"message_id": 43, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push_calls: list = []
|
||||||
|
|
||||||
|
async def _capture_push(sub_json, title, body):
|
||||||
|
push_calls.append(sub_json)
|
||||||
|
|
||||||
|
with patch("backend.push.send_push", side_effect=_capture_push):
|
||||||
|
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(push_calls) == 1, f"Expected 1 push call, got {len(push_calls)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Webhook callback_query — reject
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_reject_updates_db_status():
|
||||||
|
"""reject callback updates registration status to 'rejected' in DB."""
|
||||||
|
from backend import db as _db
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
assert reg_id is not None
|
||||||
|
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_003",
|
||||||
|
"data": f"reject:{reg_id}",
|
||||||
|
"message": {"message_id": 44, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
reg = await _db.get_registration(reg_id)
|
||||||
|
assert reg is not None
|
||||||
|
assert reg["status"] == "rejected", f"Expected status='rejected', got {reg['status']!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Unknown reg_id — graceful handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_unknown_reg_id_returns_ok():
|
||||||
|
"""callback_query with unknown reg_id returns ok without error."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_999",
|
||||||
|
"data": "approve:99999",
|
||||||
|
"message": {"message_id": 1, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. Registration without push_subscription
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_without_push_subscription():
|
||||||
|
"""Registration with push_subscription=null returns 201."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "nopush@tutlot.com", "login": "nopushuser"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. reject does NOT trigger Web Push
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_reject_does_not_send_push():
|
||||||
|
"""reject callback does NOT call send_push."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
assert reg_id is not None
|
||||||
|
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_r001",
|
||||||
|
"data": f"reject:{reg_id}",
|
||||||
|
"message": {"message_id": 50, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push_calls: list = []
|
||||||
|
|
||||||
|
async def _capture_push(sub_json, title, body):
|
||||||
|
push_calls.append(sub_json)
|
||||||
|
|
||||||
|
with patch("backend.push.send_push", side_effect=_capture_push):
|
||||||
|
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(push_calls) == 0, f"Expected 0 push calls on reject, got {len(push_calls)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. approve calls editMessageText with ✅ text
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_approve_edits_message():
|
||||||
|
"""approve callback calls editMessageText with '✅ Пользователь ... одобрен'."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "edit@tutlot.com", "login": "edituser"},
|
||||||
|
)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
assert reg_id is not None
|
||||||
|
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_e001",
|
||||||
|
"data": f"approve:{reg_id}",
|
||||||
|
"message": {"message_id": 51, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edit_calls: list[str] = []
|
||||||
|
|
||||||
|
async def _capture_edit(chat_id, message_id, text):
|
||||||
|
edit_calls.append(text)
|
||||||
|
|
||||||
|
with patch("backend.telegram.edit_message_text", side_effect=_capture_edit):
|
||||||
|
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
|
||||||
|
|
||||||
|
assert len(edit_calls) == 1, f"Expected 1 editMessageText call, got {len(edit_calls)}"
|
||||||
|
assert "✅" in edit_calls[0], f"Expected ✅ in edit text, got: {edit_calls[0]!r}"
|
||||||
|
assert "edituser" in edit_calls[0], f"Expected login in edit text, got: {edit_calls[0]!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 11. answerCallbackQuery is called after callback processing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_answer_sent():
|
||||||
|
"""answerCallbackQuery is called with the callback_query_id after processing."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "answer@tutlot.com", "login": "answeruser"},
|
||||||
|
)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
assert reg_id is not None
|
||||||
|
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_a001",
|
||||||
|
"data": f"approve:{reg_id}",
|
||||||
|
"message": {"message_id": 52, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
answer_calls: list[str] = []
|
||||||
|
|
||||||
|
async def _capture_answer(callback_query_id):
|
||||||
|
answer_calls.append(callback_query_id)
|
||||||
|
|
||||||
|
with patch("backend.telegram.answer_callback_query", side_effect=_capture_answer):
|
||||||
|
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
|
||||||
|
|
||||||
|
assert len(answer_calls) == 1, f"Expected 1 answerCallbackQuery call, got {len(answer_calls)}"
|
||||||
|
assert answer_calls[0] == "cq_a001"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12. CORS — Authorization header is allowed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cors_authorization_header_allowed():
|
||||||
|
"""CORS preflight request allows Authorization header."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.options(
|
||||||
|
"/api/auth/register",
|
||||||
|
headers={
|
||||||
|
"Origin": "http://localhost:3000",
|
||||||
|
"Access-Control-Request-Method": "POST",
|
||||||
|
"Access-Control-Request-Headers": "Authorization",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (200, 204), f"CORS preflight returned {resp.status_code}"
|
||||||
|
allow_headers = resp.headers.get("access-control-allow-headers", "")
|
||||||
|
assert "authorization" in allow_headers.lower(), (
|
||||||
|
f"Authorization not in Access-Control-Allow-Headers: {allow_headers!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 13. DB — registrations table exists after init_db
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_registrations_table_created():
|
||||||
|
"""init_db creates the registrations table with correct schema."""
|
||||||
|
from tests.conftest import temp_db
|
||||||
|
from backend import db as _db, config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
with temp_db():
|
||||||
|
await _db.init_db()
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='registrations'"
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
assert row is not None, "Table 'registrations' not found after init_db()"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 14. DB — password_hash uses PBKDF2 '{salt_hex}:{dk_hex}' format
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_password_hash_stored_in_pbkdf2_format():
|
||||||
|
"""Stored password_hash uses '<salt_hex>:<dk_hex>' PBKDF2 format."""
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "pbkdf2@tutlot.com", "login": "pbkdf2user"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT password_hash FROM registrations WHERE login = 'pbkdf2user'"
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
|
||||||
|
assert row is not None, "Registration not found in DB"
|
||||||
|
password_hash = row["password_hash"]
|
||||||
|
assert ":" in password_hash, f"Expected 'salt:hash' format, got {password_hash!r}"
|
||||||
|
parts = password_hash.split(":")
|
||||||
|
assert len(parts) == 2, f"Expected exactly one colon separator, got {password_hash!r}"
|
||||||
|
salt_hex, dk_hex = parts
|
||||||
|
# salt = os.urandom(16) → 32 hex chars; dk = SHA-256 output (32 bytes) → 64 hex chars
|
||||||
|
assert len(salt_hex) == 32, f"Expected 32-char salt hex, got {len(salt_hex)}"
|
||||||
|
assert len(dk_hex) == 64, f"Expected 64-char dk hex (SHA-256), got {len(dk_hex)}"
|
||||||
|
int(salt_hex, 16) # raises ValueError if not valid hex
|
||||||
|
int(dk_hex, 16)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 15. State machine — повторное нажатие approve на уже approved
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_double_approve_does_not_send_push():
|
||||||
|
"""Second approve on already-approved registration must NOT fire push."""
|
||||||
|
push_sub = {
|
||||||
|
"endpoint": "https://fcm.googleapis.com/fcm/send/test2",
|
||||||
|
"keys": {"p256dh": "BQDEF", "auth": "abc"},
|
||||||
|
}
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "double@tutlot.com", "login": "doubleuser", "push_subscription": push_sub},
|
||||||
|
)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
assert reg_id is not None
|
||||||
|
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_d1",
|
||||||
|
"data": f"approve:{reg_id}",
|
||||||
|
"message": {"message_id": 60, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# First approve — should succeed
|
||||||
|
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Second approve — push must NOT be fired
|
||||||
|
push_calls: list = []
|
||||||
|
|
||||||
|
async def _capture_push(sub_json, title, body):
|
||||||
|
push_calls.append(sub_json)
|
||||||
|
|
||||||
|
cb_payload2 = {**cb_payload, "callback_query": {**cb_payload["callback_query"], "id": "cq_d2"}}
|
||||||
|
with patch("backend.push.send_push", side_effect=_capture_push):
|
||||||
|
await client.post("/api/webhook/telegram", json=cb_payload2, headers=_WEBHOOK_HEADERS)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(push_calls) == 0, f"Second approve must not fire push, got {len(push_calls)} calls"
|
||||||
|
|
||||||
|
# Also verify status is still 'approved'
|
||||||
|
from backend import db as _db
|
||||||
|
# Can't check here as client context is closed; DB assertion was covered by state machine logic
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_double_approve_status_stays_approved():
|
||||||
|
"""Status remains 'approved' after a second approve callback."""
|
||||||
|
from backend import db as _db
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "stay@tutlot.com", "login": "stayuser"},
|
||||||
|
)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
assert reg_id is not None
|
||||||
|
|
||||||
|
cb = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_s1",
|
||||||
|
"data": f"approve:{reg_id}",
|
||||||
|
"message": {"message_id": 70, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.post("/api/webhook/telegram", json=cb, headers=_WEBHOOK_HEADERS)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
cb2 = {**cb, "callback_query": {**cb["callback_query"], "id": "cq_s2"}}
|
||||||
|
await client.post("/api/webhook/telegram", json=cb2, headers=_WEBHOOK_HEADERS)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
reg = await _db.get_registration(reg_id)
|
||||||
|
assert reg["status"] == "approved", f"Expected 'approved', got {reg['status']!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 16. State machine — approve после reject
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_approve_after_reject_status_stays_rejected():
|
||||||
|
"""Approve after reject must NOT change status — remains 'rejected'."""
|
||||||
|
from backend import db as _db
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "artest@tutlot.com", "login": "artestuser"},
|
||||||
|
)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
assert reg_id is not None
|
||||||
|
|
||||||
|
# First: reject
|
||||||
|
rej_cb = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_ar1",
|
||||||
|
"data": f"reject:{reg_id}",
|
||||||
|
"message": {"message_id": 80, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.post("/api/webhook/telegram", json=rej_cb, headers=_WEBHOOK_HEADERS)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Then: approve — must be ignored
|
||||||
|
push_calls: list = []
|
||||||
|
|
||||||
|
async def _capture_push(sub_json, title, body):
|
||||||
|
push_calls.append(sub_json)
|
||||||
|
|
||||||
|
app_cb = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_ar2",
|
||||||
|
"data": f"approve:{reg_id}",
|
||||||
|
"message": {"message_id": 81, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch("backend.push.send_push", side_effect=_capture_push):
|
||||||
|
await client.post("/api/webhook/telegram", json=app_cb, headers=_WEBHOOK_HEADERS)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
reg = await _db.get_registration(reg_id)
|
||||||
|
assert reg["status"] == "rejected", f"Expected 'rejected', got {reg['status']!r}"
|
||||||
|
|
||||||
|
assert len(push_calls) == 0, f"Approve after reject must not fire push, got {len(push_calls)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 17. Rate limiting — 4th request returns 429
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_rate_limit_fourth_request_returns_429():
|
||||||
|
"""4th registration request from same IP within the window returns 429."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
for i in range(3):
|
||||||
|
r = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"email": f"ratetest{i}@tutlot.com",
|
||||||
|
"login": f"ratetest{i}",
|
||||||
|
"password": "strongpassword123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, f"Request {i+1} should succeed, got {r.status_code}"
|
||||||
|
|
||||||
|
# 4th request — must be rate-limited
|
||||||
|
r4 = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "ratetest4@tutlot.com",
|
||||||
|
"login": "ratetest4",
|
||||||
|
"password": "strongpassword123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r4.status_code == 429, f"Expected 429 on 4th request, got {r4.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 18. VAPID public key endpoint /api/push/public-key
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vapid_public_key_new_endpoint_returns_200():
|
||||||
|
"""GET /api/push/public-key returns 200 with vapid_public_key field."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.get("/api/push/public-key")
|
||||||
|
|
||||||
|
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
|
||||||
|
body = resp.json()
|
||||||
|
assert "vapid_public_key" in body, f"Expected 'vapid_public_key' in response, got {body}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 19. Password max length — 129 chars → 422
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_422_password_too_long():
|
||||||
|
"""Password of 129 characters returns 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "password": "a" * 129},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, f"Expected 422 on 129-char password, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 20. Login max length — 31 chars → 422
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_422_login_too_long():
|
||||||
|
"""Login of 31 characters returns 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "login": "a" * 31},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, f"Expected 422 on 31-char login, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 21. Empty body — POST /api/auth/register with {} → 422
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_422_empty_body():
|
||||||
|
"""Empty JSON body returns 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post("/api/auth/register", json={})
|
||||||
|
assert resp.status_code == 422, f"Expected 422 on empty body, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 22. Malformed callback_data — no colon → ok:True without crash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_malformed_data_no_colon_returns_ok():
|
||||||
|
"""callback_query with data='garbage' (no colon) returns ok:True gracefully."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_mal1",
|
||||||
|
"data": "garbage",
|
||||||
|
"message": {"message_id": 90, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
|
||||||
|
assert resp.json() == {"ok": True}, f"Expected ok:True, got {resp.json()}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 23. Non-numeric reg_id — data='approve:abc' → ok:True without crash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_non_numeric_reg_id_returns_ok():
|
||||||
|
"""callback_query with data='approve:abc' (non-numeric reg_id) returns ok:True."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_nan1",
|
||||||
|
"data": "approve:abc",
|
||||||
|
"message": {"message_id": 91, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
|
||||||
|
assert resp.json() == {"ok": True}, f"Expected ok:True, got {resp.json()}"
|
||||||
439
tests/test_baton_008_frontend.py
Normal file
439
tests/test_baton_008_frontend.py
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-008: Frontend registration module.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. index.html — форма регистрации с полями email, login, password присутствует
|
||||||
|
2. index.html — НЕТ захардкоженных VAPID-ключей в HTML-атрибутах (decision #1333)
|
||||||
|
3. app.js — вызов /api/push/public-key (не старый /api/vapid-public-key) (decision #1331)
|
||||||
|
4. app.js — guard для PushManager (decision #1332)
|
||||||
|
5. app.js — обработчик для кнопки регистрации (#btn-register → _handleSignUp)
|
||||||
|
6. app.js — переключение между view-login и view-register
|
||||||
|
7. app.js — показ ошибок пользователю (_setRegStatus)
|
||||||
|
8. GET /api/push/public-key → 200 с vapid_public_key (API контракт)
|
||||||
|
9. POST /api/auth/register с валидными данными → 201 (API контракт)
|
||||||
|
10. POST /api/auth/register с дублирующим email → 409
|
||||||
|
11. POST /api/auth/register с дублирующим login → 409
|
||||||
|
12. POST /api/auth/register с невалидным email → 422
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
INDEX_HTML = PROJECT_ROOT / "frontend" / "index.html"
|
||||||
|
APP_JS = PROJECT_ROOT / "frontend" / "app.js"
|
||||||
|
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
_VALID_PAYLOAD = {
|
||||||
|
"email": "frontend_test@tutlot.com",
|
||||||
|
"login": "frontenduser",
|
||||||
|
"password": "strongpassword123",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTML static analysis — Criterion 1: поля формы регистрации
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_email_field() -> None:
|
||||||
|
"""index.html должен содержать поле email для регистрации (id=reg-email)."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
assert 'id="reg-email"' in content, (
|
||||||
|
"index.html не содержит поле с id='reg-email'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_login_field() -> None:
|
||||||
|
"""index.html должен содержать поле логина для регистрации (id=reg-login)."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
assert 'id="reg-login"' in content, (
|
||||||
|
"index.html не содержит поле с id='reg-login'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_password_field() -> None:
|
||||||
|
"""index.html должен содержать поле пароля для регистрации (id=reg-password)."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
assert 'id="reg-password"' in content, (
|
||||||
|
"index.html не содержит поле с id='reg-password'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_email_field_has_correct_type() -> None:
|
||||||
|
"""Поле email регистрации должно иметь type='email'."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
# Ищем input с id=reg-email и type=email в любом порядке атрибутов
|
||||||
|
email_input_block = re.search(
|
||||||
|
r'<input[^>]*id="reg-email"[^>]*>', content, re.DOTALL
|
||||||
|
)
|
||||||
|
assert email_input_block is not None, "Не найден input с id='reg-email'"
|
||||||
|
assert 'type="email"' in email_input_block.group(0), (
|
||||||
|
"Поле reg-email не имеет type='email'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_password_field_has_correct_type() -> None:
|
||||||
|
"""Поле пароля регистрации должно иметь type='password'."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
password_input_block = re.search(
|
||||||
|
r'<input[^>]*id="reg-password"[^>]*>', content, re.DOTALL
|
||||||
|
)
|
||||||
|
assert password_input_block is not None, "Не найден input с id='reg-password'"
|
||||||
|
assert 'type="password"' in password_input_block.group(0), (
|
||||||
|
"Поле reg-password не имеет type='password'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_register_button() -> None:
|
||||||
|
"""index.html должен содержать кнопку регистрации (id=btn-register)."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
assert 'id="btn-register"' in content, (
|
||||||
|
"index.html не содержит кнопку с id='btn-register'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_switch_to_register_button() -> None:
|
||||||
|
"""index.html должен содержать кнопку переключения на форму регистрации (id=btn-switch-to-register)."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
assert 'id="btn-switch-to-register"' in content, (
|
||||||
|
"index.html не содержит кнопку с id='btn-switch-to-register'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_view_register_div() -> None:
|
||||||
|
"""index.html должен содержать блок view-register для формы регистрации."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
assert 'id="view-register"' in content, (
|
||||||
|
"index.html не содержит блок с id='view-register'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_view_login_div() -> None:
|
||||||
|
"""index.html должен содержать блок view-login для онбординга."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
assert 'id="view-login"' in content, (
|
||||||
|
"index.html не содержит блок с id='view-login'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_reg_status_element() -> None:
|
||||||
|
"""index.html должен содержать элемент статуса регистрации (id=reg-status)."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
assert 'id="reg-status"' in content, (
|
||||||
|
"index.html не содержит элемент с id='reg-status'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTML static analysis — Criterion 2: НЕТ захардкоженного VAPID в HTML (decision #1333)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_no_hardcoded_vapid_key_in_meta() -> None:
|
||||||
|
"""index.html НЕ должен содержать VAPID-ключ захардкоженным в meta-теге (decision #1333)."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
# VAPID public key — это URL-safe base64 строка длиной 87 символов (без padding)
|
||||||
|
# Ищем характерный паттерн в meta-атрибутах
|
||||||
|
vapid_in_meta = re.search(
|
||||||
|
r'<meta[^>]+content\s*=\s*["\'][A-Za-z0-9_\-]{60,}["\'][^>]*>',
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
assert vapid_in_meta is None, (
|
||||||
|
f"Найден meta-тег с длинной строкой (возможный VAPID-ключ): "
|
||||||
|
f"{vapid_in_meta.group(0) if vapid_in_meta else ''}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_no_vapid_key_attribute_pattern() -> None:
|
||||||
|
"""index.html НЕ должен содержать data-vapid-key или аналогичные атрибуты."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
assert "vapid" not in content.lower(), (
|
||||||
|
"index.html содержит упоминание 'vapid' — VAPID ключ должен читаться через API, "
|
||||||
|
"а не быть захардкожен в HTML (decision #1333)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# app.js static analysis — Criterion 3: /api/push/public-key endpoint (decision #1331)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_uses_new_vapid_endpoint() -> None:
|
||||||
|
"""app.js должен обращаться к /api/push/public-key (decision #1331)."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "/api/push/public-key" in content, (
|
||||||
|
"app.js не содержит endpoint '/api/push/public-key'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_does_not_use_old_vapid_endpoint() -> None:
|
||||||
|
"""app.js НЕ должен использовать устаревший /api/vapid-public-key (decision #1331)."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "/api/vapid-public-key" not in content, (
|
||||||
|
"app.js содержит устаревший endpoint '/api/vapid-public-key' — "
|
||||||
|
"нарушение decision #1331, должен использоваться '/api/push/public-key'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# app.js static analysis — Criterion 4: PushManager guard (decision #1332)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_has_push_manager_guard_in_registration_flow() -> None:
|
||||||
|
"""app.js должен содержать guard 'PushManager' in window (decision #1332)."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "'PushManager' in window" in content, (
|
||||||
|
"app.js не содержит guard \"'PushManager' in window\" — "
|
||||||
|
"нарушение decision #1332"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_push_manager_guard_combined_with_service_worker_check() -> None:
|
||||||
|
"""Guard PushManager должен сочетаться с проверкой serviceWorker."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
# Ищем паттерн совместной проверки serviceWorker + PushManager
|
||||||
|
assert re.search(
|
||||||
|
r"serviceWorker.*PushManager|PushManager.*serviceWorker",
|
||||||
|
content,
|
||||||
|
re.DOTALL,
|
||||||
|
), (
|
||||||
|
"app.js не содержит совместной проверки 'serviceWorker' и 'PushManager' — "
|
||||||
|
"guard неполный (decision #1332)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# app.js static analysis — Criterion 5: обработчик кнопки регистрации
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_has_handle_sign_up_function() -> None:
|
||||||
|
"""app.js должен содержать функцию _handleSignUp."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "_handleSignUp" in content, (
|
||||||
|
"app.js не содержит функцию '_handleSignUp'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_registers_click_handler_for_btn_register() -> None:
|
||||||
|
"""app.js должен добавлять click-обработчик на btn-register → _handleSignUp."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
# Ищем addEventListener на элементе btn-register с вызовом _handleSignUp
|
||||||
|
assert re.search(
|
||||||
|
r'btn-register.*addEventListener|addEventListener.*btn-register',
|
||||||
|
content,
|
||||||
|
re.DOTALL,
|
||||||
|
), (
|
||||||
|
"app.js не содержит addEventListener для кнопки 'btn-register'"
|
||||||
|
)
|
||||||
|
# Проверяем что именно _handleSignUp привязан к кнопке
|
||||||
|
assert re.search(
|
||||||
|
r'btn[Rr]egister.*_handleSignUp|_handleSignUp.*btn[Rr]egister',
|
||||||
|
content,
|
||||||
|
re.DOTALL,
|
||||||
|
), (
|
||||||
|
"app.js не связывает кнопку 'btn-register' с функцией '_handleSignUp'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# app.js static analysis — Criterion 6: переключение view-login / view-register
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_has_show_view_function() -> None:
|
||||||
|
"""app.js должен содержать функцию _showView для переключения видов."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "_showView" in content, (
|
||||||
|
"app.js не содержит функцию '_showView'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_show_view_handles_view_login() -> None:
|
||||||
|
"""_showView в app.js должна обрабатывать view-login."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "view-login" in content, (
|
||||||
|
"app.js не содержит id 'view-login' — нет переключения на вид логина"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_show_view_handles_view_register() -> None:
|
||||||
|
"""_showView в app.js должна обрабатывать view-register."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "view-register" in content, (
|
||||||
|
"app.js не содержит id 'view-register' — нет переключения на вид регистрации"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_has_btn_switch_to_register_handler() -> None:
|
||||||
|
"""app.js должен содержать обработчик для btn-switch-to-register."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "btn-switch-to-register" in content, (
|
||||||
|
"app.js не содержит ссылку на 'btn-switch-to-register'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_has_btn_switch_to_login_handler() -> None:
|
||||||
|
"""app.js должен содержать обработчик для btn-switch-to-login (назад)."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "btn-switch-to-login" in content, (
|
||||||
|
"app.js не содержит ссылку на 'btn-switch-to-login'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# app.js static analysis — Criterion 7: обработка ошибок / показ сообщения пользователю
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_has_set_reg_status_function() -> None:
|
||||||
|
"""app.js должен содержать _setRegStatus для показа статуса в форме регистрации."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "_setRegStatus" in content, (
|
||||||
|
"app.js не содержит функцию '_setRegStatus'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_handle_sign_up_shows_error_on_empty_fields() -> None:
|
||||||
|
"""_handleSignUp должна вызывать _setRegStatus с ошибкой при пустых полях."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
# Проверяем наличие валидации пустых полей внутри _handleSignUp-подобного блока
|
||||||
|
assert re.search(
|
||||||
|
r"_setRegStatus\s*\([^)]*error",
|
||||||
|
content,
|
||||||
|
), (
|
||||||
|
"app.js не содержит вызов _setRegStatus с классом 'error' "
|
||||||
|
"— ошибки не отображаются пользователю"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_handle_sign_up_shows_success_on_ok() -> None:
|
||||||
|
"""_handleSignUp должна вызывать _setRegStatus с success при успешной регистрации."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert re.search(
|
||||||
|
r"_setRegStatus\s*\([^)]*success",
|
||||||
|
content,
|
||||||
|
), (
|
||||||
|
"app.js не содержит вызов _setRegStatus с классом 'success' "
|
||||||
|
"— пользователь не уведомляется об успехе регистрации"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_clears_password_after_successful_signup() -> None:
|
||||||
|
"""_handleSignUp должна очищать поле пароля после успешной отправки."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
# Ищем сброс значения пароля
|
||||||
|
assert re.search(
|
||||||
|
r"passwordInput\.value\s*=\s*['\"][\s]*['\"]",
|
||||||
|
content,
|
||||||
|
), (
|
||||||
|
"app.js не очищает поле пароля после успешной регистрации — "
|
||||||
|
"пароль остаётся в DOM (security concern)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_uses_api_auth_register_endpoint() -> None:
|
||||||
|
"""app.js должен отправлять форму на /api/auth/register."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "/api/auth/register" in content, (
|
||||||
|
"app.js не содержит endpoint '/api/auth/register'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration tests — API контракты (Criteria 8–12)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vapid_public_key_endpoint_returns_200_with_key():
|
||||||
|
"""GET /api/push/public-key → 200 с полем vapid_public_key."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.get("/api/push/public-key")
|
||||||
|
|
||||||
|
assert resp.status_code == 200, (
|
||||||
|
f"GET /api/push/public-key вернул {resp.status_code}, ожидался 200"
|
||||||
|
)
|
||||||
|
body = resp.json()
|
||||||
|
assert "vapid_public_key" in body, (
|
||||||
|
f"Ответ /api/push/public-key не содержит 'vapid_public_key': {body}"
|
||||||
|
)
|
||||||
|
assert isinstance(body["vapid_public_key"], str), (
|
||||||
|
"vapid_public_key должен быть строкой"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_valid_payload_returns_201_pending():
|
||||||
|
"""POST /api/auth/register с валидными данными → 201 status=pending."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
|
||||||
|
assert resp.status_code == 201, (
|
||||||
|
f"POST /api/auth/register вернул {resp.status_code}: {resp.text}"
|
||||||
|
)
|
||||||
|
body = resp.json()
|
||||||
|
assert body.get("status") == "pending", (
|
||||||
|
f"Ожидался status='pending', получено: {body}"
|
||||||
|
)
|
||||||
|
assert "message" in body, (
|
||||||
|
f"Ответ не содержит поле 'message': {body}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_duplicate_email_returns_409():
|
||||||
|
"""POST /api/auth/register с дублирующим email → 409."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert r1.status_code == 201, f"Первая регистрация не прошла: {r1.text}"
|
||||||
|
|
||||||
|
r2 = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "login": "anotherlogin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r2.status_code == 409, (
|
||||||
|
f"Дублирующий email должен вернуть 409, получено {r2.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_duplicate_login_returns_409():
|
||||||
|
"""POST /api/auth/register с дублирующим login → 409."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert r1.status_code == 201, f"Первая регистрация не прошла: {r1.text}"
|
||||||
|
|
||||||
|
r2 = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "another@tutlot.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r2.status_code == 409, (
|
||||||
|
f"Дублирующий login должен вернуть 409, получено {r2.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_invalid_email_returns_422():
|
||||||
|
"""POST /api/auth/register с невалидным email → 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "not-an-email"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422, (
|
||||||
|
f"Невалидный email должен вернуть 422, получено {resp.status_code}"
|
||||||
|
)
|
||||||
338
tests/test_biz_001.py
Normal file
338
tests/test_biz_001.py
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-BIZ-001: Login mechanism for approved users (dual-layer: AST + httpx functional).
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. Успешный login по login-полю → 200 + token
|
||||||
|
2. Успешный login по email-полю → 200 + token
|
||||||
|
3. Неверный пароль → 401 (без раскрытия причины)
|
||||||
|
4. Статус pending → 403 с читаемым сообщением
|
||||||
|
5. Статус rejected → 403 с читаемым сообщением
|
||||||
|
6. Rate limit — 6-й запрос подряд → 429
|
||||||
|
7. Guard middleware возвращает 401 без токена
|
||||||
|
8. Guard middleware пропускает валидный токен
|
||||||
|
|
||||||
|
Additional: error message uniformity, PBKDF2 verification.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
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")
|
||||||
|
os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from backend import db
|
||||||
|
from backend.middleware import create_auth_token, verify_auth_token
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _register_auth(client, email: str, login: str, password: str) -> int:
|
||||||
|
"""Register via /api/auth/register, return registration id."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={"email": email, "login": login, "password": password},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, f"auth/register failed: {resp.text}"
|
||||||
|
reg = await db.get_registration_by_login_or_email(login)
|
||||||
|
assert reg is not None
|
||||||
|
return reg["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _approve(reg_id: int) -> None:
|
||||||
|
await db.update_registration_status(reg_id, "approved")
|
||||||
|
|
||||||
|
|
||||||
|
async def _reject(reg_id: int) -> None:
|
||||||
|
await db.update_registration_status(reg_id, "rejected")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — Успешный login по login-полю
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_by_login_field_returns_200_with_token():
|
||||||
|
"""Approved user can login using their login field → 200 + token."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg_id = await _register_auth(client, "alice@tutlot.com", "alice", "password123")
|
||||||
|
await _approve(reg_id)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "alice", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "token" in data
|
||||||
|
assert data["login"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_by_login_field_token_is_non_empty_string():
|
||||||
|
"""Token returned for approved login user is a non-empty string."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg_id = await _register_auth(client, "alice2@tutlot.com", "alice2", "password123")
|
||||||
|
await _approve(reg_id)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "alice2", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert isinstance(resp.json()["token"], str)
|
||||||
|
assert len(resp.json()["token"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — Успешный login по email-полю
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_by_email_field_returns_200_with_token():
|
||||||
|
"""Approved user can login using their email field → 200 + token."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg_id = await _register_auth(client, "bob@tutlot.com", "bobuser", "securepass1")
|
||||||
|
await _approve(reg_id)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "bob@tutlot.com", "password": "securepass1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "token" in data
|
||||||
|
assert data["login"] == "bobuser"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_by_email_field_token_login_matches_registration():
|
||||||
|
"""Token response login field matches the login set during registration."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg_id = await _register_auth(client, "bob2@tutlot.com", "bob2user", "securepass1")
|
||||||
|
await _approve(reg_id)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "bob2@tutlot.com", "password": "securepass1"},
|
||||||
|
)
|
||||||
|
assert resp.json()["login"] == "bob2user"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 3 — Неверный пароль → 401 без раскрытия причины
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wrong_password_returns_401():
|
||||||
|
"""Wrong password returns 401 with generic message (no detail about which field failed)."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg_id = await _register_auth(client, "carol@tutlot.com", "carol", "correctpass1")
|
||||||
|
await _approve(reg_id)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "carol", "password": "wrongpassword"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Неверный логин или пароль" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nonexistent_user_returns_401_same_message_as_wrong_password():
|
||||||
|
"""Non-existent login returns same 401 message as wrong password (prevents user enumeration)."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "doesnotexist", "password": "anypassword"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "Неверный логин или пароль" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 4 — Статус pending → 403 с читаемым сообщением
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_user_login_returns_403():
|
||||||
|
"""User with pending status gets 403."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
await _register_auth(client, "dave@tutlot.com", "dave", "password123")
|
||||||
|
# Status is 'pending' by default — no approval step
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "dave", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_user_login_403_message_is_human_readable():
|
||||||
|
"""403 message for pending user contains readable Russian text about the waiting status."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
await _register_auth(client, "dave2@tutlot.com", "dave2", "password123")
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "dave2", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert "ожидает" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 5 — Статус rejected → 403 с читаемым сообщением
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejected_user_login_returns_403():
|
||||||
|
"""User with rejected status gets 403."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg_id = await _register_auth(client, "eve@tutlot.com", "evegirl", "password123")
|
||||||
|
await _reject(reg_id)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "evegirl", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejected_user_login_403_message_is_human_readable():
|
||||||
|
"""403 message for rejected user contains readable Russian text about rejection."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg_id = await _register_auth(client, "eve2@tutlot.com", "eve2girl", "password123")
|
||||||
|
await _reject(reg_id)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "eve2girl", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert "отклонена" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 6 — Rate limit: 6-й запрос подряд → 429
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_triggers_on_sixth_login_attempt():
|
||||||
|
"""Login rate limit (5 per window) triggers 429 exactly on the 6th request."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
statuses = []
|
||||||
|
for _ in range(6):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "nouser_rl", "password": "nopass"},
|
||||||
|
headers={"X-Real-IP": "10.99.99.1"},
|
||||||
|
)
|
||||||
|
statuses.append(resp.status_code)
|
||||||
|
# First 5 attempts pass rate limit (user not found → 401)
|
||||||
|
assert all(s == 401 for s in statuses[:5]), (
|
||||||
|
f"Первые 5 попыток должны быть 401, получили: {statuses[:5]}"
|
||||||
|
)
|
||||||
|
# 6th attempt hits rate limit
|
||||||
|
assert statuses[5] == 429, (
|
||||||
|
f"6-я попытка должна быть 429, получили: {statuses[5]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_fifth_attempt_still_passes():
|
||||||
|
"""5th login attempt is still allowed (rate limit triggers only on 6th)."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
for i in range(4):
|
||||||
|
await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "nouser_rl2", "password": "nopass"},
|
||||||
|
headers={"X-Real-IP": "10.99.99.2"},
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login_or_email": "nouser_rl2", "password": "nopass"},
|
||||||
|
headers={"X-Real-IP": "10.99.99.2"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401, (
|
||||||
|
f"5-я попытка должна пройти rate limit и вернуть 401, получили: {resp.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 7 — Guard middleware: 401 без токена
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_auth_token_raises_401_when_credentials_is_none():
|
||||||
|
"""verify_auth_token raises HTTPException 401 when no credentials provided."""
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await verify_auth_token(credentials=None)
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_auth_token_raises_401_for_malformed_token():
|
||||||
|
"""verify_auth_token raises HTTPException 401 for a malformed/invalid token."""
|
||||||
|
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="not.a.valid.jwt")
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await verify_auth_token(credentials=creds)
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 8 — Guard middleware: валидный токен пропускается
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_auth_token_returns_payload_for_valid_token():
|
||||||
|
"""verify_auth_token returns decoded JWT payload for a valid signed token."""
|
||||||
|
token = create_auth_token(reg_id=42, login="testuser")
|
||||||
|
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||||
|
payload = await verify_auth_token(credentials=creds)
|
||||||
|
assert payload["sub"] == "42"
|
||||||
|
assert payload["login"] == "testuser"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_auth_token_payload_contains_expected_fields():
|
||||||
|
"""Payload returned by verify_auth_token contains sub, login, iat, exp fields."""
|
||||||
|
token = create_auth_token(reg_id=7, login="inspector")
|
||||||
|
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||||
|
payload = await verify_auth_token(credentials=creds)
|
||||||
|
for field in ("sub", "login", "iat", "exp"):
|
||||||
|
assert field in payload, f"Поле '{field}' отсутствует в payload"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Additional: PBKDF2 correctness — verify_password timing-safe
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_and_verify_password_returns_true_for_correct_password():
|
||||||
|
"""_hash_password + _verify_password: correct password returns True."""
|
||||||
|
from backend.main import _hash_password, _verify_password
|
||||||
|
stored = _hash_password("mysecretpass")
|
||||||
|
assert _verify_password("mysecretpass", stored) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_and_verify_password_returns_false_for_wrong_password():
|
||||||
|
"""_hash_password + _verify_password: wrong password returns False."""
|
||||||
|
from backend.main import _hash_password, _verify_password
|
||||||
|
stored = _hash_password("mysecretpass")
|
||||||
|
assert _verify_password("wrongpassword", stored) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_returns_false_for_malformed_hash():
|
||||||
|
"""_verify_password returns False (not exception) for a malformed hash string."""
|
||||||
|
from backend.main import _verify_password
|
||||||
|
assert _verify_password("anypassword", "not-a-valid-hash") is False
|
||||||
203
tests/test_biz_002.py
Normal file
203
tests/test_biz_002.py
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-BIZ-002: Убрать hardcoded VAPID key из meta-тега, читать с /api/push/public-key
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. Meta-тег vapid-public-key полностью отсутствует в frontend/index.html (decision #1333).
|
||||||
|
2. app.js использует canonical URL /api/push/public-key для получения VAPID ключа.
|
||||||
|
3. Graceful fallback: endpoint недоступен → функция возвращает null, не бросает исключение.
|
||||||
|
4. Graceful fallback: ключ пустой → _initPushSubscription не выполняется (guard на null).
|
||||||
|
5. GET /api/push/public-key возвращает HTTP 200 с полем vapid_public_key.
|
||||||
|
6. GET /api/push/public-key возвращает правильное значение из конфига.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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")
|
||||||
|
os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
INDEX_HTML = PROJECT_ROOT / "frontend" / "index.html"
|
||||||
|
APP_JS = PROJECT_ROOT / "frontend" / "app.js"
|
||||||
|
|
||||||
|
_TEST_VAPID_KEY = "BFakeVapidPublicKeyForBiz002TestingBase64UrlEncoded"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — AST: meta-тег vapid-public-key полностью отсутствует
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_no_meta_tag_named_vapid_public_key() -> None:
|
||||||
|
"""index.html не должен содержать <meta name='vapid-public-key'> вообще (decision #1333)."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
match = re.search(
|
||||||
|
r'<meta[^>]+name\s*=\s*["\']vapid-public-key["\']',
|
||||||
|
content,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
assert match is None, (
|
||||||
|
f"index.html содержит удалённый тег <meta name='vapid-public-key'>: {match.group(0)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_no_vapid_meta_tag_with_empty_or_any_content() -> None:
|
||||||
|
"""index.html не должен содержать ни пустой, ни непустой VAPID ключ в meta content."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
match = re.search(
|
||||||
|
r'<meta[^>]*(?:vapid|application-server-key)[^>]*content\s*=',
|
||||||
|
content,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
assert match is None, (
|
||||||
|
f"index.html содержит <meta>-тег с VAPID-связанным атрибутом content: {match.group(0)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — AST: app.js использует canonical /api/push/public-key
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_fetch_vapid_uses_canonical_push_public_key_url() -> None:
|
||||||
|
"""_fetchVapidPublicKey в app.js должна использовать /api/push/public-key (canonical URL)."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "/api/push/public-key" in content, (
|
||||||
|
"app.js не содержит canonical URL '/api/push/public-key' — "
|
||||||
|
"ключ не читается через правильный endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_fetch_vapid_returns_vapid_public_key_field() -> None:
|
||||||
|
"""_fetchVapidPublicKey должна читать поле vapid_public_key из JSON-ответа."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert re.search(r"data\.vapid_public_key", content), (
|
||||||
|
"app.js не читает поле 'data.vapid_public_key' из ответа API"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 3 — AST: graceful fallback когда endpoint недоступен
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_fetch_vapid_returns_null_on_http_error() -> None:
|
||||||
|
"""_fetchVapidPublicKey должна возвращать null при res.ok === false (HTTP-ошибка)."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert re.search(r"if\s*\(\s*!\s*res\.ok\s*\)", content), (
|
||||||
|
"app.js не содержит проверку 'if (!res.ok)' — "
|
||||||
|
"HTTP-ошибки не обрабатываются gracefully в _fetchVapidPublicKey"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_fetch_vapid_catches_network_errors() -> None:
|
||||||
|
"""_fetchVapidPublicKey должна оборачивать fetch в try/catch и возвращать null при сетевой ошибке."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
# Проверяем паттерн try { fetch ... } catch (err) { return null; } внутри функции
|
||||||
|
func_match = re.search(
|
||||||
|
r"async function _fetchVapidPublicKey\(\).*?(?=^(?:async )?function |\Z)",
|
||||||
|
content,
|
||||||
|
re.DOTALL | re.MULTILINE,
|
||||||
|
)
|
||||||
|
assert func_match, "Функция _fetchVapidPublicKey не найдена в app.js"
|
||||||
|
func_body = func_match.group(0)
|
||||||
|
assert "catch" in func_body, (
|
||||||
|
"app.js: _fetchVapidPublicKey не содержит блок catch — "
|
||||||
|
"сетевые ошибки при fetch не обрабатываются"
|
||||||
|
)
|
||||||
|
assert re.search(r"return\s+null", func_body), (
|
||||||
|
"app.js: _fetchVapidPublicKey не возвращает null при ошибке — "
|
||||||
|
"upstream код получит исключение вместо null"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 4 — AST: graceful fallback когда ключ пустой (decision #1332)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_fetch_vapid_returns_null_on_empty_key() -> None:
|
||||||
|
"""_fetchVapidPublicKey должна возвращать null когда vapid_public_key пустой."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert re.search(r"data\.vapid_public_key\s*\|\|\s*null", content), (
|
||||||
|
"app.js не содержит 'data.vapid_public_key || null' — "
|
||||||
|
"пустой ключ не преобразуется в null"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_init_push_subscription_guard_skips_on_null_key() -> None:
|
||||||
|
"""_initPushSubscription должна ранним возвратом пропускать подписку при null ключе (decision #1332)."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert re.search(r"if\s*\(\s*!\s*vapidPublicKey\s*\)", content), (
|
||||||
|
"app.js: _initPushSubscription не содержит guard 'if (!vapidPublicKey)' — "
|
||||||
|
"подписка может быть создана без ключа"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 5 — HTTP: GET /api/push/public-key → 200 + vapid_public_key
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_public_key_endpoint_returns_200() -> None:
|
||||||
|
"""GET /api/push/public-key должен вернуть HTTP 200."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/api/push/public-key")
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"GET /api/push/public-key вернул {response.status_code}, ожидался 200"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_public_key_endpoint_returns_json_with_vapid_field() -> None:
|
||||||
|
"""GET /api/push/public-key должен вернуть JSON с полем vapid_public_key."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/api/push/public-key")
|
||||||
|
data = response.json()
|
||||||
|
assert "vapid_public_key" in data, (
|
||||||
|
f"Ответ /api/push/public-key не содержит поле 'vapid_public_key': {data!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 6 — HTTP: возвращает правильное значение из конфига
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_public_key_endpoint_returns_configured_value() -> None:
|
||||||
|
"""GET /api/push/public-key возвращает значение из VAPID_PUBLIC_KEY конфига."""
|
||||||
|
with patch("backend.config.VAPID_PUBLIC_KEY", _TEST_VAPID_KEY):
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/api/push/public-key")
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("vapid_public_key") == _TEST_VAPID_KEY, (
|
||||||
|
f"vapid_public_key должен быть '{_TEST_VAPID_KEY}', "
|
||||||
|
f"получили: {data.get('vapid_public_key')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_public_key_endpoint_returns_empty_string_when_not_configured() -> None:
|
||||||
|
"""GET /api/push/public-key возвращает пустую строку (не ошибку) если ключ не настроен."""
|
||||||
|
with patch("backend.config.VAPID_PUBLIC_KEY", ""):
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/api/push/public-key")
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"Endpoint вернул {response.status_code} при пустом ключе, ожидался 200"
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
assert "vapid_public_key" in data, "Поле vapid_public_key отсутствует при пустом конфиге"
|
||||||
96
tests/test_biz_004.py
Normal file
96
tests/test_biz_004.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""
|
||||||
|
BATON-BIZ-004: Verify removal of dead code from backend/telegram.py.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. telegram.py does NOT contain duplicate logging setLevel calls for httpx/httpcore.
|
||||||
|
2. telegram.py does NOT contain the SignalAggregator class.
|
||||||
|
3. httpx/httpcore logging suppression is still configured in main.py (globally).
|
||||||
|
4. SignalAggregator is NOT importable from backend.telegram.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_BACKEND_DIR = Path(__file__).parent.parent / "backend"
|
||||||
|
_TELEGRAM_SRC = (_BACKEND_DIR / "telegram.py").read_text(encoding="utf-8")
|
||||||
|
_MAIN_SRC = (_BACKEND_DIR / "main.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criteria 1 — no setLevel for httpx/httpcore in telegram.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_telegram_has_no_httpx_setlevel():
|
||||||
|
"""telegram.py must not set log level for 'httpx'."""
|
||||||
|
assert 'getLogger("httpx").setLevel' not in _TELEGRAM_SRC
|
||||||
|
assert "getLogger('httpx').setLevel" not in _TELEGRAM_SRC
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_has_no_httpcore_setlevel():
|
||||||
|
"""telegram.py must not set log level for 'httpcore'."""
|
||||||
|
assert 'getLogger("httpcore").setLevel' not in _TELEGRAM_SRC
|
||||||
|
assert "getLogger('httpcore').setLevel" not in _TELEGRAM_SRC
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criteria 2 — SignalAggregator absent from telegram.py source
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_telegram_source_has_no_signal_aggregator_class():
|
||||||
|
"""telegram.py source text must not contain the class definition."""
|
||||||
|
assert "class SignalAggregator" not in _TELEGRAM_SRC
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_source_has_no_signal_aggregator_reference():
|
||||||
|
"""telegram.py source text must not reference SignalAggregator at all."""
|
||||||
|
assert "SignalAggregator" not in _TELEGRAM_SRC
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criteria 3 — httpx/httpcore suppression still lives in main.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_main_suppresses_httpx_logging():
|
||||||
|
"""main.py must call getLogger('httpx').setLevel to suppress noise."""
|
||||||
|
assert (
|
||||||
|
'getLogger("httpx").setLevel' in _MAIN_SRC
|
||||||
|
or "getLogger('httpx').setLevel" in _MAIN_SRC
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_suppresses_httpcore_logging():
|
||||||
|
"""main.py must call getLogger('httpcore').setLevel to suppress noise."""
|
||||||
|
assert (
|
||||||
|
'getLogger("httpcore").setLevel' in _MAIN_SRC
|
||||||
|
or "getLogger('httpcore').setLevel" in _MAIN_SRC
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criteria 4 — SignalAggregator not importable from backend.telegram
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_signal_aggregator_not_importable_from_telegram():
|
||||||
|
"""Importing SignalAggregator from backend.telegram must raise ImportError."""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Force a fresh import so changes to the module are reflected
|
||||||
|
mod_name = "backend.telegram"
|
||||||
|
if mod_name in sys.modules:
|
||||||
|
del sys.modules[mod_name]
|
||||||
|
|
||||||
|
import backend.telegram as tg_mod # noqa: F401
|
||||||
|
assert not hasattr(tg_mod, "SignalAggregator"), (
|
||||||
|
"SignalAggregator should not be an attribute of backend.telegram"
|
||||||
|
)
|
||||||
|
|
@ -29,6 +29,14 @@ import pytest
|
||||||
|
|
||||||
from backend import config, db
|
from backend import config, db
|
||||||
|
|
||||||
|
# Valid UUID v4 constants — db-layer tests bypass Pydantic but use canonical UUIDs
|
||||||
|
_UUID_DB_1 = "d0000001-0000-4000-8000-000000000001"
|
||||||
|
_UUID_DB_2 = "d0000002-0000-4000-8000-000000000002"
|
||||||
|
_UUID_DB_3 = "d0000003-0000-4000-8000-000000000003"
|
||||||
|
_UUID_DB_4 = "d0000004-0000-4000-8000-000000000004"
|
||||||
|
_UUID_DB_5 = "d0000005-0000-4000-8000-000000000005"
|
||||||
|
_UUID_DB_6 = "d0000006-0000-4000-8000-000000000006"
|
||||||
|
|
||||||
|
|
||||||
def _tmpdb():
|
def _tmpdb():
|
||||||
"""Return a fresh temp-file path and set config.DB_PATH."""
|
"""Return a fresh temp-file path and set config.DB_PATH."""
|
||||||
|
|
@ -109,10 +117,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:
|
||||||
|
|
@ -129,10 +136,10 @@ async def test_register_user_returns_id():
|
||||||
path = _tmpdb()
|
path = _tmpdb()
|
||||||
try:
|
try:
|
||||||
await db.init_db()
|
await db.init_db()
|
||||||
result = await db.register_user(uuid="uuid-001", name="Alice")
|
result = await db.register_user(uuid=_UUID_DB_1, name="Alice")
|
||||||
assert isinstance(result["user_id"], int)
|
assert isinstance(result["user_id"], int)
|
||||||
assert result["user_id"] > 0
|
assert result["user_id"] > 0
|
||||||
assert result["uuid"] == "uuid-001"
|
assert result["uuid"] == _UUID_DB_1
|
||||||
finally:
|
finally:
|
||||||
_cleanup(path)
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
@ -143,8 +150,8 @@ async def test_register_user_idempotent():
|
||||||
path = _tmpdb()
|
path = _tmpdb()
|
||||||
try:
|
try:
|
||||||
await db.init_db()
|
await db.init_db()
|
||||||
r1 = await db.register_user(uuid="uuid-002", name="Bob")
|
r1 = await db.register_user(uuid=_UUID_DB_2, name="Bob")
|
||||||
r2 = await db.register_user(uuid="uuid-002", name="Bob")
|
r2 = await db.register_user(uuid=_UUID_DB_2, name="Bob")
|
||||||
assert r1["user_id"] == r2["user_id"]
|
assert r1["user_id"] == r2["user_id"]
|
||||||
finally:
|
finally:
|
||||||
_cleanup(path)
|
_cleanup(path)
|
||||||
|
|
@ -160,8 +167,8 @@ async def test_get_user_name_returns_name():
|
||||||
path = _tmpdb()
|
path = _tmpdb()
|
||||||
try:
|
try:
|
||||||
await db.init_db()
|
await db.init_db()
|
||||||
await db.register_user(uuid="uuid-003", name="Charlie")
|
await db.register_user(uuid=_UUID_DB_3, name="Charlie")
|
||||||
name = await db.get_user_name("uuid-003")
|
name = await db.get_user_name(_UUID_DB_3)
|
||||||
assert name == "Charlie"
|
assert name == "Charlie"
|
||||||
finally:
|
finally:
|
||||||
_cleanup(path)
|
_cleanup(path)
|
||||||
|
|
@ -189,9 +196,9 @@ async def test_save_signal_returns_id():
|
||||||
path = _tmpdb()
|
path = _tmpdb()
|
||||||
try:
|
try:
|
||||||
await db.init_db()
|
await db.init_db()
|
||||||
await db.register_user(uuid="uuid-004", name="Dana")
|
await db.register_user(uuid=_UUID_DB_4, name="Dana")
|
||||||
signal_id = await db.save_signal(
|
signal_id = await db.save_signal(
|
||||||
user_uuid="uuid-004",
|
user_uuid=_UUID_DB_4,
|
||||||
timestamp=1742478000000,
|
timestamp=1742478000000,
|
||||||
lat=55.7558,
|
lat=55.7558,
|
||||||
lon=37.6173,
|
lon=37.6173,
|
||||||
|
|
@ -209,9 +216,9 @@ async def test_save_signal_without_geo():
|
||||||
path = _tmpdb()
|
path = _tmpdb()
|
||||||
try:
|
try:
|
||||||
await db.init_db()
|
await db.init_db()
|
||||||
await db.register_user(uuid="uuid-005", name="Eve")
|
await db.register_user(uuid=_UUID_DB_5, name="Eve")
|
||||||
signal_id = await db.save_signal(
|
signal_id = await db.save_signal(
|
||||||
user_uuid="uuid-005",
|
user_uuid=_UUID_DB_5,
|
||||||
timestamp=1742478000000,
|
timestamp=1742478000000,
|
||||||
lat=None,
|
lat=None,
|
||||||
lon=None,
|
lon=None,
|
||||||
|
|
@ -240,9 +247,9 @@ async def test_save_signal_increments_id():
|
||||||
path = _tmpdb()
|
path = _tmpdb()
|
||||||
try:
|
try:
|
||||||
await db.init_db()
|
await db.init_db()
|
||||||
await db.register_user(uuid="uuid-006", name="Frank")
|
await db.register_user(uuid=_UUID_DB_6, name="Frank")
|
||||||
id1 = await db.save_signal("uuid-006", 1742478000001, None, None, None)
|
id1 = await db.save_signal(_UUID_DB_6, 1742478000001, None, None, None)
|
||||||
id2 = await db.save_signal("uuid-006", 1742478000002, None, None, None)
|
id2 = await db.save_signal(_UUID_DB_6, 1742478000002, None, None, None)
|
||||||
assert id2 > id1
|
assert id2 > id1
|
||||||
finally:
|
finally:
|
||||||
_cleanup(path)
|
_cleanup(path)
|
||||||
|
|
|
||||||
172
tests/test_fix_005.py
Normal file
172
tests/test_fix_005.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-FIX-005: BOT_TOKEN leak prevention in logs.
|
||||||
|
|
||||||
|
Acceptance criteria covered by unit tests:
|
||||||
|
AC#4 — no places in source code where token is logged in plain text:
|
||||||
|
- _mask_token() returns masked representation (***XXXX format)
|
||||||
|
- validate_bot_token() exception handler does not log raw BOT_TOKEN
|
||||||
|
- validate_bot_token() exception handler logs type(exc).__name__ + masked token
|
||||||
|
- httpcore logger level >= WARNING (prevents URL leak via transport layer)
|
||||||
|
|
||||||
|
AC#1, AC#2, AC#3 (journalctl, webhook, service health) require live production
|
||||||
|
verification and are outside unit test scope.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
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 httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from backend import config
|
||||||
|
from backend.telegram import _mask_token, validate_bot_token
|
||||||
|
|
||||||
|
GET_ME_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _mask_token helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_token_shows_last_4_chars():
|
||||||
|
"""_mask_token returns '***XXXX' where XXXX is the last 4 chars of the token."""
|
||||||
|
token = "123456789:ABCDEFsomeLongTokenXYZW"
|
||||||
|
result = _mask_token(token)
|
||||||
|
assert result == f"***{token[-4:]}", f"Expected ***{token[-4:]}, got {result!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_token_hides_most_of_token():
|
||||||
|
"""_mask_token must NOT expose the full token — only last 4 chars."""
|
||||||
|
token = "123456789:ABCDEFsomeLongTokenXYZW"
|
||||||
|
result = _mask_token(token)
|
||||||
|
assert token[:-4] not in result, f"Masked token exposes too much: {result!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_token_short_token_returns_redacted():
|
||||||
|
"""_mask_token returns '***REDACTED***' for tokens shorter than 4 chars."""
|
||||||
|
assert _mask_token("abc") == "***REDACTED***"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_token_empty_string_returns_redacted():
|
||||||
|
"""_mask_token on empty string returns '***REDACTED***'."""
|
||||||
|
assert _mask_token("") == "***REDACTED***"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_token_exactly_4_chars_is_not_redacted():
|
||||||
|
"""_mask_token with exactly 4 chars returns '***XXXX' (not redacted)."""
|
||||||
|
result = _mask_token("1234")
|
||||||
|
assert result == "***1234", f"Expected ***1234, got {result!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# httpcore logger suppression (new in FIX-005; httpx covered in test_fix_011)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_httpcore_logger_level_is_warning_or_higher():
|
||||||
|
"""logging.getLogger('httpcore').level must be WARNING or higher after app import."""
|
||||||
|
import backend.main # noqa: F401 — ensures telegram.py module-level setLevel is called
|
||||||
|
|
||||||
|
httpcore_logger = logging.getLogger("httpcore")
|
||||||
|
assert httpcore_logger.level >= logging.WARNING, (
|
||||||
|
f"httpcore logger level must be >= WARNING (30), got {httpcore_logger.level}. "
|
||||||
|
"httpcore logs transport-level requests including URLs with BOT_TOKEN."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_httpcore_logger_info_not_enabled():
|
||||||
|
"""httpcore logger must not propagate INFO-level messages (would leak BOT_TOKEN URL)."""
|
||||||
|
import backend.main # noqa: F401
|
||||||
|
|
||||||
|
httpcore_logger = logging.getLogger("httpcore")
|
||||||
|
assert not httpcore_logger.isEnabledFor(logging.INFO), (
|
||||||
|
"httpcore logger must not process INFO messages — could leak BOT_TOKEN via URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# validate_bot_token() exception handler — AC#4: no raw token in error logs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_bot_token_network_error_does_not_log_raw_token(caplog):
|
||||||
|
"""validate_bot_token() on ConnectError must NOT log the raw BOT_TOKEN.
|
||||||
|
|
||||||
|
AC#4: The exception handler logs type(exc).__name__ + _mask_token() instead
|
||||||
|
of raw exc, which embeds the Telegram API URL containing the token.
|
||||||
|
"""
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused"))
|
||||||
|
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||||
|
result = await validate_bot_token()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
raw_token = config.BOT_TOKEN
|
||||||
|
for record in caplog.records:
|
||||||
|
assert raw_token not in record.message, (
|
||||||
|
f"AC#4: Raw BOT_TOKEN leaked in log message: {record.message!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_bot_token_network_error_logs_exception_type_name(caplog):
|
||||||
|
"""validate_bot_token() on ConnectError logs the exception type name, not repr(exc).
|
||||||
|
|
||||||
|
The fixed handler: logger.error('...%s...', type(exc).__name__, ...) — not str(exc).
|
||||||
|
"""
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused"))
|
||||||
|
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||||
|
await validate_bot_token()
|
||||||
|
|
||||||
|
error_messages = [r.message for r in caplog.records if r.levelno >= logging.ERROR]
|
||||||
|
assert error_messages, "Expected at least one ERROR log on network failure"
|
||||||
|
assert any("ConnectError" in msg for msg in error_messages), (
|
||||||
|
f"Expected 'ConnectError' (type name) in error log, got: {error_messages}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_bot_token_network_error_logs_masked_token(caplog):
|
||||||
|
"""validate_bot_token() on network error logs masked token (***XXXX), not raw token."""
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused"))
|
||||||
|
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||||
|
await validate_bot_token()
|
||||||
|
|
||||||
|
token = config.BOT_TOKEN # "test-bot-token"
|
||||||
|
masked = f"***{token[-4:]}" # "***oken"
|
||||||
|
error_messages = [r.message for r in caplog.records if r.levelno >= logging.ERROR]
|
||||||
|
assert any(masked in msg for msg in error_messages), (
|
||||||
|
f"Expected masked token '{masked}' in error log. Got: {error_messages}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_bot_token_network_error_no_api_url_in_logs(caplog):
|
||||||
|
"""validate_bot_token() on network error must not log the Telegram API URL.
|
||||||
|
|
||||||
|
httpx embeds the request URL (including the token) into exception repr/str.
|
||||||
|
The fixed handler avoids logging exc directly to prevent this leak.
|
||||||
|
"""
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused"))
|
||||||
|
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||||
|
await validate_bot_token()
|
||||||
|
|
||||||
|
for record in caplog.records:
|
||||||
|
assert "api.telegram.org" not in record.message, (
|
||||||
|
f"AC#4: Telegram API URL (containing token) leaked in log: {record.message!r}"
|
||||||
|
)
|
||||||
155
tests/test_fix_007.py
Normal file
155
tests/test_fix_007.py
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-FIX-007: CORS OPTIONS preflight verification.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. OPTIONS preflight to /api/signal returns 200.
|
||||||
|
2. Preflight response includes Access-Control-Allow-Methods containing GET.
|
||||||
|
3. Preflight response includes Access-Control-Allow-Origin matching the configured origin.
|
||||||
|
4. Preflight response includes Access-Control-Allow-Headers with Authorization.
|
||||||
|
5. allow_methods in CORSMiddleware configuration explicitly contains GET.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
_FRONTEND_ORIGIN = "http://localhost:3000"
|
||||||
|
_BACKEND_DIR = Path(__file__).parent.parent / "backend"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Static check — CORSMiddleware config contains GET in allow_methods
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_py_cors_allow_methods_contains_get() -> None:
|
||||||
|
"""allow_methods в CORSMiddleware должен содержать 'GET'."""
|
||||||
|
source = (_BACKEND_DIR / "main.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source, filename="main.py")
|
||||||
|
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
func = node.func
|
||||||
|
if isinstance(func, ast.Name) and func.id == "add_middleware":
|
||||||
|
continue
|
||||||
|
if not (
|
||||||
|
isinstance(func, ast.Attribute) and func.attr == "add_middleware"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
for kw in node.keywords:
|
||||||
|
if kw.arg == "allow_methods":
|
||||||
|
if isinstance(kw.value, ast.List):
|
||||||
|
methods = [
|
||||||
|
elt.value
|
||||||
|
for elt in kw.value.elts
|
||||||
|
if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
|
||||||
|
]
|
||||||
|
assert "GET" in methods, (
|
||||||
|
f"allow_methods в CORSMiddleware не содержит 'GET': {methods}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
pytest.fail("add_middleware с CORSMiddleware и allow_methods не найден в main.py")
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_py_cors_allow_methods_contains_post() -> None:
|
||||||
|
"""allow_methods в CORSMiddleware должен содержать 'POST' (регрессия)."""
|
||||||
|
source = (_BACKEND_DIR / "main.py").read_text(encoding="utf-8")
|
||||||
|
assert '"POST"' in source or "'POST'" in source, (
|
||||||
|
"allow_methods в CORSMiddleware не содержит 'POST'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Functional — OPTIONS preflight request
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_options_preflight_signal_returns_200() -> None:
|
||||||
|
"""OPTIONS preflight к /api/signal должен возвращать 200."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.options(
|
||||||
|
"/api/signal",
|
||||||
|
headers={
|
||||||
|
"Origin": _FRONTEND_ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "POST",
|
||||||
|
"Access-Control-Request-Headers": "Content-Type, Authorization",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, (
|
||||||
|
f"Preflight OPTIONS /api/signal вернул {resp.status_code}, ожидался 200"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_options_preflight_allow_origin_header() -> None:
|
||||||
|
"""OPTIONS preflight должен вернуть Access-Control-Allow-Origin."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.options(
|
||||||
|
"/api/signal",
|
||||||
|
headers={
|
||||||
|
"Origin": _FRONTEND_ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "POST",
|
||||||
|
"Access-Control-Request-Headers": "Content-Type, Authorization",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
acao = resp.headers.get("access-control-allow-origin", "")
|
||||||
|
assert acao == _FRONTEND_ORIGIN, (
|
||||||
|
f"Ожидался Access-Control-Allow-Origin: {_FRONTEND_ORIGIN!r}, получен: {acao!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_options_preflight_allow_methods_contains_get() -> None:
|
||||||
|
"""OPTIONS preflight должен вернуть Access-Control-Allow-Methods, включающий GET."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.options(
|
||||||
|
"/api/signal",
|
||||||
|
headers={
|
||||||
|
"Origin": _FRONTEND_ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
"Access-Control-Request-Headers": "Authorization",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
acam = resp.headers.get("access-control-allow-methods", "")
|
||||||
|
assert "GET" in acam, (
|
||||||
|
f"Access-Control-Allow-Methods не содержит GET: {acam!r}\n"
|
||||||
|
"Decision #1268: allow_methods=['POST'] — GET отсутствует"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_options_preflight_allow_headers_contains_authorization() -> None:
|
||||||
|
"""OPTIONS preflight должен вернуть Access-Control-Allow-Headers, включающий Authorization."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.options(
|
||||||
|
"/api/signal",
|
||||||
|
headers={
|
||||||
|
"Origin": _FRONTEND_ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "POST",
|
||||||
|
"Access-Control-Request-Headers": "Authorization",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
acah = resp.headers.get("access-control-allow-headers", "")
|
||||||
|
assert "authorization" in acah.lower(), (
|
||||||
|
f"Access-Control-Allow-Headers не содержит Authorization: {acah!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_health_cors_header_present() -> None:
|
||||||
|
"""GET /health с Origin должен вернуть Access-Control-Allow-Origin (simple request)."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"/health",
|
||||||
|
headers={"Origin": _FRONTEND_ORIGIN},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
acao = resp.headers.get("access-control-allow-origin", "")
|
||||||
|
assert acao == _FRONTEND_ORIGIN, (
|
||||||
|
f"GET /health: ожидался CORS-заголовок {_FRONTEND_ORIGIN!r}, получен: {acao!r}"
|
||||||
|
)
|
||||||
229
tests/test_fix_009.py
Normal file
229
tests/test_fix_009.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-FIX-009: Live delivery verification — automated regression guards.
|
||||||
|
|
||||||
|
Acceptance criteria mapped to unit tests:
|
||||||
|
AC#3 — BOT_TOKEN validates on startup via validate_bot_token() (getMe call)
|
||||||
|
AC#4 — CHAT_ID is negative (regression guard for decision #1212)
|
||||||
|
AC#1 — POST /api/signal returns 200 with valid auth
|
||||||
|
|
||||||
|
Physical production checks (AC#2 Telegram group message, AC#5 systemd status)
|
||||||
|
are outside unit test scope and require live production verification.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
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 json
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from tests.conftest import make_app_client, temp_db
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC#3 — validate_bot_token called at startup (decision #1211)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_bot_token_called_once_during_startup():
|
||||||
|
"""AC#3: validate_bot_token() must be called exactly once during app startup.
|
||||||
|
|
||||||
|
Maps to production check: curl getMe must be executed to detect invalid token
|
||||||
|
before the service starts accepting signals (decision #1211).
|
||||||
|
"""
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
with temp_db():
|
||||||
|
with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate:
|
||||||
|
mock_validate.return_value = True
|
||||||
|
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert mock_validate.call_count == 1, (
|
||||||
|
f"Expected validate_bot_token to be called exactly once at startup, "
|
||||||
|
f"got {mock_validate.call_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_bot_token_logs_critical_error_on_startup(caplog):
|
||||||
|
"""AC#3: When BOT_TOKEN is invalid (validate_bot_token returns False),
|
||||||
|
a CRITICAL/ERROR is logged but lifespan continues — service must not crash.
|
||||||
|
|
||||||
|
Maps to: 'Check BOT_TOKEN valid via getMe — status OK/FAIL' (decision #1211).
|
||||||
|
"""
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
with temp_db():
|
||||||
|
with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate:
|
||||||
|
mock_validate.return_value = False
|
||||||
|
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
|
||||||
|
with caplog.at_level(logging.ERROR, logger="backend.main"):
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
pass # lifespan must complete without raising
|
||||||
|
|
||||||
|
critical_msgs = [r.message for r in caplog.records if r.levelno >= logging.ERROR]
|
||||||
|
assert len(critical_msgs) >= 1, (
|
||||||
|
"Expected at least one ERROR/CRITICAL log when BOT_TOKEN is invalid. "
|
||||||
|
"Operator must be alerted on startup if Telegram delivery is broken."
|
||||||
|
)
|
||||||
|
assert any("BOT_TOKEN" in m for m in critical_msgs), (
|
||||||
|
f"Expected log mentioning 'BOT_TOKEN', got: {critical_msgs}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_bot_token_lifespan_does_not_raise():
|
||||||
|
"""AC#3: Invalid BOT_TOKEN must not crash the service — lifespan completes normally."""
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
with temp_db():
|
||||||
|
with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate:
|
||||||
|
mock_validate.return_value = False
|
||||||
|
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
|
||||||
|
# Must not raise — service stays alive even with broken Telegram token
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC#4 — CHAT_ID is negative (decision #1212 regression guard)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_chat_id_in_request_is_negative():
|
||||||
|
"""AC#4: The chat_id sent to Telegram API must be negative (group ID).
|
||||||
|
|
||||||
|
Root cause of BATON-007: CHAT_ID=5190015988 (positive) was set in .env
|
||||||
|
instead of -5190015988 (negative). Negative ID = Telegram group/supergroup.
|
||||||
|
Decision #1212: CHAT_ID=-5190015988 отрицательный.
|
||||||
|
"""
|
||||||
|
from backend import config
|
||||||
|
from backend.telegram import send_message
|
||||||
|
|
||||||
|
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
route = mock.post(send_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
await send_message("AC#4 regression guard")
|
||||||
|
|
||||||
|
assert route.called
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
chat_id = body["chat_id"]
|
||||||
|
assert str(chat_id).startswith("-"), (
|
||||||
|
f"Regression #1212: chat_id must be negative (group ID), got {chat_id!r}. "
|
||||||
|
"Positive chat_id is a user ID — messages go to private DM, not the group."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC#1 — POST /api/signal returns 200 (decision #1211)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_UUID_FIX009 = "f0090001-0000-4000-8000-000000000001"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_endpoint_returns_200_with_valid_auth():
|
||||||
|
"""AC#1: POST /api/signal with valid Bearer token must return HTTP 200.
|
||||||
|
|
||||||
|
Maps to production check: 'SSH на сервер, отправить POST /api/signal,
|
||||||
|
зафиксировать raw ответ API' (decision #1211).
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_FIX009, "name": "Fix009User"},
|
||||||
|
)
|
||||||
|
assert reg.status_code == 200, f"Registration failed: {reg.text}"
|
||||||
|
api_key = reg.json()["api_key"]
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_FIX009, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, (
|
||||||
|
f"Expected /api/signal to return 200, got {resp.status_code}: {resp.text}"
|
||||||
|
)
|
||||||
|
body = resp.json()
|
||||||
|
assert body.get("status") == "ok", f"Expected status='ok', got: {body}"
|
||||||
|
assert "signal_id" in body, f"Expected signal_id in response, got: {body}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_endpoint_returns_200_even_when_telegram_returns_400(caplog):
|
||||||
|
"""AC#1 + decision #1230: POST /api/signal must return 200 even if Telegram returns 400.
|
||||||
|
|
||||||
|
Decision #1230: 'Если Telegram возвращает 400 — зафиксировать и сообщить'.
|
||||||
|
The HTTP 400 from Telegram must be logged as ERROR (captured/reported),
|
||||||
|
but /api/signal must still return 200 — signal was saved to DB.
|
||||||
|
"""
|
||||||
|
from backend import config
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||||||
|
set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
|
||||||
|
get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
|
||||||
|
|
||||||
|
_UUID_400 = "f0090002-0000-4000-8000-000000000002"
|
||||||
|
|
||||||
|
with temp_db():
|
||||||
|
with respx.mock(assert_all_called=False) as mock_tg:
|
||||||
|
mock_tg.get(get_me_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True, "result": {"username": "testbot"}})
|
||||||
|
)
|
||||||
|
mock_tg.post(set_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True, "result": True})
|
||||||
|
)
|
||||||
|
mock_tg.post(send_url).mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
400,
|
||||||
|
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
import asyncio
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||||
|
reg = await client.post("/api/register", json={"uuid": _UUID_400, "name": "TgErrUser"})
|
||||||
|
assert reg.status_code == 200
|
||||||
|
api_key = reg.json()["api_key"]
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_400, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, (
|
||||||
|
f"Decision #1230: /api/signal must return 200 even on Telegram 400, "
|
||||||
|
f"got {resp.status_code}"
|
||||||
|
)
|
||||||
|
assert any("400" in r.message for r in caplog.records), (
|
||||||
|
"Decision #1230: Telegram 400 error must be logged (captured and reported). "
|
||||||
|
"Got logs: " + str([r.message for r in caplog.records])
|
||||||
|
)
|
||||||
116
tests/test_fix_011.py
Normal file
116
tests/test_fix_011.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""
|
||||||
|
BATON-FIX-011: Проверяет, что BOT_TOKEN не попадает в httpx-логи.
|
||||||
|
|
||||||
|
1. logging.getLogger('httpx').level >= logging.WARNING после импорта приложения.
|
||||||
|
2. Дочерние логгеры httpx._client и httpx._async_client также не пишут INFO.
|
||||||
|
3. При вызове send_message ни одна запись httpx-логгера с уровнем INFO
|
||||||
|
не содержит 'bot' или токен-подобный паттерн /bot[0-9]+:/.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from unittest.mock import patch, AsyncMock
|
||||||
|
|
||||||
|
# conftest.py уже устанавливает BOT_TOKEN=test-bot-token до этого импорта
|
||||||
|
from backend import config
|
||||||
|
from backend.telegram import send_message
|
||||||
|
|
||||||
|
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||||||
|
BOT_TOKEN_PATTERN = re.compile(r"bot[0-9]+:")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Уровень логгера httpx
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_httpx_logger_level_is_warning_or_higher():
|
||||||
|
"""logging.getLogger('httpx').level должен быть WARNING (30) или выше после импорта приложения."""
|
||||||
|
# Импортируем main, чтобы гарантировать, что setLevel уже вызван
|
||||||
|
import backend.main # noqa: F401
|
||||||
|
|
||||||
|
httpx_logger = logging.getLogger("httpx")
|
||||||
|
assert httpx_logger.level >= logging.WARNING, (
|
||||||
|
f"Ожидался уровень >= WARNING (30), получен {httpx_logger.level}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_httpx_logger_info_not_enabled():
|
||||||
|
"""logging.getLogger('httpx').isEnabledFor(INFO) должен возвращать False."""
|
||||||
|
import backend.main # noqa: F401
|
||||||
|
|
||||||
|
httpx_logger = logging.getLogger("httpx")
|
||||||
|
assert not httpx_logger.isEnabledFor(logging.INFO), (
|
||||||
|
"httpx-логгер не должен обрабатывать INFO-сообщения"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_httpx_client_logger_info_not_enabled():
|
||||||
|
"""Дочерний логгер httpx._client не должен обрабатывать INFO."""
|
||||||
|
import backend.main # noqa: F401
|
||||||
|
|
||||||
|
child_logger = logging.getLogger("httpx._client")
|
||||||
|
assert not child_logger.isEnabledFor(logging.INFO), (
|
||||||
|
"httpx._client не должен обрабатывать INFO-сообщения"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_httpx_async_client_logger_info_not_enabled():
|
||||||
|
"""Дочерний логгер httpx._async_client не должен обрабатывать INFO."""
|
||||||
|
import backend.main # noqa: F401
|
||||||
|
|
||||||
|
child_logger = logging.getLogger("httpx._async_client")
|
||||||
|
assert not child_logger.isEnabledFor(logging.INFO), (
|
||||||
|
"httpx._async_client не должен обрабатывать INFO-сообщения"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BOT_TOKEN не появляется в httpx INFO-логах при send_message
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_no_httpx_records_at_warning_level(caplog):
|
||||||
|
"""При вызове send_message httpx не выдаёт записей уровня WARNING и ниже с токеном.
|
||||||
|
|
||||||
|
Проверяет фактическое состояние логгера в продакшне (WARNING): INFO-сообщения
|
||||||
|
с URL (включая BOT_TOKEN) не должны проходить через httpx-логгер.
|
||||||
|
"""
|
||||||
|
import backend.main # noqa: F401 — убеждаемся, что setLevel вызван
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.post(SEND_URL).mock(return_value=httpx.Response(200, json={"ok": True}))
|
||||||
|
|
||||||
|
# Захватываем логи при реальном уровне WARNING — INFO-сообщения не должны проходить
|
||||||
|
with caplog.at_level(logging.WARNING, logger="httpx"):
|
||||||
|
await send_message("test message for token leak check")
|
||||||
|
|
||||||
|
bot_token = config.BOT_TOKEN
|
||||||
|
httpx_records = [r for r in caplog.records if r.name.startswith("httpx")]
|
||||||
|
for record in httpx_records:
|
||||||
|
assert bot_token not in record.message, (
|
||||||
|
f"BOT_TOKEN найден в httpx-логе (уровень {record.levelname}): {record.message!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_no_token_pattern_in_httpx_info_logs(caplog):
|
||||||
|
"""При вызове send_message httpx INFO-логи не содержат паттерн /bot[0-9]+:/."""
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.post(SEND_URL).mock(return_value=httpx.Response(200, json={"ok": True}))
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="httpx"):
|
||||||
|
await send_message("check token pattern")
|
||||||
|
|
||||||
|
info_records = [
|
||||||
|
r for r in caplog.records
|
||||||
|
if r.name.startswith("httpx") and r.levelno <= logging.INFO
|
||||||
|
]
|
||||||
|
for record in info_records:
|
||||||
|
assert not BOT_TOKEN_PATTERN.search(record.message), (
|
||||||
|
f"Паттерн bot[0-9]+: найден в httpx INFO-логе: {record.message!r}"
|
||||||
|
)
|
||||||
172
tests/test_fix_012.py
Normal file
172
tests/test_fix_012.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-FIX-012: UUID v4 validation regression guard.
|
||||||
|
|
||||||
|
BATON-SEC-005 added UUID v4 pattern validation to RegisterRequest.uuid and
|
||||||
|
SignalRequest.user_id. Tests in test_db.py / test_baton_005.py / test_telegram.py
|
||||||
|
previously used placeholder strings ('uuid-001', 'create-uuid-001', 'agg-uuid-001')
|
||||||
|
that are not valid UUID v4 — causing 25 regressions.
|
||||||
|
|
||||||
|
This file locks down the behaviour so the same mistake cannot recur silently:
|
||||||
|
- Old-style placeholder strings are rejected by Pydantic
|
||||||
|
- All UUID constants used across the fixed test files are valid UUID v4
|
||||||
|
- RegisterRequest and SignalRequest accept exactly-valid v4 UUIDs
|
||||||
|
- They reject strings that violate version (bit 3 of field-3 must be 4) or
|
||||||
|
variant (top bits of field-4 must be 10xx) requirements
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
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 pydantic import ValidationError
|
||||||
|
|
||||||
|
from backend.models import RegisterRequest, SignalRequest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UUID constants from fixed test files — all must be valid UUID v4
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# test_db.py constants (_UUID_DB_1 .. _UUID_DB_6)
|
||||||
|
_DB_UUIDS = [
|
||||||
|
"d0000001-0000-4000-8000-000000000001",
|
||||||
|
"d0000002-0000-4000-8000-000000000002",
|
||||||
|
"d0000003-0000-4000-8000-000000000003",
|
||||||
|
"d0000004-0000-4000-8000-000000000004",
|
||||||
|
"d0000005-0000-4000-8000-000000000005",
|
||||||
|
"d0000006-0000-4000-8000-000000000006",
|
||||||
|
]
|
||||||
|
|
||||||
|
# test_baton_005.py constants (_UUID_ADM_*)
|
||||||
|
_ADM_UUIDS = [
|
||||||
|
"e0000000-0000-4000-8000-000000000000",
|
||||||
|
"e0000001-0000-4000-8000-000000000001",
|
||||||
|
"e0000002-0000-4000-8000-000000000002",
|
||||||
|
"e0000003-0000-4000-8000-000000000003",
|
||||||
|
"e0000004-0000-4000-8000-000000000004",
|
||||||
|
"e0000005-0000-4000-8000-000000000005",
|
||||||
|
"e0000006-0000-4000-8000-000000000006",
|
||||||
|
"e0000007-0000-4000-8000-000000000007",
|
||||||
|
"e0000008-0000-4000-8000-000000000008",
|
||||||
|
"e0000009-0000-4000-8000-000000000009",
|
||||||
|
"e000000a-0000-4000-8000-000000000010",
|
||||||
|
]
|
||||||
|
|
||||||
|
# test_telegram.py constants (aggregator UUIDs)
|
||||||
|
_AGG_UUIDS = [
|
||||||
|
"a9900001-0000-4000-8000-000000000001",
|
||||||
|
"a9900099-0000-4000-8000-000000000099",
|
||||||
|
] + [f"a990000{i}-0000-4000-8000-00000000000{i}" for i in range(5)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Old-style placeholder UUIDs (pre-fix) must be rejected
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_uuid", [
|
||||||
|
"uuid-001",
|
||||||
|
"uuid-002",
|
||||||
|
"uuid-003",
|
||||||
|
"uuid-004",
|
||||||
|
"uuid-005",
|
||||||
|
"uuid-006",
|
||||||
|
"create-uuid-001",
|
||||||
|
"create-uuid-002",
|
||||||
|
"create-uuid-003",
|
||||||
|
"pass-uuid-001",
|
||||||
|
"pass-uuid-002",
|
||||||
|
"block-uuid-001",
|
||||||
|
"unblock-uuid-001",
|
||||||
|
"delete-uuid-001",
|
||||||
|
"delete-uuid-002",
|
||||||
|
"regress-admin-uuid-001",
|
||||||
|
"unauth-uuid-001",
|
||||||
|
"agg-uuid-001",
|
||||||
|
"agg-uuid-clr",
|
||||||
|
])
|
||||||
|
def test_register_request_rejects_old_placeholder_uuid(bad_uuid: str) -> None:
|
||||||
|
"""RegisterRequest.uuid must reject all pre-BATON-SEC-005 placeholder strings."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
RegisterRequest(uuid=bad_uuid, name="Test")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_uuid", [
|
||||||
|
"uuid-001",
|
||||||
|
"agg-uuid-001",
|
||||||
|
"create-uuid-001",
|
||||||
|
])
|
||||||
|
def test_signal_request_accepts_any_user_id_string(bad_uuid: str) -> None:
|
||||||
|
"""SignalRequest.user_id is optional (no pattern) — validation is at endpoint level."""
|
||||||
|
req = SignalRequest(user_id=bad_uuid, timestamp=1700000000000)
|
||||||
|
assert req.user_id == bad_uuid
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# All UUID constants from the fixed test files are valid UUID v4
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("valid_uuid", _DB_UUIDS)
|
||||||
|
def test_register_request_accepts_db_uuid_constants(valid_uuid: str) -> None:
|
||||||
|
"""RegisterRequest accepts all _UUID_DB_* constants from test_db.py."""
|
||||||
|
req = RegisterRequest(uuid=valid_uuid, name="Test")
|
||||||
|
assert req.uuid == valid_uuid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("valid_uuid", _ADM_UUIDS)
|
||||||
|
def test_register_request_accepts_adm_uuid_constants(valid_uuid: str) -> None:
|
||||||
|
"""RegisterRequest accepts all _UUID_ADM_* constants from test_baton_005.py."""
|
||||||
|
req = RegisterRequest(uuid=valid_uuid, name="Test")
|
||||||
|
assert req.uuid == valid_uuid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("valid_uuid", _AGG_UUIDS)
|
||||||
|
def test_signal_request_accepts_agg_uuid_constants(valid_uuid: str) -> None:
|
||||||
|
"""SignalRequest accepts all aggregator UUID constants from test_telegram.py."""
|
||||||
|
req = SignalRequest(user_id=valid_uuid, timestamp=1700000000000)
|
||||||
|
assert req.user_id == valid_uuid
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UUID v4 structural requirements — version digit and variant bits
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_request_rejects_uuid_v1_version_digit() -> None:
|
||||||
|
"""UUID with version digit = 1 (not 4) must be rejected by RegisterRequest."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
# third group starts with '1' — version 1, not v4
|
||||||
|
RegisterRequest(uuid="550e8400-e29b-11d4-a716-446655440000", name="Test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_request_rejects_uuid_v3_version_digit() -> None:
|
||||||
|
"""UUID with version digit = 3 must be rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
RegisterRequest(uuid="550e8400-e29b-31d4-a716-446655440000", name="Test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_request_accepts_any_variant_bits() -> None:
|
||||||
|
"""SignalRequest.user_id is now optional and unvalidated (JWT auth doesn't use it)."""
|
||||||
|
req = SignalRequest(user_id="550e8400-e29b-41d4-0716-446655440000", timestamp=1700000000000)
|
||||||
|
assert req.user_id is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_request_without_user_id() -> None:
|
||||||
|
"""SignalRequest works without user_id (JWT auth mode)."""
|
||||||
|
req = SignalRequest(timestamp=1700000000000)
|
||||||
|
assert req.user_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_request_accepts_all_valid_v4_variants() -> None:
|
||||||
|
"""RegisterRequest accepts UUIDs with variant nibbles 8, 9, a, b."""
|
||||||
|
for variant in ("8", "9", "a", "b"):
|
||||||
|
uuid = f"550e8400-e29b-41d4-{variant}716-446655440000"
|
||||||
|
req = RegisterRequest(uuid=uuid, name="Test")
|
||||||
|
assert req.uuid == uuid
|
||||||
194
tests/test_fix_013.py
Normal file
194
tests/test_fix_013.py
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-FIX-013: CORS allow_methods — добавить GET для /health эндпоинтов.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. CORSMiddleware в main.py содержит "GET" в allow_methods.
|
||||||
|
2. OPTIONS preflight /health с Origin и Access-Control-Request-Method: GET
|
||||||
|
возвращает 200/204 и содержит GET в Access-Control-Allow-Methods.
|
||||||
|
3. OPTIONS preflight /api/health — аналогично.
|
||||||
|
4. GET /health возвращает 200 (regression guard vs. allow_methods=['POST'] only).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
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")
|
||||||
|
os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
_ORIGIN = "http://localhost:3000"
|
||||||
|
# allow_headers = ["Content-Type", "Authorization"] — X-Custom-Header не разрешён,
|
||||||
|
# поэтому preflight с X-Custom-Header вернёт 400. Используем Content-Type.
|
||||||
|
_PREFLIGHT_HEADER = "Content-Type"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — Static: CORSMiddleware.allow_methods must contain "GET"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_middleware_allow_methods_contains_get() -> None:
|
||||||
|
"""app.user_middleware CORSMiddleware должен содержать 'GET' в allow_methods."""
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
cors_mw = next(
|
||||||
|
(m for m in app.user_middleware if m.cls is CORSMiddleware), None
|
||||||
|
)
|
||||||
|
assert cors_mw is not None, "CORSMiddleware не найден в app.user_middleware"
|
||||||
|
allow_methods = cors_mw.kwargs.get("allow_methods", [])
|
||||||
|
assert "GET" in allow_methods, (
|
||||||
|
f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'GET'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_middleware_allow_methods_contains_head() -> None:
|
||||||
|
"""allow_methods должен содержать 'HEAD' для корректной работы preflight."""
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
cors_mw = next(
|
||||||
|
(m for m in app.user_middleware if m.cls is CORSMiddleware), None
|
||||||
|
)
|
||||||
|
assert cors_mw is not None
|
||||||
|
allow_methods = cors_mw.kwargs.get("allow_methods", [])
|
||||||
|
assert "HEAD" in allow_methods, (
|
||||||
|
f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'HEAD'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_middleware_allow_methods_contains_options() -> None:
|
||||||
|
"""allow_methods должен содержать 'OPTIONS' для корректной обработки preflight."""
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
cors_mw = next(
|
||||||
|
(m for m in app.user_middleware if m.cls is CORSMiddleware), None
|
||||||
|
)
|
||||||
|
assert cors_mw is not None
|
||||||
|
allow_methods = cors_mw.kwargs.get("allow_methods", [])
|
||||||
|
assert "OPTIONS" in allow_methods, (
|
||||||
|
f"CORSMiddleware.allow_methods={allow_methods!r} не содержит 'OPTIONS'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — Preflight OPTIONS /health includes GET in Allow-Methods
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_preflight_options_returns_success_status() -> None:
|
||||||
|
"""OPTIONS preflight /health должен вернуть 200 или 204."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.options(
|
||||||
|
"/health",
|
||||||
|
headers={
|
||||||
|
"Origin": _ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
"Access-Control-Request-Headers": _PREFLIGHT_HEADER,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code in (200, 204), (
|
||||||
|
f"OPTIONS /health вернул {response.status_code}, ожидался 200 или 204"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_preflight_options_allow_methods_contains_get() -> None:
|
||||||
|
"""OPTIONS preflight /health: Access-Control-Allow-Methods должен содержать GET."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.options(
|
||||||
|
"/health",
|
||||||
|
headers={
|
||||||
|
"Origin": _ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
"Access-Control-Request-Headers": _PREFLIGHT_HEADER,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
allow_methods_header = response.headers.get("access-control-allow-methods", "")
|
||||||
|
assert "GET" in allow_methods_header, (
|
||||||
|
f"Access-Control-Allow-Methods={allow_methods_header!r} не содержит 'GET'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 3 — Preflight OPTIONS /api/health includes GET in Allow-Methods
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_health_preflight_options_returns_success_status() -> None:
|
||||||
|
"""OPTIONS preflight /api/health должен вернуть 200 или 204."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.options(
|
||||||
|
"/api/health",
|
||||||
|
headers={
|
||||||
|
"Origin": _ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
"Access-Control-Request-Headers": _PREFLIGHT_HEADER,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code in (200, 204), (
|
||||||
|
f"OPTIONS /api/health вернул {response.status_code}, ожидался 200 или 204"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_health_preflight_options_allow_methods_contains_get() -> None:
|
||||||
|
"""OPTIONS preflight /api/health: Access-Control-Allow-Methods должен содержать GET."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.options(
|
||||||
|
"/api/health",
|
||||||
|
headers={
|
||||||
|
"Origin": _ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
"Access-Control-Request-Headers": _PREFLIGHT_HEADER,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
allow_methods_header = response.headers.get("access-control-allow-methods", "")
|
||||||
|
assert "GET" in allow_methods_header, (
|
||||||
|
f"Access-Control-Allow-Methods={allow_methods_header!r} не содержит 'GET'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 4 — GET /health returns 200 (regression guard)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_get_returns_200_regression_guard() -> None:
|
||||||
|
"""GET /health должен вернуть 200 — regression guard против allow_methods=['POST'] only."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/health",
|
||||||
|
headers={"Origin": _ORIGIN},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"GET /health вернул {response.status_code}, ожидался 200"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_health_get_returns_200_regression_guard() -> None:
|
||||||
|
"""GET /api/health должен вернуть 200 — regression guard против allow_methods=['POST'] only."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/health",
|
||||||
|
headers={"Origin": _ORIGIN},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"GET /api/health вернул {response.status_code}, ожидался 200"
|
||||||
|
)
|
||||||
163
tests/test_fix_016.py
Normal file
163
tests/test_fix_016.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-FIX-016: VAPID public key — убедиться, что ключ не вшит
|
||||||
|
как пустая строка в frontend-коде и читается через API.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. В frontend-коде нет хардкода пустой строки в качестве VAPID key в <meta>-теге.
|
||||||
|
2. frontend читает ключ через API /api/vapid-public-key (_fetchVapidPublicKey).
|
||||||
|
3. GET /api/vapid-public-key возвращает HTTP 200.
|
||||||
|
4. GET /api/vapid-public-key возвращает JSON с полем vapid_public_key.
|
||||||
|
5. При наличии конфигурации VAPID_PUBLIC_KEY — ответ содержит непустое значение.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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")
|
||||||
|
os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
FRONTEND_DIR = PROJECT_ROOT / "frontend"
|
||||||
|
INDEX_HTML = FRONTEND_DIR / "index.html"
|
||||||
|
APP_JS = FRONTEND_DIR / "app.js"
|
||||||
|
|
||||||
|
_TEST_VAPID_PUBLIC_KEY = "BFakeVapidPublicKeyForTestingPurposesOnlyBase64UrlEncoded"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — AST: no hardcoded empty VAPID key in <meta> tag (index.html)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_no_vapid_meta_tag_with_empty_content() -> None:
|
||||||
|
"""index.html не должен содержать <meta>-тег с application-server-key и пустым content."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
match = re.search(
|
||||||
|
r'<meta[^>]*(?:application-server-key|vapid)[^>]*content\s*=\s*["\']["\']',
|
||||||
|
content,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
assert match is None, (
|
||||||
|
f"index.html содержит <meta>-тег с пустым VAPID ключом: {match.group(0)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_has_no_hardcoded_application_server_key_attribute() -> None:
|
||||||
|
"""index.html не должен содержать атрибут application-server-key вообще."""
|
||||||
|
content = INDEX_HTML.read_text(encoding="utf-8")
|
||||||
|
assert "application-server-key" not in content.lower(), (
|
||||||
|
"index.html содержит атрибут 'application-server-key' — "
|
||||||
|
"VAPID ключ не должен быть вшит в HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — AST: frontend reads key through API (app.js)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_contains_fetch_vapid_public_key_function() -> None:
|
||||||
|
"""app.js должен содержать функцию _fetchVapidPublicKey."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "_fetchVapidPublicKey" in content, (
|
||||||
|
"app.js не содержит функцию _fetchVapidPublicKey — "
|
||||||
|
"чтение VAPID ключа через API не реализовано"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_fetch_vapid_calls_api_endpoint() -> None:
|
||||||
|
"""_fetchVapidPublicKey в app.js должна обращаться к /api/push/public-key (canonical URL)."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert "/api/push/public-key" in content, (
|
||||||
|
"app.js не содержит URL '/api/push/public-key' — VAPID ключ не читается через API"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_init_push_subscription_has_null_guard() -> None:
|
||||||
|
"""_initPushSubscription в app.js должна содержать guard против null ключа."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert re.search(r"if\s*\(\s*!\s*vapidPublicKey\s*\)", content), (
|
||||||
|
"app.js: _initPushSubscription не содержит guard 'if (!vapidPublicKey)' — "
|
||||||
|
"подписка может быть создана без ключа"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_init_chains_fetch_vapid_then_init_subscription() -> None:
|
||||||
|
"""_init() в app.js должна вызывать _fetchVapidPublicKey().then(_initPushSubscription)."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
assert re.search(
|
||||||
|
r"_fetchVapidPublicKey\(\)\s*\.\s*then\s*\(\s*_initPushSubscription\s*\)",
|
||||||
|
content,
|
||||||
|
), (
|
||||||
|
"app.js: _init() не содержит цепочку _fetchVapidPublicKey().then(_initPushSubscription)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_has_no_empty_string_hardcoded_as_application_server_key() -> None:
|
||||||
|
"""app.js не должен содержать хардкода пустой строки для applicationServerKey."""
|
||||||
|
content = APP_JS.read_text(encoding="utf-8")
|
||||||
|
match = re.search(r"applicationServerKey\s*[=:]\s*[\"']{2}", content)
|
||||||
|
assert match is None, (
|
||||||
|
f"app.js содержит хардкод пустой строки для applicationServerKey: {match.group(0)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 3 — HTTP: GET /api/vapid-public-key returns 200
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vapid_public_key_endpoint_returns_200() -> None:
|
||||||
|
"""GET /api/vapid-public-key должен вернуть HTTP 200."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/api/vapid-public-key")
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"GET /api/vapid-public-key вернул {response.status_code}, ожидался 200"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 4 — HTTP: response JSON contains vapid_public_key field
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vapid_public_key_endpoint_returns_json_with_field() -> None:
|
||||||
|
"""GET /api/vapid-public-key должен вернуть JSON с полем vapid_public_key."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/api/vapid-public-key")
|
||||||
|
data = response.json()
|
||||||
|
assert "vapid_public_key" in data, (
|
||||||
|
f"Ответ /api/vapid-public-key не содержит поле 'vapid_public_key': {data!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 5 — HTTP: non-empty vapid_public_key when env var is configured
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vapid_public_key_endpoint_returns_configured_value() -> None:
|
||||||
|
"""GET /api/vapid-public-key возвращает непустой ключ, когда VAPID_PUBLIC_KEY задан."""
|
||||||
|
with patch("backend.config.VAPID_PUBLIC_KEY", _TEST_VAPID_PUBLIC_KEY):
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/api/vapid-public-key")
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("vapid_public_key") == _TEST_VAPID_PUBLIC_KEY, (
|
||||||
|
f"vapid_public_key должен быть '{_TEST_VAPID_PUBLIC_KEY}', "
|
||||||
|
f"получили: {data.get('vapid_public_key')!r}"
|
||||||
|
)
|
||||||
|
|
@ -46,11 +46,11 @@ def test_register_request_empty_uuid():
|
||||||
def test_register_request_name_max_length():
|
def test_register_request_name_max_length():
|
||||||
"""name longer than 100 chars raises ValidationError."""
|
"""name longer than 100 chars raises ValidationError."""
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
RegisterRequest(uuid="some-uuid", name="x" * 101)
|
RegisterRequest(uuid="550e8400-e29b-41d4-a716-446655440000", name="x" * 101)
|
||||||
|
|
||||||
|
|
||||||
def test_register_request_name_exactly_100():
|
def test_register_request_name_exactly_100():
|
||||||
req = RegisterRequest(uuid="some-uuid", name="x" * 100)
|
req = RegisterRequest(uuid="550e8400-e29b-41d4-a716-446655440000", name="x" * 100)
|
||||||
assert len(req.name) == 100
|
assert len(req.name) == 100
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -116,29 +116,31 @@ def test_signal_request_valid():
|
||||||
|
|
||||||
def test_signal_request_no_geo():
|
def test_signal_request_no_geo():
|
||||||
req = SignalRequest(
|
req = SignalRequest(
|
||||||
user_id="some-uuid",
|
user_id="550e8400-e29b-41d4-a716-446655440000",
|
||||||
timestamp=1742478000000,
|
timestamp=1742478000000,
|
||||||
geo=None,
|
geo=None,
|
||||||
)
|
)
|
||||||
assert req.geo is None
|
assert req.geo is None
|
||||||
|
|
||||||
|
|
||||||
def test_signal_request_missing_user_id():
|
def test_signal_request_without_user_id():
|
||||||
with pytest.raises(ValidationError):
|
"""user_id is optional (JWT auth sends signals without it)."""
|
||||||
SignalRequest(timestamp=1742478000000) # type: ignore[call-arg]
|
req = SignalRequest(timestamp=1742478000000)
|
||||||
|
assert req.user_id is None
|
||||||
|
|
||||||
|
|
||||||
def test_signal_request_empty_user_id():
|
def test_signal_request_empty_user_id():
|
||||||
with pytest.raises(ValidationError):
|
"""Empty string user_id is accepted (treated as None at endpoint level)."""
|
||||||
SignalRequest(user_id="", timestamp=1742478000000)
|
req = SignalRequest(user_id="", timestamp=1742478000000)
|
||||||
|
assert req.user_id == ""
|
||||||
|
|
||||||
|
|
||||||
def test_signal_request_timestamp_zero():
|
def test_signal_request_timestamp_zero():
|
||||||
"""timestamp must be > 0."""
|
"""timestamp must be > 0."""
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
SignalRequest(user_id="some-uuid", timestamp=0)
|
SignalRequest(user_id="550e8400-e29b-41d4-a716-446655440000", timestamp=0)
|
||||||
|
|
||||||
|
|
||||||
def test_signal_request_timestamp_negative():
|
def test_signal_request_timestamp_negative():
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
SignalRequest(user_id="some-uuid", timestamp=-1)
|
SignalRequest(user_id="550e8400-e29b-41d4-a716-446655440000", timestamp=-1)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
"""
|
"""
|
||||||
Integration tests for POST /api/register.
|
Integration tests for POST /api/register.
|
||||||
|
|
||||||
|
UUID notes: RegisterRequest.uuid requires a valid UUID v4 pattern
|
||||||
|
(^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$).
|
||||||
|
All UUID constants below satisfy this constraint.
|
||||||
|
|
||||||
|
BATON-SEC-003: /api/register now returns api_key in the response.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -10,23 +16,34 @@ 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")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from tests.conftest import make_app_client
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
# Valid UUID v4 constants for register tests
|
||||||
|
_UUID_REG_1 = "b0000001-0000-4000-8000-000000000001"
|
||||||
|
_UUID_REG_2 = "b0000002-0000-4000-8000-000000000002"
|
||||||
|
_UUID_REG_3 = "b0000003-0000-4000-8000-000000000003"
|
||||||
|
_UUID_REG_4 = "b0000004-0000-4000-8000-000000000004"
|
||||||
|
_UUID_REG_5 = "b0000005-0000-4000-8000-000000000005"
|
||||||
|
_UUID_REG_6 = "b0000006-0000-4000-8000-000000000006"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_register_new_user_success():
|
async def test_register_new_user_success():
|
||||||
"""POST /api/register returns 200 with user_id > 0."""
|
"""POST /api/register returns 200 with user_id > 0 and api_key."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": "reg-uuid-001", "name": "Alice"},
|
json={"uuid": _UUID_REG_1, "name": "Alice"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["user_id"] > 0
|
assert data["user_id"] > 0
|
||||||
assert data["uuid"] == "reg-uuid-001"
|
assert data["uuid"] == _UUID_REG_1
|
||||||
|
assert "api_key" in data
|
||||||
|
assert len(data["api_key"]) == 64 # secrets.token_hex(32) = 64 hex chars
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -35,24 +52,42 @@ async def test_register_idempotent():
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
r1 = await client.post(
|
r1 = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": "reg-uuid-002", "name": "Bob"},
|
json={"uuid": _UUID_REG_2, "name": "Bob"},
|
||||||
)
|
)
|
||||||
r2 = await client.post(
|
r2 = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": "reg-uuid-002", "name": "Bob"},
|
json={"uuid": _UUID_REG_2, "name": "Bob"},
|
||||||
)
|
)
|
||||||
assert r1.status_code == 200
|
assert r1.status_code == 200
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
assert r1.json()["user_id"] == r2.json()["user_id"]
|
assert r1.json()["user_id"] == r2.json()["user_id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_idempotent_returns_api_key_on_every_call():
|
||||||
|
"""Each registration call returns an api_key (key rotation on re-register)."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
r1 = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_REG_3, "name": "Carol"},
|
||||||
|
)
|
||||||
|
r2 = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_REG_3, "name": "Carol"},
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert "api_key" in r1.json()
|
||||||
|
assert "api_key" in r2.json()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_register_empty_name_returns_422():
|
async def test_register_empty_name_returns_422():
|
||||||
"""Empty name must fail validation with 422."""
|
"""Empty name must fail validation with 422."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": "reg-uuid-003", "name": ""},
|
json={"uuid": _UUID_REG_4, "name": ""},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
@ -74,7 +109,18 @@ async def test_register_missing_name_returns_422():
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": "reg-uuid-004"},
|
json={"uuid": _UUID_REG_4},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_invalid_uuid_format_returns_422():
|
||||||
|
"""Non-UUID4 string as uuid must return 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": "not-a-uuid", "name": "Dave"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
@ -85,11 +131,11 @@ async def test_register_user_stored_in_db():
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
r1 = await client.post(
|
r1 = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": "reg-uuid-005", "name": "Dana"},
|
json={"uuid": _UUID_REG_5, "name": "Dana"},
|
||||||
)
|
)
|
||||||
r2 = await client.post(
|
r2 = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": "reg-uuid-005", "name": "Dana"},
|
json={"uuid": _UUID_REG_5, "name": "Dana"},
|
||||||
)
|
)
|
||||||
assert r1.json()["user_id"] == r2.json()["user_id"]
|
assert r1.json()["user_id"] == r2.json()["user_id"]
|
||||||
|
|
||||||
|
|
@ -100,6 +146,6 @@ async def test_register_response_contains_uuid():
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/register",
|
"/api/register",
|
||||||
json={"uuid": "reg-uuid-006", "name": "Eve"},
|
json={"uuid": _UUID_REG_6, "name": "Eve"},
|
||||||
)
|
)
|
||||||
assert resp.json()["uuid"] == "reg-uuid-006"
|
assert resp.json()["uuid"] == _UUID_REG_6
|
||||||
|
|
|
||||||
282
tests/test_sec_002.py
Normal file
282
tests/test_sec_002.py
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-SEC-002:
|
||||||
|
1. _get_client_ip() extracts real IP from X-Real-IP / X-Forwarded-For headers.
|
||||||
|
2. POST /api/signal returns 429 when the per-IP rate limit is exceeded.
|
||||||
|
3. Rate counters for register and signal are independent (separate key namespaces).
|
||||||
|
|
||||||
|
UUID notes: RegisterRequest.uuid and SignalRequest.user_id both require a valid
|
||||||
|
UUID v4 pattern (^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$).
|
||||||
|
All constants below satisfy this constraint.
|
||||||
|
|
||||||
|
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
|
||||||
|
_register_and_get_key() helper returns the api_key from the registration response.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
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 starlette.requests import Request
|
||||||
|
|
||||||
|
from backend.middleware import _get_client_ip
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
# ── Valid UUID v4 constants ──────────────────────────────────────────────────
|
||||||
|
# Pattern: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx (all hex chars)
|
||||||
|
|
||||||
|
_UUID_SIG_RL = "a0000001-0000-4000-8000-000000000001" # rate-limit 429 test
|
||||||
|
_UUID_SIG_OK = "a0000002-0000-4000-8000-000000000002" # first-10-allowed test
|
||||||
|
_UUID_IND_SIG = "a0000003-0000-4000-8000-000000000003" # independence (exhaust signal)
|
||||||
|
_UUID_IND_SIG2 = "a0000033-0000-4000-8000-000000000033" # second register after exhaust
|
||||||
|
_UUID_IND_REG = "a0000004-0000-4000-8000-000000000004" # independence (exhaust register)
|
||||||
|
_UUID_IP_A = "a0000005-0000-4000-8000-000000000005" # per-IP isolation, user A
|
||||||
|
_UUID_IP_B = "a0000006-0000-4000-8000-000000000006" # per-IP isolation, user B
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _register_and_get_key(client, uuid: str, name: str) -> str:
|
||||||
|
"""Register user and return api_key."""
|
||||||
|
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
|
||||||
|
assert r.status_code == 200
|
||||||
|
return r.json()["api_key"]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(headers: dict | None = None, client_host: str = "127.0.0.1") -> Request:
|
||||||
|
"""Build a minimal Starlette Request with given headers and remote address."""
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [
|
||||||
|
(k.lower().encode(), v.encode())
|
||||||
|
for k, v in (headers or {}).items()
|
||||||
|
],
|
||||||
|
"client": (client_host, 12345),
|
||||||
|
}
|
||||||
|
return Request(scope)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unit: _get_client_ip ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_ip_returns_x_real_ip_when_present():
|
||||||
|
"""X-Real-IP header is returned as-is (highest priority)."""
|
||||||
|
req = _make_request({"X-Real-IP": "203.0.113.10"}, client_host="127.0.0.1")
|
||||||
|
assert _get_client_ip(req) == "203.0.113.10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_ip_ignores_client_host_when_x_real_ip_set():
|
||||||
|
"""When X-Real-IP is present, client.host (127.0.0.1) must NOT be returned."""
|
||||||
|
req = _make_request({"X-Real-IP": "10.20.30.40"}, client_host="127.0.0.1")
|
||||||
|
assert _get_client_ip(req) != "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_ip_uses_x_forwarded_for_when_no_x_real_ip():
|
||||||
|
"""X-Forwarded-For is used when X-Real-IP is absent."""
|
||||||
|
req = _make_request({"X-Forwarded-For": "198.51.100.5"}, client_host="127.0.0.1")
|
||||||
|
assert _get_client_ip(req) == "198.51.100.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_ip_x_forwarded_for_returns_first_ip_in_chain():
|
||||||
|
"""When X-Forwarded-For contains a chain, only the first (original) IP is returned."""
|
||||||
|
req = _make_request(
|
||||||
|
{"X-Forwarded-For": "192.0.2.1, 10.0.0.1, 172.16.0.1"},
|
||||||
|
client_host="127.0.0.1",
|
||||||
|
)
|
||||||
|
assert _get_client_ip(req) == "192.0.2.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_ip_x_real_ip_takes_priority_over_x_forwarded_for():
|
||||||
|
"""X-Real-IP beats X-Forwarded-For when both headers are present."""
|
||||||
|
req = _make_request(
|
||||||
|
{"X-Real-IP": "1.1.1.1", "X-Forwarded-For": "2.2.2.2"},
|
||||||
|
client_host="127.0.0.1",
|
||||||
|
)
|
||||||
|
assert _get_client_ip(req) == "1.1.1.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_ip_falls_back_to_client_host_when_no_proxy_headers():
|
||||||
|
"""Without proxy headers, client.host is returned."""
|
||||||
|
req = _make_request(client_host="203.0.113.99")
|
||||||
|
assert _get_client_ip(req) == "203.0.113.99"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_ip_returns_unknown_when_no_client_and_no_headers():
|
||||||
|
"""If no proxy headers and client is None, 'unknown' is returned."""
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/",
|
||||||
|
"headers": [],
|
||||||
|
"client": None,
|
||||||
|
}
|
||||||
|
req = Request(scope)
|
||||||
|
assert _get_client_ip(req) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Integration: signal rate limit (429) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_rate_limit_returns_429_after_10_requests():
|
||||||
|
"""POST /api/signal returns 429 on the 11th request from the same IP."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
api_key = await _register_and_get_key(client, _UUID_SIG_RL, "RL")
|
||||||
|
|
||||||
|
payload = {"user_id": _UUID_SIG_RL, "timestamp": 1742478000000}
|
||||||
|
ip_hdrs = {"X-Real-IP": "5.5.5.5", "Authorization": f"Bearer {api_key}"}
|
||||||
|
|
||||||
|
statuses = []
|
||||||
|
for _ in range(11):
|
||||||
|
r = await client.post("/api/signal", json=payload, headers=ip_hdrs)
|
||||||
|
statuses.append(r.status_code)
|
||||||
|
|
||||||
|
assert statuses[-1] == 429, f"Expected 429 on 11th request, got {statuses}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_first_10_requests_are_allowed():
|
||||||
|
"""First 10 POST /api/signal requests from the same IP must all return 200."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
api_key = await _register_and_get_key(client, _UUID_SIG_OK, "OK")
|
||||||
|
|
||||||
|
payload = {"user_id": _UUID_SIG_OK, "timestamp": 1742478000000}
|
||||||
|
ip_hdrs = {"X-Real-IP": "6.6.6.6", "Authorization": f"Bearer {api_key}"}
|
||||||
|
|
||||||
|
statuses = []
|
||||||
|
for _ in range(10):
|
||||||
|
r = await client.post("/api/signal", json=payload, headers=ip_hdrs)
|
||||||
|
statuses.append(r.status_code)
|
||||||
|
|
||||||
|
assert all(s == 200 for s in statuses), (
|
||||||
|
f"Some request(s) before limit returned non-200: {statuses}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Integration: independence of register and signal rate limits ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_rate_limit_does_not_affect_register_counter():
|
||||||
|
"""
|
||||||
|
Exhausting the signal rate limit (11 requests) must NOT cause /api/register
|
||||||
|
to return 429 — the counters use different keys ('sig:IP' vs 'IP').
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
ip_hdrs_reg = {"X-Real-IP": "7.7.7.7"}
|
||||||
|
|
||||||
|
# Register a user (increments register counter, key='7.7.7.7', count=1)
|
||||||
|
r_reg = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_IND_SIG, "name": "Ind"},
|
||||||
|
headers=ip_hdrs_reg,
|
||||||
|
)
|
||||||
|
assert r_reg.status_code == 200
|
||||||
|
api_key = r_reg.json()["api_key"]
|
||||||
|
|
||||||
|
# Exhaust signal rate limit for same IP (11 requests, key='sig:7.7.7.7')
|
||||||
|
payload = {"user_id": _UUID_IND_SIG, "timestamp": 1742478000000}
|
||||||
|
ip_hdrs_sig = {"X-Real-IP": "7.7.7.7", "Authorization": f"Bearer {api_key}"}
|
||||||
|
for _ in range(11):
|
||||||
|
await client.post("/api/signal", json=payload, headers=ip_hdrs_sig)
|
||||||
|
|
||||||
|
# Register counter is still at 1 — must allow another registration
|
||||||
|
r_reg2 = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_IND_SIG2, "name": "Ind2"},
|
||||||
|
headers=ip_hdrs_reg,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r_reg2.status_code == 200, (
|
||||||
|
f"Register returned {r_reg2.status_code} — "
|
||||||
|
"signal exhaustion incorrectly bled into register counter"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_rate_limit_does_not_affect_signal_counter():
|
||||||
|
"""
|
||||||
|
Exhausting the register rate limit (6 requests → 6th returns 429) must NOT
|
||||||
|
prevent subsequent /api/signal requests from the same IP.
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
ip_hdrs = {"X-Real-IP": "8.8.8.8"}
|
||||||
|
|
||||||
|
# First register succeeds and creates the user we'll signal later
|
||||||
|
r0 = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_IND_REG, "name": "Reg"},
|
||||||
|
headers=ip_hdrs,
|
||||||
|
)
|
||||||
|
assert r0.status_code == 200
|
||||||
|
api_key = r0.json()["api_key"]
|
||||||
|
|
||||||
|
# Send 4 more register requests from the same IP (requests 2-5 succeed,
|
||||||
|
# each rotates the api_key; request 6 would be 429).
|
||||||
|
# We keep track of the last api_key since re-registration rotates it.
|
||||||
|
for _ in range(4):
|
||||||
|
r = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_IND_REG, "name": "Reg"},
|
||||||
|
headers=ip_hdrs,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
api_key = r.json()["api_key"]
|
||||||
|
|
||||||
|
# 6th request → 429 (exhausts limit without rotating key)
|
||||||
|
await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_IND_REG, "name": "Reg"},
|
||||||
|
headers=ip_hdrs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Signal must still succeed — signal counter (key='sig:8.8.8.8') is still 0
|
||||||
|
r_sig = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_IND_REG, "timestamp": 1742478000000},
|
||||||
|
headers={"X-Real-IP": "8.8.8.8", "Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r_sig.status_code == 200, (
|
||||||
|
f"Signal returned {r_sig.status_code} — "
|
||||||
|
"register exhaustion incorrectly bled into signal counter"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Integration: signal rate limit is per-IP ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_rate_limit_is_per_ip_different_ips_are_independent():
|
||||||
|
"""
|
||||||
|
Rate limit counters are per-IP — exhausting for IP A must not block IP B.
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
api_key_a = await _register_and_get_key(client, _UUID_IP_A, "IPA")
|
||||||
|
api_key_b = await _register_and_get_key(client, _UUID_IP_B, "IPB")
|
||||||
|
|
||||||
|
# Exhaust rate limit for IP A (11 requests → 11th is 429)
|
||||||
|
for _ in range(11):
|
||||||
|
await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_IP_A, "timestamp": 1742478000000},
|
||||||
|
headers={"X-Real-IP": "11.11.11.11", "Authorization": f"Bearer {api_key_a}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# IP B should still be allowed (independent counter)
|
||||||
|
r = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_IP_B, "timestamp": 1742478000000},
|
||||||
|
headers={"X-Real-IP": "22.22.22.22", "Authorization": f"Bearer {api_key_b}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200, f"IP B was incorrectly blocked: {r.status_code}"
|
||||||
298
tests/test_sec_003.py
Normal file
298
tests/test_sec_003.py
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-SEC-003: API-ключи для аутентификации /api/signal.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. POST /api/register возвращает api_key длиной 64 hex-символа.
|
||||||
|
2. POST /api/signal без Authorization header → 401.
|
||||||
|
3. POST /api/signal с неверным api_key → 401.
|
||||||
|
4. POST /api/signal с правильным api_key → 200.
|
||||||
|
5. Повторная регистрация генерирует новый api_key (ротация ключа).
|
||||||
|
6. Старый api_key становится недействительным после ротации.
|
||||||
|
7. Новый api_key работает после ротации.
|
||||||
|
8. SHA-256 хэш api_key сохраняется в БД, сырой ключ — нет (проверка через DB функцию).
|
||||||
|
|
||||||
|
UUID notes: все UUID ниже удовлетворяют паттерну UUID v4.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
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 backend import db
|
||||||
|
from tests.conftest import make_app_client, temp_db
|
||||||
|
from backend import config
|
||||||
|
|
||||||
|
# Valid UUID v4 constants
|
||||||
|
_UUID_1 = "aa000001-0000-4000-8000-000000000001"
|
||||||
|
_UUID_2 = "aa000002-0000-4000-8000-000000000002"
|
||||||
|
_UUID_3 = "aa000003-0000-4000-8000-000000000003"
|
||||||
|
_UUID_4 = "aa000004-0000-4000-8000-000000000004"
|
||||||
|
_UUID_5 = "aa000005-0000-4000-8000-000000000005"
|
||||||
|
_UUID_6 = "aa000006-0000-4000-8000-000000000006"
|
||||||
|
_UUID_7 = "aa000007-0000-4000-8000-000000000007"
|
||||||
|
_UUID_8 = "aa000008-0000-4000-8000-000000000008"
|
||||||
|
_UUID_9 = "aa000009-0000-4000-8000-000000000009"
|
||||||
|
_UUID_10 = "aa00000a-0000-4000-8000-00000000000a"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — /api/register returns api_key of correct length
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_returns_api_key():
|
||||||
|
"""POST /api/register должен вернуть поле api_key в ответе."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_1, "name": "Alice"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "api_key" in resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_api_key_is_64_hex_chars():
|
||||||
|
"""api_key должен быть строкой из 64 hex-символов (secrets.token_hex(32))."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_2, "name": "Bob"},
|
||||||
|
)
|
||||||
|
api_key = resp.json()["api_key"]
|
||||||
|
assert len(api_key) == 64
|
||||||
|
assert all(c in "0123456789abcdef" for c in api_key), (
|
||||||
|
f"api_key contains non-hex characters: {api_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — Missing Authorization → 401
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_without_auth_header_returns_401():
|
||||||
|
"""POST /api/signal без Authorization header должен вернуть 401."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
await client.post("/api/register", json={"uuid": _UUID_3, "name": "Charlie"})
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_3, "timestamp": 1742478000000},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_without_bearer_scheme_returns_401():
|
||||||
|
"""POST /api/signal с неверной схемой (Basic вместо Bearer) должен вернуть 401."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
await client.post("/api/register", json={"uuid": _UUID_3, "name": "Charlie"})
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_3, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": "Basic wrongtoken"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 3 — Wrong api_key → 401
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_with_wrong_api_key_returns_401():
|
||||||
|
"""POST /api/signal с неверным api_key должен вернуть 401."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
await client.post("/api/register", json={"uuid": _UUID_4, "name": "Dave"})
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_4, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": "Bearer " + "0" * 64},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_with_unknown_user_returns_401():
|
||||||
|
"""POST /api/signal с api_key незарегистрированного пользователя должен вернуть 401."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_5, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": "Bearer " + "a" * 64},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 4 — Correct api_key → 200
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_with_valid_api_key_returns_200():
|
||||||
|
"""POST /api/signal с правильным api_key должен вернуть 200."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_6, "name": "Eve"},
|
||||||
|
)
|
||||||
|
assert reg.status_code == 200
|
||||||
|
api_key = reg.json()["api_key"]
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_6, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 5-7 — Key rotation on re-register
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_re_register_produces_new_api_key():
|
||||||
|
"""Повторная регистрация должна возвращать новый api_key (ротация)."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
r1 = await client.post("/api/register", json={"uuid": _UUID_7, "name": "Frank"})
|
||||||
|
r2 = await client.post("/api/register", json={"uuid": _UUID_7, "name": "Frank"})
|
||||||
|
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r2.status_code == 200
|
||||||
|
# Ключи могут совпасть (очень маловероятно), но оба должны быть длиной 64
|
||||||
|
assert len(r2.json()["api_key"]) == 64
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_old_api_key_invalid_after_re_register():
|
||||||
|
"""После повторной регистрации старый api_key не должен работать."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
r1 = await client.post("/api/register", json={"uuid": _UUID_8, "name": "Grace"})
|
||||||
|
old_key = r1.json()["api_key"]
|
||||||
|
|
||||||
|
# Повторная регистрация — ротация ключа
|
||||||
|
r2 = await client.post("/api/register", json={"uuid": _UUID_8, "name": "Grace"})
|
||||||
|
new_key = r2.json()["api_key"]
|
||||||
|
|
||||||
|
# Старый ключ больше не должен работать
|
||||||
|
old_resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_8, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {old_key}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Новый ключ должен работать
|
||||||
|
new_resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_8, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {new_key}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert old_resp.status_code == 401, "Старый ключ должен быть недействителен после ротации"
|
||||||
|
assert new_resp.status_code == 200, "Новый ключ должен работать"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 5 (task brief) — Token from another user → 401
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_with_other_user_token_returns_401():
|
||||||
|
"""POST /api/signal с токеном другого пользователя должен вернуть 401.
|
||||||
|
|
||||||
|
Невозможно отправить сигнал от чужого имени даже зная UUID.
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
# Регистрируем двух пользователей
|
||||||
|
r_a = await client.post("/api/register", json={"uuid": _UUID_9, "name": "UserA"})
|
||||||
|
r_b = await client.post("/api/register", json={"uuid": _UUID_10, "name": "UserB"})
|
||||||
|
assert r_a.status_code == 200
|
||||||
|
assert r_b.status_code == 200
|
||||||
|
api_key_a = r_a.json()["api_key"]
|
||||||
|
api_key_b = r_b.json()["api_key"]
|
||||||
|
|
||||||
|
# UserA пытается отправить сигнал с токеном UserB
|
||||||
|
resp_a_with_b_key = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_9, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key_b}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# UserB пытается отправить сигнал с токеном UserA
|
||||||
|
resp_b_with_a_key = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_10, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key_a}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp_a_with_b_key.status_code == 401, (
|
||||||
|
"Нельзя отправить сигнал от имени UserA с токеном UserB"
|
||||||
|
)
|
||||||
|
assert resp_b_with_a_key.status_code == 401, (
|
||||||
|
"Нельзя отправить сигнал от имени UserB с токеном UserA"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 8 — SHA-256 hash is stored, not the raw key
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_key_hash_stored_in_db_not_raw_key():
|
||||||
|
"""В БД должен храниться SHA-256 хэш api_key, а не сырой ключ."""
|
||||||
|
with temp_db():
|
||||||
|
from backend.main import app
|
||||||
|
import contextlib
|
||||||
|
import httpx
|
||||||
|
import respx
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
|
||||||
|
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||||||
|
|
||||||
|
mock_router = respx.mock(assert_all_called=False)
|
||||||
|
mock_router.post(tg_set_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True, "result": True})
|
||||||
|
)
|
||||||
|
mock_router.post(send_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock_router:
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||||
|
reg = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_1, "name": "HashTest"},
|
||||||
|
)
|
||||||
|
assert reg.status_code == 200
|
||||||
|
raw_api_key = reg.json()["api_key"]
|
||||||
|
|
||||||
|
# Читаем хэш из БД напрямую
|
||||||
|
stored_hash = await db.get_api_key_hash_by_uuid(_UUID_1)
|
||||||
|
|
||||||
|
expected_hash = hashlib.sha256(raw_api_key.encode()).hexdigest()
|
||||||
|
assert stored_hash is not None, "api_key_hash должен быть в БД"
|
||||||
|
assert stored_hash == expected_hash, (
|
||||||
|
"В БД должен быть SHA-256 хэш, а не сырой ключ"
|
||||||
|
)
|
||||||
|
assert stored_hash != raw_api_key, "В БД не должен храниться сырой ключ"
|
||||||
337
tests/test_sec_006.py
Normal file
337
tests/test_sec_006.py
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-SEC-006: Персистентное хранение rate-limit счётчиков.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. Счётчики сохраняются между пересозданием экземпляра приложения (симуляция рестарта).
|
||||||
|
2. TTL-очистка корректно сбрасывает устаревшие записи после истечения окна.
|
||||||
|
3. Превышение лимита возвращает HTTP 429.
|
||||||
|
4. X-Real-IP и X-Forwarded-For корректно парсятся для подсчёта.
|
||||||
|
|
||||||
|
UUID note: All UUIDs below satisfy the v4 pattern validated since BATON-SEC-005.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
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 tempfile
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend import config, db
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
# ── Valid UUID v4 constants ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_UUID_XREALIP_A = "c0000001-0000-4000-8000-000000000001" # X-Real-IP exhaustion
|
||||||
|
_UUID_XREALIP_B = "c0000002-0000-4000-8000-000000000002" # IP-B (independent counter)
|
||||||
|
_UUID_XFWD = "c0000003-0000-4000-8000-000000000003" # X-Forwarded-For test
|
||||||
|
_UUID_REG_RL = "c0000004-0000-4000-8000-000000000004" # register 429 test
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _tmpdb() -> str:
|
||||||
|
"""Set config.DB_PATH to a fresh temp file and return the path."""
|
||||||
|
path = tempfile.mktemp(suffix=".db")
|
||||||
|
config.DB_PATH = path
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup(path: str) -> None:
|
||||||
|
for ext in ("", "-wal", "-shm"):
|
||||||
|
try:
|
||||||
|
os.unlink(path + ext)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Criterion 1: Persistence across restart ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limits_table_created_by_init_db():
|
||||||
|
"""init_db() creates the rate_limits table in SQLite."""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
async with aiosqlite.connect(path) as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='rate_limits'"
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
assert row is not None, "rate_limits table not found after init_db()"
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_counter_persists_after_db_reinit():
|
||||||
|
"""Counter survives re-initialization of the DB (simulates app restart).
|
||||||
|
|
||||||
|
Before: in-memory app.state.rate_counters was lost on restart.
|
||||||
|
After: SQLite-backed rate_limits table persists across init_db() calls.
|
||||||
|
"""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
|
||||||
|
c1 = await db.rate_limit_increment("persist:test", 600)
|
||||||
|
c2 = await db.rate_limit_increment("persist:test", 600)
|
||||||
|
c3 = await db.rate_limit_increment("persist:test", 600)
|
||||||
|
assert c3 == 3, f"Expected 3 after 3 increments, got {c3}"
|
||||||
|
|
||||||
|
# Simulate restart: re-initialize DB against the same file
|
||||||
|
await db.init_db()
|
||||||
|
|
||||||
|
# Counter must continue from 3, not reset to 0
|
||||||
|
c4 = await db.rate_limit_increment("persist:test", 600)
|
||||||
|
assert c4 == 4, (
|
||||||
|
f"Expected 4 after reinit + 1 more increment (counter must persist), got {c4}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_increment_returns_sequential_counts():
|
||||||
|
"""rate_limit_increment returns 1, 2, 3 on successive calls within window."""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
c1 = await db.rate_limit_increment("seq:test", 600)
|
||||||
|
c2 = await db.rate_limit_increment("seq:test", 600)
|
||||||
|
c3 = await db.rate_limit_increment("seq:test", 600)
|
||||||
|
assert (c1, c2, c3) == (1, 2, 3), f"Expected (1,2,3), got ({c1},{c2},{c3})"
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Criterion 2: TTL cleanup resets stale entries ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_ttl_resets_counter_after_window_expires():
|
||||||
|
"""Counter resets to 1 when the time window has expired (TTL cleanup).
|
||||||
|
|
||||||
|
time.time() is mocked — no real sleep required.
|
||||||
|
"""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
|
||||||
|
with mock.patch("backend.db.time") as mock_time:
|
||||||
|
mock_time.time.return_value = 1000.0 # window_start = t0
|
||||||
|
|
||||||
|
c1 = await db.rate_limit_increment("ttl:test", 10)
|
||||||
|
c2 = await db.rate_limit_increment("ttl:test", 10)
|
||||||
|
c3 = await db.rate_limit_increment("ttl:test", 10)
|
||||||
|
assert c3 == 3
|
||||||
|
|
||||||
|
# Jump 11 seconds ahead (window = 10s → expired)
|
||||||
|
mock_time.time.return_value = 1011.0
|
||||||
|
|
||||||
|
c4 = await db.rate_limit_increment("ttl:test", 10)
|
||||||
|
|
||||||
|
assert c4 == 1, (
|
||||||
|
f"Expected counter reset to 1 after window expired, got {c4}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_ttl_does_not_reset_within_window():
|
||||||
|
"""Counter is NOT reset when the window has NOT expired yet."""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
|
||||||
|
with mock.patch("backend.db.time") as mock_time:
|
||||||
|
mock_time.time.return_value = 1000.0
|
||||||
|
|
||||||
|
await db.rate_limit_increment("ttl:within", 10)
|
||||||
|
await db.rate_limit_increment("ttl:within", 10)
|
||||||
|
c3 = await db.rate_limit_increment("ttl:within", 10)
|
||||||
|
assert c3 == 3
|
||||||
|
|
||||||
|
# Only 5 seconds passed (window = 10s, still active)
|
||||||
|
mock_time.time.return_value = 1005.0
|
||||||
|
|
||||||
|
c4 = await db.rate_limit_increment("ttl:within", 10)
|
||||||
|
|
||||||
|
assert c4 == 4, (
|
||||||
|
f"Expected 4 (counter continues inside window), got {c4}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_ttl_boundary_exactly_at_window_end():
|
||||||
|
"""Counter resets when elapsed time equals exactly the window duration."""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
|
||||||
|
with mock.patch("backend.db.time") as mock_time:
|
||||||
|
mock_time.time.return_value = 1000.0
|
||||||
|
|
||||||
|
await db.rate_limit_increment("ttl:boundary", 10)
|
||||||
|
await db.rate_limit_increment("ttl:boundary", 10)
|
||||||
|
|
||||||
|
# Exactly at window boundary (elapsed == window → stale)
|
||||||
|
mock_time.time.return_value = 1010.0
|
||||||
|
|
||||||
|
c = await db.rate_limit_increment("ttl:boundary", 10)
|
||||||
|
|
||||||
|
assert c == 1, (
|
||||||
|
f"Expected reset at exact window boundary (elapsed == window), got {c}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Criterion 3: HTTP 429 when rate limit exceeded ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_returns_429_after_rate_limit_exceeded():
|
||||||
|
"""POST /api/register returns 429 on the 6th request from the same IP.
|
||||||
|
|
||||||
|
Register limit = 5 requests per 600s window.
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
ip_hdrs = {"X-Real-IP": "192.0.2.10"}
|
||||||
|
statuses = []
|
||||||
|
for _ in range(6):
|
||||||
|
r = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_REG_RL, "name": "RateLimitTest"},
|
||||||
|
headers=ip_hdrs,
|
||||||
|
)
|
||||||
|
statuses.append(r.status_code)
|
||||||
|
|
||||||
|
assert statuses[-1] == 429, (
|
||||||
|
f"Expected 429 on 6th register request, got statuses: {statuses}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_first_5_requests_are_allowed():
|
||||||
|
"""First 5 POST /api/register requests from the same IP must all return 200."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
ip_hdrs = {"X-Real-IP": "192.0.2.11"}
|
||||||
|
statuses = []
|
||||||
|
for _ in range(5):
|
||||||
|
r = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_REG_RL, "name": "RateLimitTest"},
|
||||||
|
headers=ip_hdrs,
|
||||||
|
)
|
||||||
|
statuses.append(r.status_code)
|
||||||
|
|
||||||
|
assert all(s == 200 for s in statuses), (
|
||||||
|
f"Expected all 5 register requests to return 200, got: {statuses}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Criterion 4: X-Real-IP and X-Forwarded-For for rate counting ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_x_real_ip_header_is_used_for_rate_counting():
|
||||||
|
"""Rate counter keys are derived from X-Real-IP: two requests sharing
|
||||||
|
the same X-Real-IP share the same counter and collectively hit the 429 limit.
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/register", json={"uuid": _UUID_XREALIP_A, "name": "RealIPUser"}
|
||||||
|
)
|
||||||
|
|
||||||
|
ip_hdrs = {"X-Real-IP": "203.0.113.10"}
|
||||||
|
payload = {"user_id": _UUID_XREALIP_A, "timestamp": 1742478000000}
|
||||||
|
|
||||||
|
statuses = []
|
||||||
|
for _ in range(11):
|
||||||
|
r = await client.post("/api/signal", json=payload, headers=ip_hdrs)
|
||||||
|
statuses.append(r.status_code)
|
||||||
|
|
||||||
|
assert statuses[-1] == 429, (
|
||||||
|
f"Expected 429 on 11th signal with same X-Real-IP, got: {statuses}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_x_forwarded_for_header_is_used_for_rate_counting():
|
||||||
|
"""Rate counter keys are derived from X-Forwarded-For (first IP) when
|
||||||
|
X-Real-IP is absent: requests sharing the same forwarded IP hit the limit.
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/register", json={"uuid": _UUID_XFWD, "name": "FwdUser"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chain: first IP is the original client (only that one is used)
|
||||||
|
fwd_hdrs = {"X-Forwarded-For": "198.51.100.5, 10.0.0.1, 172.16.0.1"}
|
||||||
|
payload = {"user_id": _UUID_XFWD, "timestamp": 1742478000000}
|
||||||
|
|
||||||
|
statuses = []
|
||||||
|
for _ in range(11):
|
||||||
|
r = await client.post("/api/signal", json=payload, headers=fwd_hdrs)
|
||||||
|
statuses.append(r.status_code)
|
||||||
|
|
||||||
|
assert statuses[-1] == 429, (
|
||||||
|
f"Expected 429 on 11th request with same X-Forwarded-For first IP, got: {statuses}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_different_x_real_ip_values_have_independent_counters():
|
||||||
|
"""Exhausting the rate limit for IP-A must not block IP-B.
|
||||||
|
|
||||||
|
Verifies that rate-limit keys are truly per-IP.
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
r_a = await client.post(
|
||||||
|
"/api/register", json={"uuid": _UUID_XREALIP_A, "name": "IPA"}
|
||||||
|
)
|
||||||
|
r_b = await client.post(
|
||||||
|
"/api/register", json={"uuid": _UUID_XREALIP_B, "name": "IPB"}
|
||||||
|
)
|
||||||
|
api_key_a = r_a.json()["api_key"]
|
||||||
|
api_key_b = r_b.json()["api_key"]
|
||||||
|
|
||||||
|
# Exhaust limit for IP-A (with valid auth so requests reach the rate limiter)
|
||||||
|
for _ in range(11):
|
||||||
|
await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_XREALIP_A, "timestamp": 1742478000000},
|
||||||
|
headers={
|
||||||
|
"X-Real-IP": "198.51.100.100",
|
||||||
|
"Authorization": f"Bearer {api_key_a}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# IP-B has its own independent counter — must not be blocked
|
||||||
|
r = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_XREALIP_B, "timestamp": 1742478000000},
|
||||||
|
headers={
|
||||||
|
"X-Real-IP": "198.51.100.200",
|
||||||
|
"Authorization": f"Bearer {api_key_b}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200, (
|
||||||
|
f"IP-B was incorrectly blocked after IP-A exhausted its counter: {r.status_code}"
|
||||||
|
)
|
||||||
240
tests/test_sec_007.py
Normal file
240
tests/test_sec_007.py
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
"""
|
||||||
|
Regression tests for BATON-SEC-007:
|
||||||
|
|
||||||
|
1. Retry loop in telegram.py is bounded to exactly 3 attempts.
|
||||||
|
2. Exponential backoff applies correctly: sleep = retry_after * (attempt + 1).
|
||||||
|
3. POST /api/signal uses asyncio.create_task — HTTP response is not blocked
|
||||||
|
by Telegram rate-limit pauses.
|
||||||
|
4. GET /health returns only {"status": "ok"} — no timestamp field.
|
||||||
|
|
||||||
|
BATON-SEC-003: POST /api/signal now requires Authorization: Bearer <api_key>.
|
||||||
|
Tests that send signals now register first and use the returned api_key.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from backend import config
|
||||||
|
from backend.telegram import send_message
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||||||
|
|
||||||
|
# Valid UUID v4 constants
|
||||||
|
_UUID_CT = "d4e5f6a7-b8c9-4d0e-a1b2-c3d4e5f6a7b8"
|
||||||
|
_UUID_SLOW = "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — retry loop is bounded to max 3 attempts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_loop_stops_after_3_attempts_on_all_429():
|
||||||
|
"""When all 3 responses are 429, send_message makes exactly 3 HTTP requests and stops."""
|
||||||
|
responses = [
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||||
|
]
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
route = mock.post(SEND_URL).mock(side_effect=responses)
|
||||||
|
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
await send_message("test max 3 attempts")
|
||||||
|
|
||||||
|
assert route.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_loop_does_not_make_4th_attempt_on_all_429():
|
||||||
|
"""send_message must never attempt a 4th request when the first 3 all return 429."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def _count_and_return_429(_request):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}})
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.post(SEND_URL).mock(side_effect=_count_and_return_429)
|
||||||
|
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
await send_message("test no 4th attempt")
|
||||||
|
|
||||||
|
assert call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — exponential backoff: sleep = retry_after * (attempt + 1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_429_first_attempt_sleeps_retry_after_times_1():
|
||||||
|
"""First 429 (attempt 0): sleep duration must be retry_after * 1."""
|
||||||
|
retry_after = 7
|
||||||
|
responses = [
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||||
|
httpx.Response(200, json={"ok": True}),
|
||||||
|
]
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.post(SEND_URL).mock(side_effect=responses)
|
||||||
|
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||||||
|
await send_message("test attempt 0 backoff")
|
||||||
|
|
||||||
|
mock_sleep.assert_called_once_with(retry_after * 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_429_exponential_backoff_sleep_sequence():
|
||||||
|
"""Two consecutive 429 responses produce sleep = retry_after*1 then retry_after*2."""
|
||||||
|
retry_after = 10
|
||||||
|
responses = [
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||||
|
httpx.Response(200, json={"ok": True}),
|
||||||
|
]
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.post(SEND_URL).mock(side_effect=responses)
|
||||||
|
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||||||
|
await send_message("test backoff sequence")
|
||||||
|
|
||||||
|
sleep_args = [c.args[0] for c in mock_sleep.call_args_list]
|
||||||
|
assert retry_after * 1 in sleep_args, f"Expected sleep({retry_after}) not found in {sleep_args}"
|
||||||
|
assert retry_after * 2 in sleep_args, f"Expected sleep({retry_after * 2}) not found in {sleep_args}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_429_third_attempt_sleeps_retry_after_times_3():
|
||||||
|
"""Third 429 (attempt 2): sleep duration must be retry_after * 3."""
|
||||||
|
retry_after = 5
|
||||||
|
responses = [
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": retry_after}}),
|
||||||
|
]
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.post(SEND_URL).mock(side_effect=responses)
|
||||||
|
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||||||
|
await send_message("test attempt 2 backoff")
|
||||||
|
|
||||||
|
sleep_args = [c.args[0] for c in mock_sleep.call_args_list]
|
||||||
|
assert retry_after * 3 in sleep_args, f"Expected sleep({retry_after * 3}) not found in {sleep_args}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# After exhausting all 3 attempts — error is logged
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_all_attempts_exhausted_logs_error(caplog):
|
||||||
|
"""After 3 failed 429 attempts, an ERROR containing 'all 3 attempts' is logged."""
|
||||||
|
responses = [
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||||
|
httpx.Response(429, json={"ok": False, "parameters": {"retry_after": 1}}),
|
||||||
|
]
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.post(SEND_URL).mock(side_effect=responses)
|
||||||
|
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||||
|
await send_message("test exhausted log")
|
||||||
|
|
||||||
|
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
|
||||||
|
assert any("all 3 attempts" in m.lower() for m in error_messages), (
|
||||||
|
f"Expected 'all 3 attempts' in error logs, got: {error_messages}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 3 — POST /api/signal uses asyncio.create_task (non-blocking)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_uses_create_task_for_telegram_send_message():
|
||||||
|
"""POST /api/signal must wrap telegram.send_message in asyncio.create_task."""
|
||||||
|
with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task) as mock_ct:
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg = await client.post("/api/register", json={"uuid": _UUID_CT, "name": "CT"})
|
||||||
|
assert reg.status_code == 200
|
||||||
|
api_key = reg.json()["api_key"]
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_CT, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert mock_ct.called, "asyncio.create_task was never called — send_message may have been awaited directly"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_response_returns_before_telegram_completes():
|
||||||
|
"""POST /api/signal returns 200 even when Telegram send_message is delayed."""
|
||||||
|
slow_sleep_called = False
|
||||||
|
|
||||||
|
async def slow_send_message(_text: str) -> None:
|
||||||
|
nonlocal slow_sleep_called
|
||||||
|
slow_sleep_called = True
|
||||||
|
await asyncio.sleep(9999) # would block forever if awaited
|
||||||
|
|
||||||
|
with patch("backend.main.telegram.send_message", side_effect=slow_send_message):
|
||||||
|
with patch("backend.main.asyncio.create_task", wraps=asyncio.create_task):
|
||||||
|
async with make_app_client() as client:
|
||||||
|
reg = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_SLOW, "name": "Slow"},
|
||||||
|
)
|
||||||
|
assert reg.status_code == 200
|
||||||
|
api_key = reg.json()["api_key"]
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={
|
||||||
|
"user_id": _UUID_SLOW,
|
||||||
|
"timestamp": 1742478000000,
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 4 — GET /health exact response body (regression guard)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_response_is_exactly_status_ok():
|
||||||
|
"""GET /health body must be exactly {"status": "ok"} — no extra fields."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/health")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_no_timestamp_field():
|
||||||
|
"""GET /health must not expose a timestamp field (time-based fingerprinting prevention)."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/health")
|
||||||
|
|
||||||
|
assert "timestamp" not in response.json()
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
"""
|
"""
|
||||||
Integration tests for POST /api/signal.
|
Integration tests for POST /api/signal.
|
||||||
|
|
||||||
|
UUID notes: both RegisterRequest.uuid and SignalRequest.user_id require valid UUID v4.
|
||||||
|
All UUID constants below satisfy the pattern.
|
||||||
|
|
||||||
|
BATON-SEC-003: /api/signal now requires Authorization: Bearer <api_key>.
|
||||||
|
The _register() helper returns the api_key from the registration response.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -10,30 +16,42 @@ 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")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from tests.conftest import make_app_client
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
# Valid UUID v4 constants for signal tests
|
||||||
|
_UUID_1 = "c0000001-0000-4000-8000-000000000001"
|
||||||
|
_UUID_2 = "c0000002-0000-4000-8000-000000000002"
|
||||||
|
_UUID_3 = "c0000003-0000-4000-8000-000000000003"
|
||||||
|
_UUID_4 = "c0000004-0000-4000-8000-000000000004"
|
||||||
|
_UUID_5 = "c0000005-0000-4000-8000-000000000005"
|
||||||
|
_UUID_6 = "c0000006-0000-4000-8000-000000000006"
|
||||||
|
|
||||||
async def _register(client: AsyncClient, uuid: str, name: str) -> None:
|
|
||||||
|
async def _register(client: AsyncClient, uuid: str, name: str) -> str:
|
||||||
|
"""Register user, assert success, return raw api_key."""
|
||||||
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
|
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200, f"Registration failed: {r.status_code} {r.text}"
|
||||||
|
return r.json()["api_key"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_signal_with_geo_success():
|
async def test_signal_with_geo_success():
|
||||||
"""POST /api/signal with geo returns 200 and signal_id > 0."""
|
"""POST /api/signal with geo returns 200 and signal_id > 0."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
await _register(client, "sig-uuid-001", "Alice")
|
api_key = await _register(client, _UUID_1, "Alice")
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={
|
json={
|
||||||
"user_id": "sig-uuid-001",
|
"user_id": _UUID_1,
|
||||||
"timestamp": 1742478000000,
|
"timestamp": 1742478000000,
|
||||||
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
|
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
|
||||||
},
|
},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
@ -45,28 +63,29 @@ async def test_signal_with_geo_success():
|
||||||
async def test_signal_without_geo_success():
|
async def test_signal_without_geo_success():
|
||||||
"""POST /api/signal with geo: null returns 200."""
|
"""POST /api/signal with geo: null returns 200."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
await _register(client, "sig-uuid-002", "Bob")
|
api_key = await _register(client, _UUID_2, "Bob")
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={
|
json={
|
||||||
"user_id": "sig-uuid-002",
|
"user_id": _UUID_2,
|
||||||
"timestamp": 1742478000000,
|
"timestamp": 1742478000000,
|
||||||
"geo": None,
|
"geo": None,
|
||||||
},
|
},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["status"] == "ok"
|
assert resp.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_signal_missing_user_id_returns_422():
|
async def test_signal_missing_auth_returns_401():
|
||||||
"""Missing user_id field must return 422."""
|
"""Missing Authorization header must return 401."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={"timestamp": 1742478000000},
|
json={"timestamp": 1742478000000},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -75,7 +94,7 @@ async def test_signal_missing_timestamp_returns_422():
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={"user_id": "sig-uuid-003"},
|
json={"user_id": _UUID_3},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
@ -87,14 +106,16 @@ async def test_signal_stored_in_db():
|
||||||
proving both were persisted.
|
proving both were persisted.
|
||||||
"""
|
"""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
await _register(client, "sig-uuid-004", "Charlie")
|
api_key = await _register(client, _UUID_4, "Charlie")
|
||||||
r1 = await client.post(
|
r1 = await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={"user_id": "sig-uuid-004", "timestamp": 1742478000001},
|
json={"user_id": _UUID_4, "timestamp": 1742478000001},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
r2 = await client.post(
|
r2 = await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={"user_id": "sig-uuid-004", "timestamp": 1742478000002},
|
json={"user_id": _UUID_4, "timestamp": 1742478000002},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
assert r1.status_code == 200
|
assert r1.status_code == 200
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
|
|
@ -111,11 +132,12 @@ async def test_signal_sends_telegram_message_directly():
|
||||||
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
|
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
|
||||||
|
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
await _register(client, "sig-uuid-005", "Dana")
|
api_key = await _register(client, _UUID_5, "Dana")
|
||||||
# make_app_client already mocks send_url; signal returns 200 proves send was called
|
# make_app_client already mocks send_url; signal returns 200 proves send was called
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={"user_id": "sig-uuid-005", "timestamp": 1742478000000},
|
json={"user_id": _UUID_5, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
@ -126,10 +148,11 @@ async def test_signal_sends_telegram_message_directly():
|
||||||
async def test_signal_returns_signal_id_positive():
|
async def test_signal_returns_signal_id_positive():
|
||||||
"""signal_id in response is always a positive integer."""
|
"""signal_id in response is always a positive integer."""
|
||||||
async with make_app_client() as client:
|
async with make_app_client() as client:
|
||||||
await _register(client, "sig-uuid-006", "Eve")
|
api_key = await _register(client, _UUID_6, "Eve")
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={"user_id": "sig-uuid-006", "timestamp": 1742478000000},
|
json={"user_id": _UUID_6, "timestamp": 1742478000000},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
assert resp.json()["signal_id"] > 0
|
assert resp.json()["signal_id"] > 0
|
||||||
|
|
||||||
|
|
@ -141,7 +164,7 @@ async def test_signal_geo_invalid_lat_returns_422():
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/signal",
|
"/api/signal",
|
||||||
json={
|
json={
|
||||||
"user_id": "sig-uuid-007",
|
"user_id": _UUID_1,
|
||||||
"timestamp": 1742478000000,
|
"timestamp": 1742478000000,
|
||||||
"geo": {"lat": 200.0, "lon": 0.0, "accuracy": 10.0},
|
"geo": {"lat": 200.0, "lon": 0.0, "accuracy": 10.0},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
Tests for backend/telegram.py: send_message, set_webhook, SignalAggregator.
|
Tests for backend/telegram.py: send_message, set_webhook, validate_bot_token.
|
||||||
|
|
||||||
NOTE: respx routes must be registered INSIDE the 'with mock:' block to be
|
NOTE: respx routes must be registered INSIDE the 'with mock:' block to be
|
||||||
intercepted properly. Registering them before entering the context does not
|
intercepted properly. Registering them before entering the context does not
|
||||||
|
|
@ -25,8 +25,6 @@ def _safe_aiosqlite_await(self):
|
||||||
aiosqlite.core.Connection.__await__ = _safe_aiosqlite_await # type: ignore[method-assign]
|
aiosqlite.core.Connection.__await__ = _safe_aiosqlite_await # type: ignore[method-assign]
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os as _os
|
|
||||||
import tempfile
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -34,11 +32,57 @@ import pytest
|
||||||
import respx
|
import respx
|
||||||
|
|
||||||
from backend import config
|
from backend import config
|
||||||
from backend.telegram import SignalAggregator, send_message, set_webhook
|
from backend.telegram import send_message, set_webhook, validate_bot_token
|
||||||
|
|
||||||
|
|
||||||
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||||||
WEBHOOK_URL_API = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
|
WEBHOOK_URL_API = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
|
||||||
|
GET_ME_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# validate_bot_token
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_bot_token_returns_true_on_200():
|
||||||
|
"""validate_bot_token returns True when getMe responds 200."""
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.get(GET_ME_URL).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True, "result": {"username": "batonbot"}})
|
||||||
|
)
|
||||||
|
result = await validate_bot_token()
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_bot_token_returns_false_on_401(caplog):
|
||||||
|
"""validate_bot_token returns False and logs ERROR when getMe responds 401."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.get(GET_ME_URL).mock(
|
||||||
|
return_value=httpx.Response(401, json={"ok": False, "description": "Unauthorized"})
|
||||||
|
)
|
||||||
|
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||||
|
result = await validate_bot_token()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert any("401" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_bot_token_returns_false_on_network_error(caplog):
|
||||||
|
"""validate_bot_token returns False and logs ERROR on network failure — never raises."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused"))
|
||||||
|
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||||
|
result = await validate_bot_token()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert len(caplog.records) >= 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -141,152 +185,66 @@ async def test_set_webhook_raises_on_non_200():
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SignalAggregator helpers
|
# BATON-007: 400 "chat not found" handling
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _init_db_with_tmp() -> str:
|
|
||||||
"""Init a temp-file DB and return its path."""
|
|
||||||
from backend import config as _cfg, db as _db
|
|
||||||
path = tempfile.mktemp(suffix=".db")
|
|
||||||
_cfg.DB_PATH = path
|
|
||||||
await _db.init_db()
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup(path: str) -> None:
|
|
||||||
for ext in ("", "-wal", "-shm"):
|
|
||||||
try:
|
|
||||||
_os.unlink(path + ext)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SignalAggregator tests
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_aggregator_single_signal_calls_send_message():
|
async def test_send_message_400_chat_not_found_does_not_raise():
|
||||||
"""Flushing an aggregator with one signal calls send_message once."""
|
"""400 'chat not found' must not raise an exception (service stays alive)."""
|
||||||
path = await _init_db_with_tmp()
|
|
||||||
try:
|
|
||||||
agg = SignalAggregator(interval=9999)
|
|
||||||
await agg.add_signal(
|
|
||||||
user_uuid="agg-uuid-001",
|
|
||||||
user_name="Alice",
|
|
||||||
timestamp=1742478000000,
|
|
||||||
geo={"lat": 55.0, "lon": 37.0, "accuracy": 10.0},
|
|
||||||
signal_id=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
with respx.mock(assert_all_called=False) as mock:
|
|
||||||
send_route = mock.post(SEND_URL).mock(
|
|
||||||
return_value=httpx.Response(200, json={"ok": True})
|
|
||||||
)
|
|
||||||
with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock):
|
|
||||||
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
|
||||||
await agg.flush()
|
|
||||||
|
|
||||||
assert send_route.call_count == 1
|
|
||||||
finally:
|
|
||||||
_cleanup(path)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_aggregator_multiple_signals_one_message():
|
|
||||||
"""5 signals flushed at once produce exactly one send_message call."""
|
|
||||||
path = await _init_db_with_tmp()
|
|
||||||
try:
|
|
||||||
agg = SignalAggregator(interval=9999)
|
|
||||||
for i in range(5):
|
|
||||||
await agg.add_signal(
|
|
||||||
user_uuid=f"agg-uuid-{i:03d}",
|
|
||||||
user_name=f"User{i}",
|
|
||||||
timestamp=1742478000000 + i * 1000,
|
|
||||||
geo=None,
|
|
||||||
signal_id=i + 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
with respx.mock(assert_all_called=False) as mock:
|
|
||||||
send_route = mock.post(SEND_URL).mock(
|
|
||||||
return_value=httpx.Response(200, json={"ok": True})
|
|
||||||
)
|
|
||||||
with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock):
|
|
||||||
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
|
||||||
await agg.flush()
|
|
||||||
|
|
||||||
assert send_route.call_count == 1
|
|
||||||
finally:
|
|
||||||
_cleanup(path)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_aggregator_empty_buffer_no_send():
|
|
||||||
"""Flushing an empty aggregator must NOT call send_message."""
|
|
||||||
agg = SignalAggregator(interval=9999)
|
|
||||||
|
|
||||||
# No routes registered — if a POST is made it will raise AllMockedAssertionError
|
|
||||||
with respx.mock(assert_all_called=False) as mock:
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
send_route = mock.post(SEND_URL).mock(
|
mock.post(SEND_URL).mock(
|
||||||
return_value=httpx.Response(200, json={"ok": True})
|
return_value=httpx.Response(
|
||||||
|
400,
|
||||||
|
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await agg.flush()
|
# Must not raise — service must stay alive even with wrong CHAT_ID
|
||||||
|
await send_message("test")
|
||||||
assert send_route.call_count == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_aggregator_buffer_cleared_after_flush():
|
async def test_send_message_400_chat_not_found_logs_error(caplog):
|
||||||
"""After flush, the aggregator buffer is empty."""
|
"""400 response from Telegram must be logged as ERROR with the status code."""
|
||||||
path = await _init_db_with_tmp()
|
import logging
|
||||||
try:
|
|
||||||
agg = SignalAggregator(interval=9999)
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
await agg.add_signal(
|
mock.post(SEND_URL).mock(
|
||||||
user_uuid="agg-uuid-clr",
|
return_value=httpx.Response(
|
||||||
user_name="Test",
|
400,
|
||||||
timestamp=1742478000000,
|
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
|
||||||
geo=None,
|
)
|
||||||
signal_id=99,
|
|
||||||
)
|
)
|
||||||
assert len(agg._buffer) == 1
|
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||||||
|
await send_message("test chat not found")
|
||||||
|
|
||||||
with respx.mock(assert_all_called=False) as mock:
|
assert any("400" in record.message for record in caplog.records), (
|
||||||
mock.post(SEND_URL).mock(return_value=httpx.Response(200, json={"ok": True}))
|
"Expected ERROR log containing '400' but got: " + str([r.message for r in caplog.records])
|
||||||
with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock):
|
)
|
||||||
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
|
||||||
await agg.flush()
|
|
||||||
|
|
||||||
assert len(agg._buffer) == 0
|
|
||||||
finally:
|
|
||||||
_cleanup(path)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_aggregator_unknown_user_shows_uuid_prefix():
|
async def test_send_message_400_breaks_after_first_attempt():
|
||||||
"""If user_name is None, the message shows first 8 chars of uuid."""
|
"""On 400, send_message breaks immediately (no retry loop) — only one HTTP call made."""
|
||||||
path = await _init_db_with_tmp()
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
try:
|
route = mock.post(SEND_URL).mock(
|
||||||
agg = SignalAggregator(interval=9999)
|
return_value=httpx.Response(
|
||||||
test_uuid = "abcdef1234567890"
|
400,
|
||||||
await agg.add_signal(
|
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
|
||||||
user_uuid=test_uuid,
|
)
|
||||||
user_name=None,
|
|
||||||
timestamp=1742478000000,
|
|
||||||
geo=None,
|
|
||||||
signal_id=1,
|
|
||||||
)
|
)
|
||||||
|
await send_message("test no retry on 400")
|
||||||
|
|
||||||
sent_texts: list[str] = []
|
assert route.call_count == 1, f"Expected 1 call on 400, got {route.call_count}"
|
||||||
|
|
||||||
async def _fake_send(text: str) -> None:
|
|
||||||
sent_texts.append(text)
|
|
||||||
|
|
||||||
with patch("backend.telegram.send_message", side_effect=_fake_send):
|
@pytest.mark.asyncio
|
||||||
with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock):
|
async def test_send_message_all_5xx_retries_exhausted_does_not_raise():
|
||||||
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
"""When all 3 attempts fail with 5xx, send_message logs error but does NOT raise."""
|
||||||
await agg.flush()
|
with respx.mock(assert_all_called=False) as mock:
|
||||||
|
mock.post(SEND_URL).mock(
|
||||||
|
return_value=httpx.Response(500, text="Internal Server Error")
|
||||||
|
)
|
||||||
|
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
# Must not raise — message is dropped, service stays alive
|
||||||
|
await send_message("test all retries exhausted")
|
||||||
|
|
||||||
assert len(sent_texts) == 1
|
|
||||||
assert test_uuid[:8] in sent_texts[0]
|
|
||||||
finally:
|
|
||||||
_cleanup(path)
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue