mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
adb42c66da
When a scheduled workstream finishes execution, deliver the final assistant response to configured Discord channels/users via the existing channel gateway notify infrastructure. - Add notify_targets column to scheduled_tasks (migration 034) - Add notify_targets field to Workstream dataclass - Storage: accept/return/update notify_targets in protocol, SQLite, PostgreSQL - Server: validate targets, extract last assistant content, deliver via gateway with retry, post-completion hook in _run_initial finally block - Schedule targets override skill notify_on_complete (dedup rule) - SDK: notify_targets param on async + sync create_workstream - Console scheduler: pass notify_targets through dispatch - Console server: schedule CRUD accepts/validates/returns notify_targets - API schemas: notify_targets on schedule + workstream request/response - Admin UI: notify textarea in schedule create/edit modals with JSON validation, monospace font, aria-describedby hints - Governance UI: notify_on_complete textarea in skill create/edit with client-side JSON validation and field reset on create - Bounds: max 10 targets, 256 char field limit, gateway response body verification matching _exec_notify pattern - Gateway: 30s asyncio.wait_for timeout on adapter.send to prevent hung Discord API calls from blocking the notify endpoint indefinitely - 39 new tests covering validation, extraction, delivery, dispatch, CRUD, and adapter timeout
357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""Tests for the channel gateway HTTP notify endpoint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from turnstone.channels._http import create_channel_app
|
|
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
_JWT_SECRET = "a" * 32
|
|
|
|
|
|
def _make_jwt() -> str:
|
|
"""Create a valid JWT for channel auth."""
|
|
return create_jwt(
|
|
user_id="system",
|
|
scopes=frozenset({"write"}),
|
|
source="service",
|
|
secret=_JWT_SECRET,
|
|
audience=JWT_AUD_CHANNEL,
|
|
)
|
|
|
|
|
|
def _auth_headers() -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {_make_jwt()}"}
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path):
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_adapter():
|
|
adapter = AsyncMock()
|
|
adapter.channel_type = "discord"
|
|
adapter.send = AsyncMock(return_value="msg_001")
|
|
return adapter
|
|
|
|
|
|
@pytest.fixture
|
|
def no_auth_client(storage, mock_adapter):
|
|
"""Client with no auth configured (for fail-closed tests)."""
|
|
app = create_channel_app({"discord": mock_adapter}, storage)
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(storage, mock_adapter):
|
|
"""Default client with JWT auth configured."""
|
|
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def authed_client(storage, mock_adapter):
|
|
"""Alias -- same as client, for auth-specific test clarity."""
|
|
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def jwt_client(storage, mock_adapter):
|
|
"""Client with JWT auth configured."""
|
|
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
|
return TestClient(app)
|
|
|
|
|
|
class TestNotifyEndpoint:
|
|
def test_health(self, client):
|
|
resp = client.get("/health")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "ok"
|
|
|
|
def test_direct_discord_target(self, client, mock_adapter):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123456"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200
|
|
results = resp.json()["results"]
|
|
assert len(results) == 1
|
|
assert results[0]["status"] == "sent"
|
|
assert results[0]["message_id"] == "msg_001"
|
|
mock_adapter.send.assert_called_once_with("123456", "Hello!")
|
|
|
|
def test_with_title(self, client, mock_adapter):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123456"},
|
|
"message": "Hello!",
|
|
"title": "Alert",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200
|
|
mock_adapter.send.assert_called_once_with("123456", "**Alert**\nHello!")
|
|
|
|
def test_username_resolution(self, client, storage, mock_adapter):
|
|
# Create a user and link a channel
|
|
storage.create_user("u1", "testuser", "Test User", "hash")
|
|
storage.create_channel_user("discord", "disc_123", "u1")
|
|
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"username": "testuser"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200
|
|
results = resp.json()["results"]
|
|
assert len(results) == 1
|
|
assert results[0]["status"] == "sent"
|
|
mock_adapter.send.assert_called_once_with("disc_123", "Hello!")
|
|
|
|
def test_unknown_username(self, client):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"username": "nobody"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 404
|
|
error = resp.json()["error"]
|
|
assert "nobody" not in error
|
|
assert "not found or has no linked channels" in error
|
|
|
|
def test_user_no_channels(self, authed_client, storage):
|
|
storage.create_user("u1", "testuser", "Test User", "hash")
|
|
|
|
resp = authed_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"username": "testuser"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 404
|
|
# Generic message -- must not differentiate "not found" vs "no channels"
|
|
error = resp.json()["error"]
|
|
assert "testuser" not in error
|
|
assert "not found or has no linked channels" in error
|
|
|
|
def test_missing_fields(self, client):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={"target": {"username": "x"}},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_missing_target(self, client):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={"message": "Hello!"},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_invalid_target(self, client):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"invalid": "field"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_no_adapter(self, client, storage):
|
|
# App has discord adapter, try email target
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "email", "channel_id": "test@example.com"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200
|
|
results = resp.json()["results"]
|
|
assert results[0]["status"] == "no_adapter"
|
|
|
|
def test_adapter_failure(self, client, mock_adapter):
|
|
mock_adapter.send.side_effect = RuntimeError("Discord API error")
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123456"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200
|
|
results = resp.json()["results"]
|
|
assert results[0]["status"] == "failed"
|
|
|
|
def test_adapter_timeout(self, storage, mock_adapter, monkeypatch):
|
|
"""Adapter calls that exceed the timeout return timeout status."""
|
|
import asyncio
|
|
|
|
async def _hang(*_args: object) -> str:
|
|
await asyncio.sleep(300)
|
|
return ""
|
|
|
|
mock_adapter.send = _hang
|
|
|
|
# Use a very short timeout to keep the test fast
|
|
from turnstone.channels import _http as _http_mod
|
|
|
|
monkeypatch.setattr(_http_mod, "_NOTIFY_ADAPTER_TIMEOUT", 0.1)
|
|
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
|
tc = TestClient(app)
|
|
resp = tc.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123456"},
|
|
"message": "Hello!",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200
|
|
results = resp.json()["results"]
|
|
assert results[0]["status"] == "timeout"
|
|
|
|
def test_invalid_json(self, client):
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
content=b"not json",
|
|
headers={
|
|
"content-type": "application/json",
|
|
"Authorization": f"Bearer {_make_jwt()}",
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_whitespace_only_message(self, client):
|
|
"""Whitespace-only messages should be rejected."""
|
|
resp = client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": " ",
|
|
},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
class TestNotifyAuth:
|
|
"""Tests for authentication on the /v1/api/notify endpoint."""
|
|
|
|
def test_reject_when_unconfigured(self, no_auth_client):
|
|
"""Requests are rejected (fail closed) when no auth is configured."""
|
|
resp = no_auth_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_reject_without_token(self, authed_client):
|
|
"""Requests without Authorization header are rejected when auth is configured."""
|
|
resp = authed_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_reject_wrong_token(self, authed_client):
|
|
"""Requests with wrong token are rejected."""
|
|
resp = authed_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
headers={"Authorization": "Bearer wrong-token"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_accept_valid_jwt(self, jwt_client, mock_adapter):
|
|
"""Requests with a valid JWT for the channel audience are accepted."""
|
|
token = _make_jwt()
|
|
resp = jwt_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_reject_jwt_wrong_audience(self, jwt_client):
|
|
"""JWTs with wrong audience are rejected."""
|
|
token = create_jwt(
|
|
user_id="system",
|
|
scopes=frozenset({"write"}),
|
|
source="service",
|
|
secret=_JWT_SECRET,
|
|
audience="turnstone-server", # wrong audience
|
|
)
|
|
resp = jwt_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_reject_jwt_wrong_secret(self, jwt_client):
|
|
"""JWTs signed with wrong secret are rejected."""
|
|
token = create_jwt(
|
|
user_id="system",
|
|
scopes=frozenset({"write"}),
|
|
source="service",
|
|
secret="b" * 32, # wrong secret
|
|
audience=JWT_AUD_CHANNEL,
|
|
)
|
|
resp = jwt_client.post(
|
|
"/v1/api/notify",
|
|
json={
|
|
"target": {"channel_type": "discord", "channel_id": "123"},
|
|
"message": "Hello!",
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_health_bypasses_auth(self, authed_client):
|
|
"""Health endpoint is always accessible regardless of auth config."""
|
|
resp = authed_client.get("/health")
|
|
assert resp.status_code == 200
|