Fix all integrations
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
85
core/tests/test_settings_integrity.py
Normal file
85
core/tests/test_settings_integrity.py
Normal 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"])
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
103
core/tests/test_xmpp_attachment_bridge.py
Normal file
103
core/tests/test_xmpp_attachment_bridge.py
Normal 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,
|
||||
)
|
||||
165
core/tests/test_xmpp_carbons.py
Normal file
165
core/tests/test_xmpp_carbons.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user