Files
Lamont/tests/unit/test_api_routes.py
Chelsea Lee fbdf33e894
Some checks failed
CI / test (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled
Build reusable bot framework
2026-07-19 21:53:24 -05:00

593 lines
19 KiB
Python

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"}