from types import SimpleNamespace from unittest.mock import MagicMock, call import flask import pytest from api import main as apiMain from api import security def _bearer(token="token"): return {"Authorization": f"Bearer {token}"} def _userPrincipal(userUUID="user-1", authentication="jwt"): return { "type": "user", "authentication": authentication, "user_uuid": userUUID, "scopes": [], } def _servicePrincipal(*scopes): return { "type": "service", "authentication": "api_key", "service_name": "test-service", "scopes": list(scopes), } @pytest.fixture def securityClient(): app = flask.Flask("security-test") app.config["TESTING"] = True @app.route("/json", methods=["POST"]) def parseJson(): return flask.jsonify({"parsed": security.jsonObject()}) @app.route("/user") @security.requireUser() def userRoute(): return flask.jsonify( { "user_uuid": flask.g.user_uuid, "principal_type": flask.g.principal["type"], } ) @app.route("/login-user") @security.requireUser(requireLogin=True) def loginUserRoute(): return flask.jsonify({"ok": True}) @app.route("/service") @security.requireService("jobs:claim") def serviceRoute(): return flask.jsonify({"service": flask.g.principal["service_name"]}) return app.test_client() @pytest.fixture def apiClient(monkeypatch): registry = SimpleNamespace(route_registrars=[]) monkeypatch.setattr(apiMain, "discover_modules", MagicMock(return_value=registry)) app = apiMain.createApp() app.config.update(TESTING=True, SERVICE_KEY_READY=True) return app.test_client() def test_json_object_accepts_only_json_objects(securityClient): assert securityClient.post("/json", json={"value": 1}).get_json() == { "parsed": {"value": 1} } assert securityClient.post("/json", json=[1, 2]).get_json() == {"parsed": None} assert securityClient.post( "/json", data="not-json", content_type="application/json", ).get_json() == {"parsed": None} def test_require_user_sets_context_and_forwards_login_requirement( monkeypatch, securityClient ): principal = _userPrincipal() authenticate = MagicMock(return_value=principal) isUser = MagicMock(return_value=True) monkeypatch.setattr(security.auth, "authenticateBearerToken", authenticate) monkeypatch.setattr(security.auth, "isUserPrincipal", isUser) response = securityClient.get("/user", headers=_bearer("user-token")) loginResponse = securityClient.get( "/login-user", headers=_bearer("user-token"), ) assert response.status_code == 200 assert response.get_json() == { "user_uuid": "user-1", "principal_type": "user", } assert loginResponse.status_code == 200 assert authenticate.call_args_list == [ call("Bearer user-token", allowService=False), call("Bearer user-token", allowService=False), ] assert isUser.call_args_list == [ call(principal, requireLogin=False), call(principal, requireLogin=True), ] def test_require_user_rejects_invalid_principal(monkeypatch, securityClient): monkeypatch.setattr( security.auth, "authenticateBearerToken", MagicMock(return_value=None), ) monkeypatch.setattr( security.auth, "isUserPrincipal", MagicMock(return_value=False), ) response = securityClient.get("/user") assert response.status_code == 401 assert response.get_json() == {"error": "unauthorized"} def test_require_service_enforces_scope_and_sets_principal( monkeypatch, securityClient ): principal = _servicePrincipal("jobs:claim") authenticate = MagicMock(side_effect=[principal, None]) hasScope = MagicMock(side_effect=[True, False]) monkeypatch.setattr(security.auth, "authenticateBearerToken", authenticate) monkeypatch.setattr(security.auth, "hasServiceScope", hasScope) accepted = securityClient.get("/service", headers=_bearer("service-key")) rejected = securityClient.get("/service", headers=_bearer("wrong-key")) assert accepted.status_code == 200 assert accepted.get_json() == {"service": "test-service"} assert rejected.status_code == 401 assert rejected.get_json() == {"error": "unauthorized"} assert authenticate.call_args_list == [ call( "Bearer service-key", requiredScopes=["jobs:claim"], allowUser=False, ), call( "Bearer wrong-key", requiredScopes=["jobs:claim"], allowUser=False, ), ] assert hasScope.call_args_list == [ call(principal, "jobs:claim"), call(None, "jobs:claim"), ] def test_registration_and_login_success_and_errors(monkeypatch, apiClient): registerUser = MagicMock( side_effect=[True, False, ValueError("invalid registration")] ) getToken = MagicMock(side_effect=["login-token", False]) monkeypatch.setattr(apiMain.users, "registerUser", registerUser) monkeypatch.setattr(apiMain.auth, "getLoginToken", getToken) registered = apiClient.post( "/api/register", json={"username": "alice", "password": "password123", "timezone": "UTC"}, ) duplicate = apiClient.post( "/api/register", json={"username": "alice", "password": "password123"}, ) invalid = apiClient.post( "/api/register", json={"username": "", "password": "password123"}, ) invalidJson = apiClient.post("/api/register", json=["not", "an", "object"]) loggedIn = apiClient.post( "/api/login", json={"username": "alice", "password": "password123"}, ) denied = apiClient.post( "/api/login", json={"username": "alice", "password": "wrong-password"}, ) assert registered.status_code == 201 assert registered.get_json() == {"success": True} assert duplicate.status_code == 409 assert duplicate.get_json() == {"error": "username taken"} assert invalid.status_code == 400 assert invalid.get_json() == {"error": "invalid registration"} assert invalidJson.status_code == 400 assert loggedIn.get_json() == {"token": "login-token"} assert denied.status_code == 401 assert denied.get_json() == {"error": "invalid credentials"} def test_discord_session_success_and_enrollment_errors(monkeypatch, apiClient): principal = _servicePrincipal("discord:session") monkeypatch.setattr( apiMain.auth, "authenticateBearerToken", MagicMock(return_value=principal), ) getOrCreate = MagicMock( side_effect=[ValueError("invalid Discord ID"), None, {"id": "user-1"}] ) monkeypatch.setattr(apiMain.identity, "getOrCreateDiscordUser", getOrCreate) monkeypatch.setattr( apiMain.auth, "createLoginToken", MagicMock(return_value="discord-token"), ) missing = apiClient.post( "/api/auth/discord/session", headers=_bearer("service-key"), json={}, ) invalid = apiClient.post( "/api/auth/discord/session", headers=_bearer("service-key"), json={"discord_id": "bad"}, ) denied = apiClient.post( "/api/auth/discord/session", headers=_bearer("service-key"), json={"discord_id": "456"}, ) accepted = apiClient.post( "/api/auth/discord/session", headers=_bearer("service-key"), json={"discord_id": "123", "display_name": "Alice"}, ) assert missing.status_code == 400 assert invalid.get_json() == {"error": "invalid Discord ID"} assert denied.status_code == 403 assert accepted.status_code == 200 assert accepted.get_json() == { "token": "discord-token", "user_uuid": "user-1", "timezone": "UTC", } apiMain.auth.createLoginToken.assert_called_once_with( "user-1", name="Alice", extraClaims={"provider": "discord"}, ) def test_api_key_routes_use_authenticated_owner(monkeypatch, apiClient): monkeypatch.setattr( apiMain.auth, "authenticateBearerToken", MagicMock(return_value=_userPrincipal()), ) listKeys = MagicMock(return_value=[{"id": "key-1", "name": "CLI"}]) createKey = MagicMock( side_effect=[ {"id": "key-2", "name": "new", "key": "secret"}, ValueError("invalid expiry"), ] ) revokeKey = MagicMock(side_effect=[False, True]) monkeypatch.setattr(apiMain.apiKeys, "listUserApiKeys", listKeys) monkeypatch.setattr(apiMain.apiKeys, "createUserApiKey", createKey) monkeypatch.setattr(apiMain.apiKeys, "revokeUserApiKey", revokeKey) listed = apiClient.get("/api/keys", headers=_bearer()) created = apiClient.post( "/api/keys", headers=_bearer(), json={"name": "new", "expires_at": "2099-01-01T00:00:00Z"}, ) invalid = apiClient.post( "/api/keys", headers=_bearer(), json={"name": "bad"}, ) missing = apiClient.delete("/api/keys/missing", headers=_bearer()) revoked = apiClient.delete("/api/keys/key-1", headers=_bearer()) assert listed.get_json() == {"keys": [{"id": "key-1", "name": "CLI"}]} assert created.status_code == 201 assert created.get_json()["key"] == "secret" assert invalid.status_code == 400 assert invalid.get_json() == {"error": "invalid expiry"} assert missing.status_code == 404 assert revoked.get_json() == {"success": True} listKeys.assert_called_once_with("user-1") createKey.assert_has_calls( [ call("user-1", "new", expiresAt="2099-01-01T00:00:00Z"), call("user-1", "bad", expiresAt=None), ] ) def test_user_profile_and_username_routes_enforce_ownership(monkeypatch, apiClient): monkeypatch.setattr( apiMain.auth, "authenticateBearerToken", MagicMock(return_value=_userPrincipal()), ) getUser = MagicMock( side_effect=[ { "id": "user-1", "username": "alice", "password_hashed": b"secret-hash", }, None, ] ) getUserUUID = MagicMock(side_effect=["user-1", "user-2", False]) monkeypatch.setattr(apiMain.users, "getUser", getUser) monkeypatch.setattr(apiMain.users, "getUserUUID", getUserUUID) foreign = apiClient.get("/api/user/user-2", headers=_bearer()) own = apiClient.get("/api/user/user-1", headers=_bearer()) missing = apiClient.get("/api/user/user-1", headers=_bearer()) username = apiClient.get("/api/getUserUUID/alice", headers=_bearer()) foreignUsername = apiClient.get("/api/getUserUUID/bob", headers=_bearer()) missingUsername = apiClient.get("/api/getUserUUID/missing", headers=_bearer()) assert foreign.status_code == 403 assert getUser.call_count == 2 assert own.status_code == 200 assert own.get_json() == {"id": "user-1", "username": "alice"} assert missing.status_code == 404 assert username.get_json() == "user-1" assert foreignUsername.status_code == 403 assert missingUsername.status_code == 404 def test_user_update_and_delete_success_and_errors(monkeypatch, apiClient): monkeypatch.setattr( apiMain.auth, "authenticateBearerToken", MagicMock(return_value=_userPrincipal()), ) updateUser = MagicMock(side_effect=[ValueError("bad timezone"), False, True]) unregisterUser = MagicMock(side_effect=[False, True]) monkeypatch.setattr(apiMain.users, "updateUser", updateUser) monkeypatch.setattr(apiMain.auth, "unregisterUser", unregisterUser) assert apiClient.put( "/api/user/user-2", headers=_bearer(), json={"timezone": "UTC"} ).status_code == 403 assert apiClient.put( "/api/user/user-1", headers=_bearer(), json=[] ).status_code == 400 invalid = apiClient.put( "/api/user/user-1", headers=_bearer(), json={"timezone": "Invalid"} ) empty = apiClient.put( "/api/user/user-1", headers=_bearer(), json={"username": "ignored"} ) updated = apiClient.put( "/api/user/user-1", headers=_bearer(), json={"timezone": "UTC"} ) missingPassword = apiClient.delete( "/api/user/user-1", headers=_bearer(), json={} ) wrongPassword = apiClient.delete( "/api/user/user-1", headers=_bearer(), json={"password": "wrong"} ) deleted = apiClient.delete( "/api/user/user-1", headers=_bearer(), json={"password": "correct"} ) assert invalid.get_json() == {"error": "bad timezone"} assert empty.get_json() == {"error": "no valid fields to update"} assert updated.get_json() == {"success": True} assert missingPassword.status_code == 400 assert wrongPassword.status_code == 401 assert deleted.get_json() == {"success": True} def test_timezone_routes_read_validate_and_update(monkeypatch, apiClient): monkeypatch.setattr( apiMain.auth, "authenticateBearerToken", MagicMock(return_value=_userPrincipal()), ) normalize = MagicMock( side_effect=["America/Chicago", ValueError("invalid timezone")] ) updateUser = MagicMock(return_value=True) monkeypatch.setattr( apiMain.users, "getUserTimezone", MagicMock(return_value="UTC"), ) monkeypatch.setattr(apiMain.users, "normalizeTimezone", normalize) monkeypatch.setattr(apiMain.users, "updateUser", updateUser) current = apiClient.get("/api/user/me/timezone", headers=_bearer()) updated = apiClient.put( "/api/user/me/timezone", headers=_bearer(), json={"timezone": "America/Chicago"}, ) invalid = apiClient.put( "/api/user/me/timezone", headers=_bearer(), json={"timezone": "Invalid"}, ) assert current.get_json() == {"timezone": "UTC"} assert updated.get_json() == {"timezone": "America/Chicago"} assert invalid.status_code == 400 assert invalid.get_json() == {"error": "invalid timezone"} updateUser.assert_called_once_with( "user-1", {"timezone": "America/Chicago"}, ) def test_outbox_claim_filters_missing_discord_identities(monkeypatch, apiClient): monkeypatch.setattr( apiMain.auth, "authenticateBearerToken", MagicMock(return_value=_servicePrincipal("outbox:claim")), ) claimMessages = MagicMock( side_effect=[ ValueError("invalid limit"), [ { "id": "message-1", "user_uuid": "user-1", "payload": {"content": "hello"}, "attempts": 1, }, { "id": "message-2", "user_uuid": "user-2", "payload": {}, "attempts": 2, }, ], ] ) identities = MagicMock( side_effect=[ [{"provider": "discord", "provider_user_id": "discord-1"}], [], ] ) retryMessage = MagicMock() monkeypatch.setattr(apiMain.outbox, "claim_messages", claimMessages) monkeypatch.setattr(apiMain.identity, "listUserIdentities", identities) monkeypatch.setattr(apiMain.outbox, "retry_message", retryMessage) missingWorker = apiClient.post( "/api/internal/outbox/claim", headers=_bearer(), json={}, ) invalid = apiClient.post( "/api/internal/outbox/claim", headers=_bearer(), json={"worker_id": "worker-1", "limit": 0}, ) claimed = apiClient.post( "/api/internal/outbox/claim", headers=_bearer(), json={"worker_id": "worker-1", "channel": "discord_dm", "limit": 2}, ) assert missingWorker.status_code == 400 assert invalid.get_json() == {"error": "invalid limit"} assert claimed.get_json() == { "messages": [ { "id": "message-1", "provider_user_id": "discord-1", "content": "hello", "attempts": 1, "worker_id": "worker-1", } ] } retryMessage.assert_called_once_with( "message-2", "worker-1", "user has no Discord identity", ) def test_outbox_result_enforces_lease_owner_and_records_results( monkeypatch, apiClient ): monkeypatch.setattr( apiMain.auth, "authenticateBearerToken", MagicMock(return_value=_servicePrincipal("outbox:deliver")), ) leased = {"id": "message-1", "status": "delivering", "leased_by": "worker-1"} getMessage = MagicMock(side_effect=[None, leased, leased, leased]) deliveredRecord = {"id": "message-1", "status": "delivered"} retryRecord = {"id": "message-1", "status": "pending"} markDelivered = MagicMock(return_value=deliveredRecord) retryMessage = MagicMock(return_value=retryRecord) monkeypatch.setattr(apiMain.outbox, "get_message", getMessage) monkeypatch.setattr(apiMain.outbox, "mark_delivered", markDelivered) monkeypatch.setattr(apiMain.outbox, "retry_message", retryMessage) invalid = apiClient.post( "/api/internal/outbox/message-1/result", headers=_bearer(), json={"status": "unknown"}, ) missing = apiClient.post( "/api/internal/outbox/message-1/result", headers=_bearer(), json={"status": "sent", "worker_id": "worker-1"}, ) wrongWorker = apiClient.post( "/api/internal/outbox/message-1/result", headers=_bearer(), json={"status": "sent", "worker_id": "worker-2"}, ) delivered = apiClient.post( "/api/internal/outbox/message-1/result", headers=_bearer(), json={ "status": "sent", "worker_id": "worker-1", "external_message_id": "x" * 300, }, ) retried = apiClient.post( "/api/internal/outbox/message-1/result", headers=_bearer(), json={ "status": "retry", "worker_id": "worker-1", "error": "temporary failure", }, ) assert invalid.status_code == 400 assert missing.status_code == 404 assert wrongWorker.status_code == 409 assert delivered.get_json()["message"] == deliveredRecord assert retried.get_json()["message"] == retryRecord markDelivered.assert_called_once_with( "message-1", "worker-1", external_message_id="x" * 255, ) retryMessage.assert_called_once_with( "message-1", "worker-1", "temporary failure", ) def test_health_and_standard_error_responses(monkeypatch, apiClient): execute = MagicMock(side_effect=[[{"ready": 1}], RuntimeError("offline")]) monkeypatch.setattr(apiMain.postgres, "execute", execute) live = apiClient.get("/health/live") ready = apiClient.get("/health/ready") unavailable = apiClient.get("/health") missing = apiClient.get("/missing") wrongMethod = apiClient.get("/api/register") assert live.get_json() == {"status": "ok"} assert ready.get_json() == {"status": "ready"} assert unavailable.status_code == 503 assert unavailable.get_json() == {"status": "not ready"} assert missing.status_code == 404 assert missing.get_json() == {"error": "not found"} assert wrongMethod.status_code == 405 assert wrongMethod.get_json() == {"error": "method not allowed"}