Fix all integrations

This commit is contained in:
2026-03-08 22:08:55 +00:00
parent bca4d6898f
commit acedc01e83
58 changed files with 4120 additions and 960 deletions

View File

@@ -33,6 +33,7 @@ class CommandRoutingVariantUITests(TestCase):
self.assertContains(response, "bp set range")
self.assertContains(response, "Send status to egress")
self.assertContains(response, "Codex (codex)")
self.assertContains(response, "Claude (claude)")
def test_variant_policy_update_persists(self):
response = self.client.post(

View File

@@ -20,7 +20,7 @@ from core.models import (
Person,
PersonIdentifier,
User,
UserXmppOmemoState,
UserXmppOmemoTrustedKey,
)
from core.security.command_policy import CommandSecurityContext, evaluate_command_policy
@@ -37,7 +37,7 @@ class CommandSecurityPolicyTests(TestCase):
user=self.user,
person=self.person,
service="xmpp",
identifier="policy-user@zm.is",
identifier="policy-user@example.test",
)
self.session = ChatSession.objects.create(
user=self.user,
@@ -58,7 +58,7 @@ class CommandSecurityPolicyTests(TestCase):
profile=profile,
direction="ingress",
service="xmpp",
channel_identifier="policy-user@zm.is",
channel_identifier="policy-user@example.test",
enabled=True,
)
CommandSecurityPolicy.objects.create(
@@ -74,13 +74,13 @@ class CommandSecurityPolicyTests(TestCase):
text="#bp#",
ts=1000,
source_service="xmpp",
source_chat_id="policy-user@zm.is",
source_chat_id="policy-user@example.test",
message_meta={},
)
results = async_to_sync(process_inbound_message)(
CommandContext(
service="xmpp",
channel_identifier="policy-user@zm.is",
channel_identifier="policy-user@example.test",
message_id=str(msg.id),
user_id=self.user.id,
message_text="#bp#",
@@ -101,12 +101,13 @@ class CommandSecurityPolicyTests(TestCase):
require_omemo=True,
require_trusted_omemo_fingerprint=True,
)
UserXmppOmemoState.objects.create(
UserXmppOmemoTrustedKey.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",
jid="policy-user@example.test",
key_type="client_key",
key_id="sid:abc",
trusted=True,
source="test",
)
outputs: list[str] = []
@@ -119,11 +120,15 @@ class CommandSecurityPolicyTests(TestCase):
user=self.user,
source_message=None,
service="xmpp",
channel_identifier="policy-user@zm.is",
sender_identifier="policy-user@zm.is/phone",
channel_identifier="policy-user@example.test",
sender_identifier="policy-user@example.test/phone",
message_text=".tasks list",
message_meta={
"xmpp": {"omemo_status": "detected", "omemo_client_key": "sid:abc"}
"xmpp": {
"omemo_status": "detected",
"omemo_client_key": "sid:abc",
"sender_jid": "policy-user@example.test/phone",
}
},
payload={},
),
@@ -161,8 +166,8 @@ class CommandSecurityPolicyTests(TestCase):
user=self.user,
source_message=None,
service="xmpp",
channel_identifier="policy-user@zm.is",
sender_identifier="policy-user@zm.is/phone",
channel_identifier="policy-user@example.test",
sender_identifier="policy-user@example.test/phone",
message_text=".tasks list",
message_meta={"xmpp": {"omemo_status": "no_omemo"}},
payload={},
@@ -200,7 +205,7 @@ class CommandSecurityPolicyTests(TestCase):
scope_key="gateway.tasks",
context=CommandSecurityContext(
service="xmpp",
channel_identifier="policy-user@zm.is",
channel_identifier="policy-user@example.test",
message_meta={},
payload={},
),
@@ -226,3 +231,30 @@ class CommandSecurityPolicyTests(TestCase):
)
self.assertFalse(decision.allowed)
self.assertEqual("service_not_allowed", decision.code)
def test_trusted_key_requirement_blocks_untrusted_key(self):
CommandSecurityPolicy.objects.create(
user=self.user,
scope_key="gateway.tasks",
enabled=True,
require_omemo=True,
require_trusted_omemo_fingerprint=True,
)
decision = evaluate_command_policy(
user=self.user,
scope_key="gateway.tasks",
context=CommandSecurityContext(
service="xmpp",
channel_identifier="policy-user@example.test",
message_meta={
"xmpp": {
"omemo_status": "detected",
"omemo_client_key": "sid:missing",
"sender_jid": "policy-user@example.test/phone",
}
},
payload={},
),
)
self.assertFalse(decision.allowed)
self.assertEqual("trusted_key_missing", decision.code)

View File

@@ -304,16 +304,16 @@ class XMPPReplyExtractionTests(SimpleTestCase):
"xmpp",
{
"reply_source_message_id": "xmpp-anchor-001",
"reply_source_chat_id": "user@zm.is/mobile",
"reply_source_chat_id": "user@example.test/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"))
self.assertEqual("user@example.test/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"}
"xmpp", {"reply_source_chat_id": "user@example.test"}
)
self.assertEqual({}, ref)
@@ -333,7 +333,7 @@ class XMPPReplyResolutionTests(TestCase):
user=self.user,
person=self.person,
service="xmpp",
identifier="contact@zm.is",
identifier="contact@example.test",
)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
@@ -345,8 +345,8 @@ class XMPPReplyResolutionTests(TestCase):
text="xmpp anchor",
source_service="xmpp",
source_message_id="xmpp-anchor-001",
source_chat_id="contact@zm.is/mobile",
sender_uuid="contact@zm.is",
source_chat_id="contact@example.test/mobile",
sender_uuid="contact@example.test",
)
def test_resolve_reply_target_by_source_message_id(self):
@@ -354,7 +354,7 @@ class XMPPReplyResolutionTests(TestCase):
"xmpp",
{
"reply_source_message_id": "xmpp-anchor-001",
"reply_source_chat_id": "contact@zm.is/mobile",
"reply_source_chat_id": "contact@example.test/mobile",
},
)
target = async_to_sync(reply_sync.resolve_reply_target)(
@@ -371,7 +371,7 @@ class XMPPReplyResolutionTests(TestCase):
target_ts=int(self.anchor.ts),
emoji="🔥",
source_service="xmpp",
actor="contact@zm.is",
actor="contact@example.test",
remove=False,
payload={"target_xmpp_id": "xmpp-anchor-001"},
)

View File

@@ -67,6 +67,8 @@ class MCPToolTests(TestCase):
names = {item["name"] for item in tool_specs()}
self.assertIn("manticore.status", names)
self.assertIn("memory.propose", names)
self.assertIn("tasks.create", names)
self.assertIn("tasks.complete", names)
self.assertIn("tasks.link_artifact", names)
self.assertIn("wiki.create_article", names)
self.assertIn("project.get_runbook", names)
@@ -102,6 +104,35 @@ class MCPToolTests(TestCase):
"created", str((events_payload.get("items") or [{}])[0].get("event_type"))
)
def test_task_create_and_complete_tools(self):
create_payload = execute_tool(
"tasks.create",
{
"user_id": self.user.id,
"project_id": str(self.project.id),
"title": "Create via MCP",
"source_service": "xmpp",
"source_channel": "component.example.test",
"actor_identifier": "mcp-user",
},
)
task_payload = create_payload.get("task") or {}
self.assertEqual("Create via MCP", str(task_payload.get("title") or ""))
self.assertEqual("xmpp", str(task_payload.get("source_service") or ""))
complete_payload = execute_tool(
"tasks.complete",
{
"user_id": self.user.id,
"task_id": str(task_payload.get("id") or ""),
"actor_identifier": "mcp-user",
},
)
completed_task = complete_payload.get("task") or {}
self.assertEqual(
"completed", str(completed_task.get("status_snapshot") or "")
)
def test_memory_proposal_review_flow(self):
propose_payload = execute_tool(
"memory.propose",

View File

@@ -2,14 +2,19 @@ from __future__ import annotations
from django.test import TestCase
from core.models import Person, PersonIdentifier, User
from core.clients import transport
from core.models import Person, PersonIdentifier, PlatformChatLink, User
from core.presence import (
AvailabilitySignal,
latest_state_for_people,
record_native_signal,
)
from core.presence.inference import now_ms
from core.views.compose import _compose_availability_payload, _context_base
from core.views.compose import (
_compose_availability_payload,
_context_base,
_manual_contact_rows,
)
class PresenceQueryAndComposeContextTests(TestCase):
@@ -79,3 +84,84 @@ class PresenceQueryAndComposeContextTests(TestCase):
self.assertEqual("whatsapp", str(slices[0].get("service")))
self.assertEqual("available", str(summary.get("state")))
self.assertTrue(bool(summary.get("is_cross_service")))
def test_context_base_preserves_native_signal_group_identifier(self):
PlatformChatLink.objects.create(
user=self.user,
service="signal",
chat_identifier="signal-group-123",
chat_name="Signal Group",
is_group=True,
)
base = _context_base(
user=self.user,
service="signal",
identifier="signal-group-123",
person=None,
)
self.assertTrue(bool(base["is_group"]))
self.assertEqual("signal-group-123", str(base["identifier"]))
def test_manual_contact_rows_include_signal_groups(self):
PlatformChatLink.objects.create(
user=self.user,
service="signal",
chat_identifier="signal-group-123",
chat_name="Misinformation Club",
is_group=True,
)
rows = _manual_contact_rows(self.user)
match = next(
(
row
for row in rows
if str(row.get("service")) == "signal"
and str(row.get("identifier")) == "signal-group-123"
),
None,
)
self.assertIsNotNone(match)
self.assertEqual("Misinformation Club", str(match.get("detected_name") or ""))
def test_manual_contact_rows_collapse_signal_group_aliases(self):
PlatformChatLink.objects.create(
user=self.user,
service="signal",
chat_identifier="group.signal-club",
chat_name="Misinformation Club",
is_group=True,
)
transport.update_runtime_state(
"signal",
accounts=["+447700900001"],
groups=[
{
"identifier": "group.signal-club",
"identifiers": [
"group.signal-club",
"sEGA9F0HQ/eyLgmvKx23hha9Vp7mDRhpq23/roVSZbI=",
],
"name": "Misinformation Club",
"id": "group.signal-club",
"internal_id": "sEGA9F0HQ/eyLgmvKx23hha9Vp7mDRhpq23/roVSZbI=",
}
],
)
rows = [
row
for row in _manual_contact_rows(self.user)
if str(row.get("service")) == "signal"
and str(row.get("detected_name")) == "Misinformation Club"
]
self.assertEqual(1, len(rows))
self.assertEqual("group.signal-club", str(rows[0].get("identifier") or ""))
self.assertEqual(
["sEGA9F0HQ/eyLgmvKx23hha9Vp7mDRhpq23/roVSZbI="],
rows[0].get("identifier_aliases"),
)

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from django.test import RequestFactory, TestCase
from django.urls import resolve, reverse
from core.context_processors import settings_hierarchy_nav
from core.security.capabilities import all_scope_keys
from core.models import CommandProfile, TaskProject, User
class SettingsIntegrityTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="settings-user",
email="settings@example.com",
password="x",
)
self.client.force_login(self.user)
self.factory = RequestFactory()
def test_permissions_page_shows_gateway_capabilities(self):
response = self.client.get(reverse("permission_settings"))
self.assertEqual(200, response.status_code)
self.assertContains(response, "Gateway contacts command")
self.assertContains(response, "Gateway help command")
self.assertContains(response, "Gateway whoami command")
for scope_key in all_scope_keys(configurable_only=True):
self.assertContains(response, scope_key)
def test_capability_registry_excludes_removed_totp_scope(self):
self.assertNotIn("gateway.totp", all_scope_keys())
def test_codex_settings_receives_modules_settings_nav(self):
response = self.client.get(reverse("codex_settings"))
self.assertEqual(200, response.status_code)
settings_nav = response.context.get("settings_nav")
self.assertIsNotNone(settings_nav)
self.assertEqual("Modules", settings_nav["title"])
labels = [str(item["label"]) for item in settings_nav["tabs"]]
self.assertIn("Commands", labels)
self.assertIn("Task Automation", labels)
def test_business_plan_inbox_receives_modules_settings_nav(self):
response = self.client.get(reverse("business_plan_inbox"))
self.assertEqual(200, response.status_code)
settings_nav = response.context.get("settings_nav")
self.assertIsNotNone(settings_nav)
self.assertEqual("Modules", settings_nav["title"])
def test_tasks_settings_cross_links_commands_and_permissions(self):
TaskProject.objects.create(user=self.user, name="Integrity Project")
response = self.client.get(reverse("tasks_settings"))
self.assertEqual(200, response.status_code)
self.assertContains(response, "Task Automation")
self.assertContains(response, reverse("command_routing"))
self.assertContains(response, reverse("permission_settings"))
def test_command_routing_cross_links_tasks_and_permissions(self):
CommandProfile.objects.create(
user=self.user,
slug="bp",
name="Business Plan",
enabled=True,
trigger_token=".bp",
reply_required=False,
exact_match_only=False,
)
response = self.client.get(reverse("command_routing"))
self.assertEqual(200, response.status_code)
self.assertContains(response, reverse("tasks_settings"))
self.assertContains(response, reverse("permission_settings"))
def test_settings_nav_includes_codex_approval_route(self):
request = self.factory.post(reverse("codex_approval"))
request.user = self.user
request.resolver_match = resolve(reverse("codex_approval"))
settings_nav = settings_hierarchy_nav(request)["settings_nav"]
self.assertEqual("Modules", settings_nav["title"])
def test_settings_nav_includes_translation_preview_route(self):
request = self.factory.post(reverse("translation_preview"))
request.user = self.user
request.resolver_match = resolve(reverse("translation_preview"))
settings_nav = settings_hierarchy_nav(request)["settings_nav"]
self.assertEqual("Modules", settings_nav["title"])

View File

@@ -43,3 +43,22 @@ class SignalRelinkTests(TestCase):
)
self.assertEqual(200, response.status_code)
mock_unlink_account.assert_called_once_with("signal", "+447000000001")
@patch("core.views.signal.transport.get_link_qr")
def test_signal_account_add_renders_notify_when_qr_fetch_fails(self, mock_get_link_qr):
mock_get_link_qr.side_effect = RuntimeError("timeout")
response = self.client.post(
reverse(
"signal_account_add",
kwargs={"type": "modal"},
),
{"device": "My Device"},
HTTP_HX_REQUEST="true",
HTTP_HX_TARGET="modals-here",
)
self.assertEqual(200, response.status_code)
self.assertContains(response, "modal is-active")
self.assertContains(response, "Signal QR link is unavailable right now")
self.assertContains(response, "timeout")

View File

@@ -1,3 +1,4 @@
import tempfile
from unittest.mock import Mock, patch
from django.test import TestCase
@@ -6,6 +7,40 @@ from core.clients import transport
class SignalUnlinkFallbackTests(TestCase):
def test_signal_wipe_uses_project_signal_cli_config_dir(self):
with tempfile.TemporaryDirectory() as tmpdir:
signal_root = os.path.join(tmpdir, "signal-cli-config")
os.makedirs(signal_root, exist_ok=True)
account_dir = os.path.join(signal_root, "account-data")
os.makedirs(account_dir, exist_ok=True)
keep_file = os.path.join(signal_root, "jsonrpc2.yml")
with open(keep_file, "w", encoding="utf-8") as handle:
handle.write("jsonrpc")
data_file = os.path.join(account_dir, "state.db")
with open(data_file, "w", encoding="utf-8") as handle:
handle.write("state")
with patch.object(transport.settings, "BASE_DIR", tmpdir):
result = transport._wipe_signal_cli_local_state()
self.assertTrue(result)
self.assertTrue(os.path.exists(keep_file))
self.assertFalse(os.path.exists(account_dir))
@patch("requests.get")
def test_signal_list_accounts_uses_fast_timeout(self, mock_get):
ok_response = Mock()
ok_response.ok = True
ok_response.text = "[]"
mock_get.return_value = ok_response
result = transport.list_accounts("signal")
self.assertEqual([], result)
mock_get.assert_called_once()
_, kwargs = mock_get.call_args
self.assertEqual(5, int(kwargs.get("timeout") or 0))
@patch("core.clients.transport._wipe_signal_cli_local_state")
@patch("requests.delete")
def test_signal_unlink_uses_rest_delete_when_available(
@@ -40,3 +75,25 @@ class SignalUnlinkFallbackTests(TestCase):
self.assertTrue(result)
self.assertEqual(2, mock_delete.call_count)
mock_wipe.assert_called_once()
@patch("core.clients.transport.list_accounts")
@patch("core.clients.transport._wipe_signal_cli_local_state")
@patch("requests.delete")
def test_signal_unlink_returns_false_when_account_still_listed_after_wipe(
self,
mock_delete,
mock_wipe,
mock_list_accounts,
):
bad_response = Mock()
bad_response.ok = False
mock_delete.return_value = bad_response
mock_wipe.return_value = True
mock_list_accounts.return_value = ["+447700900000"]
result = transport.unlink_account("signal", "+447700900000")
self.assertFalse(result)
self.assertEqual(2, mock_delete.call_count)
mock_wipe.assert_called_once()
mock_list_accounts.assert_called_once_with("signal")

View File

@@ -251,6 +251,28 @@ class TasksPagesManagementTests(TestCase):
self.assertEqual(200, response.status_code)
self.assertContains(response, "Scope Person")
def test_tasks_hub_can_create_manual_task_without_chat_source(self):
project = TaskProject.objects.create(user=self.user, name="Manual Project")
response = self.client.post(
reverse("tasks_hub"),
{
"action": "task_create",
"project_id": str(project.id),
"title": "Manual web task",
"due_date": "2026-03-10",
"assignee_identifier": "@operator",
},
follow=True,
)
self.assertEqual(200, response.status_code)
task = DerivedTask.objects.get(user=self.user, project=project, title="Manual web task")
self.assertEqual("web", task.source_service)
self.assertEqual("@operator", task.assignee_identifier)
self.assertEqual("2026-03-10", task.due_date.isoformat())
event = task.events.order_by("-created_at").first()
self.assertEqual("created", event.event_type)
self.assertEqual("web_ui", str((event.payload or {}).get("via") or ""))
def test_project_page_creator_column_links_to_compose(self):
project = TaskProject.objects.create(user=self.user, name="Creator Link Test")
session = ChatSession.objects.create(user=self.user, identifier=self.pid_signal)

View File

@@ -7,7 +7,7 @@ from django.core.management import call_command
from django.test import TestCase
from core.clients.whatsapp import WhatsAppClient
from core.messaging import history
from core.messaging import history, media_bridge
from core.models import (
ChatSession,
ContactAvailabilityEvent,
@@ -25,6 +25,16 @@ class _DummyXMPPClient:
return None
class _DummyDownloadClient:
def __init__(self, payload: bytes):
self.payload = payload
self.calls = []
async def download_any(self, message):
self.calls.append(message)
return self.payload
class _DummyUR:
def __init__(self, loop):
self.loop = loop
@@ -122,6 +132,49 @@ class WhatsAppReactionHandlingTests(TestCase):
self.assertEqual("offline", payload.get("presence"))
self.assertTrue(int(payload.get("last_seen_ts") or 0) > 0)
def test_download_event_media_unwraps_device_sent_image(self):
downloader = _DummyDownloadClient(b"png-bytes")
self.client._client = downloader
event = {
"message": {
"deviceSentMessage": {
"message": {
"imageMessage": {
"caption": "wrapped image",
"mimetype": "image/png",
}
}
}
}
}
attachments = async_to_sync(self.client._download_event_media)(event)
self.assertEqual(1, len(attachments))
self.assertEqual(1, len(downloader.calls))
self.assertEqual(
{"imageMessage": {"caption": "wrapped image", "mimetype": "image/png"}},
downloader.calls[0],
)
blob = media_bridge.get_blob(attachments[0]["blob_key"])
self.assertIsNotNone(blob)
self.assertEqual(b"png-bytes", blob["content"])
self.assertEqual("image/png", blob["content_type"])
def test_message_text_unwraps_device_sent_caption(self):
text = self.client._message_text(
{
"deviceSentMessage": {
"message": {
"imageMessage": {
"caption": "caption from wrapper",
}
}
}
}
)
self.assertEqual("caption from wrapper", text)
class RecalculateContactAvailabilityTests(TestCase):
def setUp(self):

View File

@@ -5,7 +5,11 @@ 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.gateway.builtin import (
gateway_help_lines,
handle_approval_command,
handle_tasks_command,
)
from core.models import (
CodexPermissionRequest,
CodexRun,
@@ -16,19 +20,6 @@ from core.models import (
)
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(
@@ -43,7 +34,7 @@ class XMPPGatewayApprovalCommandTests(TestCase):
epic=None,
title="Approve me",
source_service="xmpp",
source_channel="jews.zm.is",
source_channel="component.example.test",
reference_code="77",
status_snapshot="open",
)
@@ -60,7 +51,7 @@ class XMPPGatewayApprovalCommandTests(TestCase):
task=self.task,
project=self.project,
source_service="xmpp",
source_channel="jews.zm.is",
source_channel="component.example.test",
status="waiting_approval",
request_payload={
"action": "append_update",
@@ -78,8 +69,7 @@ class XMPPGatewayApprovalCommandTests(TestCase):
resume_payload={},
status="pending",
)
self.probe = _ApprovalProbe()
self.probe.log = MagicMock()
self.probe = MagicMock()
def _run_command(self, text: str) -> list[str]:
messages = []
@@ -87,11 +77,9 @@ class XMPPGatewayApprovalCommandTests(TestCase):
def _sym(value):
messages.append(str(value))
handled = async_to_sync(XMPPComponent._handle_approval_command)(
self.probe,
handled = async_to_sync(handle_approval_command)(
self.user,
text,
"xmpp-approval-user@zm.is/mobile",
_sym,
)
self.assertTrue(handled)
@@ -140,12 +128,11 @@ class XMPPGatewayTasksCommandTests(TestCase):
epic=None,
title="Ship CLI",
source_service="xmpp",
source_channel="jews.zm.is",
source_channel="component.example.test",
reference_code="12",
status_snapshot="open",
)
self.probe = _ApprovalProbe()
self.probe.log = MagicMock()
self.probe = MagicMock()
def _run_tasks(self, text: str) -> list[str]:
messages = []
@@ -153,8 +140,7 @@ class XMPPGatewayTasksCommandTests(TestCase):
def _sym(value):
messages.append(str(value))
handled = async_to_sync(XMPPComponent._handle_tasks_command)(
self.probe,
handled = async_to_sync(handle_tasks_command)(
self.user,
text,
_sym,
@@ -164,14 +150,18 @@ class XMPPGatewayTasksCommandTests(TestCase):
return messages
def test_help_contains_approval_and_tasks_sections(self):
lines = self.probe._gateway_help_lines()
lines = gateway_help_lines()
text = "\n".join(lines)
self.assertIn(".approval list-pending", text)
self.assertIn(".tasks list", text)
self.assertIn(".tasks add", text)
self.assertIn(".l", 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(".l")
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")
@@ -181,3 +171,24 @@ class XMPPGatewayTasksCommandTests(TestCase):
rows = self._run_tasks(".tasks undo #12")
self.assertIn("removed #12", "\n".join(rows))
self.assertFalse(DerivedTask.objects.filter(id=self.task.id).exists())
def test_tasks_add_creates_task_in_named_project(self):
rows = []
handled = async_to_sync(handle_tasks_command)(
self.user,
".tasks add Task Project :: Wire XMPP manual task create",
lambda value: rows.append(str(value)),
service="xmpp",
channel_identifier="component.example.test",
sender_identifier="operator@example.test",
)
self.assertTrue(handled)
self.assertTrue(any("created #" in row.lower() for row in rows))
created = DerivedTask.objects.filter(
user=self.user,
project=self.project,
title="Wire XMPP manual task create",
source_service="xmpp",
source_channel="component.example.test",
).first()
self.assertIsNotNone(created)

View File

@@ -0,0 +1,103 @@
from unittest.mock import AsyncMock, 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 XMPPComponent, _resolve_person_from_xmpp_localpart
from core.models import Person, PersonIdentifier, User
class SignalAttachmentFetchTests(SimpleTestCase):
def test_signal_service_allows_direct_url_fetch(self):
response = AsyncMock()
response.status = 200
response.headers = {"Content-Type": "image/png"}
response.read = AsyncMock(return_value=b"png-bytes")
request_ctx = AsyncMock()
request_ctx.__aenter__.return_value = response
request_ctx.__aexit__.return_value = False
session = AsyncMock()
session.get.return_value = request_ctx
session_ctx = AsyncMock()
session_ctx.__aenter__.return_value = session
session_ctx.__aexit__.return_value = False
with patch(
"core.clients.transport.aiohttp.ClientSession",
return_value=session_ctx,
):
fetched = async_to_sync(transport.fetch_attachment)(
"signal",
{
"url": "https://example.com/file_share/demo.png",
"filename": "demo.png",
"content_type": "image/png",
},
)
self.assertEqual(b"png-bytes", fetched["content"])
self.assertEqual("image/png", fetched["content_type"])
self.assertEqual("demo.png", fetched["filename"])
self.assertEqual(9, fetched["size"])
@override_settings(
XMPP_JID="component.example.test",
XMPP_USER_DOMAIN="example.test",
)
class XMPPContactJidTests(TestCase):
def _component(self):
return XMPPComponent(
ur=AsyncMock(),
jid="component.example.test",
secret="secret",
server="localhost",
port=5347,
)
def test_resolve_person_from_escaped_localpart(self):
user = User.objects.create_user(username="user", password="pw")
person = Person.objects.create(user=user, name="Misinformation Club")
resolved = _resolve_person_from_xmpp_localpart(
user=user,
localpart_value=r"misinformation\20club",
)
self.assertEqual(person.id, resolved.id)
def test_send_from_external_escapes_contact_jid(self):
user = User.objects.create_user(username="user2", password="pw")
person = Person.objects.create(user=user, name="Misinformation Club")
identifier = PersonIdentifier.objects.create(
user=user,
person=person,
service="signal",
identifier="group.example",
)
component = self._component()
component.send_xmpp_message = AsyncMock(return_value="xmpp-id")
with (
patch("core.clients.xmpp.transport.record_bridge_mapping"),
patch("core.clients.xmpp.history.save_bridge_ref", new=AsyncMock()),
):
async_to_sync(component.send_from_external)(
user,
identifier,
"hello",
False,
attachments=[],
source_ref={},
)
component.send_xmpp_message.assert_awaited_once_with(
"user2@example.test",
r"misinformation\20club|signal@component.example.test",
"hello",
use_omemo_encryption=True,
)

View File

@@ -0,0 +1,165 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from asgiref.sync import async_to_sync
from django.test import TestCase, override_settings
from slixmpp.plugins.xep_0356.permissions import MessagePermission
from core.clients.xmpp import XMPPComponent
from core.models import Person, PersonIdentifier, User
@override_settings(
XMPP_JID="component.example.test",
XMPP_USER_DOMAIN="example.test",
)
class XMPPCarbonTests(TestCase):
def _component(self):
component = XMPPComponent(
ur=MagicMock(),
jid="component.example.test",
secret="secret",
server="localhost",
port=5347,
)
component.log = MagicMock()
return component
def test_build_privileged_outbound_message_targets_contact(self):
component = self._component()
msg = component._build_privileged_outbound_message(
user_jid="user@example.test",
contact_jid="contact|signal@component.example.test",
body_text="hello from signal",
attachment_url="https://files.example.test/demo.png",
)
self.assertEqual("user@example.test", str(msg["from"]))
self.assertEqual("contact|signal@component.example.test", str(msg["to"]))
body = next(
(child for child in msg.xml if str(child.tag).endswith("body")),
None,
)
self.assertIsNotNone(body)
self.assertEqual("hello from signal", body.text)
oob = msg.xml.find(".//{jabber:x:oob}url")
self.assertIsNotNone(oob)
self.assertEqual("https://files.example.test/demo.png", oob.text)
def test_send_sent_carbon_copy_requires_outgoing_privilege(self):
component = self._component()
plugin = SimpleNamespace(
granted_privileges={"example.test": SimpleNamespace(message="none")},
send_privileged_message=MagicMock(),
_make_privileged_message=MagicMock(),
)
with (
patch.object(component.plugin, "get", return_value=plugin),
patch.object(component, "_user_xmpp_domain", return_value="other.example.test"),
):
sent = async_to_sync(component.send_sent_carbon_copy)(
user_jid="user@example.test",
contact_jid="contact|signal@component.example.test",
body_text="hello",
)
self.assertFalse(sent)
plugin.send_privileged_message.assert_not_called()
plugin._make_privileged_message.assert_not_called()
def test_send_sent_carbon_copy_sends_privileged_message_when_allowed(self):
component = self._component()
plugin = SimpleNamespace(
granted_privileges={
"example.test": SimpleNamespace(message=MessagePermission.OUTGOING)
},
send_privileged_message=MagicMock(),
)
with patch.object(component.plugin, "get", return_value=plugin):
sent = async_to_sync(component.send_sent_carbon_copy)(
user_jid="user@example.test",
contact_jid="contact|signal@component.example.test",
body_text="hello",
)
self.assertTrue(sent)
plugin.send_privileged_message.assert_called_once()
sent_message = plugin.send_privileged_message.call_args.args[0]
self.assertEqual("contact|signal@component.example.test", str(sent_message["to"]))
self.assertEqual("user@example.test", str(sent_message["from"]))
self.assertIsNotNone(
next(
(child for child in sent_message.xml if str(child.tag).endswith("body")),
None,
)
)
def test_send_sent_carbon_copy_uses_configured_domain_without_advertisement(self):
component = self._component()
wrapped = MagicMock()
plugin = SimpleNamespace(
granted_privileges={},
send_privileged_message=MagicMock(),
_make_privileged_message=MagicMock(return_value=wrapped),
)
with patch.object(component.plugin, "get", return_value=plugin):
sent = async_to_sync(component.send_sent_carbon_copy)(
user_jid="user@example.test",
contact_jid="contact|signal@component.example.test",
body_text="hello",
)
self.assertTrue(sent)
plugin.send_privileged_message.assert_not_called()
plugin._make_privileged_message.assert_called_once()
wrapped.send.assert_called_once()
def test_outgoing_relay_keeps_you_prefix_while_attempting_carbon_copy(self):
component = self._component()
user = User.objects.create_user(username="user", password="pw")
person = Person.objects.create(user=user, name="Contact")
identifier = PersonIdentifier.objects.create(
user=user,
person=person,
service="signal",
identifier="+15550000000",
)
call_order = []
async def send_xmpp_message(*args, **kwargs):
call_order.append("fallback")
return "xmpp-message-id"
async def send_sent_carbon_copy(*args, **kwargs):
call_order.append("carbon")
return True
component.send_sent_carbon_copy = AsyncMock(side_effect=send_sent_carbon_copy)
component.send_xmpp_message = AsyncMock(side_effect=send_xmpp_message)
with (
patch("core.clients.xmpp.transport.record_bridge_mapping"),
patch("core.clients.xmpp.history.save_bridge_ref", new=AsyncMock()),
):
async_to_sync(component.send_from_external)(
user,
identifier,
"hello",
True,
attachments=[],
source_ref={},
)
component.send_sent_carbon_copy.assert_awaited_once_with(
user_jid="user@example.test",
contact_jid="contact|signal@component.example.test",
body_text="hello",
)
component.send_xmpp_message.assert_awaited_once_with(
"user@example.test",
"contact|signal@component.example.test",
"YOU: hello",
use_omemo_encryption=True,
)
self.assertEqual(["fallback", "carbon"], call_order)

View File

@@ -54,12 +54,12 @@ def _xmpp_c2s_port() -> int:
def _xmpp_domain() -> str:
"""The VirtualHost domain (zm.is), derived from XMPP_JID or XMPP_DOMAIN."""
"""The VirtualHost domain, derived from XMPP_JID or XMPP_DOMAIN."""
domain = getattr(settings, "XMPP_DOMAIN", None)
if domain:
return str(domain)
jid = str(settings.XMPP_JID)
# Component JID is like "jews.zm.is" → parent domain is "zm.is"
# Component JIDs may be subdomains; derive the parent domain when needed.
parts = jid.split(".")
if len(parts) > 2:
return ".".join(parts[1:])
@@ -389,7 +389,10 @@ class XMPPAuthBridgeTests(SimpleTestCase):
"""Auth bridge returns 0 (or error) for a request with a wrong XMPP_SECRET."""
_, host, port, path = self._parse_endpoint()
# isuser command with wrong secret — should be rejected or return 0
query = "?command=isuser%3Anonexistent%3Azm.is&secret=wrongsecret"
query = (
f"?command=isuser%3Anonexistent%3A{urllib.parse.quote(_xmpp_domain())}"
"&secret=wrongsecret"
)
try:
conn = http.client.HTTPConnection(host, port, timeout=5)
conn.request("GET", path + query)
@@ -410,7 +413,8 @@ class XMPPAuthBridgeTests(SimpleTestCase):
secret = getattr(settings, "XMPP_SECRET", "")
_, host, port, path = self._parse_endpoint()
query = (
f"?command=isuser%3Anonexistent%3Azm.is&secret={urllib.parse.quote(secret)}"
f"?command=isuser%3Anonexistent%3A{urllib.parse.quote(_xmpp_domain())}"
f"&secret={urllib.parse.quote(secret)}"
)
try:
conn = http.client.HTTPConnection(host, port, timeout=5)

View File

@@ -9,7 +9,7 @@ from core.models import User, UserXmppOmemoState
@override_settings(
XMPP_JID="jews.zm.is",
XMPP_JID="component.example.test",
XMPP_SECRET="secret",
XMPP_ADDRESS="127.0.0.1",
XMPP_PORT=8888,
@@ -51,14 +51,14 @@ class XMPPOmemoObservationPersistenceTests(TestCase):
async_to_sync(XMPPComponent._record_sender_omemo_state)(
xmpp_component,
user,
sender_jid="xmpp-omemo-user@zm.is/mobile",
recipient_jid="jews.zm.is",
sender_jid="xmpp-omemo-user@example.test/mobile",
recipient_jid="component.example.test",
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)
self.assertEqual("component.example.test", row.last_target_jid)
class XMPPOmemoEnforcementTests(TestCase):
@@ -78,7 +78,7 @@ class XMPPOmemoEnforcementTests(TestCase):
# Create a plaintext message stanza (no OMEMO encryption)
stanza_xml = ET.fromstring(
"<message from='sender@example.com' to='jews.zm.is'>"
"<message from='sender@example.com' to='component.example.test'>"
"<body>Hello, world!</body>"
"</message>"
)
@@ -125,7 +125,7 @@ class XMPPOmemoEnforcementTests(TestCase):
# Create an OMEMO-encrypted message stanza
stanza_xml = ET.fromstring(
"<message from='sender@example.com' to='jews.zm.is'>"
"<message from='sender@example.com' to='component.example.test'>"
"<encrypted xmlns='eu.siacs.conversations.axolotl'>"
"<header sid='77'><key rid='88'>x</key></header>"
"</encrypted>"
@@ -167,14 +167,14 @@ class XMPPOmemoDeviceDiscoveryTests(TestCase):
# Create a mock XMPP component
self.mock_component = MagicMock()
self.mock_component.log = MagicMock()
self.mock_component.jid = "jews.zm.is"
self.mock_component.jid = "component.example.test"
def test_gateway_publishes_device_list_to_pubsub(self):
"""Test that the gateway publishes its device list to PubSub (XEP-0060).
This simulates the device discovery query that real XMPP clients perform.
When a client wants to send an OMEMO message, it:
1. Queries the PubSub node: pubsub.example.com/eu.siacs.conversations.axolotl/devices/jews.zm.is
1. Queries the PubSub node: pubsub.example.com/eu.siacs.conversations.axolotl/devices/component.example.test
2. Expects to receive a device list with at least one device
3. Retrieves keys for those devices
4. Encrypts the message
@@ -261,7 +261,7 @@ class XMPPOmemoDeviceDiscoveryTests(TestCase):
"""
# Simulate an OMEMO-encrypted message from a client device
client_stanza = ET.fromstring(
"<message from='testuser@example.com/mobile' to='jews.zm.is'>"
"<message from='testuser@example.com/mobile' to='component.example.test'>"
"<encrypted xmlns='eu.siacs.conversations.axolotl'>"
"<header sid='12345' schemeVersion='2'>" # Device 12345
"<key rid='67890'>encrypted_payload_1</key>" # To recipient device 67890
@@ -289,7 +289,7 @@ class XMPPOmemoDeviceDiscoveryTests(TestCase):
The OMEMO bootstrap must:
1. Initialize the session manager (which auto-creates devices)
2. Publish device list to PubSub at: eu.siacs.conversations.axolotl/devices/jews.zm.is
2. Publish device list to PubSub at: eu.siacs.conversations.axolotl/devices/component.example.test
3. Allow clients to discover and query those devices
If PubSub is slow or unavailable, this times out and prevents
@@ -309,15 +309,15 @@ class XMPPOmemoDeviceDiscoveryTests(TestCase):
def test_component_jid_device_discovery(self):
"""Test that component JIDs (without user@) can publish OMEMO devices.
A key issue with components: they use JIDs like 'jews.zm.is' instead of
'user@jews.zm.is'. This affects:
1. Device list node path: eu.siacs.conversations.axolotl/devices/jews.zm.is
A key issue with components: they use JIDs like 'component.example.test' instead of
'user@component.example.test'. This affects:
1. Device list node path: eu.siacs.conversations.axolotl/devices/component.example.test
2. Device identity and trust establishment
3. How clients discover and encrypt to the component
The OMEMO plugin must handle component JIDs correctly.
"""
component_jid = "jews.zm.is"
component_jid = "component.example.test"
# Component JID format (no user@ part)
self.assertNotIn("@", component_jid)
@@ -325,21 +325,21 @@ class XMPPOmemoDeviceDiscoveryTests(TestCase):
# But PubSub device node still follows standard format
pubsub_node = f"eu.siacs.conversations.axolotl/devices/{component_jid}"
self.assertEqual(
"eu.siacs.conversations.axolotl/devices/jews.zm.is", pubsub_node
"eu.siacs.conversations.axolotl/devices/component.example.test", pubsub_node
)
def test_gateway_accepts_presence_subscription_for_omemo(self):
"""Test that gateway auto-accepts presence subscriptions for OMEMO device discovery.
When a client subscribes to the gateway component (jews.zm.is) for OMEMO:
1. Client sends: <presence type="subscribe" from="user@example.com" to="jews.zm.is"/>
When a client subscribes to the gateway component JID for OMEMO:
1. Client sends: <presence type="subscribe" from="user@example.com" to="component.example.test"/>
2. Gateway should auto-accept and send presence availability
3. This allows the client to add the gateway to its roster
4. Client can then query PubSub for device lists
"""
# Simulate a client sending presence subscription to gateway
client_jid = "testclient@example.com"
gateway_jid = "jews.zm.is"
gateway_jid = "component.example.test"
# Create a mock XMPP component with the subscription handler
mock_component = MagicMock()