Build reusable bot framework
This commit is contained in:
77
tests/unit/conftest.py
Normal file
77
tests/unit/conftest.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Small import fallbacks for running focused tests without optional services."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
try:
|
||||
import psycopg2 # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
psycopg2 = types.ModuleType("psycopg2")
|
||||
extras = types.ModuleType("psycopg2.extras")
|
||||
errors = types.ModuleType("psycopg2.errors")
|
||||
|
||||
class Json:
|
||||
def __init__(self, adapted):
|
||||
self.adapted = adapted
|
||||
|
||||
class RealDictCursor:
|
||||
pass
|
||||
|
||||
class UniqueViolation(Exception):
|
||||
pass
|
||||
|
||||
def connect(**_kwargs):
|
||||
raise AssertionError("Tests must mock PostgreSQL connections")
|
||||
|
||||
def execute_values(*_args, **_kwargs):
|
||||
raise AssertionError("Tests must mock bulk PostgreSQL writes")
|
||||
|
||||
extras.Json = Json
|
||||
extras.RealDictCursor = RealDictCursor
|
||||
extras.execute_values = execute_values
|
||||
errors.UniqueViolation = UniqueViolation
|
||||
psycopg2.connect = connect
|
||||
psycopg2.extras = extras
|
||||
psycopg2.errors = errors
|
||||
sys.modules["psycopg2"] = psycopg2
|
||||
sys.modules["psycopg2.extras"] = extras
|
||||
sys.modules["psycopg2.errors"] = errors
|
||||
|
||||
|
||||
try:
|
||||
import bcrypt # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
bcrypt = types.ModuleType("bcrypt")
|
||||
|
||||
def unavailable(*_args, **_kwargs):
|
||||
raise AssertionError("Tests exercising bcrypt require project dependencies")
|
||||
|
||||
bcrypt.gensalt = unavailable
|
||||
bcrypt.hashpw = unavailable
|
||||
bcrypt.checkpw = unavailable
|
||||
sys.modules["bcrypt"] = bcrypt
|
||||
|
||||
|
||||
try:
|
||||
import jwt # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
jwt = types.ModuleType("jwt")
|
||||
exceptions = types.ModuleType("jwt.exceptions")
|
||||
|
||||
class ExpiredSignatureError(Exception):
|
||||
pass
|
||||
|
||||
class InvalidTokenError(Exception):
|
||||
pass
|
||||
|
||||
def unavailableJwt(*_args, **_kwargs):
|
||||
raise AssertionError("Tests exercising JWT encoding require project dependencies")
|
||||
|
||||
jwt.encode = unavailableJwt
|
||||
jwt.decode = unavailableJwt
|
||||
exceptions.ExpiredSignatureError = ExpiredSignatureError
|
||||
exceptions.InvalidTokenError = InvalidTokenError
|
||||
jwt.exceptions = exceptions
|
||||
sys.modules["jwt"] = jwt
|
||||
sys.modules["jwt.exceptions"] = exceptions
|
||||
310
tests/unit/test_adapters.py
Normal file
310
tests/unit/test_adapters.py
Normal file
@@ -0,0 +1,310 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, call
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from bot import api_client as apiClientModule
|
||||
from bot.context import CommandContext
|
||||
from scheduler import daemon
|
||||
|
||||
|
||||
def _response(status, payload=None, jsonError=False):
|
||||
response = MagicMock(status_code=status)
|
||||
if jsonError:
|
||||
response.json.side_effect = ValueError("not JSON")
|
||||
else:
|
||||
response.json.return_value = payload
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def apiClient(monkeypatch):
|
||||
transport = SimpleNamespace(
|
||||
post=AsyncMock(),
|
||||
request=AsyncMock(),
|
||||
aclose=AsyncMock(),
|
||||
)
|
||||
clientFactory = MagicMock(return_value=transport)
|
||||
monkeypatch.setattr(apiClientModule.httpx, "AsyncClient", clientFactory)
|
||||
monkeypatch.setenv("API_URL", "http://api.test/")
|
||||
monkeypatch.setenv("BOT_API_KEY", "service-secret")
|
||||
monkeypatch.setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
|
||||
client = apiClientModule.ApiClient("123", "Test User")
|
||||
|
||||
clientFactory.assert_called_once_with(timeout=10.0)
|
||||
return client, transport
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_authenticate_stores_session(apiClient):
|
||||
client, transport = apiClient
|
||||
transport.post.return_value = _response(
|
||||
200,
|
||||
{
|
||||
"token": "user-token",
|
||||
"user_uuid": "user-1",
|
||||
"timezone": "America/Chicago",
|
||||
},
|
||||
)
|
||||
|
||||
result, status = await client.authenticate()
|
||||
|
||||
assert status == 200
|
||||
assert result["user_uuid"] == "user-1"
|
||||
assert client.token == "user-token"
|
||||
assert client.user_uuid == "user-1"
|
||||
assert client.timezone == "America/Chicago"
|
||||
transport.post.assert_awaited_once_with(
|
||||
"http://api.test/api/auth/discord/session",
|
||||
headers={"Authorization": "Bearer service-secret"},
|
||||
json={"discord_id": "123", "display_name": "Test User"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_authentication_error_stops_user_request(apiClient):
|
||||
client, transport = apiClient
|
||||
transport.post.side_effect = httpx.ConnectError("offline")
|
||||
|
||||
result, status = await client.authenticate()
|
||||
|
||||
assert (result, status) == ({"error": "API unavailable"}, 503)
|
||||
assert client.token is None
|
||||
|
||||
result, status = await client.request("get", "/api/reminders")
|
||||
|
||||
assert (result, status) == ({"error": "authentication failed"}, 503)
|
||||
transport.request.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_request_sends_user_token_and_payload(apiClient):
|
||||
client, transport = apiClient
|
||||
client.token = "user-token"
|
||||
transport.request.return_value = _response(201, {"id": "item-1"})
|
||||
|
||||
result, status = await client.request(
|
||||
"post",
|
||||
"/api/items",
|
||||
{"name": "example"},
|
||||
params={"source": "test"},
|
||||
)
|
||||
|
||||
assert (result, status) == ({"id": "item-1"}, 201)
|
||||
transport.request.assert_awaited_once_with(
|
||||
"POST",
|
||||
"http://api.test/api/items",
|
||||
headers={"Authorization": "Bearer user-token"},
|
||||
params={"source": "test"},
|
||||
json={"name": "example"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_request_returns_safe_transport_error(apiClient):
|
||||
client, transport = apiClient
|
||||
client.token = "user-token"
|
||||
transport.request.side_effect = httpx.ReadTimeout("timed out")
|
||||
|
||||
assert await client.request("get", "/api/items") == (
|
||||
{"error": "API unavailable"},
|
||||
503,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_reauthenticates_once_after_401(apiClient):
|
||||
client, transport = apiClient
|
||||
client.token = "expired-token"
|
||||
transport.request.side_effect = [
|
||||
_response(401, {"error": "unauthorized"}),
|
||||
_response(200, {"items": [1]}),
|
||||
]
|
||||
transport.post.return_value = _response(
|
||||
200,
|
||||
{"token": "fresh-token", "user_uuid": "user-1", "timezone": "UTC"},
|
||||
)
|
||||
|
||||
result, status = await client.request("get", "/api/items", params={"page": 2})
|
||||
|
||||
assert (result, status) == ({"items": [1]}, 200)
|
||||
assert client.token == "fresh-token"
|
||||
assert transport.request.await_args_list == [
|
||||
call(
|
||||
"GET",
|
||||
"http://api.test/api/items",
|
||||
headers={"Authorization": "Bearer expired-token"},
|
||||
params={"page": 2},
|
||||
),
|
||||
call(
|
||||
"GET",
|
||||
"http://api.test/api/items",
|
||||
headers={"Authorization": "Bearer fresh-token"},
|
||||
params={"page": 2},
|
||||
),
|
||||
]
|
||||
transport.post.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_returns_failed_refresh_result_after_401(apiClient):
|
||||
client, transport = apiClient
|
||||
client.token = "expired-token"
|
||||
transport.request.return_value = _response(401, {"error": "unauthorized"})
|
||||
transport.post.return_value = _response(403, {"error": "not enrolled"})
|
||||
|
||||
assert await client.request("get", "/api/items") == (
|
||||
{"error": "not enrolled"},
|
||||
403,
|
||||
)
|
||||
transport.request.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_service_request_uses_service_key(apiClient):
|
||||
client, transport = apiClient
|
||||
transport.request.return_value = _response(200, {"messages": []})
|
||||
|
||||
result, status = await client.service_request(
|
||||
"post",
|
||||
"/api/internal/outbox/claim",
|
||||
{"worker_id": "worker-1"},
|
||||
)
|
||||
|
||||
assert (result, status) == ({"messages": []}, 200)
|
||||
transport.request.assert_awaited_once_with(
|
||||
"POST",
|
||||
"http://api.test/api/internal/outbox/claim",
|
||||
headers={"Authorization": "Bearer service-secret"},
|
||||
params=None,
|
||||
json={"worker_id": "worker-1"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_handles_non_json_response_and_closes(apiClient):
|
||||
client, transport = apiClient
|
||||
transport.request.return_value = _response(502, jsonError=True)
|
||||
|
||||
assert await client.service_request("get", "/bad-response") == ({}, 502)
|
||||
|
||||
await client.close()
|
||||
transport.aclose.assert_awaited_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_context_exposes_platform_neutral_values_and_replies():
|
||||
channel = SimpleNamespace(send=AsyncMock(return_value="sent-message"))
|
||||
message = SimpleNamespace(author=SimpleNamespace(id=987), channel=channel)
|
||||
api = SimpleNamespace(user_uuid="user-1", timezone="America/Chicago")
|
||||
|
||||
context = CommandContext(message, api)
|
||||
|
||||
assert context.api is api
|
||||
assert context.user_uuid == "user-1"
|
||||
assert context.discord_user_id == "987"
|
||||
assert context.timezone == "America/Chicago"
|
||||
assert await context.reply("hello") == "sent-message"
|
||||
channel.send.assert_awaited_once_with("hello")
|
||||
|
||||
|
||||
def test_scheduler_fails_job_with_unknown_type(monkeypatch):
|
||||
getHandler = MagicMock(return_value=None)
|
||||
failJob = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
daemon,
|
||||
"module_registry",
|
||||
SimpleNamespace(get_job_handler=getHandler),
|
||||
)
|
||||
monkeypatch.setattr(daemon.jobs, "fail_job", failJob)
|
||||
|
||||
daemon.runJob({"id": "job-1", "job_type": "missing"})
|
||||
|
||||
getHandler.assert_called_once_with("missing")
|
||||
failJob.assert_called_once_with(
|
||||
"job-1",
|
||||
daemon.WORKER_ID,
|
||||
"unknown job type: missing",
|
||||
)
|
||||
|
||||
|
||||
def test_scheduler_runs_handler_and_completes_running_job(monkeypatch):
|
||||
job = {"id": "job-1", "job_type": "example"}
|
||||
handler = MagicMock(return_value=None)
|
||||
completeJob = MagicMock()
|
||||
failJob = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
daemon,
|
||||
"module_registry",
|
||||
SimpleNamespace(get_job_handler=MagicMock(return_value=handler)),
|
||||
)
|
||||
monkeypatch.setattr(daemon.jobs, "get_job", MagicMock(return_value={"status": "running"}))
|
||||
monkeypatch.setattr(daemon.jobs, "complete_job", completeJob)
|
||||
monkeypatch.setattr(daemon.jobs, "fail_job", failJob)
|
||||
|
||||
daemon.runJob(job)
|
||||
|
||||
handler.assert_called_once_with(job, daemon.WORKER_ID)
|
||||
completeJob.assert_called_once_with("job-1", daemon.WORKER_ID)
|
||||
failJob.assert_not_called()
|
||||
|
||||
|
||||
def test_scheduler_awaits_handler_without_double_completion(monkeypatch):
|
||||
job = {"id": "job-1", "job_type": "async-example"}
|
||||
handler = AsyncMock(return_value=None)
|
||||
completeJob = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
daemon,
|
||||
"module_registry",
|
||||
SimpleNamespace(get_job_handler=MagicMock(return_value=handler)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.jobs,
|
||||
"get_job",
|
||||
MagicMock(return_value={"status": "completed"}),
|
||||
)
|
||||
monkeypatch.setattr(daemon.jobs, "complete_job", completeJob)
|
||||
|
||||
daemon.runJob(job)
|
||||
|
||||
handler.assert_awaited_once_with(job, daemon.WORKER_ID)
|
||||
completeJob.assert_not_called()
|
||||
|
||||
|
||||
def test_scheduler_records_handler_failure(monkeypatch):
|
||||
job = {"id": "job-1", "job_type": "broken"}
|
||||
failure = RuntimeError("handler failed")
|
||||
handler = MagicMock(side_effect=failure)
|
||||
failJob = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
daemon,
|
||||
"module_registry",
|
||||
SimpleNamespace(get_job_handler=MagicMock(return_value=handler)),
|
||||
)
|
||||
monkeypatch.setattr(daemon.jobs, "fail_job", failJob)
|
||||
monkeypatch.setattr(daemon.logger, "exception", MagicMock())
|
||||
|
||||
daemon.runJob(job)
|
||||
|
||||
failJob.assert_called_once_with("job-1", daemon.WORKER_ID, failure)
|
||||
|
||||
|
||||
def test_scheduler_poll_claims_configured_batch_and_runs_each_job(monkeypatch):
|
||||
claimed = [
|
||||
{"id": "job-1", "job_type": "one"},
|
||||
{"id": "job-2", "job_type": "two"},
|
||||
]
|
||||
claimJobs = MagicMock(return_value=claimed)
|
||||
runJob = MagicMock()
|
||||
monkeypatch.setattr(daemon.jobs, "claim_due_jobs", claimJobs)
|
||||
monkeypatch.setattr(daemon, "runJob", runJob)
|
||||
|
||||
assert daemon.pollJobs() == 2
|
||||
claimJobs.assert_called_once_with(
|
||||
daemon.WORKER_ID,
|
||||
limit=daemon.JOB_BATCH_SIZE,
|
||||
lease_seconds=daemon.JOB_LEASE_SECONDS,
|
||||
)
|
||||
assert runJob.call_args_list == [call(claimed[0]), call(claimed[1])]
|
||||
592
tests/unit/test_api_routes.py
Normal file
592
tests/unit/test_api_routes.py
Normal file
@@ -0,0 +1,592 @@
|
||||
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"}
|
||||
262
tests/unit/test_bot_runtime.py
Normal file
262
tests/unit/test_bot_runtime.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""Network-free tests for Discord adapter routing and delivery behavior."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from bot import bot as botModule
|
||||
|
||||
|
||||
class TypingContext:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
|
||||
class FakeChannel:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
async def send(self, content):
|
||||
self.sent.append(content)
|
||||
return content
|
||||
|
||||
def typing(self):
|
||||
return TypingContext()
|
||||
|
||||
|
||||
def _message(content="hello", authorID=123, channel=None):
|
||||
author = SimpleNamespace(
|
||||
id=authorID,
|
||||
display_name="Alice",
|
||||
__str__=lambda self: "Alice",
|
||||
)
|
||||
return SimpleNamespace(
|
||||
content=content,
|
||||
author=author,
|
||||
channel=channel or FakeChannel(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanBotCaches():
|
||||
botModule.api_clients.clear()
|
||||
botModule.message_history.clear()
|
||||
botModule.user_locks.clear()
|
||||
yield
|
||||
botModule.api_clients.clear()
|
||||
botModule.message_history.clear()
|
||||
botModule.user_locks.clear()
|
||||
|
||||
|
||||
def test_api_clients_are_cached_per_stable_discord_id(monkeypatch):
|
||||
created = []
|
||||
|
||||
def clientFactory(discordID, displayName):
|
||||
client = SimpleNamespace(discord_id=str(discordID), display_name=displayName)
|
||||
created.append(client)
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(botModule, "ApiClient", clientFactory)
|
||||
first = botModule.getApiClient(_message(authorID=123))
|
||||
second = botModule.getApiClient(_message(authorID=123))
|
||||
third = botModule.getApiClient(_message(authorID=456))
|
||||
assert first is second and third is not first
|
||||
assert len(created) == 2 and first.display_name == "Alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_message_uses_registered_module_help(monkeypatch):
|
||||
message = _message("help")
|
||||
monkeypatch.setattr(
|
||||
botModule.module_registry,
|
||||
"help_lines",
|
||||
MagicMock(return_value=["- remind me", "- list reminders"]),
|
||||
)
|
||||
await botModule.sendHelpMessage(message)
|
||||
assert "remind me" in message.channel.sent[0]
|
||||
assert "Just talk naturally" in message.channel.sent[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authentication_reuses_token_and_maps_api_failures(monkeypatch):
|
||||
message = _message()
|
||||
existing = SimpleNamespace(token="already-authenticated")
|
||||
monkeypatch.setattr(botModule, "getApiClient", lambda _message: existing)
|
||||
assert await botModule.authenticateMessage(message) is existing
|
||||
|
||||
for status, expected in [
|
||||
(403, "not enabled"),
|
||||
(503, "still starting"),
|
||||
(401, "couldn't start"),
|
||||
]:
|
||||
message = _message()
|
||||
client = SimpleNamespace(
|
||||
token=None,
|
||||
authenticate=AsyncMock(return_value=({"error": "denied"}, status)),
|
||||
)
|
||||
monkeypatch.setattr(botModule, "getApiClient", lambda _message, value=client: value)
|
||||
assert await botModule.authenticateMessage(message) is None
|
||||
assert expected in message.channel.sent[0]
|
||||
|
||||
accepted = SimpleNamespace(
|
||||
token=None,
|
||||
authenticate=AsyncMock(return_value=({"token": "new"}, 200)),
|
||||
)
|
||||
monkeypatch.setattr(botModule, "getApiClient", lambda _message: accepted)
|
||||
assert await botModule.authenticateMessage(_message()) is accepted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_routing_handles_help_parser_errors_and_unknowns(monkeypatch):
|
||||
apiClient = SimpleNamespace(timezone="UTC", user_uuid="user-one")
|
||||
helpMessage = _message("help")
|
||||
helpCall = AsyncMock()
|
||||
monkeypatch.setattr(botModule, "sendHelpMessage", helpCall)
|
||||
await botModule.routeCommand(helpMessage, apiClient)
|
||||
helpCall.assert_awaited_once_with(helpMessage)
|
||||
|
||||
parse = AsyncMock(
|
||||
side_effect=[
|
||||
{"needs_clarification": "When should I do that?"},
|
||||
{"error": "provider unavailable"},
|
||||
{"interaction_type": "missing"},
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(botModule.ai_parser, "parse_command_async", parse)
|
||||
monkeypatch.setattr(botModule.module_registry, "get_command", lambda _name: None)
|
||||
|
||||
clarification = _message("do the thing")
|
||||
await botModule.routeCommand(clarification, apiClient)
|
||||
assert clarification.channel.sent == ["When should I do that?"]
|
||||
failed = _message("do the other thing")
|
||||
await botModule.routeCommand(failed, apiClient)
|
||||
assert "provider unavailable" in failed.channel.sent[0]
|
||||
unknown = _message("unknown feature")
|
||||
await botModule.routeCommand(unknown, apiClient)
|
||||
assert unknown.channel.sent == ["Unknown command type: missing"]
|
||||
assert len(botModule.message_history[123]) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_handler_receives_context_and_failure_is_isolated(monkeypatch):
|
||||
apiClient = SimpleNamespace(timezone="UTC", user_uuid="user-one")
|
||||
parse = AsyncMock(return_value={"interaction_type": "sample", "value": 1})
|
||||
handler = AsyncMock()
|
||||
monkeypatch.setattr(botModule.ai_parser, "parse_command_async", parse)
|
||||
monkeypatch.setattr(
|
||||
botModule.module_registry,
|
||||
"get_command",
|
||||
lambda _name: {"handler": handler},
|
||||
)
|
||||
message = _message("run sample")
|
||||
await botModule.routeCommand(message, apiClient)
|
||||
context, parsed = handler.await_args.args
|
||||
assert context.user_uuid == "user-one" and parsed["value"] == 1
|
||||
|
||||
handler.side_effect = RuntimeError("module failed")
|
||||
message = _message("run sample again")
|
||||
await botModule.routeCommand(message, apiClient)
|
||||
assert "failed unexpectedly" in message.channel.sent[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_filters_and_serializes_dm_work(monkeypatch):
|
||||
class FakeDMChannel(FakeChannel):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(botModule.discord, "DMChannel", FakeDMChannel)
|
||||
botUser = SimpleNamespace(id=999)
|
||||
monkeypatch.setattr(botModule, "client", SimpleNamespace(user=botUser))
|
||||
authenticate = AsyncMock(return_value=SimpleNamespace())
|
||||
route = AsyncMock()
|
||||
monkeypatch.setattr(botModule, "authenticateMessage", authenticate)
|
||||
monkeypatch.setattr(botModule, "routeCommand", route)
|
||||
|
||||
ownMessage = _message(channel=FakeDMChannel())
|
||||
ownMessage.author = botUser
|
||||
await botModule.on_message(ownMessage)
|
||||
await botModule.on_message(_message(channel=FakeChannel()))
|
||||
authenticate.assert_not_awaited()
|
||||
|
||||
dm = _message(channel=FakeDMChannel())
|
||||
await botModule.on_message(dm)
|
||||
authenticate.assert_awaited_once_with(dm)
|
||||
route.assert_awaited_once()
|
||||
assert dm.author.id in botModule.user_locks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_delivery_reports_success_and_retry(monkeypatch):
|
||||
sent = SimpleNamespace(id=987)
|
||||
discordUser = SimpleNamespace(send=AsyncMock(return_value=sent))
|
||||
client = SimpleNamespace(fetch_user=AsyncMock(return_value=discordUser))
|
||||
service = SimpleNamespace(service_request=AsyncMock(return_value=({}, 200)))
|
||||
monkeypatch.setattr(botModule, "client", client)
|
||||
monkeypatch.setattr(botModule, "service_client", service)
|
||||
outbound = {
|
||||
"id": "message-one",
|
||||
"provider_user_id": "123",
|
||||
"content": "hello",
|
||||
"worker_id": "worker-one",
|
||||
}
|
||||
await botModule.deliverOutboundMessage(outbound)
|
||||
payload = service.service_request.await_args.args[2]
|
||||
assert payload == {
|
||||
"status": "sent",
|
||||
"external_message_id": "987",
|
||||
"worker_id": "worker-one",
|
||||
}
|
||||
|
||||
client.fetch_user.side_effect = RuntimeError("Discord offline")
|
||||
service.service_request.reset_mock()
|
||||
await botModule.deliverOutboundMessage(outbound)
|
||||
payload = service.service_request.await_args.args[2]
|
||||
assert payload["status"] == "retry"
|
||||
assert payload["worker_id"] == "worker-one"
|
||||
assert "Discord offline" in payload["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ready_starts_outbox_loop_once(monkeypatch):
|
||||
loop = SimpleNamespace(is_running=MagicMock(side_effect=[False, True]), start=MagicMock())
|
||||
monkeypatch.setattr(botModule, "outboxLoop", loop)
|
||||
monkeypatch.setattr(botModule, "client", SimpleNamespace(user="bot-user"))
|
||||
await botModule.on_ready()
|
||||
await botModule.on_ready()
|
||||
loop.start.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbox_poll_handles_api_failure_and_delivers_batch(monkeypatch):
|
||||
outbound = {"id": "message-one"}
|
||||
service = SimpleNamespace(
|
||||
service_request=AsyncMock(
|
||||
side_effect=[
|
||||
({"error": "offline"}, 503),
|
||||
({"messages": [outbound]}, 200),
|
||||
]
|
||||
)
|
||||
)
|
||||
deliver = AsyncMock()
|
||||
monkeypatch.setattr(botModule, "service_client", service)
|
||||
monkeypatch.setattr(botModule, "deliverOutboundMessage", deliver)
|
||||
|
||||
await botModule.outboxLoop.coro()
|
||||
deliver.assert_not_awaited()
|
||||
await botModule.outboxLoop.coro()
|
||||
deliver.assert_awaited_once_with(outbound)
|
||||
request = service.service_request.await_args.args
|
||||
assert request[0:2] == ("post", "/api/internal/outbox/claim")
|
||||
assert request[2]["worker_id"] == botModule.OUTBOX_WORKER_ID
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbox_before_loop_waits_for_discord_ready(monkeypatch):
|
||||
wait = AsyncMock()
|
||||
monkeypatch.setattr(botModule, "client", SimpleNamespace(wait_until_ready=wait))
|
||||
await botModule.beforeOutboxLoop()
|
||||
wait.assert_awaited_once()
|
||||
290
tests/unit/test_durable_queues.py
Normal file
290
tests/unit/test_durable_queues.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Focused unit coverage for durable job and outbound-message state changes."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import jobs, outbox
|
||||
|
||||
|
||||
NOW = datetime.now(timezone.utc)
|
||||
MESSAGE_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
class RecordingCursor:
|
||||
def __init__(self, one=None, many=None):
|
||||
self.one = list(one or [])
|
||||
self.many = list(many or [])
|
||||
self.executed = []
|
||||
|
||||
def execute(self, query, params=None):
|
||||
self.executed.append((" ".join(query.split()), params))
|
||||
|
||||
def fetchone(self):
|
||||
return self.one.pop(0) if self.one else None
|
||||
|
||||
def fetchall(self):
|
||||
return self.many
|
||||
|
||||
|
||||
@pytest.mark.parametrize("queue", [jobs, outbox])
|
||||
def test_shared_timestamp_and_positive_validation(queue):
|
||||
assert queue._timestamp("2026-01-02T03:04:05Z") == datetime(
|
||||
2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc
|
||||
)
|
||||
assert queue._timestamp(datetime(2026, 1, 2, 3, 4, 5)).tzinfo == timezone.utc
|
||||
|
||||
with pytest.raises(ValueError, match="datetime"):
|
||||
queue._timestamp(123)
|
||||
with pytest.raises(ValueError, match="whole number"):
|
||||
queue._positive(None, "limit")
|
||||
with pytest.raises(ValueError, match="at least 1"):
|
||||
queue._positive(0, "limit")
|
||||
with pytest.raises(ValueError, match="at most 2"):
|
||||
queue._positive(3, "limit", 2)
|
||||
|
||||
|
||||
def test_create_get_and_list_jobs_with_parameterized_filters():
|
||||
created = {"id": "job-one", "status": "pending"}
|
||||
cursor = RecordingCursor(one=[created])
|
||||
result = jobs.create_job(
|
||||
" sample.work ",
|
||||
{"value": 1},
|
||||
NOW,
|
||||
user_uuid="user-one",
|
||||
max_attempts="4",
|
||||
idempotency_key="unique-work",
|
||||
job_id="job-one",
|
||||
cursor=cursor,
|
||||
)
|
||||
assert result == created
|
||||
params = cursor.executed[0][1]
|
||||
assert params["job_type"] == "sample.work"
|
||||
assert params["max_attempts"] == 4
|
||||
assert params["payload"].adapted == {"value": 1}
|
||||
|
||||
with pytest.raises(ValueError, match="job_type"):
|
||||
jobs.create_job(" ", {}, NOW, cursor=cursor)
|
||||
|
||||
cursor = RecordingCursor(one=[created])
|
||||
assert jobs.get_job("job-one", cursor=cursor) == created
|
||||
|
||||
cursor = RecordingCursor(many=[created])
|
||||
assert jobs.list_jobs(
|
||||
user_uuid="user-one",
|
||||
status="pending",
|
||||
job_type="sample.work",
|
||||
limit=3,
|
||||
cursor=cursor,
|
||||
) == [created]
|
||||
query, params = cursor.executed[0]
|
||||
assert "user_uuid = %s" in query and "job_type = %s" in query
|
||||
assert params == ["user-one", "pending", "sample.work", 3]
|
||||
|
||||
|
||||
def test_job_claim_renew_complete_retry_and_cancel_paths():
|
||||
claimed = [{"id": "job-one", "status": "running"}]
|
||||
cursor = RecordingCursor(many=claimed)
|
||||
assert jobs.claim_due_jobs(
|
||||
"worker-one",
|
||||
limit=2,
|
||||
lease_seconds=60,
|
||||
job_types="sample.work",
|
||||
cursor=cursor,
|
||||
) == claimed
|
||||
assert len(cursor.executed) == 2
|
||||
assert cursor.executed[1][1]["job_types"] == ["sample.work"]
|
||||
assert jobs.claim_due_jobs("worker", job_types=[], cursor=cursor) == []
|
||||
with pytest.raises(ValueError, match="worker_id"):
|
||||
jobs.claim_due_jobs("", cursor=cursor)
|
||||
|
||||
updated = {"id": "job-one", "status": "running"}
|
||||
cursor = RecordingCursor(one=[updated, {**updated, "status": "completed"}])
|
||||
assert jobs.renew_job_lease("job-one", "worker-one", 30, cursor=cursor) == updated
|
||||
assert jobs.complete_job("job-one", "worker-one", cursor=cursor)[
|
||||
"status"
|
||||
] == "completed"
|
||||
|
||||
cursor = RecordingCursor(
|
||||
one=[
|
||||
{"attempts": 2, "max_attempts": 3},
|
||||
{"id": "job-one", "status": "pending"},
|
||||
]
|
||||
)
|
||||
retried = jobs.fail_job(
|
||||
"job-one", "worker-one", "temporary", retry_seconds=10, cursor=cursor
|
||||
)
|
||||
assert retried["status"] == "pending"
|
||||
assert cursor.executed[1][1]["delay"] == 20
|
||||
assert cursor.executed[1][1]["exhausted"] is False
|
||||
|
||||
cursor = RecordingCursor(
|
||||
one=[
|
||||
{"attempts": 3, "max_attempts": 3},
|
||||
{"id": "job-one", "status": "failed"},
|
||||
]
|
||||
)
|
||||
assert jobs.fail_job("job-one", "worker-one", "fatal", cursor=cursor)[
|
||||
"status"
|
||||
] == "failed"
|
||||
assert cursor.executed[1][1]["exhausted"] is True
|
||||
assert jobs.fail_job(
|
||||
"missing", "worker-one", "ignored", cursor=RecordingCursor()
|
||||
) is None
|
||||
|
||||
cursor = RecordingCursor(one=[{"id": "job-one", "status": "cancelled"}])
|
||||
assert jobs.cancel_job("job-one", user_uuid="user-one", cursor=cursor)[
|
||||
"status"
|
||||
] == "cancelled"
|
||||
cursor = RecordingCursor(many=[{"id": "job-two"}])
|
||||
assert jobs.cancel_jobs(job_type="sample.work", cursor=cursor) == [
|
||||
{"id": "job-two"}
|
||||
]
|
||||
with pytest.raises(ValueError, match="filter"):
|
||||
jobs.cancel_jobs(cursor=cursor)
|
||||
|
||||
|
||||
def test_enqueue_get_and_list_messages_with_parameterized_filters():
|
||||
created = {"id": "message-one", "status": "pending"}
|
||||
cursor = RecordingCursor(one=[created])
|
||||
result = outbox.enqueue_message(
|
||||
"user-one",
|
||||
" discord_dm ",
|
||||
{"content": "hello"},
|
||||
"unique-message",
|
||||
available_at=NOW,
|
||||
max_attempts=4,
|
||||
message_id="message-one",
|
||||
cursor=cursor,
|
||||
)
|
||||
assert result == created
|
||||
params = cursor.executed[0][1]
|
||||
assert params["channel"] == "discord_dm"
|
||||
assert params["payload"].adapted == {"content": "hello"}
|
||||
|
||||
invalidValues = [
|
||||
(None, "discord_dm", {}, "key", "user_uuid"),
|
||||
("user", " ", {}, "key", "channel"),
|
||||
("user", "discord_dm", {}, None, "idempotency_key"),
|
||||
("user", "discord_dm", None, "key", "payload"),
|
||||
]
|
||||
for userUUID, channel, payload, key, error in invalidValues:
|
||||
with pytest.raises(ValueError, match=error):
|
||||
outbox.enqueue_message(
|
||||
userUUID, channel, payload, key, available_at=NOW, cursor=cursor
|
||||
)
|
||||
|
||||
cursor = RecordingCursor(one=[created])
|
||||
assert outbox.get_message(MESSAGE_ID, cursor=cursor) == created
|
||||
assert outbox.get_message("not-a-uuid", cursor=cursor) is None
|
||||
cursor = RecordingCursor(many=[created])
|
||||
assert outbox.list_messages(
|
||||
user_uuid="user-one",
|
||||
status="pending",
|
||||
channel="discord_dm",
|
||||
limit=2,
|
||||
cursor=cursor,
|
||||
) == [created]
|
||||
|
||||
|
||||
def test_outbox_claim_renew_delivery_retry_and_cancel_paths():
|
||||
claimed = [{"id": "message-one", "status": "delivering"}]
|
||||
cursor = RecordingCursor(many=claimed)
|
||||
assert outbox.claim_messages(
|
||||
"worker-one",
|
||||
channel="discord_dm",
|
||||
limit=2,
|
||||
lease_seconds=60,
|
||||
cursor=cursor,
|
||||
) == claimed
|
||||
assert len(cursor.executed) == 2
|
||||
with pytest.raises(ValueError, match="worker_id"):
|
||||
outbox.claim_messages(None, cursor=cursor)
|
||||
|
||||
cursor = RecordingCursor(
|
||||
one=[
|
||||
{"id": "message-one", "status": "delivering"},
|
||||
{"id": "message-one", "status": "delivered"},
|
||||
]
|
||||
)
|
||||
assert outbox.renew_message_lease(
|
||||
"message-one", "worker-one", 60, cursor=cursor
|
||||
)["status"] == "delivering"
|
||||
delivered = outbox.mark_delivered(
|
||||
"message-one",
|
||||
"worker-one",
|
||||
external_message_id="discord-123",
|
||||
cursor=cursor,
|
||||
)
|
||||
assert delivered["status"] == "delivered"
|
||||
assert cursor.executed[1][1][0] == "discord-123"
|
||||
|
||||
cursor = RecordingCursor(
|
||||
one=[
|
||||
{"attempts": 1, "max_attempts": 3},
|
||||
{"id": "message-one", "status": "pending"},
|
||||
]
|
||||
)
|
||||
retried = outbox.retry_message(
|
||||
"message-one", "worker-one", "temporary", retry_seconds=15, cursor=cursor
|
||||
)
|
||||
assert retried["status"] == "pending"
|
||||
assert cursor.executed[1][1]["delay"] == 15
|
||||
|
||||
cursor = RecordingCursor(
|
||||
one=[
|
||||
{"attempts": 3, "max_attempts": 3},
|
||||
{"id": "message-one", "status": "failed"},
|
||||
]
|
||||
)
|
||||
assert outbox.retry_message(
|
||||
"message-one", "worker-one", "fatal", cursor=cursor
|
||||
)["status"] == "failed"
|
||||
assert outbox.retry_message(
|
||||
"missing", "worker-one", "ignored", cursor=RecordingCursor()
|
||||
) is None
|
||||
|
||||
cursor = RecordingCursor(one=[{"id": "message-one", "status": "cancelled"}])
|
||||
assert outbox.cancel_message(
|
||||
"message-one", user_uuid="user-one", cursor=cursor
|
||||
)["status"] == "cancelled"
|
||||
cursor = RecordingCursor(many=[{"id": "message-two"}])
|
||||
assert outbox.cancel_messages(channel="discord_dm", cursor=cursor) == [
|
||||
{"id": "message-two"}
|
||||
]
|
||||
with pytest.raises(ValueError, match="filter"):
|
||||
outbox.cancel_messages(cursor=cursor)
|
||||
|
||||
|
||||
def test_retry_backoff_is_capped():
|
||||
cursor = RecordingCursor(
|
||||
one=[
|
||||
{"attempts": 20, "max_attempts": 30},
|
||||
{"id": "job", "status": "pending"},
|
||||
]
|
||||
)
|
||||
jobs.fail_job(
|
||||
"job",
|
||||
"worker",
|
||||
"retry",
|
||||
retry_seconds=30,
|
||||
max_retry_seconds=90,
|
||||
cursor=cursor,
|
||||
)
|
||||
assert cursor.executed[1][1]["delay"] == 90
|
||||
|
||||
cursor = RecordingCursor(
|
||||
one=[
|
||||
{"attempts": 20, "max_attempts": 30},
|
||||
{"id": "message", "status": "pending"},
|
||||
]
|
||||
)
|
||||
outbox.retry_message(
|
||||
"message",
|
||||
"worker",
|
||||
"retry",
|
||||
retry_seconds=30,
|
||||
max_retry_seconds=90,
|
||||
cursor=cursor,
|
||||
)
|
||||
assert cursor.executed[1][1]["delay"] == 90
|
||||
170
tests/unit/test_migration_engine.py
Normal file
170
tests/unit/test_migration_engine.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Unit tests for migration discovery and history decisions."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
|
||||
from core import migrations
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def migrationRoot():
|
||||
with TemporaryDirectory(prefix=".migration-test-", dir=Path.cwd()) as directory:
|
||||
yield Path(directory)
|
||||
|
||||
|
||||
def _writeMigration(root, relativePath, content="SELECT 1;"):
|
||||
path = root / relativePath
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, applied=None, historyTable=True):
|
||||
self.applied = list(applied or [])
|
||||
self.historyTable = historyTable
|
||||
self.executed = []
|
||||
self._one = None
|
||||
self._all = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def execute(self, query, params=None):
|
||||
normalized = " ".join(query.split())
|
||||
self.executed.append((normalized, params))
|
||||
if "SELECT to_regclass" in normalized:
|
||||
self._one = {
|
||||
"table_name": "schema_migrations" if self.historyTable else None
|
||||
}
|
||||
elif normalized.startswith("SELECT namespace, version, checksum"):
|
||||
self._all = self.applied
|
||||
|
||||
def fetchone(self):
|
||||
return self._one
|
||||
|
||||
def fetchall(self):
|
||||
return self._all
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, cursor):
|
||||
self.activeCursor = cursor
|
||||
|
||||
def cursor(self, **_kwargs):
|
||||
return self.activeCursor
|
||||
|
||||
|
||||
def _connectionFor(cursor):
|
||||
@contextmanager
|
||||
def fakeConnection():
|
||||
yield FakeConnection(cursor)
|
||||
|
||||
return fakeConnection
|
||||
|
||||
|
||||
def test_discovery_orders_core_before_feature_namespaces(migrationRoot):
|
||||
_writeMigration(migrationRoot, "config/migrations/0002_second.sql", "SELECT 2;")
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 1;")
|
||||
_writeMigration(migrationRoot, "modules/zeta/migrations/0001_zeta.sql")
|
||||
_writeMigration(migrationRoot, "modules/alpha/migrations/0002_alpha.sql")
|
||||
|
||||
found = migrations.discover_migrations(migrationRoot)
|
||||
|
||||
assert [(item.namespace, item.version) for item in found] == [
|
||||
("core", 1),
|
||||
("core", 2),
|
||||
("alpha", 2),
|
||||
("zeta", 1),
|
||||
]
|
||||
assert len(found[0].checksum) == 64
|
||||
assert found[0].path == Path(
|
||||
migrationRoot, "config/migrations/0001_first.sql"
|
||||
)
|
||||
|
||||
|
||||
def test_discovery_rejects_bad_names_and_duplicate_versions(migrationRoot):
|
||||
_writeMigration(migrationRoot, "config/migrations/not-numbered.sql")
|
||||
with pytest.raises(migrations.MigrationError, match="Invalid migration filename"):
|
||||
migrations.discover_migrations(migrationRoot)
|
||||
|
||||
Path(migrationRoot, "config/migrations/not-numbered.sql").unlink()
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_first.sql")
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_duplicate.sql")
|
||||
with pytest.raises(migrations.MigrationError, match="Duplicate migration"):
|
||||
migrations.discover_migrations(migrationRoot)
|
||||
|
||||
|
||||
def test_upgrade_applies_pending_and_skips_matching_history(
|
||||
migrationRoot, monkeypatch
|
||||
):
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 42;")
|
||||
migration = migrations.discover_migrations(migrationRoot)[0]
|
||||
cursor = FakeCursor()
|
||||
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
|
||||
|
||||
assert migrations.upgrade(migrationRoot) == [migration]
|
||||
queries = [query for query, _params in cursor.executed]
|
||||
assert any("pg_advisory_xact_lock" in query for query in queries)
|
||||
assert "SELECT 42;" in queries
|
||||
assert any(query.startswith("INSERT INTO schema_migrations") for query in queries)
|
||||
|
||||
cursor = FakeCursor(
|
||||
applied=[
|
||||
{
|
||||
"namespace": "core",
|
||||
"version": 1,
|
||||
"checksum": migration.checksum,
|
||||
"applied_at": None,
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
|
||||
assert migrations.upgrade(migrationRoot) == []
|
||||
assert "SELECT 42;" not in [query for query, _params in cursor.executed]
|
||||
|
||||
|
||||
def test_upgrade_rejects_changed_applied_migration(migrationRoot, monkeypatch):
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 1;")
|
||||
cursor = FakeCursor(
|
||||
applied=[
|
||||
{
|
||||
"namespace": "core",
|
||||
"version": 1,
|
||||
"checksum": "0" * 64,
|
||||
"applied_at": None,
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
|
||||
|
||||
with pytest.raises(migrations.MigrationError, match="checksum changed"):
|
||||
migrations.upgrade(migrationRoot)
|
||||
|
||||
|
||||
def test_status_handles_new_database_and_missing_source(migrationRoot, monkeypatch):
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 1;")
|
||||
cursor = FakeCursor(historyTable=False)
|
||||
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
|
||||
assert migrations.migration_status(migrationRoot)[0]["state"] == "pending"
|
||||
|
||||
cursor = FakeCursor(
|
||||
applied=[
|
||||
{
|
||||
"namespace": "removed_feature",
|
||||
"version": 3,
|
||||
"checksum": "a" * 64,
|
||||
"applied_at": "earlier",
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
|
||||
status = migrations.migration_status(migrationRoot)
|
||||
assert [item["state"] for item in status] == ["pending", "missing"]
|
||||
assert status[1]["namespace"] == "removed_feature"
|
||||
223
tests/unit/test_notifications.py
Normal file
223
tests/unit/test_notifications.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
import pytest
|
||||
|
||||
from core import notifications
|
||||
|
||||
|
||||
def test_get_notification_settings_returns_record_or_false(monkeypatch):
|
||||
selectOne = MagicMock(
|
||||
side_effect=[{"user_uuid": "user-1", "ntfy_enabled": True}, None]
|
||||
)
|
||||
monkeypatch.setattr(notifications.postgres, "select_one", selectOne)
|
||||
|
||||
assert notifications.getNotificationSettings("user-1") == {
|
||||
"user_uuid": "user-1",
|
||||
"ntfy_enabled": True,
|
||||
}
|
||||
assert notifications.getNotificationSettings("user-2") is False
|
||||
assert selectOne.call_args_list == [
|
||||
call("notifications", {"user_uuid": "user-1"}),
|
||||
call("notifications", {"user_uuid": "user-2"}),
|
||||
]
|
||||
|
||||
|
||||
def test_notification_settings_filter_fields_and_update_existing(monkeypatch):
|
||||
update = MagicMock()
|
||||
insert = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
notifications.postgres,
|
||||
"select_one",
|
||||
MagicMock(return_value={"id": "notification-1"}),
|
||||
)
|
||||
monkeypatch.setattr(notifications.postgres, "update", update)
|
||||
monkeypatch.setattr(notifications.postgres, "insert", insert)
|
||||
|
||||
result = notifications.setNotificationSettings(
|
||||
"user-1",
|
||||
{
|
||||
"ntfy_topic": "team-alerts",
|
||||
"ntfy_enabled": True,
|
||||
"user_uuid": "another-user",
|
||||
"created_at": "not-allowed",
|
||||
},
|
||||
)
|
||||
|
||||
assert result is True
|
||||
update.assert_called_once_with(
|
||||
"notifications",
|
||||
{"ntfy_topic": "team-alerts", "ntfy_enabled": True},
|
||||
{"user_uuid": "user-1"},
|
||||
)
|
||||
insert.assert_not_called()
|
||||
|
||||
|
||||
def test_notification_settings_insert_new_record(monkeypatch):
|
||||
insert = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
notifications.postgres,
|
||||
"select_one",
|
||||
MagicMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(notifications.postgres, "insert", insert)
|
||||
monkeypatch.setattr(notifications.uuid, "uuid4", lambda: "notification-1")
|
||||
|
||||
result = notifications.setNotificationSettings(
|
||||
"user-1",
|
||||
{"discord_enabled": False, "ntfy_topic": "personal"},
|
||||
)
|
||||
|
||||
assert result is True
|
||||
insert.assert_called_once_with(
|
||||
"notifications",
|
||||
{
|
||||
"discord_enabled": False,
|
||||
"ntfy_topic": "personal",
|
||||
"id": "notification-1",
|
||||
"user_uuid": "user-1",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"settings",
|
||||
[None, [], "invalid", {}, {"created_at": "not-allowed"}],
|
||||
)
|
||||
def test_notification_settings_reject_invalid_or_empty_updates(monkeypatch, settings):
|
||||
selectOne = MagicMock()
|
||||
monkeypatch.setattr(notifications.postgres, "select_one", selectOne)
|
||||
|
||||
assert notifications.setNotificationSettings("user-1", settings) is False
|
||||
selectOne.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"webhook",
|
||||
[
|
||||
"https://discord.com/api/webhooks/123/token",
|
||||
"https://canary.discord.com/api/webhooks/123/token",
|
||||
"https://ptb.discord.com/api/webhooks/123/token",
|
||||
],
|
||||
)
|
||||
def test_discord_webhook_validation_accepts_official_https_urls(webhook):
|
||||
assert notifications._validateDiscordWebhook(webhook) == webhook
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("webhook", "error"),
|
||||
[
|
||||
(
|
||||
"http://discord.com/api/webhooks/123/token",
|
||||
"official HTTPS Discord host",
|
||||
),
|
||||
(
|
||||
"https://discord.com.evil.example/api/webhooks/123/token",
|
||||
"official HTTPS Discord host",
|
||||
),
|
||||
("https://discord.com/channels/123", "Invalid Discord webhook path"),
|
||||
],
|
||||
)
|
||||
def test_discord_webhook_validation_rejects_unsafe_urls(webhook, error):
|
||||
with pytest.raises(ValueError, match=error):
|
||||
notifications._validateDiscordWebhook(webhook)
|
||||
|
||||
|
||||
def test_discord_webhook_delivery_posts_content(monkeypatch):
|
||||
post = MagicMock(return_value=MagicMock(status_code=204))
|
||||
monkeypatch.setattr(notifications.requests, "post", post)
|
||||
webhook = "https://discord.com/api/webhooks/123/token"
|
||||
|
||||
assert notifications.discord.send(webhook, 42) is True
|
||||
post.assert_called_once_with(
|
||||
webhook,
|
||||
json={"content": "42"},
|
||||
timeout=notifications.REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [429, notifications.requests.ConnectionError("offline")])
|
||||
def test_discord_webhook_delivery_reports_failures(monkeypatch, failure):
|
||||
post = MagicMock()
|
||||
if isinstance(failure, int):
|
||||
post.return_value = MagicMock(status_code=failure)
|
||||
else:
|
||||
post.side_effect = failure
|
||||
monkeypatch.setattr(notifications.requests, "post", post)
|
||||
|
||||
assert notifications.discord.send(
|
||||
"https://discord.com/api/webhooks/123/token",
|
||||
"hello",
|
||||
) is False
|
||||
|
||||
|
||||
def test_ntfy_encodes_topic_and_sends_bearer_token(monkeypatch):
|
||||
post = MagicMock(return_value=MagicMock(status_code=201))
|
||||
monkeypatch.setattr(notifications.requests, "post", post)
|
||||
monkeypatch.setenv("NTFY_BASE_URL", "https://notify.example/base/")
|
||||
monkeypatch.setenv("NTFY_TOKEN", "ntfy-secret")
|
||||
|
||||
assert notifications.ntfy.send(" alerts/team #1 ", 42) is True
|
||||
post.assert_called_once_with(
|
||||
"https://notify.example/base/alerts%2Fteam%20%231",
|
||||
data=b"42",
|
||||
headers={"Authorization": "Bearer ntfy-secret"},
|
||||
timeout=notifications.REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [500, notifications.requests.Timeout("slow")])
|
||||
def test_ntfy_reports_http_and_transport_failures(monkeypatch, failure):
|
||||
post = MagicMock()
|
||||
if isinstance(failure, int):
|
||||
post.return_value = MagicMock(status_code=failure)
|
||||
else:
|
||||
post.side_effect = failure
|
||||
monkeypatch.setattr(notifications.requests, "post", post)
|
||||
monkeypatch.delenv("NTFY_TOKEN", raising=False)
|
||||
|
||||
assert notifications.ntfy.send("alerts", "hello") is False
|
||||
|
||||
|
||||
def test_ntfy_rejects_empty_topic_without_request(monkeypatch):
|
||||
post = MagicMock()
|
||||
monkeypatch.setattr(notifications.requests, "post", post)
|
||||
|
||||
assert notifications.ntfy.send(" ", "hello") is False
|
||||
assert notifications.ntfy.send(None, "hello") is False
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_channel_aggregation_tries_each_enabled_channel(monkeypatch):
|
||||
discordSend = MagicMock(return_value=False)
|
||||
ntfySend = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(notifications.discord, "send", discordSend)
|
||||
monkeypatch.setattr(notifications.ntfy, "send", ntfySend)
|
||||
|
||||
result = notifications._sendToEnabledChannels(
|
||||
{
|
||||
"discord_enabled": True,
|
||||
"discord_webhook": "https://discord.com/api/webhooks/123/token",
|
||||
"ntfy_enabled": True,
|
||||
"ntfy_topic": "alerts",
|
||||
},
|
||||
"hello",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
discordSend.assert_called_once_with(
|
||||
"https://discord.com/api/webhooks/123/token",
|
||||
"hello",
|
||||
)
|
||||
ntfySend.assert_called_once_with("alerts", "hello")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("settings", [None, {}, {"discord_enabled": False}])
|
||||
def test_channel_aggregation_skips_unconfigured_channels(monkeypatch, settings):
|
||||
discordSend = MagicMock()
|
||||
ntfySend = MagicMock()
|
||||
monkeypatch.setattr(notifications.discord, "send", discordSend)
|
||||
monkeypatch.setattr(notifications.ntfy, "send", ntfySend)
|
||||
|
||||
assert notifications._sendToEnabledChannels(settings, "hello") is False
|
||||
discordSend.assert_not_called()
|
||||
ntfySend.assert_not_called()
|
||||
275
tests/unit/test_parser.py
Normal file
275
tests/unit/test_parser.py
Normal file
@@ -0,0 +1,275 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from ai import parser
|
||||
from core.registry import FrameworkRegistry
|
||||
|
||||
|
||||
def _response(content=None, reasoning=None, choices=True):
|
||||
message = SimpleNamespace(content=content, reasoning=reasoning)
|
||||
return SimpleNamespace(choices=[SimpleNamespace(message=message)] if choices else [])
|
||||
|
||||
|
||||
def test_template_rendering_preserves_literal_json_braces():
|
||||
template = (
|
||||
'Schema: {"action":"create","payload":{"items":[1, 2]}}\n'
|
||||
"Input: {user_input}\nUnknown: {leave_this_alone}"
|
||||
)
|
||||
|
||||
rendered = parser._render_template(template, {"user_input": "remind me"})
|
||||
|
||||
assert '{"action":"create","payload":{"items":[1, 2]}}' in rendered
|
||||
assert "Input: remind me" in rendered
|
||||
assert "Unknown: {leave_this_alone}" in rendered
|
||||
|
||||
|
||||
def test_json_extraction_handles_fenced_text_and_nested_values():
|
||||
response = """```json
|
||||
Here is the structured result:
|
||||
{"action":"create","payload":{"items":[{"id":1},{"id":2}]}}
|
||||
```"""
|
||||
|
||||
assert parser._extract_json_from_text(response) == {
|
||||
"action": "create",
|
||||
"payload": {"items": [{"id": 1}, {"id": 2}]},
|
||||
}
|
||||
|
||||
|
||||
def test_parse_retries_invalid_json_and_validator_failures(monkeypatch):
|
||||
responses = iter(
|
||||
[
|
||||
"not JSON",
|
||||
'{"valid":false}',
|
||||
'```json\n{"valid":true,"nested":{"value":7}}\n```',
|
||||
]
|
||||
)
|
||||
prompts = []
|
||||
|
||||
def fake_llm(_system_prompt, user_prompt):
|
||||
prompts.append(user_prompt)
|
||||
return next(responses)
|
||||
|
||||
prompt = {
|
||||
"system": "Return an object",
|
||||
"user_template": (
|
||||
'Literal schema: {"valid":true}\nUser message: {user_input}'
|
||||
),
|
||||
}
|
||||
validator = lambda value: [] if value.get("valid") else ["valid must be true"]
|
||||
monkeypatch.setitem(parser.AI_CONFIG["validation"], "max_retries", 3)
|
||||
monkeypatch.setattr(parser, "_call_llm", fake_llm)
|
||||
|
||||
result = parser.parse(
|
||||
"test input",
|
||||
"focused",
|
||||
prompt_override=prompt,
|
||||
validator=validator,
|
||||
)
|
||||
|
||||
assert result == {"valid": True, "nested": {"value": 7}}
|
||||
assert len(prompts) == 3
|
||||
assert 'Literal schema: {"valid":true}' in prompts[0]
|
||||
assert "Response was not valid JSON" in prompts[1]
|
||||
assert "valid must be true" in prompts[2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_parser_routes_then_uses_focused_module_prompt(monkeypatch):
|
||||
target = FrameworkRegistry()
|
||||
target.begin_module("reminders", "modules.reminders")
|
||||
target.register_command(
|
||||
"reminder",
|
||||
lambda _context, _parsed: None,
|
||||
{
|
||||
"system": "Focused reminder parser",
|
||||
"user_template": (
|
||||
'Timezone: {timezone}\nMessage: {user_input}\n'
|
||||
'Schema: {"action":"create"}'
|
||||
),
|
||||
},
|
||||
validator=lambda value: (
|
||||
[] if value.get("action") == "create" else ["invalid action"]
|
||||
),
|
||||
description="Create and manage reminders",
|
||||
)
|
||||
target.finish_module()
|
||||
|
||||
llm = AsyncMock(
|
||||
side_effect=[
|
||||
'{"interaction_type":"reminder","confidence":0.99}',
|
||||
(
|
||||
'```json\n{"action":"create","message":"call home",'
|
||||
'"payload":{"source":"dm"}}\n```'
|
||||
),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(parser, "_call_llm_async", llm)
|
||||
|
||||
result = await parser.parse_command_async(
|
||||
"remind me to call home",
|
||||
target,
|
||||
history=[("hello", {"interaction_type": "reminder"})],
|
||||
timezone_name="America/Chicago",
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"action": "create",
|
||||
"message": "call home",
|
||||
"payload": {"source": "dm"},
|
||||
"interaction_type": "reminder",
|
||||
}
|
||||
assert llm.await_count == 2
|
||||
route_system, route_user = llm.await_args_list[0].args
|
||||
focused_system, focused_user = llm.await_args_list[1].args
|
||||
assert route_system == parser.AI_CONFIG["prompts"]["command_parser"]["system"]
|
||||
assert "- reminder: Create and manage reminders" in route_user
|
||||
assert focused_system == "Focused reminder parser"
|
||||
assert "Timezone: America/Chicago" in focused_user
|
||||
assert 'Schema: {"action":"create"}' in focused_user
|
||||
|
||||
|
||||
def test_clients_are_created_lazily_and_cached(monkeypatch):
|
||||
syncClient = SimpleNamespace()
|
||||
asyncClient = SimpleNamespace()
|
||||
syncFactory = MagicMock(return_value=syncClient)
|
||||
asyncFactory = MagicMock(return_value=asyncClient)
|
||||
monkeypatch.setattr(parser, "OpenAI", syncFactory)
|
||||
monkeypatch.setattr(parser, "AsyncOpenAI", asyncFactory)
|
||||
monkeypatch.setattr(parser, "_sync_client", None)
|
||||
monkeypatch.setattr(parser, "_async_client", None)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "provider-key")
|
||||
|
||||
assert parser._get_client() is syncClient
|
||||
assert parser._get_client() is syncClient
|
||||
assert parser._get_client(async_client=True) is asyncClient
|
||||
assert parser._get_client(async_client=True) is asyncClient
|
||||
assert syncFactory.call_count == 1
|
||||
assert asyncFactory.call_count == 1
|
||||
|
||||
|
||||
def test_response_text_and_request_arguments(monkeypatch):
|
||||
assert parser._response_text(_response(" result ")) == "result"
|
||||
assert parser._response_text(_response(reasoning=" reason ")) == "reason"
|
||||
assert parser._response_text(_response(choices=False)) is None
|
||||
|
||||
monkeypatch.setitem(parser.AI_CONFIG, "json_mode", True)
|
||||
arguments = parser._request_args("system", "user")
|
||||
assert arguments["messages"] == [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "user"},
|
||||
]
|
||||
assert arguments["response_format"] == {"type": "json_object"}
|
||||
|
||||
|
||||
def test_sync_llm_call_returns_content_and_absorbs_provider_failure(monkeypatch):
|
||||
completions = MagicMock()
|
||||
completions.create.side_effect = [
|
||||
_response('{"ok":true}'),
|
||||
RuntimeError("provider unavailable"),
|
||||
]
|
||||
client = SimpleNamespace(chat=SimpleNamespace(completions=completions))
|
||||
monkeypatch.setattr(parser, "_get_client", lambda **_kwargs: client)
|
||||
|
||||
assert parser._call_llm("system", "user") == '{"ok":true}'
|
||||
assert parser._call_llm("system", "user") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_llm_call_returns_content_and_absorbs_failure(monkeypatch):
|
||||
create = AsyncMock(
|
||||
side_effect=[_response('{"ok":true}'), RuntimeError("offline")]
|
||||
)
|
||||
client = SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
|
||||
)
|
||||
monkeypatch.setattr(parser, "_get_client", lambda **_kwargs: client)
|
||||
|
||||
assert await parser._call_llm_async("system", "user") == '{"ok":true}'
|
||||
assert await parser._call_llm_async("system", "user") is None
|
||||
|
||||
|
||||
def test_parse_reports_unknown_unavailable_and_exhausted_results(monkeypatch):
|
||||
assert parser.parse("hello", "missing") == {
|
||||
"error": "Unknown interaction type: missing"
|
||||
}
|
||||
|
||||
prompt = {"system": "system", "user_template": "{user_input}"}
|
||||
monkeypatch.setattr(parser, "_call_llm", lambda *_args: None)
|
||||
assert parser.parse("hello", "test", prompt_override=prompt) == {
|
||||
"error": "AI service unavailable",
|
||||
"user_input": "hello",
|
||||
}
|
||||
|
||||
monkeypatch.setitem(parser.AI_CONFIG["validation"], "max_retries", 2)
|
||||
monkeypatch.setattr(parser, "_call_llm", lambda *_args: "not json")
|
||||
exhausted = parser.parse("hello", "test", prompt_override=prompt)
|
||||
assert exhausted["error"] == "Failed to parse after 2 attempts"
|
||||
assert exhausted["validation_errors"] == ["Response was not valid JSON"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_parse_unknown_unavailable_and_exhausted(monkeypatch):
|
||||
assert await parser.parse_async("hello", "missing") == {
|
||||
"error": "Unknown interaction type: missing"
|
||||
}
|
||||
prompt = {"system": "system", "user_template": "{user_input}"}
|
||||
call = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(parser, "_call_llm_async", call)
|
||||
assert await parser.parse_async("hello", "test", prompt_override=prompt) == {
|
||||
"error": "AI service unavailable",
|
||||
"user_input": "hello",
|
||||
}
|
||||
|
||||
monkeypatch.setitem(parser.AI_CONFIG["validation"], "max_retries", 1)
|
||||
call.return_value = "[]"
|
||||
exhausted = await parser.parse_async(
|
||||
"hello", "test", prompt_override=prompt
|
||||
)
|
||||
assert exhausted["validation_errors"] == [
|
||||
"Response must be a JSON object"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_router_handles_early_and_ambiguous_results(monkeypatch):
|
||||
target = FrameworkRegistry()
|
||||
target.begin_module("one", "modules.one")
|
||||
target.register_command(
|
||||
"one",
|
||||
lambda *_args: None,
|
||||
{"system": "system", "user_template": "{user_input}"},
|
||||
)
|
||||
target.finish_module()
|
||||
|
||||
call = AsyncMock(return_value={"error": "offline"})
|
||||
monkeypatch.setattr(parser, "parse_async", call)
|
||||
assert await parser.parse_command_async("hello", target) == {
|
||||
"error": "offline"
|
||||
}
|
||||
|
||||
call.return_value = {
|
||||
"needs_clarification": "which one?",
|
||||
"confidence": 0.95,
|
||||
}
|
||||
assert await parser.parse_command_async("hello", target) == {
|
||||
"needs_clarification": "which one?",
|
||||
"confidence": 0.95,
|
||||
}
|
||||
|
||||
call.return_value = {"interaction_type": "one", "confidence": 0.2}
|
||||
ambiguous = await parser.parse_command_async("hello", target)
|
||||
assert "needs_clarification" in ambiguous
|
||||
|
||||
call.return_value = {"interaction_type": "missing", "confidence": 1.0}
|
||||
assert await parser.parse_command_async("hello", target) == {
|
||||
"error": "Unknown command type: missing"
|
||||
}
|
||||
|
||||
|
||||
def test_registered_validator_is_used(monkeypatch):
|
||||
prompt = {"system": "system", "user_template": "{user_input}"}
|
||||
parser.register_validator("registered", lambda value: [] if value["ok"] else ["bad"])
|
||||
monkeypatch.setattr(parser, "_call_llm", lambda *_args: '{"ok":true}')
|
||||
assert parser.parse("hello", "registered", prompt_override=prompt) == {"ok": True}
|
||||
78
tests/unit/test_postgres.py
Normal file
78
tests/unit/test_postgres.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core import postgres
|
||||
|
||||
|
||||
def test_safe_identifier_quotes_names_and_rejects_sql_expressions():
|
||||
assert postgres._safe_id("scheduled_jobs") == '"scheduled_jobs"'
|
||||
|
||||
for unsafe in ["jobs.id", "jobs; DROP TABLE jobs", "two words", "", 7, None]:
|
||||
with pytest.raises(ValueError, match="Invalid SQL identifier"):
|
||||
postgres._safe_id(unsafe)
|
||||
|
||||
|
||||
def test_order_clause_allows_only_identifiers_and_directions():
|
||||
assert postgres._order_clause(
|
||||
["created_at desc", ("id", "ASC")]
|
||||
) == '"created_at" DESC, "id" ASC'
|
||||
|
||||
unsafe_values = [
|
||||
"created_at DESC NULLS LAST",
|
||||
"created_at;drop DESC",
|
||||
[("created_at", "SIDEWAYS")],
|
||||
[("created_at", "ASC", "extra")],
|
||||
]
|
||||
for value in unsafe_values:
|
||||
with pytest.raises(ValueError):
|
||||
postgres._order_clause(value)
|
||||
|
||||
|
||||
def test_select_builds_parameterized_where_and_safe_order(monkeypatch):
|
||||
cursor = MagicMock()
|
||||
cursor.fetchall.return_value = [{"id": "job-1"}]
|
||||
|
||||
@contextmanager
|
||||
def fake_cursor():
|
||||
yield cursor
|
||||
|
||||
monkeypatch.setattr(postgres, "get_cursor", fake_cursor)
|
||||
|
||||
rows = postgres.select(
|
||||
"scheduled_jobs",
|
||||
where={"user_uuid": "user-1", "status": ("IN", ["pending", "running"])},
|
||||
order_by=[("run_at", "ASC"), ("id", "DESC")],
|
||||
limit=10,
|
||||
)
|
||||
|
||||
assert rows == [{"id": "job-1"}]
|
||||
query, params = cursor.execute.call_args.args
|
||||
assert 'FROM "scheduled_jobs"' in query
|
||||
assert '"user_uuid" = %(user_uuid_0)s' in query
|
||||
assert '"status" IN (%(status_1_0)s, %(status_1_1)s)' in query
|
||||
assert 'ORDER BY "run_at" ASC, "id" DESC' in query
|
||||
assert "LIMIT %(query_limit)s" in query
|
||||
assert params == {
|
||||
"user_uuid_0": "user-1",
|
||||
"status_1_0": "pending",
|
||||
"status_1_1": "running",
|
||||
"query_limit": 10,
|
||||
}
|
||||
|
||||
|
||||
def test_empty_update_and_delete_conditions_fail_before_opening_cursor(monkeypatch):
|
||||
cursor_factory = MagicMock(
|
||||
side_effect=AssertionError("a database cursor must not be opened")
|
||||
)
|
||||
monkeypatch.setattr(postgres, "get_cursor", cursor_factory)
|
||||
|
||||
with pytest.raises(ValueError, match="update data cannot be empty"):
|
||||
postgres.update("users", {}, {"id": "user-1"})
|
||||
with pytest.raises(ValueError, match="non-empty where"):
|
||||
postgres.update("users", {"timezone": "UTC"}, {})
|
||||
with pytest.raises(ValueError, match="non-empty where"):
|
||||
postgres.delete("users", {})
|
||||
|
||||
cursor_factory.assert_not_called()
|
||||
156
tests/unit/test_registry.py
Normal file
156
tests/unit/test_registry.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import core.registry as registry_module
|
||||
from core.registry import FrameworkRegistry
|
||||
|
||||
|
||||
PROMPT = {"system": "Return JSON", "user_template": "Message: {user_input}"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_global_registry():
|
||||
registry_module.reset_registry()
|
||||
yield
|
||||
registry_module.reset_registry()
|
||||
|
||||
|
||||
def test_discovery_is_alphabetical_ignores_private_packages_and_runs_once(monkeypatch):
|
||||
loaded = []
|
||||
|
||||
def feature(name):
|
||||
def register(target):
|
||||
loaded.append(name)
|
||||
target.describe(f"{name} feature")
|
||||
target.register_command(
|
||||
name,
|
||||
lambda _context, _parsed: None,
|
||||
PROMPT,
|
||||
description=f"Handle {name}",
|
||||
help_text=[f"use {name}"],
|
||||
)
|
||||
|
||||
return SimpleNamespace(register=register)
|
||||
|
||||
fake_modules = {
|
||||
"modules.alpha": feature("alpha"),
|
||||
"modules.zeta": feature("zeta"),
|
||||
}
|
||||
|
||||
def fake_import(name):
|
||||
if name == "modules":
|
||||
return SimpleNamespace(__path__=["unused"])
|
||||
return fake_modules[name]
|
||||
|
||||
discovered = [
|
||||
SimpleNamespace(name="zeta", ispkg=True),
|
||||
SimpleNamespace(name="_private", ispkg=True),
|
||||
SimpleNamespace(name="single_file", ispkg=False),
|
||||
SimpleNamespace(name="alpha", ispkg=True),
|
||||
]
|
||||
monkeypatch.setattr(registry_module.importlib, "import_module", fake_import)
|
||||
monkeypatch.setattr(
|
||||
registry_module.pkgutil, "iter_modules", lambda _path: discovered
|
||||
)
|
||||
|
||||
result = registry_module.discover_modules()
|
||||
|
||||
assert loaded == ["alpha", "zeta"]
|
||||
assert list(result.modules) == ["alpha", "zeta"]
|
||||
assert result.list_commands() == ["alpha", "zeta"]
|
||||
assert registry_module.discover_modules() is result
|
||||
assert loaded == ["alpha", "zeta"]
|
||||
|
||||
|
||||
def test_duplicate_names_and_malformed_registrations_are_rejected():
|
||||
target = FrameworkRegistry()
|
||||
handler = lambda _context, _parsed: None
|
||||
|
||||
target.begin_module("first", "modules.first")
|
||||
target.register_command("shared", handler, PROMPT)
|
||||
target.register_job("shared.job", handler)
|
||||
target.finish_module()
|
||||
|
||||
with pytest.raises(ValueError, match="Duplicate module name: first"):
|
||||
target.begin_module("first", "modules.again")
|
||||
|
||||
target.begin_module("second", "modules.second")
|
||||
with pytest.raises(ValueError, match="Duplicate command type: shared"):
|
||||
target.register_command("shared", handler, PROMPT)
|
||||
with pytest.raises(ValueError, match="Duplicate job type: shared.job"):
|
||||
target.register_job("shared.job", handler)
|
||||
with pytest.raises(TypeError, match="must be callable"):
|
||||
target.register_command("not_callable", None, PROMPT)
|
||||
with pytest.raises(TypeError, match="Validator .* must be callable"):
|
||||
target.register_command("bad_validator", handler, PROMPT, validator="bad")
|
||||
with pytest.raises(ValueError, match="system and user_template"):
|
||||
target.register_command("bad_prompt", handler, {"system": "only one"})
|
||||
target.finish_module()
|
||||
|
||||
with pytest.raises(RuntimeError, match="inside a module register"):
|
||||
target.describe("orphan metadata")
|
||||
|
||||
|
||||
def test_help_and_router_context_are_generated_from_sorted_metadata():
|
||||
target = FrameworkRegistry()
|
||||
handler = lambda _context, _parsed: None
|
||||
target.begin_module("examples", "modules.examples")
|
||||
target.register_command(
|
||||
"zeta",
|
||||
handler,
|
||||
PROMPT,
|
||||
description="Last command",
|
||||
)
|
||||
target.register_command(
|
||||
"alpha",
|
||||
handler,
|
||||
PROMPT,
|
||||
description="First command",
|
||||
help_text=["say alpha", "ask alpha for help"],
|
||||
)
|
||||
target.finish_module()
|
||||
|
||||
assert target.router_context() == (
|
||||
"- alpha: First command\n- zeta: Last command"
|
||||
)
|
||||
assert target.help_lines() == [
|
||||
"- say alpha",
|
||||
"- ask alpha for help",
|
||||
"- zeta: Last command",
|
||||
]
|
||||
|
||||
|
||||
def test_failed_forced_discovery_clears_partial_state_and_can_retry(monkeypatch):
|
||||
broken = SimpleNamespace(register=lambda target: target.describe("partial"))
|
||||
monkeypatch.setattr(
|
||||
registry_module.importlib,
|
||||
"import_module",
|
||||
lambda name: (
|
||||
SimpleNamespace(__path__=["unused"]) if name == "modules" else broken
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
registry_module.pkgutil,
|
||||
"iter_modules",
|
||||
lambda _path: [SimpleNamespace(name="broken", ispkg=True)],
|
||||
)
|
||||
|
||||
registry_module.discover_modules()
|
||||
assert registry_module.registry.modules == {
|
||||
"broken": {
|
||||
"name": "broken",
|
||||
"package": "modules.broken",
|
||||
"description": "partial",
|
||||
}
|
||||
}
|
||||
|
||||
del broken.register
|
||||
with pytest.raises(RuntimeError, match="must expose register"):
|
||||
registry_module.discover_modules(force=True)
|
||||
assert registry_module.registry.modules == {}
|
||||
|
||||
broken.register = lambda target: target.describe("recovered")
|
||||
assert registry_module.discover_modules().modules["broken"][
|
||||
"description"
|
||||
] == "recovered"
|
||||
220
tests/unit/test_reminders.py
Normal file
220
tests/unit/test_reminders.py
Normal file
@@ -0,0 +1,220 @@
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.reminders.commands import _errorMessage, handleReminder, validateReminder
|
||||
from modules.reminders.service import _nextRun, normalizeRecurrence
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
{"action": "list"},
|
||||
{"action": "cancel", "reminder_id": "reminder-1"},
|
||||
{"action": "set_timezone", "timezone": "America/Chicago"},
|
||||
{
|
||||
"action": "create",
|
||||
"message": "call home",
|
||||
"run_at": "2999-03-08T09:00:00-05:00",
|
||||
"recurrence": {"frequency": "weekly", "interval": 2},
|
||||
},
|
||||
{"needs_clarification": "What time should I use?"},
|
||||
],
|
||||
)
|
||||
def test_reminder_validator_accepts_supported_commands(command):
|
||||
assert validateReminder(command) == []
|
||||
|
||||
|
||||
def test_reminder_validator_reports_all_invalid_create_fields():
|
||||
errors = validateReminder(
|
||||
{
|
||||
"action": "create",
|
||||
"message": " ",
|
||||
"run_at": "2026-03-08T09:00:00",
|
||||
"recurrence": {"frequency": "hourly"},
|
||||
}
|
||||
)
|
||||
|
||||
assert "create requires a reminder message" in errors
|
||||
assert "run_at must include a timezone offset" in errors
|
||||
assert "recurrence frequency must be daily or weekly" in errors
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("once", None),
|
||||
("daily", {"frequency": "daily", "interval": 1}),
|
||||
(
|
||||
{"frequency": "WEEKLY", "interval": "3"},
|
||||
{"frequency": "weekly", "interval": 3},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_recurrence_normalization(value, expected):
|
||||
assert normalizeRecurrence(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"hourly",
|
||||
{"frequency": "daily", "interval": 0},
|
||||
{"frequency": "weekly", "interval": 366},
|
||||
{"frequency": "daily", "interval": "many"},
|
||||
],
|
||||
)
|
||||
def test_recurrence_normalization_rejects_invalid_values(value):
|
||||
with pytest.raises(ValueError):
|
||||
normalizeRecurrence(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("scheduled_for", "expected"),
|
||||
[
|
||||
# America/Chicago enters daylight time on March 8, 2026.
|
||||
(
|
||||
datetime(2026, 3, 7, 15, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 3, 8, 14, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
# It returns to standard time on November 1, 2026.
|
||||
(
|
||||
datetime(2026, 10, 31, 14, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 11, 1, 15, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_next_run_preserves_local_wall_clock_across_dst(scheduled_for, expected):
|
||||
result = _nextRun(
|
||||
scheduled_for,
|
||||
{"frequency": "daily", "interval": 1},
|
||||
"America/Chicago",
|
||||
now=scheduled_for,
|
||||
)
|
||||
|
||||
assert result == expected
|
||||
assert result.astimezone(__import__("zoneinfo").ZoneInfo("America/Chicago")).hour == 9
|
||||
|
||||
|
||||
def test_next_run_skips_missed_intervals_after_downtime():
|
||||
scheduled_for = datetime(2026, 3, 7, 15, 0, tzinfo=timezone.utc)
|
||||
|
||||
result = _nextRun(
|
||||
scheduled_for,
|
||||
{"frequency": "daily", "interval": 1},
|
||||
"America/Chicago",
|
||||
now=datetime(2026, 3, 9, 14, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert result == datetime(2026, 3, 10, 14, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _context(response, status):
|
||||
api = SimpleNamespace(
|
||||
request=AsyncMock(return_value=(response, status)),
|
||||
timezone="UTC",
|
||||
)
|
||||
return SimpleNamespace(api=api, timezone="UTC", reply=AsyncMock())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_handler_reports_success_and_api_error():
|
||||
context = _context(
|
||||
{
|
||||
"message": "call home",
|
||||
"next_run_at": "2099-01-01T12:00:00+00:00",
|
||||
"recurrence": {"frequency": "daily"},
|
||||
},
|
||||
201,
|
||||
)
|
||||
parsed = {
|
||||
"action": "create",
|
||||
"message": "call home",
|
||||
"run_at": "2099-01-01T12:00:00Z",
|
||||
"recurrence": {"frequency": "daily"},
|
||||
}
|
||||
await handleReminder(context, parsed)
|
||||
assert "recurring" in context.reply.await_args.args[0]
|
||||
context.api.request.assert_awaited_once_with(
|
||||
"post",
|
||||
"/api/reminders",
|
||||
{
|
||||
"message": "call home",
|
||||
"run_at": "2099-01-01T12:00:00Z",
|
||||
"recurrence": {"frequency": "daily"},
|
||||
},
|
||||
)
|
||||
|
||||
context = _context({"error": "database unavailable"}, 503)
|
||||
await handleReminder(context, parsed)
|
||||
assert "database unavailable" in context.reply.await_args.args[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_handler_formats_results_and_empty_state():
|
||||
context = _context(
|
||||
{
|
||||
"reminders": [
|
||||
{
|
||||
"id": "reminder-one",
|
||||
"next_run_at": "2099-01-01T12:00:00+00:00",
|
||||
"message": "call home",
|
||||
}
|
||||
]
|
||||
},
|
||||
200,
|
||||
)
|
||||
await handleReminder(context, {"action": "list"})
|
||||
reply = context.reply.await_args.args[0]
|
||||
assert "Active reminders" in reply and "reminder-one" in reply
|
||||
|
||||
context = _context({"reminders": []}, 200)
|
||||
await handleReminder(context, {"action": "list"})
|
||||
assert context.reply.await_args.args[0] == "You have no active reminders."
|
||||
|
||||
context = _context({"error": "offline"}, 503)
|
||||
await handleReminder(context, {"action": "list"})
|
||||
assert "offline" in context.reply.await_args.args[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_and_timezone_handlers_update_context():
|
||||
context = _context({"message": "call home"}, 200)
|
||||
await handleReminder(
|
||||
context,
|
||||
{"action": "cancel", "reminder_id": "reminder-one"},
|
||||
)
|
||||
assert context.reply.await_args.args[0] == "Cancelled reminder: call home"
|
||||
context.api.request.assert_awaited_once_with(
|
||||
"delete", "/api/reminders/reminder-one"
|
||||
)
|
||||
|
||||
context = _context({"error": "not found"}, 404)
|
||||
await handleReminder(
|
||||
context,
|
||||
{"action": "cancel", "reminder_id": "missing"},
|
||||
)
|
||||
assert "not found" in context.reply.await_args.args[0]
|
||||
|
||||
context = _context({"timezone": "America/Chicago"}, 200)
|
||||
await handleReminder(
|
||||
context,
|
||||
{"action": "set_timezone", "timezone": "America/Chicago"},
|
||||
)
|
||||
assert context.timezone == "America/Chicago"
|
||||
assert context.api.timezone == "America/Chicago"
|
||||
|
||||
context = _context({}, 400)
|
||||
await handleReminder(
|
||||
context,
|
||||
{"action": "set_timezone", "timezone": "bad"},
|
||||
)
|
||||
assert "couldn't update" in context.reply.await_args.args[0]
|
||||
|
||||
|
||||
def test_error_message_handles_structured_and_unstructured_results():
|
||||
assert _errorMessage({"error": "detail"}, "fallback") == "fallback detail"
|
||||
assert _errorMessage("not an object", "fallback") == "fallback"
|
||||
382
tests/unit/test_security_core.py
Normal file
382
tests/unit/test_security_core.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""Unit tests for user, token, API-key, and provider identity boundaries."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
import datetime
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from psycopg2.errors import UniqueViolation
|
||||
|
||||
from core import api_keys as apiKeys
|
||||
from core import auth, identity, users
|
||||
|
||||
|
||||
USER_ID = "00000000-0000-0000-0000-000000000101"
|
||||
KEY_ID = "00000000-0000-0000-0000-000000000201"
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, rows=None):
|
||||
self.rows = list(rows or [])
|
||||
self.executed = []
|
||||
|
||||
def execute(self, query, params=None):
|
||||
self.executed.append((" ".join(query.split()), params))
|
||||
|
||||
def fetchone(self):
|
||||
return self.rows.pop(0) if self.rows else None
|
||||
|
||||
|
||||
def _cursorContext(cursor):
|
||||
@contextmanager
|
||||
def context():
|
||||
yield cursor
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def test_user_timezone_and_password_validation(monkeypatch):
|
||||
assert users.isValidTimezone("America/Chicago")
|
||||
assert not users.isValidTimezone("not/a-zone")
|
||||
assert users.normalizeTimezone(" UTC ") == "UTC"
|
||||
with pytest.raises(ValueError, match="IANA"):
|
||||
users.normalizeTimezone(None)
|
||||
|
||||
monkeypatch.setenv("DEFAULT_TIMEZONE", "America/Chicago")
|
||||
assert users.getDefaultTimezone() == "America/Chicago"
|
||||
assert users.validatePassword("long-enough")[0]
|
||||
assert not users.validatePassword("short")[0]
|
||||
assert not users.validatePassword("é" * 40)[0]
|
||||
assert users.validatePassword(None) == (False, ["password"])
|
||||
|
||||
|
||||
def test_user_lookup_helpers_and_registration_boundaries(monkeypatch):
|
||||
select = MagicMock(
|
||||
side_effect=[
|
||||
{"id": USER_ID, "username": "alice", "timezone": "UTC"},
|
||||
{"id": USER_ID},
|
||||
None,
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(users.postgres, "select_one", select)
|
||||
monkeypatch.setattr(users.postgres, "exists", MagicMock(side_effect=[False, True]))
|
||||
assert users.getUser(USER_ID)["username"] == "alice"
|
||||
assert users.getUserUUID("alice") == USER_ID
|
||||
assert users.getUserUUID("missing") is False
|
||||
assert users.isUsernameAvailable("alice")
|
||||
assert users.doesUserUUIDExist(USER_ID)
|
||||
|
||||
monkeypatch.setattr(users, "isUsernameAvailable", lambda _name: True)
|
||||
monkeypatch.setattr(users.bcrypt, "gensalt", lambda: b"salt")
|
||||
monkeypatch.setattr(users.bcrypt, "hashpw", lambda value, _salt: b"hash:" + value)
|
||||
create = MagicMock()
|
||||
monkeypatch.setattr(users, "createUser", create)
|
||||
assert users.registerUser(
|
||||
" alice ",
|
||||
"password123",
|
||||
{"timezone": "UTC", "id": "attacker", "admin": True},
|
||||
)
|
||||
created = create.call_args.args[0]
|
||||
assert created["username"] == "alice"
|
||||
assert created["password_hashed"] == b"hash:password123"
|
||||
assert created["timezone"] == "UTC"
|
||||
assert created["id"] != "attacker" and "admin" not in created
|
||||
|
||||
monkeypatch.setattr(users, "isUsernameAvailable", lambda _name: False)
|
||||
assert users.registerUser("alice", "password123") is False
|
||||
monkeypatch.setattr(users, "isUsernameAvailable", lambda _name: True)
|
||||
create.side_effect = UniqueViolation()
|
||||
assert users.registerUser("alice", "password123") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("username", [None, " ", "x" * 256])
|
||||
def test_registration_rejects_invalid_usernames(username):
|
||||
with pytest.raises(ValueError, match="username"):
|
||||
users.registerUser(username, "password123")
|
||||
|
||||
|
||||
def test_user_creation_updates_password_and_delete(monkeypatch):
|
||||
monkeypatch.setattr(users, "getUser", lambda _user: {"id": USER_ID})
|
||||
update = MagicMock(return_value=[{"id": USER_ID}])
|
||||
delete = MagicMock(return_value=[{"id": USER_ID}])
|
||||
insert = MagicMock(return_value={"id": USER_ID})
|
||||
monkeypatch.setattr(users.postgres, "update", update)
|
||||
monkeypatch.setattr(users.postgres, "delete", delete)
|
||||
monkeypatch.setattr(users.postgres, "insert", insert)
|
||||
|
||||
assert users.updateUser(USER_ID, {"timezone": "UTC", "username": "ignored"})
|
||||
update.assert_called_with("users", {"timezone": "UTC"}, {"id": USER_ID})
|
||||
assert not users.updateUser(USER_ID, {"username": "ignored"})
|
||||
|
||||
monkeypatch.setattr(users.bcrypt, "gensalt", lambda: b"salt")
|
||||
monkeypatch.setattr(users.bcrypt, "hashpw", lambda _value, _salt: b"new-hash")
|
||||
assert users.changePassword(USER_ID, "new-password")
|
||||
assert users.deleteUser(USER_ID)
|
||||
|
||||
valid = {
|
||||
"id": USER_ID,
|
||||
"username": "alice",
|
||||
"password_hashed": b"hash",
|
||||
"timezone": "UTC",
|
||||
"unexpected": True,
|
||||
}
|
||||
assert users.createUser(valid) == {"id": USER_ID}
|
||||
assert "unexpected" not in insert.call_args.args[1]
|
||||
assert users.validateUser(valid)[0]
|
||||
assert not users.validateUser({"id": "bad", "timezone": "bad"})[0]
|
||||
|
||||
|
||||
def test_login_token_creation_decoding_and_protected_claims(monkeypatch):
|
||||
monkeypatch.setenv("JWT_SECRET", "test-secret")
|
||||
monkeypatch.setattr(users, "doesUserUUIDExist", lambda value: value == USER_ID)
|
||||
monkeypatch.setattr(users, "getUserFirstName", lambda _value: "Alice")
|
||||
encode = MagicMock(return_value="encoded-token")
|
||||
monkeypatch.setattr(auth.jwt, "encode", encode)
|
||||
|
||||
assert auth.createLoginToken(
|
||||
USER_ID,
|
||||
expiresIn=60,
|
||||
extraClaims={"provider": "discord", "sub": "attacker"},
|
||||
) == "encoded-token"
|
||||
payload = encode.call_args.args[0]
|
||||
assert payload["sub"] == USER_ID and payload["provider"] == "discord"
|
||||
assert payload["exp"] > payload["iat"]
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
auth.createLoginToken(USER_ID, expiresIn=0)
|
||||
with pytest.raises(ValueError, match="extra claims"):
|
||||
auth.createLoginToken(USER_ID, extraClaims="bad")
|
||||
|
||||
monkeypatch.setattr(auth.jwt, "decode", lambda *_args, **_kwargs: {"sub": USER_ID})
|
||||
assert auth.decodeLoginToken("token")["sub"] == USER_ID
|
||||
monkeypatch.setattr(auth.jwt, "decode", lambda *_args, **_kwargs: {"sub": 123})
|
||||
assert auth.decodeLoginToken("token") is None
|
||||
monkeypatch.delenv("JWT_SECRET")
|
||||
assert auth.decodeLoginToken("token") is None
|
||||
with pytest.raises(RuntimeError, match="JWT_SECRET"):
|
||||
auth.createLoginToken(USER_ID)
|
||||
|
||||
|
||||
def test_password_login_and_account_removal(monkeypatch):
|
||||
monkeypatch.setattr(users, "getUserUUID", lambda name: USER_ID if name == "alice" else False)
|
||||
monkeypatch.setattr(auth, "getUserpasswordHash", lambda _value: b"stored")
|
||||
monkeypatch.setattr(auth.bcrypt, "checkpw", lambda password, hashed: password == b"password123")
|
||||
monkeypatch.setattr(auth, "createLoginToken", lambda _value: "login-token")
|
||||
assert auth.getLoginToken("alice", "password123") == "login-token"
|
||||
assert auth.getLoginToken("alice", "wrong-password") is False
|
||||
assert auth.getLoginToken(None, "password123") is False
|
||||
|
||||
monkeypatch.setattr(users, "deleteUser", MagicMock(return_value=True))
|
||||
assert auth.unregisterUser(USER_ID, "password123")
|
||||
assert not auth.unregisterUser(USER_ID, "wrong-password")
|
||||
|
||||
|
||||
def test_bearer_authentication_and_principal_helpers(monkeypatch):
|
||||
assert auth.getBearerToken("Bearer token") == "token"
|
||||
assert auth.getBearerToken("basic token") is None
|
||||
assert auth.getBearerToken(None) is None
|
||||
|
||||
monkeypatch.setattr(auth, "decodeLoginToken", lambda _token: {"sub": USER_ID})
|
||||
principal = auth.authenticateBearerToken("Bearer a.b.c")
|
||||
assert principal["authentication"] == "jwt"
|
||||
assert auth.isUserPrincipal(principal, USER_ID, requireLogin=True)
|
||||
assert auth.authenticateBearerToken(
|
||||
"Bearer a.b.c", requiredScopes=["service:scope"]
|
||||
) is None
|
||||
|
||||
service = {
|
||||
"type": "service",
|
||||
"scopes": ["jobs:claim"],
|
||||
"authentication": "api_key",
|
||||
}
|
||||
monkeypatch.setattr(apiKeys, "authenticateApiKey", lambda *_args, **_kwargs: service)
|
||||
assert auth.authenticateBearerToken("Bearer service-key") == service
|
||||
assert auth.authenticateBearerToken("Bearer service-key", allowService=False) is None
|
||||
assert auth.hasServiceScope(service, "jobs:claim")
|
||||
assert not auth.hasServiceScope(principal, "jobs:claim")
|
||||
|
||||
|
||||
def test_api_key_normalization_public_shape_and_ids(monkeypatch):
|
||||
assert apiKeys._normalizeScopes("one, two one") == ["one", "two"]
|
||||
assert apiKeys._normalizeScopes(None) == []
|
||||
with pytest.raises(ValueError, match="scopes"):
|
||||
apiKeys._normalizeScopes(123)
|
||||
with pytest.raises(ValueError, match="future"):
|
||||
apiKeys._normalizeExpiry("2000-01-01T00:00:00Z")
|
||||
|
||||
record = {"id": KEY_ID, "key_hash": "secret", "scopes": '["one"]'}
|
||||
assert apiKeys._publicKey(record, includeSecret="raw") == {
|
||||
"id": KEY_ID,
|
||||
"scopes": ["one"],
|
||||
"key": "raw",
|
||||
}
|
||||
assert apiKeys._keyID(KEY_ID) == KEY_ID
|
||||
assert apiKeys._keyID("bad") is None
|
||||
monkeypatch.setattr(apiKeys.postgres, "select_one", MagicMock(return_value=record))
|
||||
assert apiKeys.getApiKey("bad") is None
|
||||
assert apiKeys.getApiKey(KEY_ID)["id"] == KEY_ID
|
||||
|
||||
|
||||
def test_api_key_creation_and_owner_rules(monkeypatch):
|
||||
monkeypatch.setattr(users, "doesUserUUIDExist", lambda value: value == USER_ID)
|
||||
secret = "x" * 32
|
||||
stored = {
|
||||
"id": KEY_ID,
|
||||
"name": "cli",
|
||||
"key_type": "user",
|
||||
"user_uuid": USER_ID,
|
||||
"key_hash": apiKeys._hashToken(secret),
|
||||
"scopes": [],
|
||||
}
|
||||
cursor = FakeCursor([stored])
|
||||
monkeypatch.setattr(apiKeys.postgres, "get_cursor", _cursorContext(cursor))
|
||||
created = apiKeys.createApiKey(
|
||||
"cli", "user", userUUID=USER_ID, secret=secret
|
||||
)
|
||||
assert created["key"] == secret and "key_hash" not in created
|
||||
assert cursor.executed[0][1]["key_prefix"] == secret[:20]
|
||||
|
||||
with pytest.raises(ValueError, match="user does not exist"):
|
||||
apiKeys.createUserApiKey(str(uuid.uuid4()), "missing")
|
||||
with pytest.raises(ValueError, match="cannot have a service"):
|
||||
apiKeys.createApiKey(
|
||||
"bad", "user", userUUID=USER_ID, serviceName="service"
|
||||
)
|
||||
with pytest.raises(ValueError, match="service name"):
|
||||
apiKeys.createApiKey("bad", "service", scopes=[])
|
||||
with pytest.raises(ValueError, match="cannot have a user"):
|
||||
apiKeys.createApiKey(
|
||||
"bad", "service", userUUID=USER_ID, serviceName="service"
|
||||
)
|
||||
|
||||
|
||||
def test_api_key_authentication_scope_expiry_and_revoke(monkeypatch):
|
||||
secret = "service-secret-that-is-at-least-32-characters"
|
||||
record = {
|
||||
"id": KEY_ID,
|
||||
"key_type": "service",
|
||||
"user_uuid": None,
|
||||
"service_name": "worker",
|
||||
"key_hash": apiKeys._hashToken(secret),
|
||||
"scopes": ["jobs:claim"],
|
||||
"expires_at": None,
|
||||
}
|
||||
select = MagicMock(return_value=[record])
|
||||
update = MagicMock()
|
||||
monkeypatch.setattr(apiKeys.postgres, "select", select)
|
||||
monkeypatch.setattr(apiKeys.postgres, "update", update)
|
||||
principal = apiKeys.authenticateApiKey(secret, ["jobs:claim"])
|
||||
assert principal["service_name"] == "worker"
|
||||
assert update.called
|
||||
assert apiKeys.authenticateApiKey("short") is None
|
||||
assert apiKeys.authenticateApiKey(secret, ["jobs:deliver"]) is None
|
||||
|
||||
expired = dict(record, expires_at=apiKeys._utcNow() - datetime.timedelta(seconds=1))
|
||||
select.return_value = [expired]
|
||||
assert apiKeys.authenticateApiKey(secret) is None
|
||||
select.return_value = []
|
||||
assert apiKeys.authenticateApiKey(secret) is None
|
||||
|
||||
execute = MagicMock(return_value=[{"id": KEY_ID}])
|
||||
monkeypatch.setattr(apiKeys.postgres, "execute", execute)
|
||||
assert apiKeys.revokeApiKey(KEY_ID, serviceName="worker")
|
||||
assert not apiKeys.revokeApiKey("invalid")
|
||||
|
||||
|
||||
def test_service_key_bootstrap_is_idempotent_configuration(monkeypatch):
|
||||
monkeypatch.delenv("BOT_API_KEY", raising=False)
|
||||
assert apiKeys.bootstrapServiceApiKey() is None
|
||||
secret = "configured-service-key-at-least-32-characters"
|
||||
monkeypatch.setenv("BOT_API_KEY", secret)
|
||||
monkeypatch.setenv("BOT_API_KEY_SCOPES", "discord:session, outbox:claim")
|
||||
record = {
|
||||
"id": KEY_ID,
|
||||
"key_hash": apiKeys._hashToken(secret),
|
||||
"scopes": json.dumps(["discord:session", "outbox:claim"]),
|
||||
}
|
||||
cursor = FakeCursor([record])
|
||||
monkeypatch.setattr(apiKeys.postgres, "get_cursor", _cursorContext(cursor))
|
||||
result = apiKeys.bootstrapServiceApiKey()
|
||||
assert result["scopes"] == ["discord:session", "outbox:claim"]
|
||||
assert len(cursor.executed) == 2
|
||||
|
||||
|
||||
def test_identity_normalization_enrollment_and_lookup(monkeypatch):
|
||||
assert identity._normalizeProvider(" Discord ") == "discord"
|
||||
assert identity._normalizeProviderUserID(123) == "123"
|
||||
assert identity._normalizeDisplayName(" Alice ") == "Alice"
|
||||
with pytest.raises(ValueError, match="provider"):
|
||||
identity._normalizeProvider("bad provider")
|
||||
with pytest.raises(ValueError, match="provider user ID"):
|
||||
identity._normalizeProviderUserID("")
|
||||
|
||||
monkeypatch.setenv("DISCORD_ENROLLMENT_MODE", "allowlist")
|
||||
monkeypatch.setenv("DISCORD_ALLOWLIST", "123, 456\n789")
|
||||
assert identity.getDiscordAllowlist() == {"123", "456", "789"}
|
||||
assert identity.isDiscordEnrollmentAllowed("456")
|
||||
assert not identity.isDiscordEnrollmentAllowed("000")
|
||||
monkeypatch.setenv("DISCORD_ENROLLMENT_MODE", "open")
|
||||
assert identity.isDiscordEnrollmentAllowed("000")
|
||||
|
||||
monkeypatch.setattr(
|
||||
identity.postgres,
|
||||
"select_one",
|
||||
MagicMock(return_value={"user_uuid": USER_ID}),
|
||||
)
|
||||
monkeypatch.setattr(users, "getUser", MagicMock(return_value={"id": USER_ID}))
|
||||
assert identity.getDiscordUser("123") == {"id": USER_ID}
|
||||
|
||||
|
||||
def test_link_provider_identity_existing_and_new_paths(monkeypatch):
|
||||
monkeypatch.setattr(users, "doesUserUUIDExist", lambda _value: True)
|
||||
existing = {
|
||||
"id": "identity-one",
|
||||
"user_uuid": USER_ID,
|
||||
"display_name": "Old",
|
||||
}
|
||||
monkeypatch.setattr(identity, "getProviderIdentity", MagicMock(return_value=existing))
|
||||
update = MagicMock(return_value=[dict(existing, display_name="New")])
|
||||
monkeypatch.setattr(identity.postgres, "update", update)
|
||||
assert identity.linkProviderIdentity(
|
||||
USER_ID, "discord", "123", displayName="New"
|
||||
)["display_name"] == "New"
|
||||
|
||||
identity.getProviderIdentity.return_value = dict(existing, user_uuid=str(uuid.uuid4()))
|
||||
with pytest.raises(ValueError, match="another user"):
|
||||
identity.linkProviderIdentity(USER_ID, "discord", "123")
|
||||
|
||||
identity.getProviderIdentity.return_value = None
|
||||
inserted = {"id": "identity-two", "user_uuid": USER_ID}
|
||||
monkeypatch.setattr(identity.postgres, "insert", MagicMock(return_value=inserted))
|
||||
assert identity.linkDiscordIdentity(USER_ID, "456") == inserted
|
||||
monkeypatch.setattr(identity.postgres, "delete", MagicMock(return_value=[inserted]))
|
||||
assert identity.unlinkProviderIdentity(USER_ID, "discord", "456")
|
||||
|
||||
|
||||
def test_get_or_create_discord_user_existing_denied_and_created(monkeypatch):
|
||||
monkeypatch.setattr(users, "getDefaultTimezone", lambda: "UTC")
|
||||
existingIdentity = {
|
||||
"id": "identity-one",
|
||||
"user_uuid": USER_ID,
|
||||
"display_name": "Old",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
identity, "getProviderIdentity", MagicMock(return_value=existingIdentity)
|
||||
)
|
||||
monkeypatch.setattr(users, "getUser", MagicMock(return_value={"id": USER_ID}))
|
||||
update = MagicMock()
|
||||
monkeypatch.setattr(identity.postgres, "update", update)
|
||||
assert identity.getOrCreateDiscordUser("123", "New") == {"id": USER_ID}
|
||||
assert update.called
|
||||
|
||||
identity.getProviderIdentity.return_value = None
|
||||
monkeypatch.setattr(identity, "isDiscordEnrollmentAllowed", lambda _value: False)
|
||||
assert identity.getOrCreateDiscordUser("999") is None
|
||||
|
||||
monkeypatch.setattr(identity, "isDiscordEnrollmentAllowed", lambda _value: True)
|
||||
cursor = FakeCursor([None, {"id": USER_ID, "timezone": "UTC"}])
|
||||
monkeypatch.setattr(identity.postgres, "get_cursor", _cursorContext(cursor))
|
||||
created = identity.getOrCreateDiscordUser("999", "New")
|
||||
assert created["id"] == USER_ID
|
||||
assert len(cursor.executed) == 4
|
||||
Reference in New Issue
Block a user