Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user