""" Comprehensive Tests for ThinkStorm Signal Gateway Integration Covers: 1. Valid callback-test request returns 2xx and causes no message-processing side effects. 2. Valid Signal event with correct bearer secret is durably accepted. 3. Missing bearer secret returns 401. 4. Wrong bearer secret returns 401. 5. Nullable sender_number, sender_name, group_id, and reply_to are accepted. 6. Unsupported schema_version is rejected with 400. 7. Missing or mismatched Idempotency-Key is rejected for actual events. 8. Delivering the same event_id twice returns success but produces exactly one downstream action. 9. Direct message maps to expected ThinkStorm conversation/metadata. 10. Group message maps to expected ThinkStorm conversation/metadata. 11. Callback-test payload is not parsed as a real Signal message. 12. Outbound client sends correct URL, authorization header, and JSON payload. 13. Outbound 202, 400, 401, 413, 503, and timeout behaviors are handled intentionally. 14. Secrets are absent from error messages and captured logs. 15. Service health checks. """ import pytest import json import uuid import urllib.request import urllib.error from unittest.mock import patch, MagicMock from io import BytesIO from starlette.testclient import TestClient from thinkstorm.main import app from thinkstorm.database import init_db, get_db from thinkstorm.config import config from thinkstorm.services.signal_gateway import ( SignalGatewayAdapter, SignalGatewayAuthError, SignalGatewayClientError, SignalGatewayPayloadTooLargeError, SignalGatewayUnavailableError, SignalGatewayTimeoutError, mask_secret ) TEST_SECRET = "sgw_callback_secret_test_2026_unit_testing" TEST_API_KEY = "sgw_apikey_test_2026_unit_testing" @pytest.fixture(autouse=True) def setup_test_environment(monkeypatch): init_db() monkeypatch.setattr(config.services, "signal_gateway_callback_secret", TEST_SECRET) monkeypatch.setattr(config.services, "signal_gateway_api_key", TEST_API_KEY) monkeypatch.setattr(config.services, "signal_gateway_base_url", "http://10.138.4.46:8000") @pytest.fixture def client(): return TestClient(app) # ----------------------------------------------------------------------------- # 1. Callback Test Request Tests # ----------------------------------------------------------------------------- def test_callback_test_request_success(client): """Test callback probe returns 2xx and creates no ideas or queue jobs.""" headers = { "Authorization": f"Bearer {TEST_SECRET}", "Content-Type": "application/json" } payload = { "schema_version": 1, "event_type": "signal_gateway.callback_test" } with get_db() as conn: ideas_before = conn.execute("SELECT COUNT(*) FROM ideas").fetchone()[0] events_before = conn.execute("SELECT COUNT(*) FROM signal_inbound_events").fetchone()[0] resp = client.post("/api/integrations/signal/events", headers=headers, json=payload) assert resp.status_code == 200 data = resp.json() assert data["accepted"] is True assert data["event_type"] == "signal_gateway.callback_test" with get_db() as conn: ideas_after = conn.execute("SELECT COUNT(*) FROM ideas").fetchone()[0] events_after = conn.execute("SELECT COUNT(*) FROM signal_inbound_events").fetchone()[0] assert ideas_after == ideas_before assert events_after == events_before # ----------------------------------------------------------------------------- # 2. Callback Authentication Tests # ----------------------------------------------------------------------------- def test_callback_missing_bearer_secret(client): """Missing bearer secret returns 401.""" payload = { "schema_version": 1, "event_type": "signal_gateway.callback_test" } resp = client.post("/api/integrations/signal/events", json=payload) assert resp.status_code == 401 assert "detail" in resp.json() def test_callback_wrong_bearer_secret(client): """Wrong bearer secret returns 401.""" headers = {"Authorization": "Bearer wrong_secret_token_value"} payload = { "schema_version": 1, "event_type": "signal_gateway.callback_test" } resp = client.post("/api/integrations/signal/events", headers=headers, json=payload) assert resp.status_code == 401 def test_callback_malformed_auth_header(client): """Malformed auth header (e.g. Basic or token only) returns 401.""" headers = {"Authorization": f"Basic {TEST_SECRET}"} payload = {"schema_version": 1, "event_type": "signal_gateway.callback_test"} resp = client.post("/api/integrations/signal/events", headers=headers, json=payload) assert resp.status_code == 401 # ----------------------------------------------------------------------------- # 3. Schema & Idempotency Key Validation # ----------------------------------------------------------------------------- def test_callback_unsupported_schema_version(client): """Unsupported schema_version is rejected with 400.""" headers = { "Authorization": f"Bearer {TEST_SECRET}", "Idempotency-Key": "sig_test_ver" } payload = { "schema_version": 2, "event_id": "sig_test_ver", "channel": "signal", "sender_uuid": "12345678-1234-1234-1234-123456789abc", "text": "Hello" } resp = client.post("/api/integrations/signal/events", headers=headers, json=payload) assert resp.status_code == 400 assert "schema_version" in resp.json()["detail"] def test_callback_missing_or_mismatched_idempotency_key(client): """Missing or mismatched Idempotency-Key header is rejected for actual events.""" event_id = "sig_idemp_001" payload = { "schema_version": 1, "event_id": event_id, "channel": "signal", "sender_uuid": "12345678-1234-1234-1234-123456789abc", "text": "An idea about autonomous systems." } # Missing header resp_missing = client.post( "/api/integrations/signal/events", headers={"Authorization": f"Bearer {TEST_SECRET}"}, json=payload ) assert resp_missing.status_code == 400 assert "Idempotency-Key" in resp_missing.json()["detail"] # Mismatched header resp_mismatch = client.post( "/api/integrations/signal/events", headers={ "Authorization": f"Bearer {TEST_SECRET}", "Idempotency-Key": "different_id_123" }, json=payload ) assert resp_mismatch.status_code == 400 assert "Idempotency-Key" in resp_mismatch.json()["detail"] # ----------------------------------------------------------------------------- # 4. Actual Signal Event Ingestion & Durable Idempotency # ----------------------------------------------------------------------------- def test_valid_signal_event_ingestion_and_idempotency(client): """Delivering a valid Signal event creates idea and inbound record; duplicate is harmless.""" event_id = f"sig_event_valid_{uuid.uuid4().hex[:12]}" headers = { "Authorization": f"Bearer {TEST_SECRET}", "Idempotency-Key": event_id } payload = { "schema_version": 1, "event_id": event_id, "channel": "signal", "sender_uuid": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d", "sender_number": "+15551234567", "sender_name": "Alice Developer", "group_id": None, "message_id": "1787300000001", "reply_to": None, "text": "Automated pipeline for tracking real-time API contract drifts.", "received_at": "2026-08-21T20:15:00+00:00" } # 1. First delivery -> durably accepted and enqueued resp1 = client.post("/api/integrations/signal/events", headers=headers, json=payload) assert resp1.status_code == 200 data1 = resp1.json() assert data1["accepted"] is True assert data1["event_id"] == event_id idea_id = data1["idea_id"] assert idea_id.startswith("TS-") assert data1.get("duplicate") is not True # Verify DB state with get_db() as conn: ev_row = conn.execute("SELECT * FROM signal_inbound_events WHERE event_id = ?", (event_id,)).fetchone() assert ev_row is not None assert ev_row["sender_uuid"] == "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d" assert ev_row["sender_name"] == "Alice Developer" assert ev_row["sender_number"] == "+15551234567" assert ev_row["group_id"] is None assert ev_row["idea_id"] == idea_id idea_row = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() assert idea_row is not None assert idea_row["original_text"] == "Automated pipeline for tracking real-time API contract drifts." assert idea_row["source_channel"] == "signal" assert idea_row["source_sender_uuid"] == "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d" assert idea_row["source_event_id"] == event_id assert idea_row["lifecycle_state"] == "SUBMITTED" assert idea_row["processing_state"] == "QUEUED" # 2. Duplicate delivery -> returns 200 success without creating duplicate idea with get_db() as conn: ideas_before_dup = conn.execute("SELECT COUNT(*) FROM ideas").fetchone()[0] resp2 = client.post("/api/integrations/signal/events", headers=headers, json=payload) assert resp2.status_code == 200 data2 = resp2.json() assert data2["accepted"] is True assert data2["event_id"] == event_id assert data2.get("duplicate") is True assert data2["idea_id"] == idea_id with get_db() as conn: ideas_after_dup = conn.execute("SELECT COUNT(*) FROM ideas").fetchone()[0] assert ideas_after_dup == ideas_before_dup # ----------------------------------------------------------------------------- # 5. Direct vs Group Message Mapping & Nullable Fields # ----------------------------------------------------------------------------- def test_direct_message_mapping_with_null_fields(client): """Direct message with null sender_number, sender_name, group_id, reply_to is accepted.""" event_id = f"sig_event_direct_nulls_{uuid.uuid4().hex[:12]}" headers = { "Authorization": f"Bearer {TEST_SECRET}", "Idempotency-Key": event_id } payload = { "schema_version": 1, "event_id": event_id, "channel": "signal", "sender_uuid": "bbbbbbbb-1111-2222-3333-444444444444", "sender_number": None, "sender_name": None, "group_id": None, "message_id": "1787300000002", "reply_to": None, "text": "Direct message with completely null optional metadata.", "received_at": "2026-08-21T20:20:00+00:00" } resp = client.post("/api/integrations/signal/events", headers=headers, json=payload) assert resp.status_code == 200 idea_id = resp.json()["idea_id"] with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() assert idea["source_channel"] == "signal" assert idea["source_sender_uuid"] == "bbbbbbbb-1111-2222-3333-444444444444" assert idea["source_group_id"] is None def test_group_message_mapping(client): """Group message sets source_group_id and source_sender_uuid appropriately.""" event_id = f"sig_event_group_{uuid.uuid4().hex[:12]}" group_id = "EdG5w+Xq1eAbcDeFgHiJkLmNoPqRsTuVwXyZ1234567=" headers = { "Authorization": f"Bearer {TEST_SECRET}", "Idempotency-Key": event_id } payload = { "schema_version": 1, "event_id": event_id, "channel": "signal", "sender_uuid": "cccccccc-2222-3333-4444-555555555555", "sender_number": "+15559876543", "sender_name": "Bob TeamLead", "group_id": group_id, "message_id": "1787300000003", "reply_to": None, "text": "Team brainstorming: Distributed event sourcing with sqlite-vss.", "received_at": "2026-08-21T20:25:00+00:00" } resp = client.post("/api/integrations/signal/events", headers=headers, json=payload) assert resp.status_code == 200 idea_id = resp.json()["idea_id"] with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() assert idea["source_channel"] == "signal" assert idea["source_sender_uuid"] == "cccccccc-2222-3333-4444-555555555555" assert idea["source_group_id"] == group_id ev = conn.execute("SELECT * FROM signal_inbound_events WHERE event_id = ?", (event_id,)).fetchone() assert ev["group_id"] == group_id # ----------------------------------------------------------------------------- # 6. Outbound Signal Gateway Client Tests # ----------------------------------------------------------------------------- @pytest.mark.asyncio async def test_outbound_client_success_202(): """Outbound client sends valid payload and handles 202 Accepted.""" adapter = SignalGatewayAdapter(endpoint="http://10.138.4.46:8000", api_key=TEST_API_KEY) mock_resp = MagicMock() mock_resp.status = 202 mock_resp.__enter__.return_value = mock_resp mock_resp.read.return_value = json.dumps({ "accepted": True, "application_id": "thinkstorm", "message_id": "sgw_1787300000000_0", "status": "queued" }).encode("utf-8") with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: res = await adapter.send_message( recipient="12345678-1234-1234-1234-123456789abc", text="ThinkStorm response idea processed" ) assert res["accepted"] is True assert res["status"] == "queued" assert res["message_id"] == "sgw_1787300000000_0" # Verify request details req = mock_urlopen.call_args[0][0] assert req.full_url == "http://10.138.4.46:8000/api/v1/messages" assert req.headers["Authorization"] == f"Bearer {TEST_API_KEY}" assert req.headers["Content-type"] == "application/json" body = json.loads(req.data.decode("utf-8")) assert body["recipient"] == "12345678-1234-1234-1234-123456789abc" assert body["text"] == "ThinkStorm response idea processed" @pytest.mark.asyncio async def test_outbound_client_invalid_recipient_or_text(): """Outbound client validates recipient and text length constraints before sending.""" adapter = SignalGatewayAdapter(endpoint="http://10.138.4.46:8000", api_key=TEST_API_KEY) # Empty recipient with pytest.raises(SignalGatewayClientError): await adapter.send_message(recipient="", text="Hello") # Recipient > 256 bytes with pytest.raises(SignalGatewayClientError): await adapter.send_message(recipient="a" * 257, text="Hello") # Empty text with pytest.raises(SignalGatewayClientError): await adapter.send_message(recipient="uuid-123", text="") # Text > 16000 bytes with pytest.raises(SignalGatewayPayloadTooLargeError): await adapter.send_message(recipient="uuid-123", text="x" * 16001) @pytest.mark.asyncio async def test_outbound_client_error_handling(): """Outbound client maps HTTP status codes 400, 401, 413, 503 and timeouts.""" adapter = SignalGatewayAdapter(endpoint="http://10.138.4.46:8000", api_key=TEST_API_KEY) # 400 Bad Request err_400 = urllib.error.HTTPError( url="http://10.138.4.46:8000/api/v1/messages", code=400, msg="Bad Request", hdrs={}, fp=BytesIO(json.dumps({"accepted": False, "error": {"code": "bad_request", "message": "Invalid recipient format"}}).encode("utf-8")) ) with patch("urllib.request.urlopen", side_effect=err_400): with pytest.raises(SignalGatewayClientError) as exc: await adapter.send_message(recipient="bad-recipient", text="Test") assert "400" in str(exc.value) # 401 Unauthorized err_401 = urllib.error.HTTPError( url="http://10.138.4.46:8000/api/v1/messages", code=401, msg="Unauthorized", hdrs={}, fp=BytesIO(json.dumps({"accepted": False, "error": {"code": "unauthorized", "message": "Invalid key"}}).encode("utf-8")) ) with patch("urllib.request.urlopen", side_effect=err_401): with pytest.raises(SignalGatewayAuthError): await adapter.send_message(recipient="uuid-123", text="Test") # 413 Payload Too Large err_413 = urllib.error.HTTPError( url="http://10.138.4.46:8000/api/v1/messages", code=413, msg="Payload Too Large", hdrs={}, fp=BytesIO(json.dumps({"accepted": False, "error": {"code": "payload_too_large", "message": "Too large"}}).encode("utf-8")) ) with patch("urllib.request.urlopen", side_effect=err_413): with pytest.raises(SignalGatewayPayloadTooLargeError): await adapter.send_message(recipient="uuid-123", text="Test") # 503 Service Unavailable (retries and fails) err_503 = urllib.error.HTTPError( url="http://10.138.4.46:8000/api/v1/messages", code=503, msg="Service Unavailable", hdrs={}, fp=BytesIO(json.dumps({"accepted": False, "error": {"code": "unavailable", "message": "Queue full"}}).encode("utf-8")) ) with patch("urllib.request.urlopen", side_effect=err_503): with pytest.raises(SignalGatewayUnavailableError): await adapter.send_message(recipient="uuid-123", text="Test", max_retries=1) # Timeout Error timeout_err = urllib.error.URLError(reason="Connection timed out") with patch("urllib.request.urlopen", side_effect=timeout_err): with pytest.raises(SignalGatewayTimeoutError): await adapter.send_message(recipient="uuid-123", text="Test") # ----------------------------------------------------------------------------- # 7. Secret Redaction / Safety Tests # ----------------------------------------------------------------------------- def test_secrets_redacted_in_errors_and_masking(): """Secrets are properly masked and not leaked.""" masked = mask_secret("sgw_callback_secret_123456789") assert "sgw_" in masked assert "6789" in masked assert "callback_secret" not in masked assert mask_secret("") == "" assert mask_secret("short") == "***" # ----------------------------------------------------------------------------- # 8. Admin Service Health Test # ----------------------------------------------------------------------------- def test_admin_signal_gateway_health_test(client): """Admin can trigger health probe for signal_gateway.""" # Login as admin login_resp = client.post("/api/auth/login", json={"username": "admin", "password": config.admin_bootstrap_key}) token = login_resp.json()["token"] resp = client.post("/api/admin/services/signal_gateway/test", headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 data = resp.json() assert data["service_id"] == "signal_gateway" assert "healthy" in data