Build reusable bot framework
This commit is contained in:
264
tests/integration/test_api.py
Normal file
264
tests/integration/test_api.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""Flask API integration tests for authentication and route boundaries."""
|
||||
|
||||
import uuid
|
||||
|
||||
SERVICE_KEY = "integration-service-key-0123456789abcdef"
|
||||
ALLOWED_DISCORD_ID = "integration-discord-user"
|
||||
|
||||
|
||||
def _bearer(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_health_and_error_routes(client):
|
||||
assert client.get("/health/live").get_json() == {"status": "ok"}
|
||||
assert client.get("/health/ready").get_json() == {"status": "ready"}
|
||||
assert client.get("/health").get_json() == {"status": "ready"}
|
||||
|
||||
missing = client.get("/api/route-that-does-not-exist")
|
||||
assert missing.status_code == 404
|
||||
assert missing.get_json() == {"error": "not found"}
|
||||
|
||||
wrongMethod = client.get("/api/register")
|
||||
assert wrongMethod.status_code == 405
|
||||
assert wrongMethod.get_json() == {"error": "method not allowed"}
|
||||
|
||||
invalidJson = client.post(
|
||||
"/api/register", data="[]", content_type="application/json"
|
||||
)
|
||||
assert invalidJson.status_code == 400
|
||||
assert invalidJson.get_json() == {"error": "JSON object required"}
|
||||
|
||||
|
||||
def test_password_registration_keeps_values_parameterized_and_protected(client):
|
||||
from core import postgres
|
||||
|
||||
injectedUsername = "alice'; DROP TABLE users; --"
|
||||
injectedUUID = str(uuid.uuid4())
|
||||
response = client.post(
|
||||
"/api/register",
|
||||
json={
|
||||
"username": injectedUsername,
|
||||
"password": "long-enough-password",
|
||||
"timezone": "America/Chicago",
|
||||
"id": injectedUUID,
|
||||
"password_hashed": "attacker-controlled",
|
||||
"created_at": "1900-01-01T00:00:00Z",
|
||||
"unexpected_admin": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.get_json()
|
||||
assert postgres.table_exists("users")
|
||||
|
||||
record = postgres.select_one("users", {"username": injectedUsername})
|
||||
assert record is not None
|
||||
assert str(record["id"]) != injectedUUID
|
||||
assert bytes(record["password_hashed"]) != b"attacker-controlled"
|
||||
assert record["timezone"] == "America/Chicago"
|
||||
assert record["created_at"].year > 1900
|
||||
|
||||
login = client.post(
|
||||
"/api/login",
|
||||
json={"username": injectedUsername, "password": "long-enough-password"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
assert login.get_json()["token"]
|
||||
|
||||
|
||||
def test_jwt_user_key_creation_authentication_and_revocation(
|
||||
client, createAuthenticatedUser
|
||||
):
|
||||
from core import postgres
|
||||
|
||||
user = createAuthenticatedUser("api-key-owner")
|
||||
jwtHeaders = _bearer(user["token"])
|
||||
|
||||
profile = client.get(f"/api/user/{user['user_uuid']}", headers=jwtHeaders)
|
||||
assert profile.status_code == 200
|
||||
assert profile.get_json()["username"] == user["username"]
|
||||
assert "password_hashed" not in profile.get_json()
|
||||
|
||||
update = client.put(
|
||||
f"/api/user/{user['user_uuid']}",
|
||||
headers=jwtHeaders,
|
||||
json={"timezone": "America/Chicago", "username": "cannot-change"},
|
||||
)
|
||||
assert update.status_code == 200
|
||||
assert client.get(
|
||||
"/api/user/me/timezone", headers=jwtHeaders
|
||||
).get_json() == {"timezone": "America/Chicago"}
|
||||
|
||||
created = client.post(
|
||||
"/api/keys", headers=jwtHeaders, json={"name": "integration key"}
|
||||
)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
keyRecord = created.get_json()
|
||||
secret = keyRecord["key"]
|
||||
assert secret.startswith("llmbot_user_")
|
||||
assert "key_hash" not in keyRecord
|
||||
storedKey = postgres.select_one("api_keys", {"id": keyRecord["id"]})
|
||||
assert storedKey["key_hash"] != secret
|
||||
assert secret not in {str(value) for value in storedKey.values()}
|
||||
|
||||
listed = client.get("/api/keys", headers=jwtHeaders)
|
||||
assert listed.status_code == 200
|
||||
listedKey = listed.get_json()["keys"][0]
|
||||
assert listedKey["id"] == keyRecord["id"]
|
||||
assert "key" not in listedKey
|
||||
assert "key_hash" not in listedKey
|
||||
|
||||
apiKeyHeaders = _bearer(secret)
|
||||
timezone = client.get("/api/user/me/timezone", headers=apiKeyHeaders)
|
||||
assert timezone.status_code == 200
|
||||
assert timezone.get_json() == {"timezone": "America/Chicago"}
|
||||
assert postgres.select_one("api_keys", {"id": keyRecord["id"]})[
|
||||
"last_used_at"
|
||||
] is not None
|
||||
|
||||
cannotManageKeys = client.post(
|
||||
"/api/keys", headers=apiKeyHeaders, json={"name": "nested key"}
|
||||
)
|
||||
assert cannotManageKeys.status_code == 401
|
||||
|
||||
revoked = client.delete(f"/api/keys/{keyRecord['id']}", headers=jwtHeaders)
|
||||
assert revoked.status_code == 200
|
||||
assert postgres.select_one("api_keys", {"id": keyRecord["id"]})[
|
||||
"revoked_at"
|
||||
] is not None
|
||||
assert client.get("/api/user/me/timezone", headers=apiKeyHeaders).status_code == 401
|
||||
|
||||
|
||||
def test_discord_allowlist_and_service_key_boundaries(client, createAuthenticatedUser):
|
||||
serviceHeaders = _bearer(SERVICE_KEY)
|
||||
|
||||
assert client.post(
|
||||
"/api/auth/discord/session",
|
||||
json={"discord_id": ALLOWED_DISCORD_ID},
|
||||
).status_code == 401
|
||||
assert client.post(
|
||||
"/api/auth/discord/session",
|
||||
headers=_bearer("not-the-configured-service-key-0123456789"),
|
||||
json={"discord_id": ALLOWED_DISCORD_ID},
|
||||
).status_code == 401
|
||||
|
||||
denied = client.post(
|
||||
"/api/auth/discord/session",
|
||||
headers=serviceHeaders,
|
||||
json={"discord_id": "not-allowlisted", "display_name": "No Access"},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
|
||||
accepted = client.post(
|
||||
"/api/auth/discord/session",
|
||||
headers=serviceHeaders,
|
||||
json={"discord_id": ALLOWED_DISCORD_ID, "display_name": "First Name"},
|
||||
)
|
||||
assert accepted.status_code == 200, accepted.get_json()
|
||||
discordSession = accepted.get_json()
|
||||
assert discordSession["timezone"] == "UTC"
|
||||
assert discordSession["token"]
|
||||
|
||||
repeated = client.post(
|
||||
"/api/auth/discord/session",
|
||||
headers=serviceHeaders,
|
||||
json={"discord_id": ALLOWED_DISCORD_ID, "display_name": "New Name"},
|
||||
)
|
||||
assert repeated.status_code == 200
|
||||
assert repeated.get_json()["user_uuid"] == discordSession["user_uuid"]
|
||||
|
||||
profile = client.get(
|
||||
f"/api/user/{discordSession['user_uuid']}",
|
||||
headers=_bearer(discordSession["token"]),
|
||||
)
|
||||
assert profile.status_code == 200
|
||||
assert profile.get_json()["username"] is None
|
||||
|
||||
passwordUser = createAuthenticatedUser("service-boundary-user")
|
||||
assert client.post(
|
||||
"/api/auth/discord/session",
|
||||
headers=_bearer(passwordUser["token"]),
|
||||
json={"discord_id": ALLOWED_DISCORD_ID},
|
||||
).status_code == 401
|
||||
assert client.get(
|
||||
"/api/user/me/timezone", headers=serviceHeaders
|
||||
).status_code == 401
|
||||
|
||||
|
||||
def test_service_outbox_claim_retry_and_delivery_routes(client):
|
||||
from core import outbox, postgres
|
||||
|
||||
serviceHeaders = _bearer(SERVICE_KEY)
|
||||
session = client.post(
|
||||
"/api/auth/discord/session",
|
||||
headers=serviceHeaders,
|
||||
json={"discord_id": ALLOWED_DISCORD_ID, "display_name": "Recipient"},
|
||||
).get_json()
|
||||
queued = outbox.enqueue_message(
|
||||
session["user_uuid"],
|
||||
"discord_dm",
|
||||
{"content": "integration delivery"},
|
||||
idempotency_key=f"api-outbox-{uuid.uuid4()}",
|
||||
)
|
||||
|
||||
claimed = client.post(
|
||||
"/api/internal/outbox/claim",
|
||||
headers=serviceHeaders,
|
||||
json={"worker_id": "api-worker-one", "limit": 1},
|
||||
)
|
||||
assert claimed.status_code == 200
|
||||
message = claimed.get_json()["messages"][0]
|
||||
assert message["id"] == str(queued["id"])
|
||||
assert message["provider_user_id"] == ALLOWED_DISCORD_ID
|
||||
assert message["content"] == "integration delivery"
|
||||
assert message["worker_id"] == "api-worker-one"
|
||||
|
||||
missingWorker = client.post(
|
||||
f"/api/internal/outbox/{message['id']}/result",
|
||||
headers=serviceHeaders,
|
||||
json={"status": "sent"},
|
||||
)
|
||||
assert missingWorker.status_code == 409
|
||||
|
||||
wrongWorker = client.post(
|
||||
f"/api/internal/outbox/{message['id']}/result",
|
||||
headers=serviceHeaders,
|
||||
json={"status": "sent", "worker_id": "not-the-lease-owner"},
|
||||
)
|
||||
assert wrongWorker.status_code == 409
|
||||
|
||||
stillLeased = outbox.get_message(message["id"])
|
||||
assert stillLeased["status"] == "delivering"
|
||||
assert stillLeased["leased_by"] == "api-worker-one"
|
||||
assert stillLeased["delivered_at"] is None
|
||||
|
||||
retried = client.post(
|
||||
f"/api/internal/outbox/{message['id']}/result",
|
||||
headers=serviceHeaders,
|
||||
json={
|
||||
"status": "retry",
|
||||
"error": "temporary failure",
|
||||
"worker_id": "api-worker-one",
|
||||
},
|
||||
)
|
||||
assert retried.status_code == 200
|
||||
assert retried.get_json()["message"]["status"] == "pending"
|
||||
|
||||
postgres.execute(
|
||||
"UPDATE outbound_messages SET available_at = NOW() WHERE id = %(id)s",
|
||||
{"id": message["id"]},
|
||||
)
|
||||
claimedAgain = client.post(
|
||||
"/api/internal/outbox/claim",
|
||||
headers=serviceHeaders,
|
||||
json={"worker_id": "api-worker-two", "limit": 1},
|
||||
)
|
||||
assert claimedAgain.status_code == 200
|
||||
assert claimedAgain.get_json()["messages"][0]["attempts"] == 2
|
||||
|
||||
delivered = client.post(
|
||||
f"/api/internal/outbox/{message['id']}/result",
|
||||
headers=serviceHeaders,
|
||||
json={"status": "sent", "worker_id": "api-worker-two"},
|
||||
)
|
||||
assert delivered.status_code == 200
|
||||
assert delivered.get_json()["message"]["status"] == "delivered"
|
||||
Reference in New Issue
Block a user