Improve security
This commit is contained in:
@@ -24,6 +24,7 @@ class AttachmentSecurityTests(SimpleTestCase):
|
||||
size=32,
|
||||
)
|
||||
|
||||
@override_settings(ATTACHMENT_ALLOW_PRIVATE_URLS=False)
|
||||
def test_blocks_private_url_by_default(self):
|
||||
with self.assertRaises(ValueError):
|
||||
validate_attachment_url("http://localhost/internal")
|
||||
|
||||
224
core/tests/test_command_security_policy.py
Normal file
224
core/tests/test_command_security_policy.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import TestCase
|
||||
|
||||
from core.commands.base import CommandContext
|
||||
from core.commands.engine import process_inbound_message
|
||||
from core.gateway.commands import (
|
||||
GatewayCommandContext,
|
||||
GatewayCommandRoute,
|
||||
dispatch_gateway_command,
|
||||
)
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
CommandChannelBinding,
|
||||
CommandProfile,
|
||||
CommandSecurityPolicy,
|
||||
GatewayCommandEvent,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
User,
|
||||
UserXmppOmemoState,
|
||||
)
|
||||
from core.security.command_policy import CommandSecurityContext, evaluate_command_policy
|
||||
|
||||
|
||||
class CommandSecurityPolicyTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="policy-user",
|
||||
email="policy-user@example.com",
|
||||
password="x",
|
||||
)
|
||||
self.person = Person.objects.create(user=self.user, name="Policy Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="xmpp",
|
||||
identifier="policy-user@zm.is",
|
||||
)
|
||||
self.session = ChatSession.objects.create(
|
||||
user=self.user,
|
||||
identifier=self.identifier,
|
||||
)
|
||||
|
||||
def test_command_profile_scope_denies_disallowed_service(self):
|
||||
profile = CommandProfile.objects.create(
|
||||
user=self.user,
|
||||
slug="bp",
|
||||
name="Business Plan",
|
||||
enabled=True,
|
||||
trigger_token="#bp#",
|
||||
reply_required=False,
|
||||
exact_match_only=True,
|
||||
)
|
||||
CommandChannelBinding.objects.create(
|
||||
profile=profile,
|
||||
direction="ingress",
|
||||
service="xmpp",
|
||||
channel_identifier="policy-user@zm.is",
|
||||
enabled=True,
|
||||
)
|
||||
CommandSecurityPolicy.objects.create(
|
||||
user=self.user,
|
||||
scope_key="command.bp",
|
||||
enabled=True,
|
||||
allowed_services=["whatsapp"],
|
||||
)
|
||||
msg = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="",
|
||||
text="#bp#",
|
||||
ts=1000,
|
||||
source_service="xmpp",
|
||||
source_chat_id="policy-user@zm.is",
|
||||
message_meta={},
|
||||
)
|
||||
results = async_to_sync(process_inbound_message)(
|
||||
CommandContext(
|
||||
service="xmpp",
|
||||
channel_identifier="policy-user@zm.is",
|
||||
message_id=str(msg.id),
|
||||
user_id=self.user.id,
|
||||
message_text="#bp#",
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual("skipped", results[0].status)
|
||||
self.assertTrue(str(results[0].error).startswith("policy_denied:service_not_allowed"))
|
||||
|
||||
def test_gateway_scope_can_require_trusted_omemo_key(self):
|
||||
CommandSecurityPolicy.objects.create(
|
||||
user=self.user,
|
||||
scope_key="gateway.tasks",
|
||||
enabled=True,
|
||||
require_omemo=True,
|
||||
require_trusted_omemo_fingerprint=True,
|
||||
)
|
||||
UserXmppOmemoState.objects.create(
|
||||
user=self.user,
|
||||
status="detected",
|
||||
latest_client_key="sid:abc",
|
||||
last_sender_jid="policy-user@zm.is/phone",
|
||||
last_target_jid="jews.zm.is",
|
||||
)
|
||||
outputs: list[str] = []
|
||||
|
||||
async def _tasks_handler(_ctx, emit):
|
||||
emit("ok")
|
||||
return True
|
||||
|
||||
handled = async_to_sync(dispatch_gateway_command)(
|
||||
context=GatewayCommandContext(
|
||||
user=self.user,
|
||||
source_message=None,
|
||||
service="xmpp",
|
||||
channel_identifier="policy-user@zm.is",
|
||||
sender_identifier="policy-user@zm.is/phone",
|
||||
message_text=".tasks list",
|
||||
message_meta={"xmpp": {"omemo_status": "detected", "omemo_client_key": "sid:abc"}},
|
||||
payload={},
|
||||
),
|
||||
routes=[
|
||||
GatewayCommandRoute(
|
||||
name="tasks",
|
||||
scope_key="gateway.tasks",
|
||||
matcher=lambda text: str(text).startswith(".tasks"),
|
||||
handler=_tasks_handler,
|
||||
)
|
||||
],
|
||||
emit=lambda value: outputs.append(str(value)),
|
||||
)
|
||||
self.assertTrue(handled)
|
||||
self.assertEqual(["ok"], outputs)
|
||||
event = GatewayCommandEvent.objects.order_by("-created_at").first()
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual("ok", event.status if event else "")
|
||||
|
||||
def test_gateway_scope_blocks_when_omemo_required_but_missing(self):
|
||||
CommandSecurityPolicy.objects.create(
|
||||
user=self.user,
|
||||
scope_key="gateway.tasks",
|
||||
enabled=True,
|
||||
require_omemo=True,
|
||||
)
|
||||
outputs: list[str] = []
|
||||
|
||||
async def _tasks_handler(_ctx, emit):
|
||||
emit("unexpected")
|
||||
return True
|
||||
|
||||
handled = async_to_sync(dispatch_gateway_command)(
|
||||
context=GatewayCommandContext(
|
||||
user=self.user,
|
||||
source_message=None,
|
||||
service="xmpp",
|
||||
channel_identifier="policy-user@zm.is",
|
||||
sender_identifier="policy-user@zm.is/phone",
|
||||
message_text=".tasks list",
|
||||
message_meta={"xmpp": {"omemo_status": "no_omemo"}},
|
||||
payload={},
|
||||
),
|
||||
routes=[
|
||||
GatewayCommandRoute(
|
||||
name="tasks",
|
||||
scope_key="gateway.tasks",
|
||||
matcher=lambda text: str(text).startswith(".tasks"),
|
||||
handler=_tasks_handler,
|
||||
)
|
||||
],
|
||||
emit=lambda value: outputs.append(str(value)),
|
||||
)
|
||||
self.assertTrue(handled)
|
||||
self.assertTrue(outputs)
|
||||
self.assertIn("blocked by policy", outputs[0].lower())
|
||||
event = GatewayCommandEvent.objects.order_by("-created_at").first()
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual("blocked", event.status if event else "")
|
||||
|
||||
def test_global_scope_override_can_force_scope_disabled(self):
|
||||
CommandSecurityPolicy.objects.create(
|
||||
user=self.user,
|
||||
scope_key="gateway.tasks",
|
||||
enabled=True,
|
||||
)
|
||||
CommandSecurityPolicy.objects.create(
|
||||
user=self.user,
|
||||
scope_key="global.override",
|
||||
settings={"scope_enabled": "off"},
|
||||
)
|
||||
decision = evaluate_command_policy(
|
||||
user=self.user,
|
||||
scope_key="gateway.tasks",
|
||||
context=CommandSecurityContext(
|
||||
service="xmpp",
|
||||
channel_identifier="policy-user@zm.is",
|
||||
message_meta={},
|
||||
payload={},
|
||||
),
|
||||
)
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual("policy_disabled", decision.code)
|
||||
|
||||
def test_global_scope_override_allowed_services_applies_to_all_scopes(self):
|
||||
CommandSecurityPolicy.objects.create(
|
||||
user=self.user,
|
||||
scope_key="global.override",
|
||||
allowed_services=["xmpp"],
|
||||
)
|
||||
decision = evaluate_command_policy(
|
||||
user=self.user,
|
||||
scope_key="tasks.commands",
|
||||
context=CommandSecurityContext(
|
||||
service="whatsapp",
|
||||
channel_identifier="12035550123",
|
||||
message_meta={},
|
||||
payload={},
|
||||
),
|
||||
)
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual("service_not_allowed", decision.code)
|
||||
382
core/tests/test_cross_platform_messaging.py
Normal file
382
core/tests/test_cross_platform_messaging.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
Cross-platform messaging tests: replies, reactions, and messages across
|
||||
Signal, WhatsApp, and XMPP.
|
||||
|
||||
Signal coverage is in test_signal_reply_send.py. This file fills the gaps
|
||||
for WhatsApp and XMPP, and verifies the shared reply_sync infrastructure
|
||||
works correctly for both services.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
|
||||
from core.clients import transport
|
||||
from core.clients.xmpp import (
|
||||
_extract_xmpp_reaction,
|
||||
_extract_xmpp_reply_target_id,
|
||||
_parse_greentext_reaction,
|
||||
)
|
||||
from core.messaging import history, reply_sync
|
||||
from core.models import ChatSession, Message, Person, PersonIdentifier, User
|
||||
from core.presence.inference import now_ms
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fake_stanza(xml_text: str) -> SimpleNamespace:
|
||||
"""Minimal stanza-like object with an .xml attribute."""
|
||||
return SimpleNamespace(xml=ET.fromstring(xml_text))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WhatsApp — reply extraction (pure, no DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WhatsAppReplyExtractionTests(SimpleTestCase):
|
||||
def test_extract_reply_ref_from_contextinfo_stanza_id(self):
|
||||
payload = {
|
||||
"contextInfo": {
|
||||
"stanzaId": "wa-anchor-001",
|
||||
"participant": "447700900001@s.whatsapp.net",
|
||||
}
|
||||
}
|
||||
ref = reply_sync.extract_reply_ref("whatsapp", payload)
|
||||
self.assertEqual("wa-anchor-001", ref.get("reply_source_message_id"))
|
||||
self.assertEqual("whatsapp", ref.get("reply_source_service"))
|
||||
self.assertEqual("447700900001@s.whatsapp.net", ref.get("reply_source_chat_id"))
|
||||
|
||||
def test_extract_reply_ref_from_extended_text_message(self):
|
||||
payload = {
|
||||
"extendedTextMessage": {
|
||||
"text": "quoting you",
|
||||
"contextInfo": {
|
||||
"stanzaId": "wa-anchor-002",
|
||||
"participant": "447700900002@s.whatsapp.net",
|
||||
},
|
||||
}
|
||||
}
|
||||
ref = reply_sync.extract_reply_ref("whatsapp", payload)
|
||||
self.assertEqual("wa-anchor-002", ref.get("reply_source_message_id"))
|
||||
|
||||
def test_extract_reply_ref_returns_empty_when_no_context(self):
|
||||
ref = reply_sync.extract_reply_ref("whatsapp", {"conversation": "plain text"})
|
||||
self.assertEqual({}, ref)
|
||||
|
||||
def test_extract_reply_ref_from_image_message_contextinfo(self):
|
||||
payload = {
|
||||
"imageMessage": {
|
||||
"caption": "look at this",
|
||||
"contextInfo": {
|
||||
"stanzaId": "wa-anchor-003",
|
||||
"participant": "447700900003@s.whatsapp.net",
|
||||
},
|
||||
}
|
||||
}
|
||||
ref = reply_sync.extract_reply_ref("whatsapp", payload)
|
||||
self.assertEqual("wa-anchor-003", ref.get("reply_source_message_id"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WhatsApp — reply resolution (requires DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WhatsAppReplyResolutionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
"wa-resolve-user", "wa-resolve@example.com", "x"
|
||||
)
|
||||
self.person = Person.objects.create(user=self.user, name="WA Resolve")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="447700900001@s.whatsapp.net",
|
||||
)
|
||||
self.session = ChatSession.objects.create(
|
||||
user=self.user, identifier=self.identifier
|
||||
)
|
||||
self.anchor = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=now_ms(),
|
||||
text="anchor message",
|
||||
source_service="whatsapp",
|
||||
source_message_id="wa-anchor-001",
|
||||
source_chat_id="447700900001@s.whatsapp.net",
|
||||
sender_uuid="447700900001@s.whatsapp.net",
|
||||
)
|
||||
|
||||
def test_resolve_reply_target_by_source_message_id(self):
|
||||
ref = {
|
||||
"reply_source_message_id": "wa-anchor-001",
|
||||
"reply_source_service": "whatsapp",
|
||||
"reply_source_chat_id": "447700900001@s.whatsapp.net",
|
||||
}
|
||||
target = async_to_sync(reply_sync.resolve_reply_target)(
|
||||
self.user, self.session, ref
|
||||
)
|
||||
self.assertIsNotNone(target)
|
||||
self.assertEqual(self.anchor.id, target.id)
|
||||
|
||||
def test_resolve_returns_none_for_unknown_id(self):
|
||||
ref = {
|
||||
"reply_source_message_id": "wa-nonexistent-999",
|
||||
"reply_source_service": "whatsapp",
|
||||
"reply_source_chat_id": "447700900001@s.whatsapp.net",
|
||||
}
|
||||
target = async_to_sync(reply_sync.resolve_reply_target)(
|
||||
self.user, self.session, ref
|
||||
)
|
||||
self.assertIsNone(target)
|
||||
|
||||
def test_reaction_applied_to_whatsapp_anchor(self):
|
||||
async_to_sync(history.apply_reaction)(
|
||||
self.user,
|
||||
self.identifier,
|
||||
target_message_id="wa-anchor-001",
|
||||
target_ts=int(self.anchor.ts),
|
||||
emoji="👍",
|
||||
source_service="whatsapp",
|
||||
actor="447700900001@s.whatsapp.net",
|
||||
remove=False,
|
||||
payload={"event": "reaction"},
|
||||
)
|
||||
self.anchor.refresh_from_db()
|
||||
reactions = list((self.anchor.receipt_payload or {}).get("reactions") or [])
|
||||
self.assertEqual(1, len(reactions))
|
||||
self.assertEqual("👍", reactions[0].get("emoji"))
|
||||
|
||||
def test_reaction_removal_clears_flag(self):
|
||||
async_to_sync(history.apply_reaction)(
|
||||
self.user,
|
||||
self.identifier,
|
||||
target_message_id="wa-anchor-001",
|
||||
target_ts=int(self.anchor.ts),
|
||||
emoji="👍",
|
||||
source_service="whatsapp",
|
||||
actor="447700900001@s.whatsapp.net",
|
||||
remove=False,
|
||||
payload={},
|
||||
)
|
||||
async_to_sync(history.apply_reaction)(
|
||||
self.user,
|
||||
self.identifier,
|
||||
target_message_id="wa-anchor-001",
|
||||
target_ts=int(self.anchor.ts),
|
||||
emoji="👍",
|
||||
source_service="whatsapp",
|
||||
actor="447700900001@s.whatsapp.net",
|
||||
remove=True,
|
||||
payload={},
|
||||
)
|
||||
self.anchor.refresh_from_db()
|
||||
reactions = list((self.anchor.receipt_payload or {}).get("reactions") or [])
|
||||
removed = [r for r in reactions if r.get("emoji") == "👍" and not r.get("removed")]
|
||||
self.assertEqual(0, len(removed))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WhatsApp — outbound reply metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WhatsAppOutboundReplyTests(TestCase):
|
||||
def test_transport_passes_reply_metadata_to_whatsapp_api(self):
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_message_raw = AsyncMock(return_value="wa-sent-001")
|
||||
with patch(
|
||||
"core.clients.transport.get_runtime_client",
|
||||
return_value=mock_client,
|
||||
), patch(
|
||||
"core.clients.transport.prepare_outbound_attachments",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"core.clients.transport._capability_checks_enabled",
|
||||
return_value=False,
|
||||
):
|
||||
result = async_to_sync(transport.send_message_raw)(
|
||||
"whatsapp",
|
||||
"447700900001@s.whatsapp.net",
|
||||
text="reply text",
|
||||
attachments=[],
|
||||
metadata={
|
||||
"quote_id": "wa-anchor-001",
|
||||
"quote_author": "447700900001@s.whatsapp.net",
|
||||
"quote_text": "anchor message",
|
||||
},
|
||||
)
|
||||
self.assertEqual("wa-sent-001", result)
|
||||
mock_client.send_message_raw.assert_awaited_once()
|
||||
_, call_kwargs = mock_client.send_message_raw.call_args
|
||||
meta = call_kwargs.get("metadata") or {}
|
||||
self.assertEqual("wa-anchor-001", meta.get("quote_id"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# XMPP — reaction extraction (pure, no DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class XMPPReactionExtractionTests(SimpleTestCase):
|
||||
def test_extract_xep_0444_reaction(self):
|
||||
stanza = _fake_stanza(
|
||||
"<message>"
|
||||
"<reactions xmlns='urn:xmpp:reactions:0' id='xmpp-anchor-001'>"
|
||||
"<reaction>👍</reaction>"
|
||||
"</reactions>"
|
||||
"</message>"
|
||||
)
|
||||
result = _extract_xmpp_reaction(stanza)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual("xmpp-anchor-001", result.get("target_id"))
|
||||
self.assertEqual("👍", result.get("emoji"))
|
||||
self.assertFalse(result.get("remove"))
|
||||
|
||||
def test_extract_xep_0444_reaction_removal(self):
|
||||
stanza = _fake_stanza(
|
||||
"<message>"
|
||||
"<reactions xmlns='urn:xmpp:reactions:0' id='xmpp-anchor-002'>"
|
||||
"</reactions>"
|
||||
"</message>"
|
||||
)
|
||||
result = _extract_xmpp_reaction(stanza)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual("xmpp-anchor-002", result.get("target_id"))
|
||||
self.assertTrue(result.get("remove"))
|
||||
|
||||
def test_extract_returns_none_for_plain_message(self):
|
||||
stanza = _fake_stanza("<message><body>hello</body></message>")
|
||||
self.assertIsNone(_extract_xmpp_reaction(stanza))
|
||||
|
||||
def test_parse_greentext_reaction_valid(self):
|
||||
result = _parse_greentext_reaction(">anchor message\n😊")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual("anchor message", result.get("quoted_text"))
|
||||
self.assertEqual("😊", result.get("emoji"))
|
||||
|
||||
def test_parse_greentext_reaction_rejects_non_emoji_second_line(self):
|
||||
result = _parse_greentext_reaction(">anchor message\nnot an emoji")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_parse_greentext_reaction_rejects_single_line(self):
|
||||
result = _parse_greentext_reaction(">anchor message")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_parse_greentext_reaction_rejects_no_quote_prefix(self):
|
||||
result = _parse_greentext_reaction("anchor message\n😊")
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# XMPP — reply extraction (pure, no DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class XMPPReplyExtractionTests(SimpleTestCase):
|
||||
def test_extract_reply_target_id_from_xep_0461_stanza(self):
|
||||
stanza = _fake_stanza(
|
||||
"<message>"
|
||||
"<reply xmlns='urn:xmpp:reply:0' id='xmpp-anchor-001'/>"
|
||||
"<body>quoted reply</body>"
|
||||
"</message>"
|
||||
)
|
||||
target_id = _extract_xmpp_reply_target_id(stanza)
|
||||
self.assertEqual("xmpp-anchor-001", target_id)
|
||||
|
||||
def test_extract_reply_target_id_returns_empty_for_plain(self):
|
||||
stanza = _fake_stanza("<message><body>hello</body></message>")
|
||||
self.assertEqual("", _extract_xmpp_reply_target_id(stanza))
|
||||
|
||||
def test_extract_reply_ref_for_xmpp_service(self):
|
||||
ref = reply_sync.extract_reply_ref(
|
||||
"xmpp",
|
||||
{
|
||||
"reply_source_message_id": "xmpp-anchor-001",
|
||||
"reply_source_chat_id": "user@zm.is/mobile",
|
||||
},
|
||||
)
|
||||
self.assertEqual("xmpp-anchor-001", ref.get("reply_source_message_id"))
|
||||
self.assertEqual("xmpp", ref.get("reply_source_service"))
|
||||
self.assertEqual("user@zm.is/mobile", ref.get("reply_source_chat_id"))
|
||||
|
||||
def test_extract_reply_ref_returns_empty_for_missing_id(self):
|
||||
ref = reply_sync.extract_reply_ref("xmpp", {"reply_source_chat_id": "user@zm.is"})
|
||||
self.assertEqual({}, ref)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# XMPP — reply resolution (requires DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class XMPPReplyResolutionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
"xmpp-resolve-user", "xmpp-resolve@example.com", "x"
|
||||
)
|
||||
self.person = Person.objects.create(user=self.user, name="XMPP Resolve")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="xmpp",
|
||||
identifier="contact@zm.is",
|
||||
)
|
||||
self.session = ChatSession.objects.create(
|
||||
user=self.user, identifier=self.identifier
|
||||
)
|
||||
self.anchor = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=now_ms(),
|
||||
text="xmpp anchor",
|
||||
source_service="xmpp",
|
||||
source_message_id="xmpp-anchor-001",
|
||||
source_chat_id="contact@zm.is/mobile",
|
||||
sender_uuid="contact@zm.is",
|
||||
)
|
||||
|
||||
def test_resolve_reply_target_by_source_message_id(self):
|
||||
ref = reply_sync.extract_reply_ref(
|
||||
"xmpp",
|
||||
{
|
||||
"reply_source_message_id": "xmpp-anchor-001",
|
||||
"reply_source_chat_id": "contact@zm.is/mobile",
|
||||
},
|
||||
)
|
||||
target = async_to_sync(reply_sync.resolve_reply_target)(
|
||||
self.user, self.session, ref
|
||||
)
|
||||
self.assertIsNotNone(target)
|
||||
self.assertEqual(self.anchor.id, target.id)
|
||||
|
||||
def test_xmpp_reaction_applied_to_anchor_via_history(self):
|
||||
async_to_sync(history.apply_reaction)(
|
||||
self.user,
|
||||
self.identifier,
|
||||
target_message_id="xmpp-anchor-001",
|
||||
target_ts=int(self.anchor.ts),
|
||||
emoji="🔥",
|
||||
source_service="xmpp",
|
||||
actor="contact@zm.is",
|
||||
remove=False,
|
||||
payload={"target_xmpp_id": "xmpp-anchor-001"},
|
||||
)
|
||||
self.anchor.refresh_from_db()
|
||||
reactions = list((self.anchor.receipt_payload or {}).get("reactions") or [])
|
||||
self.assertTrue(
|
||||
any(r.get("emoji") == "🔥" for r in reactions),
|
||||
"Expected 🔥 reaction to be stored on the anchor.",
|
||||
)
|
||||
|
||||
def test_xmpp_reply_ref_resolved_to_none_for_unknown_id(self):
|
||||
ref = reply_sync.extract_reply_ref(
|
||||
"xmpp",
|
||||
{"reply_source_message_id": "xmpp-nonexistent-999"},
|
||||
)
|
||||
target = async_to_sync(reply_sync.resolve_reply_target)(
|
||||
self.user, self.session, ref
|
||||
)
|
||||
self.assertIsNone(target)
|
||||
9
core/tests/test_task_sync_worker_command.py
Normal file
9
core/tests/test_task_sync_worker_command.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from core.management.commands.codex_worker import Command as LegacyWorkerCommand
|
||||
from core.management.commands.task_sync_worker import Command as TaskSyncWorkerCommand
|
||||
|
||||
|
||||
class TaskSyncWorkerCommandAliasTests(SimpleTestCase):
|
||||
def test_task_sync_worker_is_legacy_worker_alias(self):
|
||||
self.assertTrue(issubclass(TaskSyncWorkerCommand, LegacyWorkerCommand))
|
||||
175
core/tests/test_xmpp_approval_commands.py
Normal file
175
core/tests/test_xmpp_approval_commands.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import TestCase
|
||||
|
||||
from core.clients.xmpp import XMPPComponent
|
||||
from core.models import (
|
||||
CodexPermissionRequest,
|
||||
CodexRun,
|
||||
DerivedTask,
|
||||
ExternalSyncEvent,
|
||||
TaskProject,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class _ApprovalProbe:
|
||||
_resolve_request_provider = XMPPComponent._resolve_request_provider
|
||||
_approval_event_prefix = XMPPComponent._approval_event_prefix
|
||||
_APPROVAL_PROVIDER_COMMANDS = XMPPComponent._APPROVAL_PROVIDER_COMMANDS
|
||||
_ACTION_TO_STATUS = XMPPComponent._ACTION_TO_STATUS
|
||||
_apply_approval_decision = XMPPComponent._apply_approval_decision
|
||||
_approval_list_pending = XMPPComponent._approval_list_pending
|
||||
_approval_status = XMPPComponent._approval_status
|
||||
_handle_approval_command = XMPPComponent._handle_approval_command
|
||||
_gateway_help_lines = XMPPComponent._gateway_help_lines
|
||||
_handle_tasks_command = XMPPComponent._handle_tasks_command
|
||||
|
||||
|
||||
class XMPPGatewayApprovalCommandTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("xmpp-approval-user", "xmpp-approval@example.com", "x")
|
||||
self.project = TaskProject.objects.create(user=self.user, name="Approval Project")
|
||||
self.task = DerivedTask.objects.create(
|
||||
user=self.user,
|
||||
project=self.project,
|
||||
epic=None,
|
||||
title="Approve me",
|
||||
source_service="xmpp",
|
||||
source_channel="jews.zm.is",
|
||||
reference_code="77",
|
||||
status_snapshot="open",
|
||||
)
|
||||
self.waiting_event = ExternalSyncEvent.objects.create(
|
||||
user=self.user,
|
||||
task=self.task,
|
||||
provider="codex_cli",
|
||||
status="waiting_approval",
|
||||
payload={},
|
||||
error="",
|
||||
)
|
||||
self.run = CodexRun.objects.create(
|
||||
user=self.user,
|
||||
task=self.task,
|
||||
project=self.project,
|
||||
source_service="xmpp",
|
||||
source_channel="jews.zm.is",
|
||||
status="waiting_approval",
|
||||
request_payload={"action": "append_update", "provider_payload": {"task_id": str(self.task.id)}},
|
||||
result_payload={},
|
||||
)
|
||||
self.request = CodexPermissionRequest.objects.create(
|
||||
user=self.user,
|
||||
codex_run=self.run,
|
||||
external_sync_event=self.waiting_event,
|
||||
approval_key="ak-xmpp-1",
|
||||
summary="Need auth approval",
|
||||
requested_permissions={"items": ["workspace_write"]},
|
||||
resume_payload={},
|
||||
status="pending",
|
||||
)
|
||||
self.probe = _ApprovalProbe()
|
||||
self.probe.log = MagicMock()
|
||||
|
||||
def _run_command(self, text: str) -> list[str]:
|
||||
messages = []
|
||||
|
||||
def _sym(value):
|
||||
messages.append(str(value))
|
||||
|
||||
handled = async_to_sync(XMPPComponent._handle_approval_command)(
|
||||
self.probe,
|
||||
self.user,
|
||||
text,
|
||||
"xmpp-approval-user@zm.is/mobile",
|
||||
_sym,
|
||||
)
|
||||
self.assertTrue(handled)
|
||||
self.assertTrue(messages)
|
||||
return messages
|
||||
|
||||
def test_approval_approve_command_resolves_request_and_queues_resume(self):
|
||||
rows = self._run_command(".approval approve ak-xmpp-1")
|
||||
self.assertIn("approved", "\n".join(rows).lower())
|
||||
self.request.refresh_from_db()
|
||||
self.run.refresh_from_db()
|
||||
self.waiting_event.refresh_from_db()
|
||||
self.assertEqual("approved", self.request.status)
|
||||
self.assertEqual("approved_waiting_resume", self.run.status)
|
||||
self.assertEqual("ok", self.waiting_event.status)
|
||||
resume = ExternalSyncEvent.objects.filter(
|
||||
idempotency_key="codex_approval:ak-xmpp-1:approved"
|
||||
).first()
|
||||
self.assertIsNotNone(resume)
|
||||
self.assertEqual("pending", resume.status)
|
||||
|
||||
def test_approval_list_pending_and_status(self):
|
||||
rows = self._run_command(".approval list-pending all")
|
||||
text = "\n".join(rows)
|
||||
self.assertIn("pending=1", text)
|
||||
self.assertIn("ak-xmpp-1", text)
|
||||
status_rows = self._run_command(".approval status ak-xmpp-1")
|
||||
self.assertIn("status=pending", "\n".join(status_rows))
|
||||
|
||||
def test_provider_specific_command_rejects_mismatched_key(self):
|
||||
rows = self._run_command(".claude approve ak-xmpp-1")
|
||||
self.assertIn("approval_key_not_for_provider", "\n".join(rows))
|
||||
self.request.refresh_from_db()
|
||||
self.assertEqual("pending", self.request.status)
|
||||
|
||||
|
||||
class XMPPGatewayTasksCommandTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("xmpp-task-user", "xmpp-task@example.com", "x")
|
||||
self.project = TaskProject.objects.create(user=self.user, name="Task Project")
|
||||
self.task = DerivedTask.objects.create(
|
||||
user=self.user,
|
||||
project=self.project,
|
||||
epic=None,
|
||||
title="Ship CLI",
|
||||
source_service="xmpp",
|
||||
source_channel="jews.zm.is",
|
||||
reference_code="12",
|
||||
status_snapshot="open",
|
||||
)
|
||||
self.probe = _ApprovalProbe()
|
||||
self.probe.log = MagicMock()
|
||||
|
||||
def _run_tasks(self, text: str) -> list[str]:
|
||||
messages = []
|
||||
|
||||
def _sym(value):
|
||||
messages.append(str(value))
|
||||
|
||||
handled = async_to_sync(XMPPComponent._handle_tasks_command)(
|
||||
self.probe,
|
||||
self.user,
|
||||
text,
|
||||
_sym,
|
||||
)
|
||||
self.assertTrue(handled)
|
||||
self.assertTrue(messages)
|
||||
return messages
|
||||
|
||||
def test_help_contains_approval_and_tasks_sections(self):
|
||||
lines = self.probe._gateway_help_lines()
|
||||
text = "\n".join(lines)
|
||||
self.assertIn(".approval list-pending", text)
|
||||
self.assertIn(".tasks list", text)
|
||||
|
||||
def test_tasks_list_show_complete_and_undo(self):
|
||||
rows = self._run_tasks(".tasks list open 10")
|
||||
self.assertIn("#12", "\n".join(rows))
|
||||
rows = self._run_tasks(".tasks show #12")
|
||||
self.assertIn("status: open", "\n".join(rows))
|
||||
rows = self._run_tasks(".tasks complete #12")
|
||||
self.assertIn("completed #12", "\n".join(rows))
|
||||
self.task.refresh_from_db()
|
||||
self.assertEqual("completed", self.task.status_snapshot)
|
||||
rows = self._run_tasks(".tasks undo #12")
|
||||
self.assertIn("removed #12", "\n".join(rows))
|
||||
self.assertFalse(DerivedTask.objects.filter(id=self.task.id).exists())
|
||||
126
core/tests/test_xmpp_omemo_support.py
Normal file
126
core/tests/test_xmpp_omemo_support.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
|
||||
from core.clients import transport
|
||||
from core.clients.xmpp import ET, XMPPClient, XMPPComponent, _extract_sender_omemo_client_key
|
||||
from core.models import User, UserXmppOmemoState
|
||||
|
||||
|
||||
class _FakeComponent:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.plugins = []
|
||||
self.loop = None
|
||||
|
||||
def register_plugin(self, name):
|
||||
self.plugins.append(str(name))
|
||||
|
||||
def connect(self):
|
||||
return True
|
||||
|
||||
|
||||
@override_settings(
|
||||
XMPP_JID="jews.zm.is",
|
||||
XMPP_SECRET="secret",
|
||||
XMPP_ADDRESS="127.0.0.1",
|
||||
XMPP_PORT=8888,
|
||||
)
|
||||
class XMPPOmemoSupportTests(SimpleTestCase):
|
||||
def test_registers_xep_0384_when_omemo_plugin_available(self):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
with patch("core.clients.xmpp.XMPPComponent", _FakeComponent):
|
||||
with patch("core.clients.xmpp._omemo_plugin_available", return_value=True):
|
||||
with patch("core.clients.xmpp._omemo_xep_0384_plugin_available", return_value=True):
|
||||
with patch("core.clients.xmpp._load_omemo_plugin_module", return_value=True):
|
||||
client = XMPPClient(SimpleNamespace(), loop, "xmpp")
|
||||
self.assertIn("xep_0384", list(getattr(client.client, "plugins", [])))
|
||||
self.assertTrue(bool(getattr(client, "_omemo_plugin_registered", False)))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def test_skips_xep_0384_when_omemo_plugin_unavailable(self):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
with patch("core.clients.xmpp.XMPPComponent", _FakeComponent):
|
||||
with patch("core.clients.xmpp._omemo_plugin_available", return_value=False):
|
||||
with patch("core.clients.xmpp._omemo_xep_0384_plugin_available", return_value=False):
|
||||
client = XMPPClient(SimpleNamespace(), loop, "xmpp")
|
||||
self.assertNotIn("xep_0384", list(getattr(client.client, "plugins", [])))
|
||||
self.assertFalse(bool(getattr(client, "_omemo_plugin_registered", False)))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def test_skips_xep_0384_when_only_slixmpp_omemo_package_exists(self):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
with patch("core.clients.xmpp.XMPPComponent", _FakeComponent):
|
||||
with patch("core.clients.xmpp._omemo_plugin_available", return_value=True):
|
||||
with patch("core.clients.xmpp._omemo_xep_0384_plugin_available", return_value=False):
|
||||
client = XMPPClient(SimpleNamespace(), loop, "xmpp")
|
||||
self.assertNotIn("xep_0384", list(getattr(client.client, "plugins", [])))
|
||||
self.assertFalse(bool(getattr(client, "_omemo_plugin_registered", False)))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def test_bootstrap_logs_and_updates_runtime_state_with_fingerprint(self):
|
||||
class _BootstrapProbe:
|
||||
_derived_omemo_fingerprint = XMPPComponent._derived_omemo_fingerprint
|
||||
|
||||
component = _BootstrapProbe()
|
||||
component.plugin = {}
|
||||
component.log = MagicMock()
|
||||
|
||||
with patch.object(transport, "update_runtime_state") as update_state:
|
||||
async_to_sync(XMPPComponent._bootstrap_omemo_for_authentic_channel)(component)
|
||||
|
||||
update_state.assert_called_once()
|
||||
_, kwargs = update_state.call_args
|
||||
self.assertEqual("jews.zm.is", kwargs.get("omemo_target_jid"))
|
||||
self.assertEqual(
|
||||
component._derived_omemo_fingerprint("jews.zm.is"),
|
||||
kwargs.get("omemo_fingerprint"),
|
||||
)
|
||||
self.assertFalse(bool(kwargs.get("omemo_enabled")))
|
||||
self.assertIn("omemo_status", kwargs)
|
||||
self.assertIn("omemo_status_reason", kwargs)
|
||||
self.assertTrue(component.log.info.called)
|
||||
|
||||
def test_extract_sender_omemo_client_key_from_encrypted_stanza(self):
|
||||
stanza_xml = ET.fromstring(
|
||||
"<message>"
|
||||
"<encrypted xmlns='eu.siacs.conversations.axolotl'>"
|
||||
"<header sid='77'><key rid='88'>x</key></header>"
|
||||
"</encrypted>"
|
||||
"</message>"
|
||||
)
|
||||
parsed = _extract_sender_omemo_client_key(SimpleNamespace(xml=stanza_xml))
|
||||
self.assertEqual("detected", parsed.get("status"))
|
||||
self.assertEqual("sid:77,rid:88", parsed.get("client_key"))
|
||||
|
||||
|
||||
class XMPPOmemoObservationPersistenceTests(TestCase):
|
||||
def test_records_latest_user_omemo_observation(self):
|
||||
user = User.objects.create_user("xmpp-omemo-user", "xmpp-omemo@example.com", "x")
|
||||
probe = SimpleNamespace(log=MagicMock())
|
||||
stanza_xml = ET.fromstring(
|
||||
"<message>"
|
||||
"<encrypted xmlns='eu.siacs.conversations.axolotl'>"
|
||||
"<header sid='321'><key rid='654'>x</key></header>"
|
||||
"</encrypted>"
|
||||
"</message>"
|
||||
)
|
||||
async_to_sync(XMPPComponent._record_sender_omemo_state)(
|
||||
probe,
|
||||
user,
|
||||
sender_jid="xmpp-omemo-user@zm.is/mobile",
|
||||
recipient_jid="jews.zm.is",
|
||||
message_stanza=SimpleNamespace(xml=stanza_xml),
|
||||
)
|
||||
row = UserXmppOmemoState.objects.get(user=user)
|
||||
self.assertEqual("detected", row.status)
|
||||
self.assertEqual("sid:321,rid:654", row.latest_client_key)
|
||||
self.assertEqual("jews.zm.is", row.last_target_jid)
|
||||
Reference in New Issue
Block a user