Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
@@ -0,0 +1,63 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.app_settings import (
|
||||
_coerce_value,
|
||||
apply_settings_to_app,
|
||||
bootstrap_settings,
|
||||
env_locks,
|
||||
get_settings_payload,
|
||||
write_settings,
|
||||
)
|
||||
|
||||
|
||||
def test_write_and_bootstrap_settings(tmp_path, monkeypatch):
|
||||
settings_file = tmp_path / "compton_settings.json"
|
||||
monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file))
|
||||
monkeypatch.delenv("ENABLE_DOCS", raising=False)
|
||||
|
||||
merged = write_settings({"enable_docs": False, "log_level": "INFO"})
|
||||
assert merged["enable_docs"] is False
|
||||
assert settings_file.exists()
|
||||
|
||||
bootstrap_settings()
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.enable_docs is False
|
||||
|
||||
|
||||
def test_env_locks_skip_locked_keys(tmp_path, monkeypatch):
|
||||
settings_file = tmp_path / "compton_settings.json"
|
||||
monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file))
|
||||
monkeypatch.setenv("ENABLE_DOCS", "true")
|
||||
monkeypatch.setattr("app.core.app_settings.settings.enable_docs", True)
|
||||
|
||||
write_settings({"enable_docs": False})
|
||||
payload = get_settings_payload()
|
||||
assert payload["locks"]["enable_docs"] is True
|
||||
assert payload["values"]["enable_docs"] is True
|
||||
|
||||
|
||||
def test_apply_settings_coerces_list_and_bool(monkeypatch):
|
||||
apply_settings_to_app({"enable_docs": "false", "cors_origins": "http://a.test,http://b.test"})
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.enable_docs is False
|
||||
assert settings.cors_origins == ["http://a.test", "http://b.test"]
|
||||
assert isinstance(env_locks(), dict)
|
||||
|
||||
|
||||
def test_read_settings_ignores_invalid_payload(tmp_path, monkeypatch):
|
||||
settings_file = tmp_path / "compton_settings.json"
|
||||
settings_file.write_text("[]", encoding="utf-8")
|
||||
monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file))
|
||||
from app.core.app_settings import read_settings
|
||||
|
||||
assert read_settings() == {}
|
||||
|
||||
|
||||
def test_coerce_value_rejects_invalid_list():
|
||||
with pytest.raises(ValueError, match="INVALID_LIST_cors_origins"):
|
||||
_coerce_value("cors_origins", 123)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from app.core.audit_log import read_audit_events, write_audit_event
|
||||
|
||||
|
||||
def test_audit_log_roundtrip(tmp_path, monkeypatch):
|
||||
audit_file = tmp_path / "admin-audit.jsonl"
|
||||
monkeypatch.setattr("app.core.audit_log.settings.admin_audit_log_path", str(audit_file))
|
||||
write_audit_event("admin.user.patch", "u1", "admin@example.com", {"target": "u2"})
|
||||
events = read_audit_events()
|
||||
assert len(events) == 1
|
||||
assert events[0]["action"] == "admin.user.patch"
|
||||
assert events[0]["details"]["target"] == "u2"
|
||||
|
||||
|
||||
def test_read_audit_events_ignores_invalid_json(tmp_path, monkeypatch):
|
||||
audit_file = tmp_path / "admin-audit.jsonl"
|
||||
audit_file.write_text('{"action":"ok"}\nnot-json\n', encoding="utf-8")
|
||||
monkeypatch.setattr("app.core.audit_log.settings.admin_audit_log_path", str(audit_file))
|
||||
events = read_audit_events()
|
||||
assert len(events) == 1
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from app.core.dependencies import get_current_user, require_admin, require_superuser
|
||||
from app.core.security import create_access_token, hash_password
|
||||
from app.modules.users.repository import create_user, update_user
|
||||
|
||||
|
||||
def test_get_current_user_rejects_missing_credentials():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_current_user(None)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_get_current_user_rejects_pending_user():
|
||||
user = create_user("pending-dep@example.com", hash_password("Valid123"), status="pending")
|
||||
token = create_access_token(user.id, user.role, user.is_superuser)
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_current_user(credentials)
|
||||
assert exc.value.status_code == 403
|
||||
assert exc.value.detail == "EMAIL_NOT_VERIFIED"
|
||||
|
||||
|
||||
def test_get_current_user_rejects_blocked_user():
|
||||
user = create_user("blocked-dep@example.com", hash_password("Valid123"), status="active")
|
||||
user.status = "blocked"
|
||||
update_user(user)
|
||||
token = create_access_token(user.id, user.role, user.is_superuser)
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_current_user(credentials)
|
||||
assert exc.value.status_code == 403
|
||||
assert exc.value.detail == "ACCOUNT_BLOCKED"
|
||||
|
||||
|
||||
def test_get_current_user_rejects_invalid_token():
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="not-a-jwt")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_current_user(credentials)
|
||||
assert exc.value.status_code == 401
|
||||
assert exc.value.detail == "INVALID_TOKEN"
|
||||
|
||||
|
||||
def test_get_current_user_rejects_unknown_user():
|
||||
token = create_access_token("missing-user-id", "user", False)
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_current_user(credentials)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_require_admin_rejects_regular_user():
|
||||
user = create_user("regular-dep@example.com", hash_password("Valid123"), role="user", status="active")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_admin(user)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_require_superuser_rejects_non_super_admin():
|
||||
user = create_user("ops-dep@example.com", hash_password("Valid123"), role="admin", status="active")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_superuser(user)
|
||||
assert exc.value.status_code == 403
|
||||
assert exc.value.detail == "SUPERUSER_ONLY"
|
||||
@@ -0,0 +1,29 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.core.email import SmtpMailer, get_mailer, memory_mailer, send_template_email
|
||||
|
||||
|
||||
def test_memory_mailer_latest_token():
|
||||
memory_mailer.clear()
|
||||
send_template_email(
|
||||
to="user@example.com",
|
||||
template="verify_email",
|
||||
subject="Verify",
|
||||
body="Open link\nTOKEN:abc123\n",
|
||||
)
|
||||
assert memory_mailer.latest_token("user@example.com", "verify_email") == "abc123"
|
||||
|
||||
|
||||
def test_smtp_mailer_sends_message(monkeypatch):
|
||||
monkeypatch.setattr("app.core.email.settings.email_delivery_mode", "smtp")
|
||||
monkeypatch.setattr("app.core.email.settings.smtp_from", "noreply@example.com")
|
||||
monkeypatch.setattr("app.core.email.settings.smtp_host", "localhost")
|
||||
monkeypatch.setattr("app.core.email.settings.smtp_port", 1025)
|
||||
monkeypatch.setattr("app.core.email.settings.smtp_user", "")
|
||||
monkeypatch.setattr("app.core.email.settings.smtp_password", "")
|
||||
|
||||
smtp_instance = MagicMock()
|
||||
with patch("app.core.email.smtplib.SMTP") as smtp_cls:
|
||||
smtp_cls.return_value.__enter__.return_value = smtp_instance
|
||||
get_mailer().send("user@example.com", "Subject", "Body", "verify_email")
|
||||
smtp_instance.send_message.assert_called_once()
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import jwt_denylist as denylist_module
|
||||
from app.core.jwt_denylist import (
|
||||
bump_auth_epoch,
|
||||
deny_jti,
|
||||
ensure_jwt_revocation_backend,
|
||||
get_auth_epoch,
|
||||
is_jti_denied,
|
||||
revoke_access_token,
|
||||
validate_access_claims,
|
||||
)
|
||||
from app.core.security import create_access_token, hash_password
|
||||
from app.modules.users.repository import create_user
|
||||
|
||||
|
||||
def test_auth_epoch_bump_invalidates_token():
|
||||
user = create_user("epoch-user@example.com", hash_password("Valid123"), status="active")
|
||||
token = create_access_token(user.id, user.role, user.is_superuser)
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
payload = decode_access_token(token)
|
||||
validate_access_claims(payload)
|
||||
|
||||
bump_auth_epoch(user.id)
|
||||
with pytest.raises(ValueError, match="TOKEN_REVOKED"):
|
||||
validate_access_claims(payload)
|
||||
|
||||
|
||||
def test_deny_jti_blocks_specific_token():
|
||||
user = create_user("jti-user@example.com", hash_password("Valid123"), status="active")
|
||||
token = create_access_token(user.id, user.role, user.is_superuser)
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
payload = decode_access_token(token)
|
||||
deny_jti(payload["jti"], payload["exp"])
|
||||
assert is_jti_denied(payload["jti"])
|
||||
with pytest.raises(ValueError, match="TOKEN_REVOKED"):
|
||||
validate_access_claims(payload)
|
||||
|
||||
|
||||
def test_revoke_access_token_helper():
|
||||
user = create_user("revoke-user@example.com", hash_password("Valid123"), status="active")
|
||||
token = create_access_token(user.id, user.role, user.is_superuser)
|
||||
revoke_access_token(token)
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
with pytest.raises(ValueError, match="TOKEN_REVOKED"):
|
||||
validate_access_claims(decode_access_token(token))
|
||||
|
||||
|
||||
def test_get_auth_epoch_defaults_to_zero():
|
||||
assert get_auth_epoch("missing-user-id") == 0
|
||||
|
||||
|
||||
def test_validate_access_claims_rejects_missing_sub():
|
||||
with pytest.raises(ValueError, match="INVALID_TOKEN"):
|
||||
validate_access_claims({})
|
||||
|
||||
|
||||
def test_production_is_jti_denied_fail_closed(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
|
||||
mock_client = MagicMock()
|
||||
mock_client.exists.side_effect = RuntimeError("redis down")
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
|
||||
assert is_jti_denied("any-jti") is True
|
||||
|
||||
|
||||
def test_revoke_access_token_ignores_invalid_token():
|
||||
revoke_access_token("not-a-jwt")
|
||||
|
||||
|
||||
def test_bump_auth_epoch_production_raises_when_redis_fails(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
|
||||
mock_client = MagicMock()
|
||||
mock_client.incr.side_effect = RuntimeError("redis down")
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
|
||||
with pytest.raises(RuntimeError):
|
||||
bump_auth_epoch("user-x")
|
||||
|
||||
|
||||
def test_empty_jti_is_not_denied():
|
||||
assert is_jti_denied("") is False
|
||||
deny_jti("", 9999999999)
|
||||
|
||||
|
||||
def test_ensure_jwt_revocation_backend_requires_redis_in_production(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="Redis is required"):
|
||||
ensure_jwt_revocation_backend()
|
||||
|
||||
|
||||
def test_get_auth_epoch_production_raises_when_redis_fails(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
|
||||
mock_client = MagicMock()
|
||||
mock_client.get.side_effect = RuntimeError("redis down")
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
|
||||
with pytest.raises(RuntimeError):
|
||||
get_auth_epoch("user-y")
|
||||
|
||||
|
||||
def test_deny_jti_production_raises_when_redis_fails(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
|
||||
mock_client = MagicMock()
|
||||
mock_client.setex.side_effect = RuntimeError("redis down")
|
||||
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
|
||||
with pytest.raises(RuntimeError):
|
||||
deny_jti("jti-123", int(__import__("time").time()) + 3600)
|
||||
|
||||
|
||||
def test_memory_denylist_purges_expired_jti(monkeypatch):
|
||||
monkeypatch.setattr(denylist_module, "_memory_denied_jti", {"expired-jti": 1.0})
|
||||
assert is_jti_denied("expired-jti") is False
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from app.main import create_app
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def test_create_app_includes_test_routes_when_enabled(monkeypatch):
|
||||
monkeypatch.setattr(settings, "enable_test_routes", True)
|
||||
app = create_app()
|
||||
paths = set(app.openapi()["paths"])
|
||||
assert "/api/v1/test/emails/latest-token" in paths
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
def test_health_smoke(client):
|
||||
response = client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user