Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import os
import tempfile
from pathlib import Path
# Pytest must not pick up Docker install.env (postgresql@postgres) from the repo.
_test_secrets = Path(tempfile.mkdtemp(prefix="compton-pytest-secrets-"))
os.environ["COMPTON_INSTALL_SECRETS_DIR"] = str(_test_secrets)
os.environ["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)
+63
View File
@@ -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)
+19
View File
@@ -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
+68
View File
@@ -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"
+29
View File
@@ -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,47 @@
"""Install secrets must never be poisoned by sqlite test DATABASE_URL."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from app.core import install_secrets as mod
def test_adopt_from_environment_ignores_sqlite(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite+pysqlite:///:memory:")
adopted = mod._adopt_from_environment()
assert "DATABASE_URL" not in adopted
def test_ensure_install_secrets_rejects_sqlite_install_env(tmp_path, monkeypatch):
secrets_dir = tmp_path / "secrets"
secrets_dir.mkdir()
install_file = secrets_dir / "install.env"
install_file.write_text(
"DATABASE_URL=sqlite+pysqlite:////tmp/bad.db\n"
"JWT_ACCESS_SECRET=x\n"
"JWT_REFRESH_PEPPER=y\n"
"POSTGRES_PASSWORD=z\n"
"SECRETS_LOCKED=true\n",
encoding="utf-8",
)
monkeypatch.setenv("COMPTON_INSTALL_SECRETS_DIR", str(secrets_dir))
monkeypatch.setattr(mod, "INSTALL_SECRETS_DIR", secrets_dir)
monkeypatch.setattr(mod, "INSTALL_SECRETS_FILE", install_file)
with pytest.raises(RuntimeError, match="PostgreSQL"):
mod.ensure_install_secrets()
def test_docker_entrypoint_rejects_sqlite(monkeypatch):
from scripts import docker_entrypoint
monkeypatch.setattr(
docker_entrypoint.settings,
"database_url",
"sqlite+pysqlite:////tmp/bad.db",
)
with pytest.raises(RuntimeError, match="SQLite"):
docker_entrypoint.wait_for_database(max_attempts=1, delay_seconds=0)
+119
View File
@@ -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
+9
View File
@@ -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
+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,15 @@
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()
+32
View File
@@ -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"
@@ -0,0 +1,18 @@
from pathlib import Path
from app.core.server_logging import build_uvicorn_log_config, ensure_server_log_file
def test_ensure_server_log_file(tmp_path, monkeypatch):
log_file = tmp_path / "logs" / "server.log"
monkeypatch.setattr("app.core.server_logging.settings.server_log_path", str(log_file))
path = ensure_server_log_file()
assert path.is_file()
assert "server log initialized" in path.read_text(encoding="utf-8")
def test_build_uvicorn_log_config(tmp_path, monkeypatch):
log_file = tmp_path / "logs" / "server.log"
monkeypatch.setattr("app.core.server_logging.settings.server_log_path", str(log_file))
cfg = build_uvicorn_log_config()
assert cfg["handlers"]["file"]["filename"] == str(log_file.resolve())
+14
View File
@@ -0,0 +1,14 @@
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
+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,125 @@
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"
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"
def test_admin_sync_metrics_with_enterprise(client):
from app.modules.sync import repository as sync_repo
ent = sync_repo.create_enterprise("Metrics Test Farm", "metrics-test-farm")
headers = _admin_headers(client)
response = client.get("/api/v1/admin/sync-metrics", headers=headers)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload, list)
match = next((row for row in payload if row["enterprise_id"] == ent.id), None)
assert match is not None
assert match["name"] == "Metrics Test Farm"
assert "hubs" in match
assert "outbox_pending" in match
@@ -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,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
@@ -0,0 +1,88 @@
from unittest.mock import patch
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
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"
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,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,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,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,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"
@@ -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,73 @@
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_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(
"/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 == 403
assert refresh.json()["detail"] == "ACCOUNT_BLOCKED"
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,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,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,120 @@
from __future__ import annotations
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.modules.sync import repository as repo
from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise
def test_hub_conflicts_via_hub_auth(client: TestClient):
enterprise_id = make_enterprise(client)
harness = OrchestratorDualHubHarness(client, enterprise_id)
hub_a, hub_b = harness.pair_hubs()
record_id = "hub-auth-conflict-1"
harness.push_from(
hub_a,
[
{
"event_id": str(uuid4()),
"seq": 1,
"domain": "global",
"table": "component",
"record_id": record_id,
"action": "upsert",
"version": 1,
"content_hash": "hub-h1",
"payload": {"name": "A", "type": "g", "dry_matter": 1, "id": record_id, "version": 1, "content_hash": "hub-h1"},
"emitted_at": "2026-07-15T12:00:00Z",
"origin_site_id": hub_a.hub_site_id,
}
],
)
harness.push_from(
hub_b,
[
{
"event_id": str(uuid4()),
"seq": 1,
"domain": "global",
"table": "component",
"record_id": record_id,
"action": "upsert",
"version": 1,
"content_hash": "hub-h2",
"payload": {"name": "B", "type": "g", "dry_matter": 2, "id": record_id, "version": 1, "content_hash": "hub-h2"},
"emitted_at": "2026-07-15T12:01:00Z",
"origin_site_id": hub_b.hub_site_id,
}
],
)
listed = client.get("/api/v1/sync/hub/conflicts", headers=hub_a.auth_header())
assert listed.status_code == 200
conflicts = listed.json()
assert len(conflicts) >= 1
def test_multiserver_list_and_resolve(client: TestClient):
enterprise_id = make_enterprise(client)
harness = OrchestratorDualHubHarness(client, enterprise_id)
hub_a, hub_b = harness.pair_hubs()
record_id = "ms-conflict-1"
harness.push_from(
hub_a,
[
{
"event_id": str(uuid4()),
"seq": 1,
"domain": "global",
"table": "component",
"record_id": record_id,
"action": "upsert",
"version": 1,
"content_hash": "ms-h1",
"payload": {"name": "A", "type": "g", "dry_matter": 1, "id": record_id, "version": 1, "content_hash": "ms-h1"},
"emitted_at": "2026-07-15T12:00:00Z",
"origin_site_id": hub_a.hub_site_id,
}
],
)
harness.push_from(
hub_b,
[
{
"event_id": str(uuid4()),
"seq": 1,
"domain": "global",
"table": "component",
"record_id": record_id,
"action": "upsert",
"version": 1,
"content_hash": "ms-h2",
"payload": {"name": "B", "type": "g", "dry_matter": 2, "id": record_id, "version": 1, "content_hash": "ms-h2"},
"emitted_at": "2026-07-15T12:01:00Z",
"origin_site_id": hub_b.hub_site_id,
}
],
)
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
token = login.json()["access_token"]
listed = client.get(
f"/api/v1/sync/conflicts?enterprise_id={enterprise_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert listed.status_code == 200
conflicts = listed.json()
assert len(conflicts) >= 1
cid = conflicts[0]["id"]
detail = client.get(
f"/api/v1/sync/conflicts/{cid}?enterprise_id={enterprise_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert detail.status_code == 200
resolved = client.post(
f"/api/v1/sync/conflicts/{cid}/resolve?enterprise_id={enterprise_id}",
headers={"Authorization": f"Bearer {token}"},
json={"resolution": "keep_orchestrator"},
)
assert resolved.status_code == 200
@@ -0,0 +1,181 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.modules.sync import repository as repo
from app.modules.sync.engine import SyncEngine
from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise
@pytest.fixture()
def chaos_harness(client: TestClient):
enterprise_id = make_enterprise(client)
harness = OrchestratorDualHubHarness(client, enterprise_id)
hub_a, hub_b = harness.pair_hubs()
return harness, hub_a, hub_b
def test_duplicate_ack_safe(client: TestClient, chaos_harness):
harness, hub_a, _hub_b = chaos_harness
record_id = "chaos-ack-1"
event = harness.make_component_event(
hub_a, record_id, name="DupAck", version=1, content_hash="da1", event_id="evt-dup-ack"
)
harness.push_from(hub_a, [event])
events = harness.pull_for(hub_a) # hub should not see own events
assert events == []
resp = client.post(
"/api/v1/sync/changes/ack",
headers=hub_a.auth_header(),
json={"event_ids": ["evt-dup-ack"], "direction": "inbound"},
)
assert resp.status_code == 200
def test_out_of_order_seq_replay_idempotent(client: TestClient, chaos_harness):
harness, hub_a, hub_b = chaos_harness
record_id = "chaos-seq-1"
e2 = harness.make_component_event(
hub_a, record_id, name="Second", version=2, content_hash="s2", event_id="evt-seq-2"
)
e1 = harness.make_component_event(
hub_a, record_id, name="First", version=1, content_hash="s1", event_id="evt-seq-1"
)
harness.push_from(hub_a, [e2])
harness.push_from(hub_a, [e1])
harness.drain()
row = harness.get_catalog(hub_b, "component", record_id)
assert row is not None
assert row["content_hash"] in {"s1", "s2"}
def test_resolve_keep_orchestrator_fanout(client: TestClient, chaos_harness):
harness, hub_a, hub_b = chaos_harness
record_id = "chaos-resolve-1"
harness.push_from(
hub_a,
[
{
"event_id": str(uuid4()),
"seq": 1,
"domain": "global",
"table": "component",
"record_id": record_id,
"action": "upsert",
"version": 1,
"content_hash": "orch-hash",
"payload": {
"name": "Orch",
"type": "grain",
"dry_matter": 50.0,
"id": record_id,
"version": 1,
"content_hash": "orch-hash",
},
"emitted_at": datetime.now(UTC).isoformat(),
"origin_site_id": hub_a.hub_site_id,
}
],
)
harness.push_from(
hub_b,
[
{
"event_id": str(uuid4()),
"seq": 1,
"domain": "global",
"table": "component",
"record_id": record_id,
"action": "upsert",
"version": 1,
"content_hash": "hub-hash",
"payload": {
"name": "HubB",
"type": "grain",
"dry_matter": 60.0,
"id": record_id,
"version": 1,
"content_hash": "hub-hash",
},
"emitted_at": datetime.now(UTC).isoformat(),
"origin_site_id": hub_b.hub_site_id,
}
],
)
conflicts = repo.list_conflicts(harness.enterprise_id, "pending")
assert len(conflicts) == 1
engine = SyncEngine(harness.enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID)
engine.resolve_conflict(conflicts[0].id, "admin", "keep_orchestrator")
harness.drain()
row = harness.get_orchestrator_catalog("component", record_id)
assert row is not None
assert row["content_hash"] == "orch-hash"
def test_resolve_keep_hub_fanout(client: TestClient, chaos_harness):
harness, hub_a, hub_b = chaos_harness
record_id = "chaos-resolve-hub-1"
harness.push_from(
hub_a,
[
{
"event_id": str(uuid4()),
"seq": 1,
"domain": "global",
"table": "component",
"record_id": record_id,
"action": "upsert",
"version": 1,
"content_hash": "orch-hash2",
"payload": {
"name": "Orch",
"type": "grain",
"dry_matter": 50.0,
"id": record_id,
"version": 1,
"content_hash": "orch-hash2",
},
"emitted_at": "2026-07-15T10:00:00Z",
"origin_site_id": hub_a.hub_site_id,
}
],
)
harness.push_from(
hub_b,
[
{
"event_id": str(uuid4()),
"seq": 1,
"domain": "global",
"table": "component",
"record_id": record_id,
"action": "upsert",
"version": 1,
"content_hash": "hub-b-hash2",
"payload": {
"name": "Hub B wins",
"type": "grain",
"dry_matter": 77.0,
"id": record_id,
"version": 1,
"content_hash": "hub-b-hash2",
},
"emitted_at": "2026-07-15T10:01:00Z",
"origin_site_id": hub_b.hub_site_id,
}
],
)
conflicts = repo.list_conflicts(harness.enterprise_id, "pending")
assert len(conflicts) == 1
engine = SyncEngine(harness.enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID)
engine.resolve_conflict(conflicts[0].id, "admin", "keep_hub")
harness.drain()
row = harness.get_orchestrator_catalog("component", record_id)
assert row is not None
assert row["content_hash"] == "hub-b-hash2"
assert row["name"] == "Hub B wins"
@@ -0,0 +1,187 @@
from __future__ import annotations
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise
@pytest.fixture()
def dual_harness(client: TestClient):
enterprise_id = make_enterprise(client)
harness = OrchestratorDualHubHarness(client, enterprise_id)
hub_a, hub_b = harness.pair_hubs()
return harness, hub_a, hub_b
def test_hub_a_push_reaches_hub_b(client: TestClient, dual_harness):
harness, hub_a, hub_b = dual_harness
record_id = "comp-dual-001"
event = harness.make_component_event(
hub_a, record_id, name="Corn A", version=1, content_hash="hash-a1"
)
harness.push_from(hub_a, [event])
harness.drain()
row = harness.get_catalog(hub_b, "component", record_id)
assert row is not None
assert row["name"] == "Corn A"
assert row["content_hash"] == "hash-a1"
def test_hub_b_push_back_to_hub_a(client: TestClient, dual_harness):
harness, hub_a, hub_b = dual_harness
record_id = "comp-dual-002"
harness.push_from(
hub_a,
[harness.make_component_event(hub_a, record_id, name="V1", version=1, content_hash="h1")],
)
harness.drain()
harness.push_from(
hub_b,
[harness.make_component_event(hub_b, record_id, name="V2 from B", version=2, content_hash="h2")],
)
harness.drain()
row_a = harness.get_catalog(hub_a, "component", record_id)
assert row_a is not None
assert row_a["content_hash"] == "h2"
assert row_a["name"] == "V2 from B"
def test_idempotent_replay(client: TestClient, dual_harness):
harness, hub_a, hub_b = dual_harness
record_id = "comp-dual-003"
event = harness.make_component_event(
hub_a, record_id, name="Once", version=1, content_hash="once1", event_id="evt-fixed-003"
)
harness.push_from(hub_a, [event])
harness.push_from(hub_a, [event])
harness.drain()
orch = harness.get_orchestrator_catalog("component", record_id)
assert orch is not None
assert orch["name"] == "Once"
row_b = harness.get_catalog(hub_b, "component", record_id)
assert row_b is not None
assert row_b["content_hash"] == "once1"
def test_ack_cursor_no_double_apply(client: TestClient, dual_harness):
harness, hub_a, hub_b = dual_harness
record_id = "comp-dual-004"
event = harness.make_component_event(
hub_a, record_id, name="Ack safe", version=1, content_hash="ack1", event_id="evt-ack-004"
)
harness.push_from(hub_a, [event])
events1 = harness.pull_for(hub_b)
assert len(events1) == 1
events2 = harness.pull_for(hub_b)
assert len(events2) == 0
row = harness.get_catalog(hub_b, "component", record_id)
assert row is not None
assert row["name"] == "Ack safe"
def test_no_conflict_single_editor(client: TestClient, dual_harness):
harness, hub_a, hub_b = dual_harness
record_id = "comp-dual-005"
harness.push_from(
hub_a,
[harness.make_component_event(hub_a, record_id, name="Solo", version=1, content_hash="solo1")],
)
harness.drain()
from app.modules.sync import repository as repo
conflicts = repo.list_conflicts(harness.enterprise_id, "pending")
assert conflicts == []
row = harness.get_orchestrator_catalog("component", record_id)
assert row is not None
assert row["name"] == "Solo"
def test_conflict_dual_edit(client: TestClient, dual_harness):
harness, hub_a, hub_b = dual_harness
record_id = "comp-dual-006"
base = {"name": "Base", "type": "grain", "dry_matter": 50.0}
harness.push_from(
hub_a,
[
{
"event_id": str(uuid4()),
"seq": 1,
"domain": "global",
"table": "component",
"record_id": record_id,
"action": "upsert",
"version": 1,
"content_hash": "base-hash",
"payload": {**base, "id": record_id, "version": 1, "content_hash": "base-hash"},
"emitted_at": "2026-07-15T10:00:00Z",
"origin_site_id": hub_a.hub_site_id,
}
],
)
harness.drain()
harness.push_from(
hub_b,
[
{
"event_id": str(uuid4()),
"seq": 1,
"domain": "global",
"table": "component",
"record_id": record_id,
"action": "upsert",
"version": 1,
"content_hash": "b-conflict-hash",
"payload": {
"name": "B edit",
"type": "grain",
"dry_matter": 60.0,
"id": record_id,
"version": 1,
"content_hash": "b-conflict-hash",
},
"emitted_at": "2026-07-15T10:01:00Z",
"origin_site_id": hub_b.hub_site_id,
}
],
)
from app.modules.sync import repository as repo
conflicts = repo.list_conflicts(harness.enterprise_id, "pending")
assert len(conflicts) == 1
orch = harness.get_orchestrator_catalog("component", record_id)
assert orch is not None
assert orch["content_hash"] == "base-hash"
def test_period_recipes_composite_record_id(client: TestClient, dual_harness):
harness, hub_a, hub_b = dual_harness
period_id = "737a78a5-492a-4a84-94f3-18b4d7f21666"
recipe_id = "07fd17cf-22c3-44fe-92db-46363b24cee0"
record_id = f"{period_id}:{recipe_id}"
event = {
"event_id": str(uuid4()),
"seq": 1,
"domain": "global",
"table": "period_recipes",
"record_id": record_id,
"action": "upsert",
"version": 1,
"content_hash": "pr1",
"payload": {
"period_id": period_id,
"recipe_id": recipe_id,
"order": 1,
"version": 1,
"content_hash": "pr1",
},
"emitted_at": "2026-07-15T12:00:00Z",
"origin_site_id": hub_a.hub_site_id,
}
harness.push_from(hub_a, [event])
harness.drain()
row = harness.get_catalog(hub_b, "period_recipes", record_id)
assert row is not None
assert row.get("order") == 1
@@ -0,0 +1,72 @@
from __future__ import annotations
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise
def test_recipe_ingredient_tree_roundtrip(client: TestClient):
enterprise_id = make_enterprise(client)
harness = OrchestratorDualHubHarness(client, enterprise_id)
hub_a, hub_b = harness.pair_hubs()
recipe_id = "recipe-tree-1"
ing_id = "ing-tree-1"
comp_id = "comp-tree-1"
harness.push_from(
hub_a,
[
harness.make_component_event(hub_a, comp_id, name="Barley", version=1, content_hash="c1"),
{
"event_id": str(uuid4()),
"seq": 2,
"domain": "global",
"table": "recipe",
"record_id": recipe_id,
"action": "upsert",
"version": 1,
"content_hash": "r1",
"payload": {
"id": recipe_id,
"name": "Mix A",
"heads_per_trip": 100,
"ingredients": [{"id": ing_id, "component_id": comp_id, "name": "Barley", "amount": 10}],
"version": 1,
"content_hash": "r1",
},
"emitted_at": "2026-07-15T12:00:00Z",
"origin_site_id": hub_a.hub_site_id,
},
{
"event_id": str(uuid4()),
"seq": 3,
"domain": "global",
"table": "ingredient",
"record_id": ing_id,
"action": "upsert",
"version": 1,
"content_hash": "i1",
"payload": {
"id": ing_id,
"recipe_id": recipe_id,
"component_id": comp_id,
"name": "Barley",
"amount": 10.0,
"version": 1,
"content_hash": "i1",
},
"emitted_at": "2026-07-15T12:00:01Z",
"origin_site_id": hub_a.hub_site_id,
},
],
)
harness.drain([hub_b])
recipe = harness.get_catalog(hub_b, "recipe", recipe_id)
assert recipe is not None
assert recipe.get("name") == "Mix A"
ing = harness.get_catalog(hub_b, "ingredient", ing_id)
assert ing is not None
assert ing.get("component_id") == comp_id
@@ -0,0 +1,39 @@
from __future__ import annotations
import os
import pytest
from app.modules.sync import repository as repo
@pytest.mark.skipif(
"postgresql" not in os.environ.get("DATABASE_URL", ""),
reason="RLS tests require PostgreSQL",
)
def test_rls_cross_enterprise():
ent_a = repo.create_enterprise("Farm A", f"farm-a-{os.getpid()}")
ent_b = repo.create_enterprise("Farm B", f"farm-b-{os.getpid()}")
assert ent_a.id != ent_b.id
hub_a = repo.create_farm_hub(ent_a.id, "Hub A", f"hub-a-{os.getpid()}", None)
hubs_b = repo.list_farm_hubs(ent_b.id)
assert all(h.id != hub_a.id for h in hubs_b)
def test_viewer_farm_scope(client):
from uuid import uuid4
from app.modules.users.repository import get_user_by_email
slug = f"abac-{uuid4().hex[:8]}"
ent = repo.create_enterprise("ABAC Farm", slug)
admin = get_user_by_email("admin@compton.example")
assert admin
repo.add_member(admin.id, ent.id, "admin")
hub1 = repo.create_farm_hub(ent.id, "H1", f"h1-{uuid4().hex[:8]}", None)
hub2 = repo.create_farm_hub(ent.id, "H2", f"h2-{uuid4().hex[:8]}", None)
viewer_id = admin.id
repo.grant_farm_access(viewer_id, hub1.id)
allowed = repo.list_farm_access(viewer_id, ent.id)
assert hub1.id in allowed
assert hub2.id not in allowed
@@ -0,0 +1,97 @@
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.core.crypto import hash_opaque_token
from app.modules.sync import repository as repo
from uuid import uuid4
@pytest.fixture()
def enterprise(client: TestClient):
slug = f"test-farm-{uuid4().hex[:8]}"
ent = repo.create_enterprise("Test Farm Co", slug)
admin = repo.get_member.__module__ # noqa: ensure import path
from app.modules.users.repository import get_user_by_email
user = get_user_by_email("admin@compton.example")
assert user
repo.add_member(user.id, ent.id, "admin")
return ent
def test_sync_capabilities(client: TestClient):
resp = client.get("/api/v1/sync/capabilities")
assert resp.status_code == 200
data = resp.json()
assert data["protocol_version"] == "1.0"
assert "global" in data["domains"]
def test_pairing_flow(client: TestClient, enterprise):
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
token = login.json()["access_token"]
start = client.post(
"/api/v1/enterprise/pair/start",
headers={"Authorization": f"Bearer {token}"},
json={"enterprise_id": enterprise.id, "farm_name": "Farm A"},
)
assert start.status_code == 200
code = start.json()["code"]
confirm = client.post(
"/api/v1/enterprise/pair/confirm",
json={"code": code, "hub_site_id": "hub-site-001", "hub_name": "Farm A Hub"},
)
assert confirm.status_code == 200
body = confirm.json()
assert body["hub_site_id"] == "hub-site-001"
assert body["api_key"]
def test_hub_push_idempotent(client: TestClient, enterprise):
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
token = login.json()["access_token"]
start = client.post(
"/api/v1/enterprise/pair/start",
headers={"Authorization": f"Bearer {token}"},
json={"enterprise_id": enterprise.id, "farm_name": "Farm B"},
)
code = start.json()["code"]
confirm = client.post(
"/api/v1/enterprise/pair/confirm",
json={"code": code, "hub_site_id": "hub-site-002", "hub_name": "Farm B Hub"},
)
api_key = confirm.json()["api_key"]
hub_auth = f"Hub hub-site-002:{api_key}"
from datetime import UTC, datetime
event = {
"event_id": "evt-001",
"seq": 1,
"domain": "global",
"table": "component",
"record_id": "comp-001",
"action": "upsert",
"version": 1,
"content_hash": "hash1",
"payload": {"name": "Corn", "type": "grain", "dry_matter": 88.0},
"emitted_at": datetime.now(UTC).isoformat(),
"origin_site_id": "hub-site-002",
}
push1 = client.post(
"/api/v1/sync/changes/push",
headers={"Authorization": hub_auth},
json={"events": [event]},
)
assert push1.status_code == 200
assert "evt-001" in push1.json()["applied_event_ids"]
push2 = client.post(
"/api/v1/sync/changes/push",
headers={"Authorization": hub_auth},
json={"events": [event]},
)
assert push2.status_code == 200
assert "evt-001" in push2.json()["applied_event_ids"]
@@ -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"
@@ -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,11 @@
from app.modules.zootech.orchestrator_system_metrics import collect_orchestrator_system_metrics
def test_collect_orchestrator_system_metrics_shape():
metrics = collect_orchestrator_system_metrics()
if metrics.get("available"):
assert "memory" in metrics
assert "cpu" in metrics
assert "swap" in metrics
else:
assert "message" in metrics
@@ -0,0 +1,356 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.modules.zootech.catalog_apply import apply_catalog_change
from tests.orchestrator_dual_harness import make_enterprise
def test_wesp_compat_feed_dispensers(client: TestClient):
enterprise_id = make_enterprise(client)
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
token = login.json()["access_token"]
session = client.post(
f"/api/v1/enterprise/enterprises/{enterprise_id}/session",
headers={"Authorization": f"Bearer {token}"},
)
token = session.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
resp = client.get(f"/api/feed_dispensers?enterprise_id={enterprise_id}", headers=headers)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_wesp_compat_components(client: TestClient):
enterprise_id = make_enterprise(client)
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
token = login.json()["access_token"]
session = client.post(
f"/api/v1/enterprise/enterprises/{enterprise_id}/session",
headers={"Authorization": f"Bearer {token}"},
)
token = session.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
resp = client.get(f"/api/components?enterprise_id={enterprise_id}", headers=headers)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_wesp_compat_recipe_includes_ingredients_and_unloading_groups(client: TestClient):
enterprise_id = make_enterprise(client)
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
token = login.json()["access_token"]
session = client.post(
f"/api/v1/enterprise/enterprises/{enterprise_id}/session",
headers={"Authorization": f"Bearer {token}"},
)
token = session.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
recipe_id = "recipe-compat-1"
ing_id = "ing-compat-1"
grp_id = "grp-compat-1"
apply_catalog_change(
enterprise_id,
"recipe",
recipe_id,
"upsert",
{
"id": recipe_id,
"name": "Compat Mix",
"heads_per_trip": 100,
"mixing_time": 10,
"trip_percent": 100.0,
},
1,
"r1",
)
apply_catalog_change(
enterprise_id,
"ingredient",
ing_id,
"upsert",
{
"id": ing_id,
"recipe_id": recipe_id,
"name": "Corn",
"amount": 12.5,
"order": 1,
"dry_matter": 88.0,
},
1,
"i1",
)
apply_catalog_change(
enterprise_id,
"unloading_group",
grp_id,
"upsert",
{
"id": grp_id,
"recipe_id": recipe_id,
"name": "Barn A",
"distribution_type": "percent",
"value": 50.0,
"weight": 500.0,
"order": 1,
},
1,
"g1",
)
resp = client.get(f"/api/recipes/{recipe_id}?enterprise_id={enterprise_id}", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "Compat Mix"
assert len(body.get("ingredients") or []) == 1
assert body["ingredients"][0]["name"] == "Corn"
assert len(body.get("unloading_groups") or body.get("unloadingGroups") or []) == 1
assert body["unloading_groups"][0]["name"] == "Barn A"
def test_wesp_compat_recipe_calculate(client: TestClient):
resp = client.post(
"/api/recipes/calculate",
json={
"ingredients": [{"weightPerHead": 10, "dryMatter": 88, "component_id": "c1"}],
"headsCount": 100,
"tripPercent": 100,
"unloadingGroups": [{"distributionType": "percent", "value": 50}],
},
)
assert resp.status_code == 200
body = resp.json()
assert len(body.get("ingredients") or []) == 1
assert len(body.get("unloadingGroups") or []) == 1
def test_wesp_compat_recipe_put_persists_unloading_group(client: TestClient):
enterprise_id = make_enterprise(client)
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
token = login.json()["access_token"]
session = client.post(
f"/api/v1/enterprise/enterprises/{enterprise_id}/session",
headers={"Authorization": f"Bearer {token}"},
)
token = session.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
recipe_id = "recipe-put-1"
apply_catalog_change(
enterprise_id,
"recipe",
recipe_id,
"upsert",
{"id": recipe_id, "name": "Before", "heads_per_trip": 100, "mixing_time": 10, "trip_percent": 100.0},
1,
"r1",
)
put = client.put(
f"/api/recipes/{recipe_id}?enterprise_id={enterprise_id}",
headers=headers,
json={
"name": "After",
"heads_count": 100,
"mixing_time": 10,
"trip_percent": 100,
"dry_matter_locked": False,
"unloading_link_broken": False,
"ingredients": [],
"unloading_groups": [
{
"name": "Group A",
"distribution_type": "percent",
"value": 100,
"weight": 500,
"order": 1,
}
],
},
)
assert put.status_code == 200
got = client.get(f"/api/recipes/{recipe_id}?enterprise_id={enterprise_id}", headers=headers)
assert got.status_code == 200
groups = got.json().get("unloading_groups") or []
assert len(groups) == 1
assert groups[0]["name"] == "Group A"
def test_wesp_compat_analytics_endpoints(client: TestClient):
enterprise_id = make_enterprise(client)
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
token = login.json()["access_token"]
session = client.post(
f"/api/v1/enterprise/enterprises/{enterprise_id}/session",
headers={"Authorization": f"Bearer {token}"},
)
token = session.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
params = f"enterprise_id={enterprise_id}&date_from=2026-07-01&date_to=2026-07-15"
finance = client.get(f"/api/analytics/finance?{params}", headers=headers)
assert finance.status_code == 200
assert "overloadRub" in finance.json()
plan_fact = client.get(f"/api/analytics/plan-fact?{params}", headers=headers)
assert plan_fact.status_code == 200
assert plan_fact.json()["items"] == []
summary = client.get(f"/api/feed-quality/alerts/summary?{params}", headers=headers)
assert summary.status_code == 200
assert "total" in summary.json()
alerts = client.get(f"/api/feed-quality/alerts?{params}", headers=headers)
assert alerts.status_code == 200
assert "items" in alerts.json()
def test_wesp_compat_reports_list(client: TestClient):
from app.modules.zootech.report_apply import apply_report_change
enterprise_id = make_enterprise(client)
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
token = login.json()["access_token"]
session = client.post(
f"/api/v1/enterprise/enterprises/{enterprise_id}/session",
headers={"Authorization": f"Bearer {token}"},
)
token = session.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
loading_id = "lr-test-1"
apply_report_change(
enterprise_id,
"loading_report",
loading_id,
"upsert",
{
"recipe_id": "recipe-1",
"recipe_name": "Test Mix",
"start_time": datetime.now(UTC).isoformat(),
"end_time": datetime.now(UTC).isoformat(),
"total_weight": 1200.0,
"dispenser_type": "dispenser",
"components": [{"component_name": "Corn", "actual_weight": 100.0}],
},
1,
"h1",
)
apply_report_change(
enterprise_id,
"unloading_report",
"ur-test-1",
"upsert",
{
"loading_report_id": loading_id,
"start_time": datetime.now(UTC).isoformat(),
"total_weight": 1200.0,
"total_unloaded_weight": 1100.0,
"remaining_weight": 100.0,
},
1,
"u1",
)
resp = client.get(f"/api/reports?enterprise_id={enterprise_id}", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, list)
assert len(body) == 1
assert body[0]["recipe_id"] == "recipe-1"
assert body[0]["unloading_data"]["total_unloaded_weight"] == 1100.0
def test_apply_report_change_update_adds_components(client: TestClient):
import json
from app.core.database import session_scope
from app.modules.zootech.report_apply import apply_report_change
from app.modules.zootech.report_models import ZootechLoadingReport
from sqlalchemy import select
enterprise_id = make_enterprise(client)
loading_id = "lr-test-components"
apply_report_change(
enterprise_id,
"loading_report",
loading_id,
"upsert",
{
"recipe_id": "recipe-1",
"recipe_name": "Test Mix",
"start_time": datetime.now(UTC).isoformat(),
"total_weight": 1200.0,
},
1,
"h1",
)
apply_report_change(
enterprise_id,
"loading_report",
loading_id,
"upsert",
{
"recipe_id": "recipe-1",
"recipe_name": "Test Mix",
"start_time": datetime.now(UTC).isoformat(),
"total_weight": 1200.0,
"components": [{"component_name": "Corn", "actual_weight": 100.0, "overload": 5.0}],
},
2,
"h2",
)
with session_scope() as db:
row = db.scalar(
select(ZootechLoadingReport).where(
ZootechLoadingReport.enterprise_id == enterprise_id,
ZootechLoadingReport.id == loading_id,
)
)
assert row is not None
payload = json.loads(row.payload_json or "{}")
assert len(payload.get("components") or []) == 1
assert payload["components"][0]["component_name"] == "Corn"
def test_apply_report_change_feed_alert(client: TestClient):
from app.core.database import session_scope
from app.modules.zootech.report_apply import apply_report_change
from app.modules.zootech.report_models import ZootechFeedAlert
from sqlalchemy import select
enterprise_id = make_enterprise(client)
alert_id = "fa-test-1"
apply_report_change(
enterprise_id,
"feed_alert",
alert_id,
"upsert",
{
"event_type": "UNDERLOAD",
"severity": "error",
"loading_report_id": "lr-1",
"recipe_id": "recipe-1",
"recipe_name": "Mix",
"detail": "Underload detected",
"deviation_kg": -5.0,
},
1,
"h1",
)
with session_scope() as db:
row = db.scalar(
select(ZootechFeedAlert).where(
ZootechFeedAlert.enterprise_id == enterprise_id,
ZootechFeedAlert.id == alert_id,
)
)
assert row is not None
assert row.alert_type == "UNDERLOAD"
assert row.message == "Underload detected"
assert row.payload_json
@@ -0,0 +1,97 @@
def test_wesp_admin_users_shape(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
response = client.get("/api/admin/users", headers=headers)
assert response.status_code == 200
body = response.json()
assert body["status"] == "success"
assert isinstance(body["users"], list)
assert body["users"]
assert "login" in body["users"][0]
def test_wesp_admin_summary_and_stubs(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
summary = client.get("/api/admin/summary", headers=headers)
assert summary.status_code == 200
data = summary.json()
assert data["status"] == "success"
assert "counts" in data
assert "sync" in data
metrics = client.get("/api/admin/system-metrics", headers=headers)
assert metrics.status_code == 200
assert metrics.json()["status"] == "success"
assert "metrics" in metrics.json()
network = client.get("/api/admin/network-settings", headers=headers)
assert network.status_code == 200
assert network.json()["network"]["public_base_url"]
sync_diag = client.get("/api/admin/sync-diagnostics", headers=headers)
assert sync_diag.status_code == 200
assert sync_diag.json()["status"] == "success"
def test_wesp_admin_server_log(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
response = client.get("/api/admin/server-log?lines=10", headers=headers)
assert response.status_code == 200
body = response.json()
assert body["status"] in {"success", "error"}
assert "lines" in body
download = client.get("/api/admin/server-log/download", headers=headers)
assert download.status_code == 200
assert "text/plain" in (download.headers.get("content-type") or "")
def test_wesp_admin_client_logs_index(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
response = client.get("/api/admin/client-uploaded-logs", headers=headers)
assert response.status_code == 200
body = response.json()
assert body["status"] == "success"
assert isinstance(body["clients"], list)
def test_wesp_admin_ui_activity(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
response = client.post(
"/api/admin/ui-activity",
headers=headers,
json={"text": "test event", "level": "ok"},
)
assert response.status_code == 200
assert response.json()["status"] == "success"
feed = client.get("/api/admin/activity-feed", headers=headers)
assert feed.status_code == 200
body = feed.json()
assert body["status"] == "success"
assert isinstance(body["entries"], list)
@@ -0,0 +1,24 @@
def test_wesp_auth_login(client):
response = client.post(
"/api/auth/login",
json={"login": "admin@compton.example", "password": "Admin1234"},
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "success"
assert body.get("access_token")
def test_wesp_auth_check(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
token = login.json()["access_token"]
response = client.get("/api/auth/check", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
body = response.json()
assert body["status"] == "success"
assert body["authenticated"] is True
assert body["user_login"] == "admin@compton.example"
assert body["is_superuser"] is True
@@ -0,0 +1,32 @@
def test_wesp_updates_status(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
response = client.get("/api/updates/status", headers=headers)
assert response.status_code == 200
body = response.json()
assert body["update_available"] is False
def test_wesp_notifications_summary(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
response = client.get("/api/notifications?summary=1", headers=headers)
assert response.status_code == 200
assert response.json()["unreadCount"] == 0
def test_wesp_admin_hardware_stub(client):
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
response = client.get("/api/admin/hardware-status", headers=headers)
assert response.status_code == 200
assert response.json()["status"] == "success"
+178
View File
@@ -0,0 +1,178 @@
"""Orchestrator + two virtual hub clients for dual-hub sync E2E tests."""
from __future__ import annotations
import copy
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from fastapi.testclient import TestClient
from app.modules.sync import repository as repo
@dataclass
class HubClient:
hub_site_id: str
farm_hub_id: str
api_key: str
pull_cursor: int = 0
local_catalog: dict[str, dict[str, dict[str, Any]]] = field(default_factory=dict)
def auth_header(self) -> dict[str, str]:
return {"Authorization": f"Hub {self.hub_site_id}:{self.api_key}"}
class OrchestratorDualHubHarness:
def __init__(self, client: TestClient, enterprise_id: str) -> None:
self.client = client
self.enterprise_id = enterprise_id
self.hub_a: HubClient | None = None
self.hub_b: HubClient | None = None
def pair_hubs(self) -> tuple[HubClient, HubClient]:
suffix = uuid4().hex[:8]
code_a = self._start_pairing()
hub_a = self._confirm_hub(code_a, f"hub-site-a-{suffix}", "Hub A")
code_b = self._start_pairing()
hub_b = self._confirm_hub(code_b, f"hub-site-b-{suffix}", "Hub B")
self.hub_a = hub_a
self.hub_b = hub_b
return hub_a, hub_b
def _start_pairing(self) -> str:
login = self.client.post(
"/api/v1/auth/login",
json={"email": "admin@compton.example", "password": "Admin1234"},
)
token = login.json()["access_token"]
start = self.client.post(
"/api/v1/enterprise/pair/start",
headers={"Authorization": f"Bearer {token}"},
json={"enterprise_id": self.enterprise_id, "farm_name": "Farm"},
)
return start.json()["code"]
def _confirm_hub(self, code: str, hub_site_id: str, hub_name: str) -> HubClient:
confirm = self.client.post(
"/api/v1/enterprise/pair/confirm",
json={"code": code, "hub_site_id": hub_site_id, "hub_name": hub_name},
)
body = confirm.json()
return HubClient(
hub_site_id=body["hub_site_id"],
farm_hub_id=body["farm_hub_id"],
api_key=body["api_key"],
)
def edit_local(self, hub: HubClient, table: str, record_id: str, payload: dict, *, version: int, content_hash: str) -> dict:
row = copy.deepcopy(payload)
row["id"] = record_id
row["version"] = version
row["content_hash"] = content_hash
hub.local_catalog.setdefault(table, {})[record_id] = row
return row
def push_from(self, hub: HubClient, events: list[dict]) -> dict:
resp = self.client.post(
"/api/v1/sync/changes/push",
headers=hub.auth_header(),
json={"events": events},
)
resp.raise_for_status()
return resp.json()
def pull_for(self, hub: HubClient) -> list[dict]:
resp = self.client.post(
"/api/v1/sync/changes/pull",
headers=hub.auth_header(),
json={"cursor": hub.pull_cursor, "limit": 100},
)
resp.raise_for_status()
body = resp.json()
events = body.get("events") or []
ack_ids: list[str] = []
for ev in events:
self._apply_local(hub, ev)
ack_ids.append(ev["event_id"])
if ack_ids:
self.client.post(
"/api/v1/sync/changes/ack",
headers=hub.auth_header(),
json={"event_ids": ack_ids, "direction": "inbound"},
)
if body.get("next_cursor") is not None:
hub.pull_cursor = int(body["next_cursor"])
return events
def _apply_local(self, hub: HubClient, event: dict) -> None:
table = event["table"]
record_id = event["record_id"]
hub.local_catalog.setdefault(table, {})[record_id] = {
**(event.get("payload") or {}),
"id": record_id,
"version": event.get("version"),
"content_hash": event.get("content_hash"),
}
def drain(self, hubs: list[HubClient] | None = None, rounds: int = 10) -> None:
targets = hubs or [h for h in (self.hub_a, self.hub_b) if h]
for _ in range(rounds):
for hub in targets:
self.pull_for(hub)
def get_catalog(self, hub: HubClient, table: str, record_id: str) -> dict | None:
return hub.local_catalog.get(table, {}).get(record_id)
def get_orchestrator_catalog(self, table: str, record_id: str) -> dict | None:
from app.modules.zootech.catalog_apply import load_catalog_row
return load_catalog_row(self.enterprise_id, table, record_id)
def make_component_event(
self,
hub: HubClient,
record_id: str,
*,
name: str,
version: int,
content_hash: str,
event_id: str | None = None,
dry_matter: float = 88.0,
) -> dict:
payload = {
"name": name,
"type": "grain",
"dry_matter": dry_matter,
"protein": 8.0,
"energy": 1.2,
"price": 0.0,
"is_active": True,
}
self.edit_local(hub, "component", record_id, payload, version=version, content_hash=content_hash)
return {
"event_id": event_id or str(uuid4()),
"seq": version,
"domain": "global",
"table": "component",
"record_id": record_id,
"action": "upsert",
"version": version,
"content_hash": content_hash,
"payload": {**payload, "id": record_id, "version": version, "content_hash": content_hash},
"emitted_at": datetime.now(UTC).isoformat(),
"origin_site_id": hub.hub_site_id,
}
def make_enterprise(client: TestClient) -> str:
slug = f"test-ent-{uuid4().hex[:8]}"
ent = repo.create_enterprise("Test Enterprise", slug)
from app.modules.users.repository import get_user_by_email
user = get_user_by_email("admin@compton.example")
assert user
repo.add_member(user.id, ent.id, "admin")
return ent.id
@@ -0,0 +1,101 @@
from sqlalchemy import create_engine, inspect
from scripts.docker_entrypoint import HEAD_REVISION, 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) == HEAD_REVISION
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) == HEAD_REVISION
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) == HEAD_REVISION
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from pathlib import Path
def test_wesp_ui_parity_files_exist():
root = Path(__file__).resolve().parents[2] / "web" / "public" / "static"
required = [
"recipes.html",
"components.html",
"feed_dispensers.html",
"consumption.html",
"reports.html",
"admin.html",
"js/wesp-orchestrator-boot.js",
"js/wesp-orchestrator-auth-bridge.js",
"js/pages/recipes-data-controller.js",
"js/pages/multiserver-panel.js",
"css/wesp-zootech-shell.css",
]
for rel in required:
assert (root / rel).is_file(), f"missing {rel}"
@@ -0,0 +1,35 @@
"""Port of WESP test_zootech_k_hub_ui_contract for orchestrator public/wesp copy."""
from __future__ import annotations
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2] / "web" / "public" / "static"
ZOOTECH_PAGES = (
"recipes.html",
"reports.html",
"components.html",
"feed_dispensers.html",
"consumption.html",
)
def test_zootech_pages_include_k_hub_assets():
for page in ZOOTECH_PAGES:
html = (PROJECT_ROOT / page).read_text(encoding="utf-8")
assert "wesp-zootech-k-hub.css" in html, page
assert "daily-plan-panel.js" in html, page
assert "wesp-zootech-notification-center.js" in html, page
def test_orchestrator_boot_present():
boot = (PROJECT_ROOT / "js" / "wesp-orchestrator-boot.js").read_text(encoding="utf-8")
assert "WespOrchestratorBoot" in boot
assert "enterprise_id" in boot
def test_multiserver_panel_uses_sync_api():
js = (PROJECT_ROOT / "js" / "pages" / "multiserver-panel.js").read_text(encoding="utf-8")
assert "/api/v1/sync/conflicts" in js
assert "resolve" in js