Update admin theme/layout and refresh README details.

Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
vlad
2026-07-14 17:12:28 +03:00
commit 86cc3fa541
278 changed files with 19416 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
import os
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("EMAIL_DELIVERY_MODE", "memory")
from fastapi.testclient import TestClient
import pytest
from app.core import database as db_module
from app.core.email import memory_mailer
from app.core.storage import memory_store
from app.db.base import Base
from app.db.seed import run_seed
from app.main import app
@pytest.fixture(scope="session", autouse=True)
def setup_database():
Base.metadata.create_all(db_module.engine)
run_seed(include_demo_pages=False)
yield
Base.metadata.drop_all(db_module.engine)
@pytest.fixture(autouse=True)
def disable_rate_limits(monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "enable_rate_limit", False)
monkeypatch.setattr(settings, "email_delivery_mode", "memory")
monkeypatch.setattr(settings, "storage_mode", "memory")
@pytest.fixture(autouse=True)
def clear_email_outbox():
memory_mailer.clear()
yield
memory_mailer.clear()
@pytest.fixture(autouse=True)
def clear_storage():
memory_store.clear()
yield
memory_store.clear()
@pytest.fixture()
def client() -> TestClient:
return TestClient(app)
+38
View File
@@ -0,0 +1,38 @@
from app.core.media_signing import build_signed_media_url, verify_signed_media
from app.core.config import settings
import time
def test_build_and_verify_signed_media_url():
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
assert signed is not None
assert "expires=" in signed
assert "sig=" in signed
path = "avatars/user/file.png"
query = signed.split("?", 1)[1]
params = dict(part.split("=") for part in query.split("&"))
assert int(params["expires"]) - int(time.time()) <= settings.media_url_ttl_seconds
assert verify_signed_media(path, int(params["expires"]), params["sig"])
def test_build_signed_media_url_none():
assert build_signed_media_url(None) is None
def test_verify_signed_media_rejects_expired_signature():
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
assert signed is not None
path = "avatars/user/file.png"
query = signed.split("?", 1)[1]
params = dict(part.split("=") for part in query.split("&"))
assert not verify_signed_media(path, int(params["expires"]) - 10_000, params["sig"])
def test_verify_signed_media_rejects_tampered_signature():
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
assert signed is not None
path = "avatars/user/file.png"
query = signed.split("?", 1)[1]
params = dict(part.split("=") for part in query.split("&"))
assert not verify_signed_media(path, int(params["expires"]), "invalid")
@@ -0,0 +1,6 @@
from app.core.media_signing import build_signed_media_url
def test_build_signed_media_url_passthrough():
external = "https://cdn.example.com/avatar.png"
assert build_signed_media_url(external) == external
@@ -0,0 +1,5 @@
from app.core.password_denylist import is_denied_password
def test_password_denylist_blocks_common_password():
assert is_denied_password("password123")
+5
View File
@@ -0,0 +1,5 @@
from app.core.storage import ensure_bucket
def test_ensure_bucket_noop_in_memory():
ensure_bucket()
+32
View File
@@ -0,0 +1,32 @@
from app.db.seed import run_seed
from app.modules.users.repository import get_user_by_email
def test_seed_ensures_admin_is_superuser():
admin = get_user_by_email("admin@compton.example")
assert admin is not None
admin.is_superuser = False
from app.modules.users import repository
repository.update_user(admin)
run_seed(include_demo_pages=False)
refreshed = get_user_by_email("admin@compton.example")
assert refreshed is not None
assert refreshed.is_superuser is True
def test_seed_ensures_ops_is_not_superuser():
ops = get_user_by_email("ops@compton.example")
assert ops is not None
ops.is_superuser = True
from app.modules.users import repository
repository.update_user(ops)
run_seed(include_demo_pages=False)
refreshed = get_user_by_email("ops@compton.example")
assert refreshed is not None
assert refreshed.is_superuser is False
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from app.core.database import session_scope
from app.db.token_cleanup import cleanup_expired_tokens
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
from app.modules.users.repository import get_user_by_email
def test_cleanup_expired_tokens_removes_stale_rows():
user = get_user_by_email("admin@compton.example")
now = datetime.now(UTC)
with session_scope() as db:
db.add(
RefreshToken(
user_id=user.id,
token_hash="f" * 64,
family_id="fam-cleanup-1",
expires_at=now - timedelta(days=2),
revoked_at=now - timedelta(days=2),
)
)
db.add(
PasswordResetToken(
user_id=user.id,
token_hash="e" * 64,
expires_at=now - timedelta(days=1),
used_at=None,
)
)
db.add(
EmailVerificationToken(
user_id=user.id,
token_hash="d" * 64,
expires_at=now - timedelta(days=1),
used_at=None,
)
)
result = cleanup_expired_tokens(retention_days=1)
assert result["refresh_tokens_deleted"] >= 1
assert result["password_reset_tokens_deleted"] >= 1
assert result["email_verification_tokens_deleted"] >= 1
+3
View File
@@ -0,0 +1,3 @@
def test_health_smoke(client):
response = client.get("/api/v1/health")
assert response.status_code == 200
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from io import BytesIO
from PIL import Image
def make_test_png() -> bytes:
image = Image.new("RGB", (8, 8), color=(70, 129, 109))
buffer = BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from app.core.email import memory_mailer
def clear_sent_emails() -> None:
memory_mailer.clear()
def latest_token(recipient: str, template: str) -> str:
token = memory_mailer.latest_token(recipient, template)
if not token:
raise AssertionError(f"No {template} email sent to {recipient}")
return token
def register_and_verify(client: TestClient, email: str, password: str = "Valid123") -> None:
client.post("/api/v1/auth/register", json={"email": email, "password": password})
token = latest_token(email, "verify_email")
response = client.post("/api/v1/auth/verify-email", json={"token": token})
assert response.status_code == 200
def register_verify_login(client: TestClient, email: str, password: str = "Valid123") -> dict[str, str]:
register_and_verify(client, email, password)
login = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert login.status_code == 200
return {"Authorization": f"Bearer {login.json()['access_token']}"}
@@ -0,0 +1,26 @@
from __future__ import annotations
def _super_headers(client) -> dict[str, str]:
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
assert login.status_code == 200
return {"Authorization": f"Bearer {login.json()['access_token']}"}
def test_admin_create_user_rejects_weak_password(client):
headers = _super_headers(client)
response = client.post(
"/api/v1/admin/users",
headers=headers,
json={
"email": "weak-pass@example.com",
"password": "password",
"role": "user",
"is_superuser": False,
"status": "active",
},
)
assert response.status_code == 422
@@ -0,0 +1,73 @@
from app.core.security import hash_password
from app.modules.users import repository
def _admin_headers(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
token = login.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def _plain_admin_headers(client):
email = "ops-admin@compton.example"
if not repository.get_user_by_email(email):
repository.create_user(
email=email,
password_hash=hash_password("Admin1234"),
role="admin",
is_superuser=False,
status="active",
)
login = client.post("/api/v1/auth/login", json={"email": email, "password": "Admin1234"})
token = login.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def test_admin_users_list(client):
response = client.get("/api/v1/admin/users", headers=_admin_headers(client))
assert response.status_code == 200
assert "data" in response.json()
def test_non_superuser_cannot_patch_settings(client):
response = client.patch(
"/api/v1/admin/settings",
json={"values": {"enable_docs": False}},
headers=_plain_admin_headers(client),
)
assert response.status_code == 403
assert response.json()["detail"] == "SUPERUSER_ONLY"
def test_superuser_can_patch_settings(client):
response = client.patch(
"/api/v1/admin/settings",
json={"values": {"enable_docs": False}},
headers=_admin_headers(client),
)
assert response.status_code == 200
payload = response.json()
assert "values" in payload
def test_superuser_can_create_and_delete_user(client):
created = client.post(
"/api/v1/admin/users",
json={
"email": "created-by-admin@compton.example",
"password": "StrongPass123A",
"role": "user",
"is_superuser": False,
"status": "active",
},
headers=_admin_headers(client),
)
assert created.status_code == 200
user_id = created.json()["id"]
deleted = client.delete(f"/api/v1/admin/users/{user_id}", headers=_admin_headers(client))
assert deleted.status_code == 200
assert deleted.json()["status"] == "deleted"
@@ -0,0 +1,35 @@
from __future__ import annotations
def _login(client, email: str, password: str) -> dict[str, str]:
response = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
def test_superuser_can_read_install_secrets(client):
headers = _login(client, "admin@compton.example", "Admin1234")
response = client.get("/api/v1/admin/secrets", headers=headers)
assert response.status_code == 200
payload = response.json()
assert "secrets_status" in payload
assert payload["secrets_status"]["postgres_password"] in {"configured", "missing"}
assert "POSTGRES_PASSWORD" not in str(payload)
def test_non_superuser_cannot_read_install_secrets(client):
headers = _login(client, "ops@compton.example", "OpsAdmin1234")
response = client.get("/api/v1/admin/secrets", headers=headers)
assert response.status_code == 403
def test_superuser_can_reveal_db_password(client):
headers = _login(client, "admin@compton.example", "Admin1234")
response = client.post(
"/api/v1/admin/secrets/reveal",
headers=headers,
json={"key": "database_password"},
)
assert response.status_code == 200
assert response.json()["key"] == "database_password"
assert "value" in response.json()
@@ -0,0 +1,33 @@
from unittest.mock import patch
from app.modules.admin.service import patch_user
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
def test_admin_cannot_self_demote():
admin = get_user_by_email("admin@compton.example")
try:
patch_user(admin, admin.id, "user", None)
assert False, "Expected self-demotion error"
except ValueError as exc:
assert str(exc) == "SELF_DEMOTION_FORBIDDEN"
def test_admin_can_promote_user():
admin = get_user_by_email("admin@compton.example")
regular = create_user("sample@example.com", hash_password("Valid123"), role="user", status="active")
result = patch_user(admin, regular.id, "admin", None)
assert result["role"] == "admin"
def test_last_admin_protected():
admin = get_user_by_email("admin@compton.example")
target = create_user("target@example.com", hash_password("Valid123"), role="admin", status="active")
with patch.object(repository, "count_admins", return_value=1):
try:
patch_user(admin, target.id, "user", None)
assert False, "Expected last-admin protection"
except ValueError as exc:
assert str(exc) == "LAST_ADMIN_PROTECTED"
@@ -0,0 +1,42 @@
from tests.helpers import latest_token, register_and_verify
def test_health(client):
response = client.get("/api/v1/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_register_and_verify_and_login(client):
register_response = client.post(
"/api/v1/auth/register",
json={"email": "user@example.com", "password": "Valid123"},
)
assert register_response.status_code == 200
assert "user_id" not in register_response.json()
duplicate = client.post(
"/api/v1/auth/register",
json={"email": "user@example.com", "password": "Valid123"},
)
assert duplicate.status_code == 200
assert "user_id" not in duplicate.json()
verify_response = client.post(
"/api/v1/auth/verify-email",
json={"token": latest_token("user@example.com", "verify_email")},
)
assert verify_response.status_code == 200
login_response = client.post(
"/api/v1/auth/login",
json={"email": "user@example.com", "password": "Valid123"},
)
assert login_response.status_code == 200
assert "access_token" in login_response.json()
def test_forgot_password_anti_enumeration(client):
response = client.post("/api/v1/auth/forgot-password", json={"email": "missing@example.com"})
assert response.status_code == 200
assert "If email is registered" in response.json()["message"]
@@ -0,0 +1,20 @@
from tests.helpers import latest_token
def test_verify_email_rejects_invalid_token(client):
client.post("/api/v1/auth/register", json={"email": "bad@example.com", "password": "Valid123"})
response = client.post("/api/v1/auth/verify-email", json={"token": "invalid-token"})
assert response.status_code == 400
assert response.json()["detail"] == "INVALID_TOKEN"
def test_resend_verification_sends_new_token(client):
client.post("/api/v1/auth/register", json={"email": "resend@example.com", "password": "Valid123"})
first_token = latest_token("resend@example.com", "verify_email")
client.post("/api/v1/auth/resend-verification", json={"email": "resend@example.com"})
second_token = latest_token("resend@example.com", "verify_email")
assert first_token != second_token
verify = client.post("/api/v1/auth/verify-email", json={"token": second_token})
assert verify.status_code == 200
@@ -0,0 +1,19 @@
from tests.helpers import latest_token, register_and_verify
def test_auth_brute_force_lockout(client):
register_and_verify(client, "locked@example.com")
for _ in range(5):
response = client.post(
"/api/v1/auth/login",
json={"email": "locked@example.com", "password": "WrongPass1"},
)
assert response.status_code == 401
locked = client.post(
"/api/v1/auth/login",
json={"email": "locked@example.com", "password": "Valid123"},
)
assert locked.status_code == 429
assert locked.json()["detail"] == "ACCOUNT_TEMPORARILY_LOCKED"
@@ -0,0 +1,7 @@
from app.core.password_policy import validate_password_strength
import pytest
def test_password_policy_rejects_denied_password():
with pytest.raises(ValueError, match="too common"):
validate_password_strength("Password123")
@@ -0,0 +1,12 @@
import pytest
from app.core.password_policy import validate_password_strength
def test_password_policy_accepts_valid_password():
assert validate_password_strength("Valid123") == "Valid123"
def test_password_policy_rejects_weak_password():
with pytest.raises(ValueError, match="uppercase letter"):
validate_password_strength("valid123")
@@ -0,0 +1,22 @@
from uuid import uuid4
def test_login_rate_limit(client, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "enable_rate_limit", True)
email = f"missing-{uuid4().hex}@example.com"
for _ in range(5):
response = client.post(
"/api/v1/auth/login",
json={"email": email, "password": "Valid123"},
)
assert response.status_code == 401
blocked = client.post(
"/api/v1/auth/login",
json={"email": email, "password": "Valid123"},
)
assert blocked.status_code == 429
assert blocked.json()["detail"] == "RATE_LIMIT_EXCEEDED"
@@ -0,0 +1,47 @@
from __future__ import annotations
from tests.helpers import register_and_verify
def _admin_headers(client) -> dict[str, str]:
from app.core.security import create_access_token
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_refresh_fails_for_blocked_user(client):
register_and_verify(client, "blocked-refresh@example.com")
login = client.post(
"/api/v1/auth/login",
json={"email": "blocked-refresh@example.com", "password": "Valid123"},
)
assert login.status_code == 200
admin_headers = _admin_headers(client)
me = client.get(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {login.json()['access_token']}"},
)
user_id = me.json()["user"]["id"]
blocked = client.patch(
f"/api/v1/admin/users/{user_id}",
headers=admin_headers,
json={"status": "blocked"},
)
assert blocked.status_code == 200
refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"})
assert refresh.status_code == 401
def test_refresh_requires_origin_header_when_cookie_present(client):
register_and_verify(client, "origin-required@example.com")
login = client.post(
"/api/v1/auth/login",
json={"email": "origin-required@example.com", "password": "Valid123"},
)
assert login.status_code == 200
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 403
assert response.json()["detail"] == "INVALID_ORIGIN"
@@ -0,0 +1,26 @@
from app.core.security import (
create_access_token,
decode_access_token,
generate_refresh_token,
hash_password,
hash_refresh_token,
verify_password,
)
def test_password_hashing_roundtrip():
hashed = hash_password("Strong123")
assert verify_password("Strong123", hashed) is True
def test_access_token_encode_decode():
token = create_access_token("u1", "user", False)
payload = decode_access_token(token)
assert payload["sub"] == "u1"
assert payload["role"] == "user"
assert payload["is_superuser"] is False
def test_refresh_token_hashing():
token = generate_refresh_token()
assert hash_refresh_token(token) == hash_refresh_token(token)
@@ -0,0 +1,45 @@
def _admin_headers(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
token = login.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def test_create_and_read_published_page(client):
headers = _admin_headers(client)
created = client.post(
"/api/v1/content/pages",
headers=headers,
json={"slug": "about", "title": "About", "body": "<p>Hello</p>", "status": "published"},
)
assert created.status_code == 200
fetched = client.get("/api/v1/content/pages/about")
assert fetched.status_code == 200
assert fetched.json()["slug"] == "about"
def test_list_all_pages_requires_admin(client):
response = client.get("/api/v1/content/pages/manage/all")
assert response.status_code == 401
def test_list_all_pages_includes_drafts(client):
headers = _admin_headers(client)
created = client.post(
"/api/v1/content/pages",
headers=headers,
json={"slug": "draft-page", "title": "Draft", "body": "<p>Draft</p>", "status": "draft"},
)
assert created.status_code == 200
listed = client.get("/api/v1/content/pages/manage/all", headers=headers)
assert listed.status_code == 200
slugs = [page["slug"] for page in listed.json()["data"]]
assert "draft-page" in slugs
public = client.get("/api/v1/content/pages")
public_slugs = [page["slug"] for page in public.json()["data"]]
assert "draft-page" not in public_slugs
@@ -0,0 +1,9 @@
from __future__ import annotations
from app.modules.content.service import sanitize_html
def test_sanitize_html_strips_javascript_protocol():
raw = '<a href="javascript:alert(1)">x</a><img src="javascript:alert(1)" alt="x" />'
clean = sanitize_html(raw)
assert "javascript:" not in clean
@@ -0,0 +1,21 @@
def test_media_requires_signature(client):
response = client.get("/api/v1/media/files/avatars/missing.png")
assert response.status_code == 422
def test_media_file_not_found(client):
from app.core.media_signing import build_signed_media_url
signed_url = build_signed_media_url("/api/v1/media/files/avatars/missing.png")
response = client.get(signed_url)
assert response.status_code == 404
assert response.json()["detail"] == "FILE_NOT_FOUND"
def test_media_rejects_non_avatar_path(client):
from app.core.media_signing import build_signed_media_url
signed_url = build_signed_media_url("/api/v1/media/files/other/file.png")
response = client.get(signed_url)
assert response.status_code == 404
assert response.json()["detail"] == "FILE_NOT_FOUND"
@@ -0,0 +1,14 @@
import pytest
from app.modules.media.service import AvatarValidationError, validate_and_process_avatar
from tests.fixtures import make_test_png
def test_validate_rejects_empty():
with pytest.raises(AvatarValidationError, match="INVALID_IMAGE"):
validate_and_process_avatar(b"")
def test_validate_rejects_corrupt_bytes():
with pytest.raises(AvatarValidationError, match="INVALID_IMAGE"):
validate_and_process_avatar(b"not-an-image")
@@ -0,0 +1,7 @@
from app.modules.notifications.service import enqueue_email
def test_enqueue_email_returns_payload():
data = enqueue_email("verify", "u@example.com", {"token": "123"})
assert data["queued"] is True
assert data["template"] == "verify"
@@ -0,0 +1,170 @@
from tests.fixtures import make_test_png
from tests.helpers import latest_token, register_and_verify, register_verify_login
def _admin_headers(client) -> dict[str, str]:
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
token = login.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def test_auth_refresh_and_logout(client):
register_and_verify(client, "refresh@example.com")
login = client.post(
"/api/v1/auth/login",
json={"email": "refresh@example.com", "password": "Valid123"},
)
assert login.status_code == 200
assert "refresh_token" in login.cookies
refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"})
assert refresh.status_code == 200
assert "access_token" in refresh.json()
logout = client.post("/api/v1/auth/logout", headers={"Origin": "http://localhost:5173"})
assert logout.status_code == 200
def test_auth_invalid_login(client):
response = client.post(
"/api/v1/auth/login",
json={"email": "missing@example.com", "password": "Valid123"},
)
assert response.status_code == 401
def test_users_patch_and_password_and_avatar(client):
headers = register_verify_login(client, "patch@example.com")
patch = client.patch("/api/v1/users/me", headers=headers, json={"display_name": "Patched"})
assert patch.status_code == 200
assert patch.json()["profile"]["display_name"] == "Patched"
bad_password = client.post(
"/api/v1/users/me/password",
headers=headers,
json={"current_password": "wrong", "new_password": "NewValid1"},
)
assert bad_password.status_code == 400
avatar = client.post(
"/api/v1/users/me/avatar",
headers=headers,
files={"file": ("avatar.png", make_test_png(), "image/png")},
)
assert avatar.status_code == 200
assert avatar.json()["profile"]["avatar_url"]
def test_users_unauthorized(client):
response = client.get("/api/v1/users/me")
assert response.status_code == 401
def test_content_crud_flow(client):
headers = _admin_headers(client)
created = client.post(
"/api/v1/content/pages",
headers=headers,
json={"slug": "terms", "title": "Terms", "body": "<p>Terms</p>", "status": "draft"},
)
assert created.status_code == 200
page_id = created.json()["id"]
listed = client.get("/api/v1/content/pages")
assert listed.status_code == 200
updated = client.patch(
f"/api/v1/content/pages/{page_id}",
headers=headers,
json={"status": "published"},
)
assert updated.status_code == 200
fetched = client.get("/api/v1/content/pages/terms")
assert fetched.status_code == 200
deleted = client.delete(f"/api/v1/content/pages/{page_id}", headers=headers)
assert deleted.status_code == 200
def test_admin_stats_and_patch_user(client):
headers = _admin_headers(client)
stats = client.get("/api/v1/admin/stats", headers=headers)
assert stats.status_code == 200
user_headers = register_verify_login(client, "blockme@example.com")
me = client.get("/api/v1/users/me", headers=user_headers)
user_id = me.json()["user"]["id"]
blocked = client.patch(
f"/api/v1/admin/users/{user_id}",
headers=headers,
json={"status": "blocked"},
)
assert blocked.status_code == 200
assert blocked.json()["status"] == "blocked"
def test_auth_pending_user_cannot_login(client):
client.post("/api/v1/auth/register", json={"email": "pending@example.com", "password": "Valid123"})
response = client.post(
"/api/v1/auth/login",
json={"email": "pending@example.com", "password": "Valid123"},
)
assert response.status_code == 403
assert response.json()["detail"] == "EMAIL_NOT_VERIFIED"
def test_pending_user_cannot_access_profile(client):
from app.core.security import create_access_token
from app.modules.users.repository import get_user_by_email
client.post("/api/v1/auth/register", json={"email": "pendingme@example.com", "password": "Valid123"})
user = get_user_by_email("pendingme@example.com")
token = create_access_token(user.id, user.role)
response = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 403
assert response.json()["detail"] == "EMAIL_NOT_VERIFIED"
def test_auth_invalid_refresh_token(client):
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 401
client.cookies.set("refresh_token", "invalid-token", path="/api/v1/auth")
invalid = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"})
assert invalid.status_code == 401
def test_auth_resend_and_reset_password(client):
client.post("/api/v1/auth/register", json={"email": "resetme@example.com", "password": "Valid123"})
resend = client.post("/api/v1/auth/resend-verification", json={"email": "resetme@example.com"})
assert resend.status_code == 200
verify_token = latest_token("resetme@example.com", "verify_email")
verify = client.post("/api/v1/auth/verify-email", json={"token": verify_token})
assert verify.status_code == 200
forgot = client.post("/api/v1/auth/forgot-password", json={"email": "resetme@example.com"})
assert forgot.status_code == 200
reset_token = latest_token("resetme@example.com", "reset_password")
reset = client.post(
"/api/v1/auth/reset-password",
json={"token": reset_token, "new_password": "NewValid1"},
)
assert reset.status_code == 200
login = client.post(
"/api/v1/auth/login",
json={"email": "resetme@example.com", "password": "NewValid1"},
)
assert login.status_code == 200
invalid = client.post(
"/api/v1/auth/reset-password",
json={"token": "invalid-token", "new_password": "NewValid1"},
)
assert invalid.status_code == 400
@@ -0,0 +1,42 @@
from tests.fixtures import make_test_png
from tests.helpers import register_verify_login
def test_upload_avatar_success(client):
headers = register_verify_login(client, "avatar@example.com")
response = client.post(
"/api/v1/users/me/avatar",
headers=headers,
files={"file": ("avatar.png", make_test_png(), "image/png")},
)
assert response.status_code == 200
avatar_url = response.json()["profile"]["avatar_url"]
assert avatar_url.startswith("/api/v1/media/files/avatars/")
media = client.get(avatar_url)
assert media.status_code == 200
assert media.headers["content-type"].startswith("image/")
def test_upload_avatar_rejects_svg(client):
headers = register_verify_login(client, "svg@example.com")
svg = b"<svg xmlns='http://www.w3.org/2000/svg'><rect width='10' height='10'/></svg>"
response = client.post(
"/api/v1/users/me/avatar",
headers=headers,
files={"file": ("avatar.svg", svg, "image/svg+xml")},
)
assert response.status_code == 400
assert response.json()["detail"] in {"INVALID_MIME", "INVALID_IMAGE"}
def test_upload_avatar_rejects_oversize(client):
headers = register_verify_login(client, "big@example.com")
oversized = make_test_png() + b"0" * (2 * 1024 * 1024)
response = client.post(
"/api/v1/users/me/avatar",
headers=headers,
files={"file": ("big.png", oversized, "image/png")},
)
assert response.status_code == 400
assert response.json()["detail"] == "FILE_TOO_LARGE"
@@ -0,0 +1,30 @@
from tests.helpers import register_verify_login
def test_me_endpoint(client):
headers = register_verify_login(client, "me@example.com", "Valid123")
response = client.get("/api/v1/users/me", headers=headers)
assert response.status_code == 200
assert response.json()["user"]["email"] == "me@example.com"
def test_patch_me_updates_display_name(client):
headers = register_verify_login(client, "display@example.com", "Valid123")
response = client.patch(
"/api/v1/users/me",
headers=headers,
json={"display_name": "Updated Name"},
)
assert response.status_code == 200
assert response.json()["profile"]["display_name"] == "Updated Name"
def test_change_password_rejects_invalid_current(client):
headers = register_verify_login(client, "pwd@example.com", "Valid123")
response = client.post(
"/api/v1/users/me/password",
headers=headers,
json={"current_password": "Wrong123", "new_password": "NewValid1"},
)
assert response.status_code == 400
assert response.json()["detail"] == "INVALID_CURRENT_PASSWORD"
@@ -0,0 +1,101 @@
from sqlalchemy import create_engine, inspect
from scripts.docker_entrypoint import INITIAL_REVISION, current_revision, run_migrations
def test_run_migrations_on_empty_sqlite(tmp_path, monkeypatch):
db_path = tmp_path / "migrate.sqlite"
database_url = f"sqlite+pysqlite:///{db_path.as_posix()}"
monkeypatch.setenv("DATABASE_URL", database_url)
from app.core.config import settings
monkeypatch.setattr(settings, "database_url", database_url)
engine = create_engine(database_url)
run_migrations(engine)
tables = set(inspect(engine).get_table_names())
assert "users" in tables
assert current_revision(engine) == "20260714_0004"
def test_run_migrations_stamps_existing_schema_without_alembic(tmp_path, monkeypatch):
db_path = tmp_path / "existing.sqlite"
database_url = f"sqlite+pysqlite:///{db_path.as_posix()}"
monkeypatch.setenv("DATABASE_URL", database_url)
from app.core.config import settings
monkeypatch.setattr(settings, "database_url", database_url)
engine = create_engine(database_url)
with engine.begin() as connection:
connection.exec_driver_sql(
"""
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL,
failed_login_attempts INTEGER NOT NULL,
locked_until TIMESTAMP,
email_verified_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
"""
)
run_migrations(engine)
tables = set(inspect(engine).get_table_names())
assert "refresh_tokens" in tables
assert current_revision(engine) == "20260714_0004"
assert INITIAL_REVISION == "20260711_0001"
def test_run_migrations_repairs_partial_schema_with_stale_alembic(tmp_path, monkeypatch):
db_path = tmp_path / "stale.sqlite"
database_url = f"sqlite+pysqlite:///{db_path.as_posix()}"
monkeypatch.setenv("DATABASE_URL", database_url)
from app.core.config import settings
monkeypatch.setattr(settings, "database_url", database_url)
engine = create_engine(database_url)
with engine.begin() as connection:
connection.exec_driver_sql(
"""
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL,
failed_login_attempts INTEGER NOT NULL,
locked_until TIMESTAMP,
email_verified_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
"""
)
connection.exec_driver_sql(
"""
CREATE TABLE alembic_version (
version_num VARCHAR(32) NOT NULL PRIMARY KEY
)
"""
)
connection.exec_driver_sql(
f"INSERT INTO alembic_version (version_num) VALUES ('{INITIAL_REVISION}')"
)
run_migrations(engine)
tables = set(inspect(engine).get_table_names())
assert "refresh_tokens" in tables
assert current_revision(engine) == "20260714_0004"