Шхуна не тонет: security, infra и доки на русском.
Безопасность довёл до ума — Cursor-генерацию переписал руками. IDOR закрыл, CSRF задушил, refresh rotation теперь как надо. HSTS на staging, ENABLE_DOCS=false, install.env recovery протестил. Backend: - jwt_denylist + auth_epoch: мгновенный revoke access JWT (logout/block/reset) - auth/admin/users: bump epoch, logout с Bearer, forgot_password skip для blocked - install_secrets: путь всегда apps/api/data/secrets/ (bootstrap из корня не ломает Docker) - seed: SEED_DEMO_USERS=false на prod/staging - тесты: jwt revoke, integration, coverage gate 90% Frontend: - logout шлёт Bearer, обработка TOKEN_REVOKED - guards TypeScript fix - E2E: blocked user → 401 сразу после block Infra: - staging/prod compose, TLS nginx, deploy-скрипты - k6 §17.2, backup/health/smoke scripts Docs: - docs/ на русском: project, security, deploy, release (старые md слили) - README короткий + план ТЗ + стандартные логины dev Код готов к плаванию. Капитан может идти писать фронт.
This commit is contained in:
@@ -40,3 +40,4 @@ DEMO_USER_PASSWORD=User1234
|
||||
DEMO_OPS_PASSWORD=OpsAdmin1234
|
||||
# E2E only, never enable in production.
|
||||
ENABLE_TEST_ROUTES=false
|
||||
SEED_DEMO_USERS=true
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Production API environment (non-secret keys). Secrets live in data/secrets/install.env
|
||||
APP_ENV=production
|
||||
ENABLE_DOCS=false
|
||||
ENABLE_TEST_ROUTES=false
|
||||
COOKIE_SECURE=true
|
||||
ENABLE_RATE_LIMIT=true
|
||||
EMAIL_DELIVERY_MODE=smtp
|
||||
SEED_DEMO_USERS=false
|
||||
JWT_ACCESS_TTL_MIN=15
|
||||
JWT_REFRESH_TTL_DAYS=30
|
||||
STORAGE_MODE=s3
|
||||
S3_BUCKET=compton
|
||||
S3_REGION=us-east-1
|
||||
AVATAR_MAX_BYTES=2097152
|
||||
MEDIA_URL_TTL_SECONDS=600
|
||||
LOG_LEVEL=INFO
|
||||
AUDIT_RETENTION_DAYS=90
|
||||
COMPTON_SETTINGS_PATH=data/compton_settings.json
|
||||
ADMIN_AUDIT_LOG_PATH=data/logs/admin-audit.jsonl
|
||||
SERVER_LOG_PATH=data/logs/server.log
|
||||
PASSWORD_DENYLIST_PATH=data/security/password-denylist.txt
|
||||
# Set on server: FRONTEND_URL, PUBLIC_BASE_URL, CORS_ORIGINS, SMTP_*, DATABASE_URL (via install.env)
|
||||
@@ -23,6 +23,7 @@ class Settings(BaseSettings):
|
||||
admin_initial_password: str = "Admin1234"
|
||||
demo_user_password: str = "User1234"
|
||||
demo_ops_password: str = "OpsAdmin1234"
|
||||
seed_demo_users: bool = True
|
||||
smtp_host: str = "localhost"
|
||||
smtp_port: int = 1025
|
||||
smtp_user: str = ""
|
||||
|
||||
@@ -48,12 +48,14 @@ def verify_password(raw_password: str, password_hash: str) -> bool:
|
||||
|
||||
def create_access_token(user_id: str, role: str, is_superuser: bool = False) -> str:
|
||||
from app.core.config import settings
|
||||
from app.core.jwt_denylist import get_auth_epoch
|
||||
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"role": role,
|
||||
"is_superuser": is_superuser,
|
||||
"auth_epoch": get_auth_epoch(user_id),
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()),
|
||||
"jti": generate_secret_token_hex(16),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.jwt_denylist import validate_access_claims
|
||||
from app.core.security import decode_access_token
|
||||
from app.modules.users.repository import get_user_by_id
|
||||
|
||||
@@ -12,6 +13,12 @@ def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
validate_access_claims(payload)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "TOKEN_REVOKED":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="TOKEN_REVOKED")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
|
||||
user = get_user_by_id(payload["sub"])
|
||||
|
||||
@@ -9,7 +9,8 @@ from uuid import uuid4
|
||||
|
||||
from app.core.crypto import generate_install_bundle
|
||||
|
||||
INSTALL_SECRETS_DIR = Path("data/secrets")
|
||||
_API_ROOT = Path(__file__).resolve().parents[2]
|
||||
INSTALL_SECRETS_DIR = _API_ROOT / "data" / "secrets"
|
||||
INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env"
|
||||
INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json"
|
||||
REQUIRED_KEYS = (
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Redis-backed JWT jti denylist and per-user auth_epoch for instant access revocation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.redis import get_redis_client
|
||||
|
||||
AUTH_EPOCH_PREFIX = "auth:epoch:"
|
||||
JWT_DENY_PREFIX = "jwt:deny:"
|
||||
|
||||
_memory_epochs: dict[str, int] = {}
|
||||
_memory_denied_jti: dict[str, float] = {}
|
||||
|
||||
|
||||
def _purge_expired_memory_jtis() -> None:
|
||||
now = time.time()
|
||||
expired = [jti for jti, exp in _memory_denied_jti.items() if exp <= now]
|
||||
for jti in expired:
|
||||
_memory_denied_jti.pop(jti, None)
|
||||
|
||||
|
||||
def ensure_jwt_revocation_backend() -> None:
|
||||
if settings.app_env.lower() != "production":
|
||||
return
|
||||
if get_redis_client() is None:
|
||||
raise RuntimeError("Redis is required for JWT revocation in production")
|
||||
|
||||
|
||||
def get_auth_epoch(user_id: str) -> int:
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
value = client.get(f"{AUTH_EPOCH_PREFIX}{user_id}")
|
||||
return int(value) if value is not None else 0
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
raise
|
||||
return _memory_epochs.get(user_id, 0)
|
||||
|
||||
|
||||
def bump_auth_epoch(user_id: str) -> int:
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
return int(client.incr(f"{AUTH_EPOCH_PREFIX}{user_id}"))
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
raise
|
||||
next_epoch = _memory_epochs.get(user_id, 0) + 1
|
||||
_memory_epochs[user_id] = next_epoch
|
||||
return next_epoch
|
||||
|
||||
|
||||
def deny_jti(jti: str, exp: int) -> None:
|
||||
if not jti:
|
||||
return
|
||||
ttl = max(int(exp - time.time()), 1)
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
client.setex(f"{JWT_DENY_PREFIX}{jti}", ttl, "1")
|
||||
return
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
raise
|
||||
_purge_expired_memory_jtis()
|
||||
_memory_denied_jti[jti] = time.time() + ttl
|
||||
|
||||
|
||||
def is_jti_denied(jti: str) -> bool:
|
||||
if not jti:
|
||||
return False
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
return bool(client.exists(f"{JWT_DENY_PREFIX}{jti}"))
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
return True
|
||||
_purge_expired_memory_jtis()
|
||||
return jti in _memory_denied_jti
|
||||
|
||||
|
||||
def revoke_access_token(token: str) -> None:
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
except Exception:
|
||||
return
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if jti and exp:
|
||||
deny_jti(str(jti), int(exp))
|
||||
|
||||
|
||||
def validate_access_claims(payload: dict) -> None:
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise ValueError("INVALID_TOKEN")
|
||||
jti = payload.get("jti")
|
||||
if jti and is_jti_denied(str(jti)):
|
||||
raise ValueError("TOKEN_REVOKED")
|
||||
token_epoch = int(payload.get("auth_epoch", 0))
|
||||
if token_epoch != get_auth_epoch(str(user_id)):
|
||||
raise ValueError("TOKEN_REVOKED")
|
||||
+15
-14
@@ -50,20 +50,21 @@ def run_seed(include_demo_pages: bool = True) -> None:
|
||||
is_superuser=True,
|
||||
status="active",
|
||||
)
|
||||
_ensure_user(
|
||||
email="user@compton.example",
|
||||
password=settings.demo_user_password,
|
||||
role="user",
|
||||
is_superuser=False,
|
||||
status="active",
|
||||
)
|
||||
_ensure_user(
|
||||
email="ops@compton.example",
|
||||
password=settings.demo_ops_password,
|
||||
role="admin",
|
||||
is_superuser=False,
|
||||
status="active",
|
||||
)
|
||||
if settings.seed_demo_users and settings.app_env.lower() != "production":
|
||||
_ensure_user(
|
||||
email="user@compton.example",
|
||||
password=settings.demo_user_password,
|
||||
role="user",
|
||||
is_superuser=False,
|
||||
status="active",
|
||||
)
|
||||
_ensure_user(
|
||||
email="ops@compton.example",
|
||||
password=settings.demo_ops_password,
|
||||
role="admin",
|
||||
is_superuser=False,
|
||||
status="active",
|
||||
)
|
||||
|
||||
if not include_demo_pages:
|
||||
return
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.install_secrets import ensure_install_secrets
|
||||
from app.core.config import settings
|
||||
from app.core.jwt_denylist import ensure_jwt_revocation_backend
|
||||
from app.core.app_settings import bootstrap_settings
|
||||
from app.core.storage import ensure_bucket
|
||||
from app.core import database as db_module
|
||||
@@ -50,6 +51,7 @@ def create_app() -> FastAPI:
|
||||
async def lifespan(_: FastAPI):
|
||||
ensure_install_secrets()
|
||||
bootstrap_settings()
|
||||
ensure_jwt_revocation_backend()
|
||||
_assert_production_guards()
|
||||
if settings.enable_test_routes:
|
||||
Base.metadata.create_all(db_module.engine)
|
||||
|
||||
@@ -4,6 +4,7 @@ from app.core.app_settings import apply_settings_to_app, get_settings_payload, w
|
||||
from app.core.audit_log import read_audit_events, write_audit_event
|
||||
from app.core.config import settings
|
||||
from app.core.install_secrets import install_secrets_payload, reveal_install_secret
|
||||
from app.core.jwt_denylist import bump_auth_epoch
|
||||
from app.core.security import hash_password
|
||||
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
|
||||
from app.modules.auth.service import revoke_user_refresh_family
|
||||
@@ -63,6 +64,7 @@ def patch_user(
|
||||
if status:
|
||||
target.status = status
|
||||
if status == "blocked":
|
||||
bump_auth_epoch(target.id)
|
||||
revoke_user_refresh_family(target.id)
|
||||
repository.update_user(target)
|
||||
write_audit_event(
|
||||
@@ -111,6 +113,7 @@ def reset_user_password(admin_user, target_user_id: str, password: str) -> dict:
|
||||
raise ValueError("USER_NOT_FOUND")
|
||||
target.password_hash = hash_password(password)
|
||||
repository.update_user(target)
|
||||
bump_auth_epoch(target.id)
|
||||
revoke_user_refresh_family(target.id)
|
||||
write_audit_event(
|
||||
action="admin.user.reset_password",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from app.core.datetime_utils import ensure_utc, utc_now
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.datetime_utils import ensure_utc, utc_now
|
||||
from app.core.redis import check_rate_limit, client_ip
|
||||
from app.modules.auth.schemas import (
|
||||
ForgotPasswordIn,
|
||||
@@ -15,16 +16,17 @@ from app.modules.auth.schemas import (
|
||||
from app.modules.auth.service import (
|
||||
forgot_password,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
register,
|
||||
resend_verification,
|
||||
reset_password,
|
||||
revoke_refresh_token,
|
||||
verify_email_token,
|
||||
)
|
||||
from app.modules.users import repository
|
||||
|
||||
router = APIRouter()
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _allowed_origins() -> set[str]:
|
||||
@@ -139,7 +141,12 @@ async def refresh_route(request: Request, response: Response):
|
||||
check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60)
|
||||
try:
|
||||
access_token, new_refresh, user = refresh(refresh_token)
|
||||
except PermissionError:
|
||||
except PermissionError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "EMAIL_NOT_VERIFIED":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
|
||||
if detail == "ACCOUNT_BLOCKED":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH")
|
||||
_set_refresh_cookie(response, new_refresh)
|
||||
return {
|
||||
@@ -156,11 +163,15 @@ async def refresh_route(request: Request, response: Response):
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout_route(request: Request, response: Response):
|
||||
async def logout_route(
|
||||
request: Request,
|
||||
response: Response,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||
):
|
||||
_enforce_origin(request, require_header=True)
|
||||
refresh_token = request.cookies.get("refresh_token")
|
||||
if refresh_token:
|
||||
revoke_refresh_token(refresh_token)
|
||||
access_token = credentials.credentials if credentials else None
|
||||
logout(refresh_token, access_token)
|
||||
response.delete_cookie(
|
||||
"refresh_token",
|
||||
path="/api/v1/auth",
|
||||
|
||||
@@ -6,6 +6,7 @@ from uuid import uuid4
|
||||
from app.core.config import settings
|
||||
from app.core.datetime_utils import ensure_utc, utc_now
|
||||
from app.core.email import send_template_email
|
||||
from app.core.jwt_denylist import bump_auth_epoch, revoke_access_token
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
generate_opaque_token,
|
||||
@@ -111,7 +112,7 @@ def resend_verification(email: str) -> None:
|
||||
|
||||
def forgot_password(email: str) -> None:
|
||||
user = repository.get_user_by_email(email)
|
||||
if not user:
|
||||
if not user or user.status == "blocked":
|
||||
return
|
||||
token = _issue_password_reset_token(user.id)
|
||||
_send_password_reset_email(user, token)
|
||||
@@ -132,6 +133,7 @@ def reset_password(token: str, new_password: str) -> None:
|
||||
user.password_hash = hash_password(new_password)
|
||||
repository.update_user(user)
|
||||
auth_repository.mark_password_reset_token_used(token_hash)
|
||||
bump_auth_epoch(user.id)
|
||||
revoke_user_refresh_family(user.id)
|
||||
|
||||
|
||||
@@ -199,18 +201,21 @@ def refresh(refresh_token: str) -> tuple[str, str, User]:
|
||||
token_row = auth_repository.get_refresh_token(token_hash)
|
||||
if not token_row:
|
||||
raise PermissionError("INVALID_REFRESH")
|
||||
|
||||
user = repository.get_user_by_id(token_row.user_id)
|
||||
if not user:
|
||||
raise PermissionError("INVALID_REFRESH")
|
||||
if user.status == "pending":
|
||||
raise PermissionError("EMAIL_NOT_VERIFIED")
|
||||
if user.status == "blocked":
|
||||
raise PermissionError("ACCOUNT_BLOCKED")
|
||||
|
||||
if token_row.revoked_at is not None:
|
||||
auth_repository.revoke_family_tokens(token_row.family_id)
|
||||
raise PermissionError("INVALID_REFRESH")
|
||||
if ensure_utc(token_row.expires_at) < utc_now():
|
||||
raise PermissionError("EXPIRED_REFRESH")
|
||||
|
||||
user = repository.get_user_by_id(token_row.user_id)
|
||||
if not user:
|
||||
raise PermissionError("INVALID_REFRESH")
|
||||
if user.status in {"pending", "blocked"}:
|
||||
raise PermissionError("INVALID_REFRESH")
|
||||
|
||||
auth_repository.revoke_refresh_token(token_hash)
|
||||
new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id)
|
||||
access = create_access_token(user.id, user.role, user.is_superuser)
|
||||
@@ -224,5 +229,12 @@ def revoke_refresh_token(refresh_token: str) -> None:
|
||||
auth_repository.revoke_family_tokens(token_row.family_id)
|
||||
|
||||
|
||||
def logout(refresh_token: str | None, access_token: str | None) -> None:
|
||||
if access_token:
|
||||
revoke_access_token(access_token)
|
||||
if refresh_token:
|
||||
revoke_refresh_token(refresh_token)
|
||||
|
||||
|
||||
def revoke_user_refresh_family(user_id: str) -> None:
|
||||
auth_repository.revoke_user_families(user_id)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from app.core.jwt_denylist import bump_auth_epoch
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.modules.auth.service import revoke_user_refresh_family
|
||||
from app.modules.media.service import upload_user_avatar
|
||||
@@ -26,4 +27,5 @@ def change_password(user: User, current_password: str, new_password: str) -> Non
|
||||
raise ValueError("INVALID_CURRENT_PASSWORD")
|
||||
user.password_hash = hash_password(new_password)
|
||||
repository.update_user(user)
|
||||
bump_auth_epoch(user.id)
|
||||
revoke_user_refresh_family(user.id)
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.core.install_secrets import ensure_install_secrets
|
||||
from app.core.config import settings
|
||||
|
||||
INITIAL_REVISION = "20260711_0001"
|
||||
HEAD_REVISION = "20260714_0005"
|
||||
SCHEMA_TABLES = (
|
||||
"content_pages",
|
||||
"email_verification_tokens",
|
||||
@@ -49,7 +50,7 @@ def wait_for_database(max_attempts: int = 30, delay_seconds: float = 1.0):
|
||||
"Local dev fix — deletes Docker DB data:\n"
|
||||
" docker compose --profile docker-web down -v\n"
|
||||
" docker compose --profile docker-web up -d --build\n"
|
||||
"See docs/secrets-recovery.md"
|
||||
"See docs/deploy.md#восстановление-секретов"
|
||||
)
|
||||
raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.app_settings import (
|
||||
_coerce_value,
|
||||
apply_settings_to_app,
|
||||
bootstrap_settings,
|
||||
env_locks,
|
||||
get_settings_payload,
|
||||
write_settings,
|
||||
)
|
||||
|
||||
|
||||
def test_write_and_bootstrap_settings(tmp_path, monkeypatch):
|
||||
settings_file = tmp_path / "compton_settings.json"
|
||||
monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file))
|
||||
monkeypatch.delenv("ENABLE_DOCS", raising=False)
|
||||
|
||||
merged = write_settings({"enable_docs": False, "log_level": "INFO"})
|
||||
assert merged["enable_docs"] is False
|
||||
assert settings_file.exists()
|
||||
|
||||
bootstrap_settings()
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.enable_docs is False
|
||||
|
||||
|
||||
def test_env_locks_skip_locked_keys(tmp_path, monkeypatch):
|
||||
settings_file = tmp_path / "compton_settings.json"
|
||||
monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file))
|
||||
monkeypatch.setenv("ENABLE_DOCS", "true")
|
||||
monkeypatch.setattr("app.core.app_settings.settings.enable_docs", True)
|
||||
|
||||
write_settings({"enable_docs": False})
|
||||
payload = get_settings_payload()
|
||||
assert payload["locks"]["enable_docs"] is True
|
||||
assert payload["values"]["enable_docs"] is True
|
||||
|
||||
|
||||
def test_apply_settings_coerces_list_and_bool(monkeypatch):
|
||||
apply_settings_to_app({"enable_docs": "false", "cors_origins": "http://a.test,http://b.test"})
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.enable_docs is False
|
||||
assert settings.cors_origins == ["http://a.test", "http://b.test"]
|
||||
assert isinstance(env_locks(), dict)
|
||||
|
||||
|
||||
def test_read_settings_ignores_invalid_payload(tmp_path, monkeypatch):
|
||||
settings_file = tmp_path / "compton_settings.json"
|
||||
settings_file.write_text("[]", encoding="utf-8")
|
||||
monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file))
|
||||
from app.core.app_settings import read_settings
|
||||
|
||||
assert read_settings() == {}
|
||||
|
||||
|
||||
def test_coerce_value_rejects_invalid_list():
|
||||
with pytest.raises(ValueError, match="INVALID_LIST_cors_origins"):
|
||||
_coerce_value("cors_origins", 123)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from app.core.audit_log import read_audit_events, write_audit_event
|
||||
|
||||
|
||||
def test_audit_log_roundtrip(tmp_path, monkeypatch):
|
||||
audit_file = tmp_path / "admin-audit.jsonl"
|
||||
monkeypatch.setattr("app.core.audit_log.settings.admin_audit_log_path", str(audit_file))
|
||||
write_audit_event("admin.user.patch", "u1", "admin@example.com", {"target": "u2"})
|
||||
events = read_audit_events()
|
||||
assert len(events) == 1
|
||||
assert events[0]["action"] == "admin.user.patch"
|
||||
assert events[0]["details"]["target"] == "u2"
|
||||
|
||||
|
||||
def test_read_audit_events_ignores_invalid_json(tmp_path, monkeypatch):
|
||||
audit_file = tmp_path / "admin-audit.jsonl"
|
||||
audit_file.write_text('{"action":"ok"}\nnot-json\n', encoding="utf-8")
|
||||
monkeypatch.setattr("app.core.audit_log.settings.admin_audit_log_path", str(audit_file))
|
||||
events = read_audit_events()
|
||||
assert len(events) == 1
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from app.core.dependencies import get_current_user, require_admin, require_superuser
|
||||
from app.core.security import create_access_token, hash_password
|
||||
from app.modules.users.repository import create_user, update_user
|
||||
|
||||
|
||||
def test_get_current_user_rejects_missing_credentials():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_current_user(None)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_get_current_user_rejects_pending_user():
|
||||
user = create_user("pending-dep@example.com", hash_password("Valid123"), status="pending")
|
||||
token = create_access_token(user.id, user.role, user.is_superuser)
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_current_user(credentials)
|
||||
assert exc.value.status_code == 403
|
||||
assert exc.value.detail == "EMAIL_NOT_VERIFIED"
|
||||
|
||||
|
||||
def test_get_current_user_rejects_blocked_user():
|
||||
user = create_user("blocked-dep@example.com", hash_password("Valid123"), status="active")
|
||||
user.status = "blocked"
|
||||
update_user(user)
|
||||
token = create_access_token(user.id, user.role, user.is_superuser)
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_current_user(credentials)
|
||||
assert exc.value.status_code == 403
|
||||
assert exc.value.detail == "ACCOUNT_BLOCKED"
|
||||
|
||||
|
||||
def test_get_current_user_rejects_invalid_token():
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="not-a-jwt")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_current_user(credentials)
|
||||
assert exc.value.status_code == 401
|
||||
assert exc.value.detail == "INVALID_TOKEN"
|
||||
|
||||
|
||||
def test_get_current_user_rejects_unknown_user():
|
||||
token = create_access_token("missing-user-id", "user", False)
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_current_user(credentials)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_require_admin_rejects_regular_user():
|
||||
user = create_user("regular-dep@example.com", hash_password("Valid123"), role="user", status="active")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_admin(user)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_require_superuser_rejects_non_super_admin():
|
||||
user = create_user("ops-dep@example.com", hash_password("Valid123"), role="admin", status="active")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_superuser(user)
|
||||
assert exc.value.status_code == 403
|
||||
assert exc.value.detail == "SUPERUSER_ONLY"
|
||||
@@ -0,0 +1,29 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.core.email import SmtpMailer, get_mailer, memory_mailer, send_template_email
|
||||
|
||||
|
||||
def test_memory_mailer_latest_token():
|
||||
memory_mailer.clear()
|
||||
send_template_email(
|
||||
to="user@example.com",
|
||||
template="verify_email",
|
||||
subject="Verify",
|
||||
body="Open link\nTOKEN:abc123\n",
|
||||
)
|
||||
assert memory_mailer.latest_token("user@example.com", "verify_email") == "abc123"
|
||||
|
||||
|
||||
def test_smtp_mailer_sends_message(monkeypatch):
|
||||
monkeypatch.setattr("app.core.email.settings.email_delivery_mode", "smtp")
|
||||
monkeypatch.setattr("app.core.email.settings.smtp_from", "noreply@example.com")
|
||||
monkeypatch.setattr("app.core.email.settings.smtp_host", "localhost")
|
||||
monkeypatch.setattr("app.core.email.settings.smtp_port", 1025)
|
||||
monkeypatch.setattr("app.core.email.settings.smtp_user", "")
|
||||
monkeypatch.setattr("app.core.email.settings.smtp_password", "")
|
||||
|
||||
smtp_instance = MagicMock()
|
||||
with patch("app.core.email.smtplib.SMTP") as smtp_cls:
|
||||
smtp_cls.return_value.__enter__.return_value = smtp_instance
|
||||
get_mailer().send("user@example.com", "Subject", "Body", "verify_email")
|
||||
smtp_instance.send_message.assert_called_once()
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import install_secrets as secrets
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def secrets_dir(tmp_path: Path, monkeypatch):
|
||||
install_dir = tmp_path / "secrets"
|
||||
install_file = install_dir / "install.env"
|
||||
meta_file = install_dir / "install.meta.json"
|
||||
monkeypatch.setattr(secrets, "INSTALL_SECRETS_DIR", install_dir)
|
||||
monkeypatch.setattr(secrets, "INSTALL_SECRETS_FILE", install_file)
|
||||
monkeypatch.setattr(secrets, "INSTALL_SECRETS_META_FILE", meta_file)
|
||||
return install_dir, install_file, meta_file
|
||||
|
||||
|
||||
def test_read_install_secrets_empty_when_missing(secrets_dir):
|
||||
_, install_file, _ = secrets_dir
|
||||
assert not install_file.exists()
|
||||
assert secrets.read_install_secrets() == {}
|
||||
|
||||
|
||||
def test_write_and_read_install_secrets_roundtrip(secrets_dir):
|
||||
install_dir, install_file, _ = secrets_dir
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
install_file.write_text(
|
||||
"JWT_ACCESS_SECRET=abc\n# comment\nPOSTGRES_PASSWORD=secret\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
values = secrets.read_install_secrets()
|
||||
assert values["JWT_ACCESS_SECRET"] == "abc"
|
||||
assert values["POSTGRES_PASSWORD"] == "secret"
|
||||
|
||||
|
||||
def test_sync_minio_s3_secrets_aligns_keys():
|
||||
values = {
|
||||
"S3_ACCESS_KEY": "minio",
|
||||
"MINIO_ROOT_USER": "minio",
|
||||
"MINIO_ROOT_PASSWORD": "root-secret",
|
||||
"S3_SECRET_KEY": "old-secret",
|
||||
}
|
||||
synced = secrets._sync_minio_s3_secrets(values)
|
||||
assert synced["S3_SECRET_KEY"] == "root-secret"
|
||||
|
||||
|
||||
def test_masked_database_url_hides_password():
|
||||
masked = secrets.masked_database_url("postgresql://user:pass@localhost:5432/compton")
|
||||
assert "pass" not in masked
|
||||
assert "user:***" in masked
|
||||
|
||||
|
||||
def test_install_secrets_payload_reports_status(secrets_dir):
|
||||
install_dir, install_file, _ = secrets_dir
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
install_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"SECRETS_LOCKED=true",
|
||||
"DATABASE_URL=postgresql://compton_app:secret@postgres:5432/compton",
|
||||
"JWT_ACCESS_SECRET=abc",
|
||||
"JWT_REFRESH_PEPPER=def",
|
||||
"POSTGRES_PASSWORD=secret",
|
||||
"S3_SECRET_KEY=key",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
payload = secrets.install_secrets_payload()
|
||||
assert payload["initialized"] is True
|
||||
assert payload["locked"] is True
|
||||
assert payload["database"]["user"] == "compton_app"
|
||||
assert "***" in payload["connection_string_masked"]
|
||||
|
||||
|
||||
def test_reveal_install_secret_supported_and_unsupported(secrets_dir):
|
||||
install_dir, install_file, _ = secrets_dir
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
install_file.write_text("JWT_ACCESS_SECRET=top-secret\n", encoding="utf-8")
|
||||
assert secrets.reveal_install_secret("jwt_access_secret") == "top-secret"
|
||||
with pytest.raises(ValueError, match="UNSUPPORTED_SECRET_KEY"):
|
||||
secrets.reveal_install_secret("unknown")
|
||||
|
||||
|
||||
def test_ensure_install_secrets_creates_locked_bundle(secrets_dir, monkeypatch):
|
||||
install_dir, install_file, meta_file = secrets_dir
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
status = secrets.ensure_install_secrets()
|
||||
assert status.created is True
|
||||
assert status.locked is True
|
||||
assert install_file.exists()
|
||||
assert meta_file.exists()
|
||||
values = secrets.read_install_secrets()
|
||||
assert values["SECRETS_LOCKED"] == "true"
|
||||
assert values["JWT_ACCESS_SECRET"]
|
||||
|
||||
|
||||
def test_ensure_install_secrets_adopts_existing_locked_file(secrets_dir):
|
||||
install_dir, install_file, meta_file = secrets_dir
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
install_file.write_text(
|
||||
"SECRETS_LOCKED=true\nJWT_ACCESS_SECRET=existing\nJWT_REFRESH_PEPPER=pepper\n"
|
||||
"POSTGRES_PASSWORD=pw\nPOSTGRES_USER=u\nPOSTGRES_DB=db\nDATABASE_URL=postgresql://u:pw@localhost/db\n"
|
||||
"S3_ACCESS_KEY=a\nS3_SECRET_KEY=b\nMINIO_ROOT_USER=a\nMINIO_ROOT_PASSWORD=b\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
meta_file.write_text('{"install_id":"x","locked_at":"2026-01-01T00:00:00+00:00"}\n', encoding="utf-8")
|
||||
status = secrets.ensure_install_secrets()
|
||||
assert status.created is False
|
||||
assert status.locked is True
|
||||
assert secrets.read_install_secrets()["JWT_ACCESS_SECRET"] == "existing"
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import jwt_denylist as denylist_module
|
||||
from app.core.jwt_denylist import (
|
||||
bump_auth_epoch,
|
||||
deny_jti,
|
||||
ensure_jwt_revocation_backend,
|
||||
get_auth_epoch,
|
||||
is_jti_denied,
|
||||
revoke_access_token,
|
||||
validate_access_claims,
|
||||
)
|
||||
from app.core.security import create_access_token, hash_password
|
||||
from app.modules.users.repository import create_user
|
||||
|
||||
|
||||
def test_auth_epoch_bump_invalidates_token():
|
||||
user = create_user("epoch-user@example.com", hash_password("Valid123"), status="active")
|
||||
token = create_access_token(user.id, user.role, user.is_superuser)
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
payload = decode_access_token(token)
|
||||
validate_access_claims(payload)
|
||||
|
||||
bump_auth_epoch(user.id)
|
||||
with pytest.raises(ValueError, match="TOKEN_REVOKED"):
|
||||
validate_access_claims(payload)
|
||||
|
||||
|
||||
def test_deny_jti_blocks_specific_token():
|
||||
user = create_user("jti-user@example.com", hash_password("Valid123"), status="active")
|
||||
token = create_access_token(user.id, user.role, user.is_superuser)
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
payload = decode_access_token(token)
|
||||
deny_jti(payload["jti"], payload["exp"])
|
||||
assert is_jti_denied(payload["jti"])
|
||||
with pytest.raises(ValueError, match="TOKEN_REVOKED"):
|
||||
validate_access_claims(payload)
|
||||
|
||||
|
||||
def test_revoke_access_token_helper():
|
||||
user = create_user("revoke-user@example.com", hash_password("Valid123"), status="active")
|
||||
token = create_access_token(user.id, user.role, user.is_superuser)
|
||||
revoke_access_token(token)
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
with pytest.raises(ValueError, match="TOKEN_REVOKED"):
|
||||
validate_access_claims(decode_access_token(token))
|
||||
|
||||
|
||||
def test_get_auth_epoch_defaults_to_zero():
|
||||
assert get_auth_epoch("missing-user-id") == 0
|
||||
|
||||
|
||||
def test_validate_access_claims_rejects_missing_sub():
|
||||
with pytest.raises(ValueError, match="INVALID_TOKEN"):
|
||||
validate_access_claims({})
|
||||
|
||||
|
||||
def test_production_is_jti_denied_fail_closed(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
|
||||
mock_client = MagicMock()
|
||||
mock_client.exists.side_effect = RuntimeError("redis down")
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
|
||||
assert is_jti_denied("any-jti") is True
|
||||
|
||||
|
||||
def test_revoke_access_token_ignores_invalid_token():
|
||||
revoke_access_token("not-a-jwt")
|
||||
|
||||
|
||||
def test_bump_auth_epoch_production_raises_when_redis_fails(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
|
||||
mock_client = MagicMock()
|
||||
mock_client.incr.side_effect = RuntimeError("redis down")
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
|
||||
with pytest.raises(RuntimeError):
|
||||
bump_auth_epoch("user-x")
|
||||
|
||||
|
||||
def test_empty_jti_is_not_denied():
|
||||
assert is_jti_denied("") is False
|
||||
deny_jti("", 9999999999)
|
||||
|
||||
|
||||
def test_ensure_jwt_revocation_backend_requires_redis_in_production(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="Redis is required"):
|
||||
ensure_jwt_revocation_backend()
|
||||
|
||||
|
||||
def test_get_auth_epoch_production_raises_when_redis_fails(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
|
||||
mock_client = MagicMock()
|
||||
mock_client.get.side_effect = RuntimeError("redis down")
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
|
||||
with pytest.raises(RuntimeError):
|
||||
get_auth_epoch("user-y")
|
||||
|
||||
|
||||
def test_deny_jti_production_raises_when_redis_fails(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
|
||||
mock_client = MagicMock()
|
||||
mock_client.setex.side_effect = RuntimeError("redis down")
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
|
||||
with pytest.raises(RuntimeError):
|
||||
deny_jti("jti-123", int(__import__("time").time()) + 3600)
|
||||
|
||||
|
||||
def test_memory_denylist_purges_expired_jti(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module, "_memory_denied_jti", {"expired-jti": 1.0})
|
||||
assert is_jti_denied("expired-jti") is False
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from app.main import create_app
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def test_create_app_includes_test_routes_when_enabled(monkeypatch):
|
||||
monkeypatch.setattr(settings, "enable_test_routes", True)
|
||||
app = create_app()
|
||||
paths = set(app.openapi()["paths"])
|
||||
assert "/api/v1/test/emails/latest-token" in paths
|
||||
@@ -1,5 +1,15 @@
|
||||
from app.core.password_denylist import is_denied_password
|
||||
from app.core.password_denylist import is_denied_password, load_denylist
|
||||
|
||||
|
||||
def test_password_denylist_blocks_common_password():
|
||||
assert is_denied_password("password123")
|
||||
|
||||
|
||||
def test_password_denylist_loads_custom_entries(tmp_path, monkeypatch):
|
||||
denylist_file = tmp_path / "denylist.txt"
|
||||
denylist_file.write_text("# comment\nCustomBad1\n", encoding="utf-8")
|
||||
monkeypatch.setattr("app.core.password_denylist.settings.password_denylist_path", str(denylist_file))
|
||||
denylist = load_denylist()
|
||||
assert "custombad1" in denylist
|
||||
assert is_denied_password("CustomBad1")
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.main import _assert_production_guards
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_app_env(monkeypatch):
|
||||
monkeypatch.setattr(settings, "app_env", "development")
|
||||
monkeypatch.setattr(settings, "enable_test_routes", True)
|
||||
monkeypatch.setattr(settings, "enable_docs", True)
|
||||
monkeypatch.setattr(settings, "enable_rate_limit", False)
|
||||
monkeypatch.setattr(settings, "cookie_secure", False)
|
||||
monkeypatch.setattr(settings, "jwt_access_secret", "dev-access-secret-32bytes-minimum!!")
|
||||
monkeypatch.setattr(settings, "jwt_refresh_pepper", "dev-refresh-pepper-32bytes-minimum!!")
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"database_url",
|
||||
"postgresql+psycopg://compton_app:secret@postgres:5432/compton?sslmode=require",
|
||||
)
|
||||
|
||||
|
||||
def test_production_guards_skip_in_development():
|
||||
_assert_production_guards()
|
||||
|
||||
|
||||
def test_production_guards_reject_test_routes(monkeypatch):
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
with pytest.raises(RuntimeError, match="ENABLE_TEST_ROUTES"):
|
||||
_assert_production_guards()
|
||||
|
||||
|
||||
def test_production_guards_reject_insecure_cookie(monkeypatch):
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
monkeypatch.setattr(settings, "enable_test_routes", False)
|
||||
monkeypatch.setattr(settings, "enable_docs", False)
|
||||
monkeypatch.setattr(settings, "enable_rate_limit", True)
|
||||
with pytest.raises(RuntimeError, match="COOKIE_SECURE"):
|
||||
_assert_production_guards()
|
||||
|
||||
|
||||
def test_production_guards_reject_default_db_credentials(monkeypatch):
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
monkeypatch.setattr(settings, "enable_test_routes", False)
|
||||
monkeypatch.setattr(settings, "enable_docs", False)
|
||||
monkeypatch.setattr(settings, "enable_rate_limit", True)
|
||||
monkeypatch.setattr(settings, "cookie_secure", True)
|
||||
monkeypatch.setattr(settings, "database_url", "postgresql://user:pass@db/app?sslmode=require")
|
||||
with pytest.raises(RuntimeError, match="Default database credentials"):
|
||||
_assert_production_guards()
|
||||
|
||||
|
||||
def test_production_guards_reject_placeholder_jwt(monkeypatch):
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
monkeypatch.setattr(settings, "enable_test_routes", False)
|
||||
monkeypatch.setattr(settings, "enable_docs", False)
|
||||
monkeypatch.setattr(settings, "enable_rate_limit", True)
|
||||
monkeypatch.setattr(settings, "cookie_secure", True)
|
||||
monkeypatch.setattr(settings, "jwt_access_secret", "change-me-access")
|
||||
with pytest.raises(RuntimeError, match="JWT_ACCESS_SECRET"):
|
||||
_assert_production_guards()
|
||||
|
||||
|
||||
def test_production_guards_reject_placeholder_refresh_pepper(monkeypatch):
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
monkeypatch.setattr(settings, "enable_test_routes", False)
|
||||
monkeypatch.setattr(settings, "enable_docs", False)
|
||||
monkeypatch.setattr(settings, "enable_rate_limit", True)
|
||||
monkeypatch.setattr(settings, "cookie_secure", True)
|
||||
monkeypatch.setattr(settings, "jwt_access_secret", "prod-access-secret-32bytes-minimum!!")
|
||||
monkeypatch.setattr(settings, "jwt_refresh_pepper", "change-me-pepper")
|
||||
with pytest.raises(RuntimeError, match="JWT_REFRESH_PEPPER"):
|
||||
_assert_production_guards()
|
||||
|
||||
|
||||
def test_production_guards_reject_missing_sslmode(monkeypatch):
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
monkeypatch.setattr(settings, "enable_test_routes", False)
|
||||
monkeypatch.setattr(settings, "enable_docs", False)
|
||||
monkeypatch.setattr(settings, "enable_rate_limit", True)
|
||||
monkeypatch.setattr(settings, "cookie_secure", True)
|
||||
monkeypatch.setattr(settings, "database_url", "postgresql://app:secret@db/app")
|
||||
with pytest.raises(RuntimeError, match="sslmode=require"):
|
||||
_assert_production_guards()
|
||||
@@ -0,0 +1,32 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.redis import check_rate_limit, client_ip, _buckets
|
||||
|
||||
|
||||
def test_check_rate_limit_uses_memory_fallback(monkeypatch):
|
||||
monkeypatch.setattr(settings, "enable_rate_limit", True)
|
||||
_buckets.clear()
|
||||
with patch("app.core.redis.get_redis_client", return_value=None):
|
||||
check_rate_limit("memory-key", limit=1, window_seconds=60)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
check_rate_limit("memory-key", limit=1, window_seconds=60)
|
||||
assert exc.value.status_code == 429
|
||||
|
||||
|
||||
def test_client_ip_honors_trusted_proxy(monkeypatch):
|
||||
monkeypatch.setattr(settings, "trusted_proxy_ips", "127.0.0.1")
|
||||
request = MagicMock(spec=Request)
|
||||
request.headers = {"X-Forwarded-For": "203.0.113.10, 127.0.0.1"}
|
||||
request.client.host = "127.0.0.1"
|
||||
assert client_ip(request) == "203.0.113.10"
|
||||
|
||||
|
||||
def test_client_ip_unknown_without_client():
|
||||
request = MagicMock(spec=Request)
|
||||
request.headers = {}
|
||||
request.client = None
|
||||
assert client_ip(request) == "unknown"
|
||||
@@ -1,5 +1,14 @@
|
||||
from app.core.storage import ensure_bucket
|
||||
from app.core.storage import download_object, ensure_bucket, memory_store, upload_object
|
||||
|
||||
|
||||
def test_ensure_bucket_noop_in_memory():
|
||||
ensure_bucket()
|
||||
|
||||
|
||||
def test_memory_storage_roundtrip():
|
||||
memory_store.clear()
|
||||
upload_object("avatars/test.png", b"abc", "image/png")
|
||||
payload = download_object("avatars/test.png")
|
||||
assert payload == (b"abc", "image/png")
|
||||
assert download_object("missing") is None
|
||||
|
||||
|
||||
@@ -71,3 +71,39 @@ def test_superuser_can_create_and_delete_user(client):
|
||||
deleted = client.delete(f"/api/v1/admin/users/{user_id}", headers=_admin_headers(client))
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json()["status"] == "deleted"
|
||||
|
||||
|
||||
def test_admin_summary_and_stats(client):
|
||||
headers = _admin_headers(client)
|
||||
summary = client.get("/api/v1/admin/summary", headers=headers)
|
||||
assert summary.status_code == 200
|
||||
assert "users_count" in summary.json()
|
||||
|
||||
stats = client.get("/api/v1/admin/stats", headers=headers)
|
||||
assert stats.status_code == 200
|
||||
|
||||
|
||||
def test_superuser_diagnostics_and_server_log(client):
|
||||
headers = _admin_headers(client)
|
||||
diagnostics = client.get("/api/v1/admin/diagnostics/report", headers=headers)
|
||||
assert diagnostics.status_code == 200
|
||||
assert "checks" in diagnostics.json()
|
||||
|
||||
activity = client.get("/api/v1/admin/activity-feed", headers=headers)
|
||||
assert activity.status_code == 200
|
||||
assert "events" in activity.json()
|
||||
|
||||
server_log = client.get("/api/v1/admin/server-log", headers=headers)
|
||||
assert server_log.status_code == 200
|
||||
assert "lines" in server_log.json()
|
||||
|
||||
|
||||
def test_admin_ui_activity(client):
|
||||
response = client.post(
|
||||
"/api/v1/admin/ui-activity",
|
||||
json={"event": "tab_open", "meta": {"tab": "users"}},
|
||||
headers=_admin_headers(client),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
|
||||
|
||||
|
||||
def test_build_security_diagnostics_report_returns_checks():
|
||||
report = build_security_diagnostics_report()
|
||||
assert "checks" in report
|
||||
assert len(report["checks"]) >= 10
|
||||
ids = {check["id"] for check in report["checks"]}
|
||||
assert "jwt_access_secret" in ids
|
||||
assert "install_secrets_locked" in ids
|
||||
@@ -1,6 +1,12 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.modules.admin.service import patch_user
|
||||
from app.modules.admin.service import (
|
||||
create_admin_user,
|
||||
delete_admin_user,
|
||||
get_server_log_tail,
|
||||
patch_user,
|
||||
reset_user_password,
|
||||
)
|
||||
from app.modules.users import repository
|
||||
from app.modules.users.repository import create_user, get_user_by_email
|
||||
from app.core.security import hash_password
|
||||
@@ -31,3 +37,52 @@ def test_last_admin_protected():
|
||||
assert False, "Expected last-admin protection"
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "LAST_ADMIN_PROTECTED"
|
||||
|
||||
|
||||
def test_admin_cannot_self_block():
|
||||
admin = get_user_by_email("admin@compton.example")
|
||||
try:
|
||||
patch_user(admin, admin.id, None, "blocked")
|
||||
assert False, "Expected self-block error"
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "SELF_BLOCK_FORBIDDEN"
|
||||
|
||||
|
||||
def test_reset_user_password_revokes_sessions():
|
||||
admin = get_user_by_email("admin@compton.example")
|
||||
target = create_user("reset-pw@example.com", hash_password("Valid123"), status="active")
|
||||
result = reset_user_password(admin, target.id, "NewValid123")
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
def test_delete_admin_user_forbidden_for_self():
|
||||
admin = get_user_by_email("admin@compton.example")
|
||||
try:
|
||||
delete_admin_user(admin, admin.id)
|
||||
assert False, "Expected self-delete error"
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "SELF_DELETE_FORBIDDEN"
|
||||
|
||||
|
||||
def test_create_admin_user_rejects_duplicate_email():
|
||||
admin = get_user_by_email("admin@compton.example")
|
||||
try:
|
||||
create_admin_user(
|
||||
admin,
|
||||
email="user@compton.example",
|
||||
password="Valid123",
|
||||
role="user",
|
||||
is_superuser=False,
|
||||
status="active",
|
||||
)
|
||||
assert False, "Expected duplicate user error"
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "USER_EXISTS"
|
||||
|
||||
|
||||
def test_get_server_log_tail_empty_when_missing():
|
||||
with patch("app.modules.admin.service.settings") as mock_settings:
|
||||
mock_settings.server_log_path = "/tmp/compton-missing-log.txt"
|
||||
payload = get_server_log_tail()
|
||||
assert payload["lines"] == []
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from app.core.security import create_access_token, hash_password
|
||||
from tests.helpers import register_and_verify
|
||||
|
||||
|
||||
def _admin_headers(client) -> dict[str, str]:
|
||||
from app.modules.users.repository import get_user_by_email
|
||||
|
||||
admin = get_user_by_email("admin@compton.example")
|
||||
token = create_access_token(admin.id, admin.role, admin.is_superuser)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_blocked_user_access_token_revoked_after_admin_block(client):
|
||||
register_and_verify(client, "blocked-jwt@example.com")
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "blocked-jwt@example.com", "password": "Valid123"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
access_token = login.json()["access_token"]
|
||||
|
||||
me = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {access_token}"})
|
||||
assert me.status_code == 200
|
||||
|
||||
user_id = me.json()["user"]["id"]
|
||||
blocked = client.patch(
|
||||
f"/api/v1/admin/users/{user_id}",
|
||||
headers=_admin_headers(client),
|
||||
json={"status": "blocked"},
|
||||
)
|
||||
assert blocked.status_code == 200
|
||||
|
||||
revoked = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {access_token}"})
|
||||
assert revoked.status_code == 401
|
||||
assert revoked.json()["detail"] == "TOKEN_REVOKED"
|
||||
@@ -0,0 +1,77 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import jwt_denylist as denylist_module
|
||||
from app.core.jwt_denylist import (
|
||||
bump_auth_epoch,
|
||||
deny_jti,
|
||||
ensure_jwt_revocation_backend,
|
||||
get_auth_epoch,
|
||||
is_jti_denied,
|
||||
)
|
||||
|
||||
|
||||
def test_ensure_jwt_revocation_backend_requires_redis_in_production(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="Redis is required"):
|
||||
ensure_jwt_revocation_backend()
|
||||
|
||||
|
||||
def test_redis_auth_epoch_roundtrip():
|
||||
mock_client = MagicMock()
|
||||
mock_client.get.return_value = "3"
|
||||
mock_client.incr.return_value = 4
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
|
||||
assert get_auth_epoch("user-1") == 3
|
||||
assert bump_auth_epoch("user-1") == 4
|
||||
mock_client.incr.assert_called_once()
|
||||
|
||||
|
||||
def test_redis_deny_jti_and_check():
|
||||
mock_client = MagicMock()
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
|
||||
with patch("app.core.jwt_denylist.time.time", return_value=1000):
|
||||
deny_jti("abc-jti", 1060)
|
||||
mock_client.setex.assert_called_once_with("jwt:deny:abc-jti", 60, "1")
|
||||
mock_client.exists.return_value = 1
|
||||
assert is_jti_denied("abc-jti") is True
|
||||
|
||||
|
||||
def test_forgot_password_skips_blocked_user(client):
|
||||
from app.modules.users.repository import create_user, get_user_by_email, update_user
|
||||
from app.core.security import hash_password
|
||||
|
||||
create_user("blocked-forgot@example.com", hash_password("Valid123"), status="active")
|
||||
user = get_user_by_email("blocked-forgot@example.com")
|
||||
user.status = "blocked"
|
||||
update_user(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": "blocked-forgot@example.com"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
from app.core.email import memory_mailer
|
||||
|
||||
assert not any(msg.to == "blocked-forgot@example.com" for msg in memory_mailer.sent)
|
||||
|
||||
|
||||
def test_logout_revokes_access_token(client):
|
||||
from tests.helpers import register_and_verify
|
||||
|
||||
register_and_verify(client, "logout-jti@example.com")
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "logout-jti@example.com", "password": "Valid123"},
|
||||
)
|
||||
token = login.json()["access_token"]
|
||||
logout = client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Authorization": f"Bearer {token}", "Origin": "http://localhost:5173"},
|
||||
)
|
||||
assert logout.status_code == 200
|
||||
me = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert me.status_code == 401
|
||||
assert me.json()["detail"] == "TOKEN_REVOKED"
|
||||
@@ -12,6 +12,31 @@ def _admin_headers(client) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_refresh_fails_for_pending_user(client):
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "pending-refresh@example.com", "password": "Valid123"},
|
||||
)
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "pending-refresh@example.com", "password": "Valid123"},
|
||||
)
|
||||
assert login.status_code == 403
|
||||
assert login.json()["detail"] == "EMAIL_NOT_VERIFIED"
|
||||
|
||||
# Simulate stale refresh cookie from an earlier active session edge case via direct token issue.
|
||||
from app.modules.auth.service import issue_refresh_token
|
||||
from app.modules.users.repository import get_user_by_email
|
||||
|
||||
user = get_user_by_email("pending-refresh@example.com")
|
||||
refresh_token = issue_refresh_token(user.id)
|
||||
client.cookies.set("refresh_token", refresh_token, path="/api/v1/auth")
|
||||
|
||||
refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"})
|
||||
assert refresh.status_code == 403
|
||||
assert refresh.json()["detail"] == "EMAIL_NOT_VERIFIED"
|
||||
|
||||
|
||||
def test_refresh_fails_for_blocked_user(client):
|
||||
register_and_verify(client, "blocked-refresh@example.com")
|
||||
login = client.post(
|
||||
@@ -32,7 +57,8 @@ def test_refresh_fails_for_blocked_user(client):
|
||||
)
|
||||
assert blocked.status_code == 200
|
||||
refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"})
|
||||
assert refresh.status_code == 401
|
||||
assert refresh.status_code == 403
|
||||
assert refresh.json()["detail"] == "ACCOUNT_BLOCKED"
|
||||
|
||||
|
||||
def test_refresh_requires_origin_header_when_cookie_present(client):
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.modules.auth import repository as auth_repository
|
||||
from app.modules.auth.service import issue_refresh_token, refresh
|
||||
from app.modules.users.repository import create_user, get_user_by_id, update_user
|
||||
|
||||
|
||||
def test_refresh_returns_account_blocked_for_blocked_user():
|
||||
user = create_user("blocked-svc@example.com", hash_password("Valid123"), status="active")
|
||||
token = issue_refresh_token(user.id)
|
||||
user.status = "blocked"
|
||||
update_user(user)
|
||||
auth_repository.revoke_user_families(user.id)
|
||||
|
||||
with pytest.raises(PermissionError, match="ACCOUNT_BLOCKED"):
|
||||
refresh(token)
|
||||
|
||||
|
||||
def test_refresh_returns_email_not_verified_for_pending_user():
|
||||
user = create_user("pending-svc@example.com", hash_password("Valid123"), status="pending")
|
||||
token = issue_refresh_token(user.id)
|
||||
|
||||
with pytest.raises(PermissionError, match="EMAIL_NOT_VERIFIED"):
|
||||
refresh(token)
|
||||
|
||||
|
||||
def test_refresh_rejects_revoked_token_for_active_user():
|
||||
user = create_user("active-svc@example.com", hash_password("Valid123"), status="active")
|
||||
token = issue_refresh_token(user.id)
|
||||
auth_repository.revoke_user_families(user.id)
|
||||
|
||||
with patch.object(auth_repository, "revoke_family_tokens") as revoke_family:
|
||||
with pytest.raises(PermissionError, match="INVALID_REFRESH"):
|
||||
refresh(token)
|
||||
revoke_family.assert_called_once()
|
||||
@@ -0,0 +1,38 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.email import memory_mailer
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def _test_client(monkeypatch) -> TestClient:
|
||||
monkeypatch.setattr(settings, "enable_test_routes", True)
|
||||
monkeypatch.setattr(settings, "email_delivery_mode", "memory")
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
def test_latest_email_token_route(monkeypatch):
|
||||
client = _test_client(monkeypatch)
|
||||
memory_mailer.clear()
|
||||
memory_mailer.send(
|
||||
to="token-route@example.com",
|
||||
subject="Verify",
|
||||
body="TOKEN:route-token\n",
|
||||
template="verify_email",
|
||||
)
|
||||
response = client.get(
|
||||
"/api/v1/test/emails/latest-token",
|
||||
params={"to": "token-route@example.com", "template": "verify_email"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["token"] == "route-token"
|
||||
|
||||
|
||||
def test_latest_email_token_route_missing_token(monkeypatch):
|
||||
client = _test_client(monkeypatch)
|
||||
response = client.get(
|
||||
"/api/v1/test/emails/latest-token",
|
||||
params={"to": "missing@example.com", "template": "verify_email"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "TOKEN_NOT_FOUND"
|
||||
@@ -1,6 +1,6 @@
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from scripts.docker_entrypoint import INITIAL_REVISION, current_revision, run_migrations
|
||||
from scripts.docker_entrypoint import HEAD_REVISION, INITIAL_REVISION, current_revision, run_migrations
|
||||
|
||||
|
||||
def test_run_migrations_on_empty_sqlite(tmp_path, monkeypatch):
|
||||
@@ -17,7 +17,7 @@ def test_run_migrations_on_empty_sqlite(tmp_path, monkeypatch):
|
||||
|
||||
tables = set(inspect(engine).get_table_names())
|
||||
assert "users" in tables
|
||||
assert current_revision(engine) == "20260714_0004"
|
||||
assert current_revision(engine) == HEAD_REVISION
|
||||
|
||||
|
||||
def test_run_migrations_stamps_existing_schema_without_alembic(tmp_path, monkeypatch):
|
||||
@@ -52,7 +52,7 @@ def test_run_migrations_stamps_existing_schema_without_alembic(tmp_path, monkeyp
|
||||
|
||||
tables = set(inspect(engine).get_table_names())
|
||||
assert "refresh_tokens" in tables
|
||||
assert current_revision(engine) == "20260714_0004"
|
||||
assert current_revision(engine) == HEAD_REVISION
|
||||
assert INITIAL_REVISION == "20260711_0001"
|
||||
|
||||
|
||||
@@ -98,4 +98,4 @@ def test_run_migrations_repairs_partial_schema_with_stale_alembic(tmp_path, monk
|
||||
|
||||
tables = set(inspect(engine).get_table_names())
|
||||
assert "refresh_tokens" in tables
|
||||
assert current_revision(engine) == "20260714_0004"
|
||||
assert current_revision(engine) == HEAD_REVISION
|
||||
|
||||
@@ -2,6 +2,25 @@ import { expect, test } from "@playwright/test";
|
||||
import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api";
|
||||
|
||||
test.describe("§15.7 scenarios 5 & 9: Admin users", () => {
|
||||
test("blocked user access token rejected immediately after block", async ({ request }) => {
|
||||
const email = uniqueEmail("e2e-block-jwt");
|
||||
const session = await registerVerifyLogin(request, email);
|
||||
const admin = await adminLogin(request);
|
||||
const adminToken = (await admin.json()).access_token;
|
||||
|
||||
const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
data: { status: "blocked" }
|
||||
});
|
||||
expect(patch.ok()).toBeTruthy();
|
||||
|
||||
const me = await request.get(`${API_URL}/api/v1/users/me`, {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` }
|
||||
});
|
||||
expect(me.status()).toBe(401);
|
||||
expect((await me.json()).detail).toBe("TOKEN_REVOKED");
|
||||
});
|
||||
|
||||
test("admin blocks user → blocked user cannot login", async ({ page, request }) => {
|
||||
const email = uniqueEmail("e2e-block");
|
||||
const session = await registerVerifyLogin(request, email);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
|
||||
import { useAuth } from "@modules/auth";
|
||||
import { useAuthStore } from "@modules/auth/store/authStore";
|
||||
|
||||
export function AdminGuard({ children }: PropsWithChildren): JSX.Element {
|
||||
export function AdminGuard({ children }: PropsWithChildren): JSX.Element | null {
|
||||
const bootstrapped = useAuthStore((state) => state.bootstrapped);
|
||||
const auth = useAuth();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
|
||||
import { useAuth } from "@modules/auth";
|
||||
import { useAuthStore } from "@modules/auth/store/authStore";
|
||||
|
||||
export function AuthGuard({ children }: PropsWithChildren): JSX.Element {
|
||||
export function AuthGuard({ children }: PropsWithChildren): JSX.Element | null {
|
||||
const bootstrapped = useAuthStore((state) => state.bootstrapped);
|
||||
const auth = useAuth();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
|
||||
import { useAuth } from "@modules/auth";
|
||||
import { useAuthStore } from "@modules/auth/store/authStore";
|
||||
|
||||
export function GuestGuard({ children }: PropsWithChildren): JSX.Element {
|
||||
export function GuestGuard({ children }: PropsWithChildren): JSX.Element | null {
|
||||
const bootstrapped = useAuthStore((state) => state.bootstrapped);
|
||||
const auth = useAuth();
|
||||
|
||||
|
||||
@@ -43,9 +43,21 @@ vi.mock("@shared/api/client", () => ({
|
||||
}
|
||||
return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } };
|
||||
}),
|
||||
patch: vi.fn(async () => ({
|
||||
data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" }
|
||||
})),
|
||||
patch: vi.fn(async (url: string) => {
|
||||
if (url === "/api/v1/admin/settings") {
|
||||
return {
|
||||
data: {
|
||||
values: { enable_docs: false },
|
||||
locks: {},
|
||||
settings_path: "data/compton_settings.json",
|
||||
secrets: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" }
|
||||
};
|
||||
}),
|
||||
post: vi.fn(async (url: string) => {
|
||||
if (url === "/api/v1/admin/users") {
|
||||
return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } };
|
||||
@@ -95,7 +107,7 @@ describe("adminApi", () => {
|
||||
expect((await resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked");
|
||||
expect((await deleteAdminUser("1")).status).toBe("deleted");
|
||||
expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json");
|
||||
expect((await patchAdminSettings({ enable_docs: false })).id).toBe("1");
|
||||
expect((await patchAdminSettings({ enable_docs: false })).settings_path).toBe("data/compton_settings.json");
|
||||
expect((await getAdminDiagnostics()).checks[0].status).toBe("ok");
|
||||
expect((await getAdminActivityFeed()).events.length).toBe(1);
|
||||
expect((await postAdminUiActivity("click")).status).toBe("ok");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AdminStats } from "./AdminStats";
|
||||
|
||||
@@ -23,9 +23,14 @@ describe("AdminStats", () => {
|
||||
expect(await screen.findByText("CPU")).toBeInTheDocument();
|
||||
expect(await screen.findByText("WESP")).toBeInTheDocument();
|
||||
expect(await screen.findByText("nx throughput (instant)")).toBeInTheDocument();
|
||||
expect(await screen.findByText("users")).toBeInTheDocument();
|
||||
expect(await screen.findByText("10")).toBeInTheDocument();
|
||||
expect(await screen.findByText("3")).toBeInTheDocument();
|
||||
expect(await screen.findByText("2")).toBeInTheDocument();
|
||||
expect(await screen.findByText("registrations today")).toBeInTheDocument();
|
||||
expect((await screen.findAllByText("users")).length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const statCards = document.querySelectorAll(".wesp-admin-stat-card");
|
||||
expect(statCards).toHaveLength(4);
|
||||
expect(within(statCards[0] as HTMLElement).getByText("10")).toBeInTheDocument();
|
||||
expect(within(statCards[1] as HTMLElement).getByText("3")).toBeInTheDocument();
|
||||
expect(within(statCards[2] as HTMLElement).getByText("2")).toBeInTheDocument();
|
||||
expect(within(statCards[3] as HTMLElement).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authClient } from "@shared/api/client";
|
||||
import { authClient, applyAuthHeader } from "@shared/api/client";
|
||||
|
||||
export interface LoginPayload {
|
||||
email: string;
|
||||
@@ -38,8 +38,9 @@ export async function register(payload: RegisterPayload) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await authClient.post("/api/v1/auth/logout");
|
||||
export async function logout(accessToken?: string | null) {
|
||||
const headers = accessToken ? applyAuthHeader({}) : {};
|
||||
await authClient.post("/api/v1/auth/logout", undefined, { headers });
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
|
||||
@@ -19,7 +19,7 @@ export function useAuth() {
|
||||
return data.user as AuthUser;
|
||||
},
|
||||
async logout() {
|
||||
await apiLogout();
|
||||
await apiLogout(accessToken);
|
||||
clearSession();
|
||||
},
|
||||
async refreshSession() {
|
||||
|
||||
@@ -72,6 +72,10 @@ apiClient.interceptors.response.use(
|
||||
useAuthStore.getState().clearSession();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (error.response?.status === 401 && detail === "TOKEN_REVOKED") {
|
||||
useAuthStore.getState().clearSession();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) {
|
||||
return Promise.reject(error);
|
||||
|
||||
@@ -40,6 +40,7 @@ export default defineConfig({
|
||||
exclude: ["e2e/**", "node_modules/**"],
|
||||
environment: "jsdom",
|
||||
setupFiles: ["src/__tests__/setup.ts"],
|
||||
testTimeout: 10_000,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
|
||||
Reference in New Issue
Block a user