mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-17 09:21:34 -06:00
e7fe8fca9d
* Add channel notification tool with security hardening Implements the `notify` tool allowing the LLM to send notifications to Discord channels/users via the channel gateway. Includes fixes for 11 review findings: JWT auth on the gateway endpoint, first-healthy gateway delivery with retry+backoff, rate limiting only on success, SSRF URL scheme validation, Discord mention sanitization, SQLite ON CONFLICT upsert preserving created timestamps, advertise URL resolution for 0.0.0.0 bind, randomized service IDs, generic error messages to prevent internal state leakage, and partial direct-target validation. Service registry with heartbeat-based health filtering (migration 005). Channel gateway registers on startup, heartbeats every 30s, deregisters on shutdown. 70 new tests covering tool prepare/execute, HTTP endpoint auth (static + JWT), storage CRUD, and retry behavior. * Add notify documentation, diagrams, and review fixes Documentation: - New sequence diagram 17-notify-flow.puml showing end-to-end delivery - Updated 16-channel-architecture.puml with services table, notify HTTP path, and Notification Flow note - channels.md: Notifications section (targeting, delivery flow, service registry, security) and new config table entries - tools.md: notify tool reference, updated counts/tables (14→15 tools) - security.md: channel gateway row in service-to-service auth table - architecture.md: notification subsystem paragraph Review fixes (copilot): - _http.py: fail closed when auth unconfigured (401 instead of pass- through), strip whitespace on message/title, generic error messages for user-not-found vs no-linked-channels - session.py: parse gateway response JSON and require at least one result with status=="sent" before counting as success - _postgresql.py: use index_elements instead of constraint for upsert
87 lines
3.4 KiB
Python
87 lines
3.4 KiB
Python
"""Tests for the services registry storage methods."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path):
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
class TestServiceRegistry:
|
|
def test_register_and_list(self, storage):
|
|
storage.register_service("channel", "ch-1", "http://localhost:8091")
|
|
services = storage.list_services("channel", max_age_seconds=120)
|
|
assert len(services) == 1
|
|
assert services[0]["service_type"] == "channel"
|
|
assert services[0]["service_id"] == "ch-1"
|
|
assert services[0]["url"] == "http://localhost:8091"
|
|
|
|
def test_register_upsert(self, storage):
|
|
storage.register_service("channel", "ch-1", "http://old:8091")
|
|
storage.register_service("channel", "ch-1", "http://new:8091")
|
|
services = storage.list_services("channel", max_age_seconds=120)
|
|
assert len(services) == 1
|
|
assert services[0]["url"] == "http://new:8091"
|
|
|
|
def test_heartbeat(self, storage):
|
|
storage.register_service("channel", "ch-1", "http://localhost:8091")
|
|
result = storage.heartbeat_service("channel", "ch-1")
|
|
assert result is True
|
|
|
|
def test_heartbeat_nonexistent(self, storage):
|
|
result = storage.heartbeat_service("channel", "nonexistent")
|
|
assert result is False
|
|
|
|
def test_list_filters_stale(self, storage):
|
|
storage.register_service("channel", "ch-1", "http://localhost:8091")
|
|
# Manually set heartbeat to the past so it's stale
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from turnstone.core.storage._schema import services
|
|
|
|
old_time = (datetime.now(UTC) - timedelta(seconds=300)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
with storage._engine.connect() as conn:
|
|
conn.execute(sa.update(services).values(last_heartbeat=old_time))
|
|
conn.commit()
|
|
|
|
# Should be excluded with 120s max age
|
|
result = storage.list_services("channel", max_age_seconds=120)
|
|
assert len(result) == 0
|
|
|
|
def test_list_empty(self, storage):
|
|
services = storage.list_services("channel", max_age_seconds=120)
|
|
assert services == []
|
|
|
|
def test_list_filters_by_type(self, storage):
|
|
storage.register_service("channel", "ch-1", "http://localhost:8091")
|
|
storage.register_service("bridge", "br-1", "http://localhost:8080")
|
|
channels = storage.list_services("channel", max_age_seconds=120)
|
|
bridges = storage.list_services("bridge", max_age_seconds=120)
|
|
assert len(channels) == 1
|
|
assert len(bridges) == 1
|
|
|
|
def test_deregister(self, storage):
|
|
storage.register_service("channel", "ch-1", "http://localhost:8091")
|
|
result = storage.deregister_service("channel", "ch-1")
|
|
assert result is True
|
|
services = storage.list_services("channel", max_age_seconds=120)
|
|
assert services == []
|
|
|
|
def test_deregister_nonexistent(self, storage):
|
|
result = storage.deregister_service("channel", "nonexistent")
|
|
assert result is False
|
|
|
|
def test_metadata(self, storage):
|
|
storage.register_service(
|
|
"channel", "ch-1", "http://localhost:8091", metadata='{"adapter": "discord"}'
|
|
)
|
|
services = storage.list_services("channel", max_age_seconds=120)
|
|
assert services[0]["metadata"] == '{"adapter": "discord"}'
|