Files
Cortex-Inara/cortex/tests/test_webhooks.py
Scott Idem 92a8f5d894 test: add Cortex test suite (77 tests, no LLM calls)
Tests cover:
  - Smoke: /health, /auth/status, /distill/status (test_health.py)
  - Persona validation: path traversal, bad names, list_personas (test_persona.py)
  - Chat API: persona routing, session persistence, error handling (test_api_chat.py)
  - Files API: ALLOWED set enforcement, read/write, missing files (test_api_files.py)
  - Webhooks: NC Talk HMAC accept/reject, Google Chat JWT (test_webhooks.py)
  - Tools: scratch read/write/append/clear, tasks CRUD, cron parser + tools (test_tools.py)
  - Security: path traversal, replay attack, known gaps documented (test_security.py)

All LLM calls mocked — suite runs in ~1.4s.
Run: cd cortex && .venv/bin/pytest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:03:42 -04:00

165 lines
5.6 KiB
Python

"""
Webhook auth tests — NC Talk HMAC, Google Chat JWT.
These tests verify that auth is enforced, not that full LLM responses work.
"""
import hashlib
import hmac
import json
import pytest
from unittest.mock import AsyncMock, patch
# ---------------------------------------------------------------------------
# Nextcloud Talk
# ---------------------------------------------------------------------------
_NC_SECRET = "test-bot-secret-12345"
_VALID_NC_PAYLOAD = {
"type": "Create",
"actor": {"type": "users", "id": "testuser", "name": "Test User"},
"object": {
"type": "Note",
"content": json.dumps({"message": "Hello Inara"}),
},
"target": {"id": "abc123token"},
}
def _nc_headers(body: bytes, secret: str) -> dict:
random_str = "abc123"
sig = hmac.new(
secret.encode(),
(random_str + body.decode("utf-8")).encode(),
hashlib.sha256,
).hexdigest()
return {
"X-Nextcloud-Talk-Random": random_str,
"X-Nextcloud-Talk-Signature": sig,
}
@pytest.mark.anyio
async def test_nct_valid_signature(client, mock_llm):
body = json.dumps(_VALID_NC_PAYLOAD).encode()
with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET):
with patch("routers.nextcloud_talk._send_reply", new_callable=AsyncMock):
headers = _nc_headers(body, _NC_SECRET)
r = await client.post(
"/inara-nextcloud-talk-webhook",
content=body,
headers={**headers, "Content-Type": "application/json"},
)
assert r.status_code == 200
@pytest.mark.anyio
async def test_nct_wrong_signature(client):
body = json.dumps(_VALID_NC_PAYLOAD).encode()
with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET):
r = await client.post(
"/inara-nextcloud-talk-webhook",
content=body,
headers={
"Content-Type": "application/json",
"X-Nextcloud-Talk-Random": "abc123",
"X-Nextcloud-Talk-Signature": "badsignature",
},
)
assert r.status_code == 401
@pytest.mark.anyio
async def test_nct_missing_signature(client):
body = json.dumps(_VALID_NC_PAYLOAD).encode()
with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET):
r = await client.post(
"/inara-nextcloud-talk-webhook",
content=body,
headers={"Content-Type": "application/json"},
)
assert r.status_code == 401
@pytest.mark.anyio
async def test_nct_no_secret_configured(client):
"""Service should return 500 if secret is not set, not process the message."""
body = json.dumps(_VALID_NC_PAYLOAD).encode()
with patch("config.settings.nextcloud_talk_bot_secret", ""):
r = await client.post(
"/inara-nextcloud-talk-webhook",
content=body,
headers={"Content-Type": "application/json"},
)
assert r.status_code == 500
@pytest.mark.anyio
async def test_nct_bot_message_ignored(client):
"""Messages from other bots should be silently ignored (not processed)."""
payload = {**_VALID_NC_PAYLOAD, "actor": {"type": "bots", "id": "otherbot", "name": "Bot"}}
body = json.dumps(payload).encode()
with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET):
headers = _nc_headers(body, _NC_SECRET)
r = await client.post(
"/inara-nextcloud-talk-webhook",
content=body,
headers={**headers, "Content-Type": "application/json"},
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# Google Chat
# ---------------------------------------------------------------------------
_GCHAT_PAYLOAD = {
"chat": {
"messagePayload": {
"message": {"text": "Hello", "argumentText": "Hello"},
"space": {"name": "spaces/test123", "type": "ROOM"},
},
"user": {"displayName": "Test User"},
}
}
@pytest.mark.anyio
async def test_gchat_no_audience_configured(client, mock_llm):
"""When audience is not set, JWT check is skipped (current behaviour — documented bypass)."""
with patch("config.settings.google_chat_audience", ""):
r = await client.post("/channels/google-chat", json=_GCHAT_PAYLOAD)
# Should process the message (no auth enforcement when audience is empty)
assert r.status_code == 200
@pytest.mark.anyio
async def test_gchat_missing_token_with_audience(client):
"""When audience IS configured, requests without a token must be rejected."""
with patch("config.settings.google_chat_audience", "123456789"):
r = await client.post("/channels/google-chat", json=_GCHAT_PAYLOAD)
assert r.status_code == 401
@pytest.mark.anyio
async def test_gchat_invalid_token_with_audience(client):
"""A fake token should fail JWT verification."""
payload_with_token = {
**_GCHAT_PAYLOAD,
"authorizationEventObject": {"systemIdToken": "not.a.valid.jwt"},
}
with patch("config.settings.google_chat_audience", "123456789"):
r = await client.post("/channels/google-chat", json=payload_with_token)
assert r.status_code == 401
@pytest.mark.anyio
async def test_gchat_added_to_space(client, mock_llm):
"""Bot added to a space — should return a greeting, no auth when audience empty."""
payload = {"chat": {"addedToSpacePayload": {"space": {"type": "ROOM"}}}}
with patch("config.settings.google_chat_audience", ""):
r = await client.post("/channels/google-chat", json=payload)
assert r.status_code == 200
assert "hostAppDataAction" in r.json()