Files
upstream-zulip/zerver/tests/test_webhooks_common.py
upstream-mirror 64898b2c79
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
2026-09-15 15:05:30 +05:00

457 lines
18 KiB
Python

import hashlib
import hmac
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import requests
from django.http import HttpRequest, QueryDict
from django.http.response import HttpResponse
from django.test import override_settings
from django.utils.encoding import force_bytes
from typing_extensions import override
from version import ZULIP_VERSION
from zerver.actions.custom_profile_fields import try_add_realm_custom_profile_field
from zerver.actions.streams import do_rename_stream
from zerver.decorator import webhook_view
from zerver.lib.exceptions import InvalidJSONError, JsonableError
from zerver.lib.request import RequestNotes
from zerver.lib.send_email import FromAddress
from zerver.lib.test_classes import WebhookTestCase, ZulipTestCase
from zerver.lib.test_helpers import HostRequestMock
from zerver.lib.webhooks.common import (
INVALID_JSON_MESSAGE,
MISSING_EVENT_HEADER_MESSAGE,
MissingHTTPEventHeaderError,
call_fixture_to_headers,
check_send_webhook_message,
get_event_header,
get_service_api_data,
guess_zulip_user_from_external_account,
standardize_headers,
validate_webhook_signature,
)
from zerver.models import Client, CustomProfileField, Message, UserProfile
from zerver.models.realms import get_realm
from zerver.models.users import get_user
class WebhooksCommonTestCase(ZulipTestCase):
def test_webhook_http_header_header_exists(self) -> None:
webhook_bot = get_user("webhook-bot@zulip.com", get_realm("zulip"))
request = HostRequestMock()
request.META["HTTP_X_CUSTOM_HEADER"] = "custom_value"
request.user = webhook_bot
header_value = get_event_header(request, "X-Custom-Header", "test_webhook")
self.assertEqual(header_value, "custom_value")
def test_webhook_http_header_header_does_not_exist(self) -> None:
realm = get_realm("zulip")
webhook_bot = get_user("webhook-bot@zulip.com", realm)
webhook_bot.last_reminder = None
notification_bot = self.notification_bot(realm)
request = HostRequestMock()
request.user = webhook_bot
request.path = "some/random/path"
exception_msg = "Missing the HTTP event header 'X-Custom-Header'"
with self.assertRaisesRegex(MissingHTTPEventHeaderError, exception_msg):
get_event_header(request, "X-Custom-Header", "test_webhook")
msg = self.get_last_message()
expected_message = MISSING_EVENT_HEADER_MESSAGE.format(
bot_name=webhook_bot.full_name,
request_path=request.path,
header_name="X-Custom-Header",
integration_name="test_webhook",
support_email=FromAddress.SUPPORT,
).rstrip()
self.assertEqual(msg.sender.id, notification_bot.id)
self.assertEqual(msg.content, expected_message)
def test_notify_bot_owner_on_invalid_json(self) -> None:
@webhook_view("ClientName", notify_bot_owner_on_invalid_json=False)
def my_webhook_no_notify(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
raise InvalidJSONError("Malformed JSON")
@webhook_view("ClientName", notify_bot_owner_on_invalid_json=True)
def my_webhook_notify(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
raise InvalidJSONError("Malformed JSON")
webhook_bot_email = "webhook-bot@zulip.com"
webhook_bot_realm = get_realm("zulip")
webhook_bot = get_user(webhook_bot_email, webhook_bot_realm)
webhook_bot_api_key = webhook_bot.api_key
request = HostRequestMock()
request.POST["api_key"] = webhook_bot_api_key
request.host = "zulip.testserver"
expected_msg = INVALID_JSON_MESSAGE.format(webhook_name="ClientName")
last_message_id = self.get_last_message().id
with self.assertRaisesRegex(JsonableError, "Malformed JSON"):
my_webhook_no_notify(request)
# First verify that without the setting, it doesn't send a direct
# message to bot owner.
msg = self.get_last_message()
self.assertEqual(msg.id, last_message_id)
self.assertNotEqual(msg.content, expected_msg.strip())
# Then verify that with the setting, it does send such a message.
request = HostRequestMock()
request.POST["api_key"] = webhook_bot_api_key
request.host = "zulip.testserver"
with self.assertRaisesRegex(JsonableError, "Malformed JSON"):
my_webhook_notify(request)
msg = self.get_last_message()
self.assertNotEqual(msg.id, last_message_id)
self.assertEqual(msg.sender.id, self.notification_bot(webhook_bot_realm).id)
self.assertEqual(msg.content, expected_msg.strip())
@patch("zerver.lib.webhooks.common.importlib.import_module")
def test_call_fixture_to_headers_for_success(self, import_module_mock: MagicMock) -> None:
def fixture_to_headers(fixture_name: str) -> dict[str, str]:
# A sample function which would normally perform some
# extra operations before returning a dictionary
# corresponding to the fixture name passed. For this test,
# we just return a fixed dictionary.
return {"key": "value"}
fake_module = SimpleNamespace(fixture_to_headers=fixture_to_headers)
import_module_mock.return_value = fake_module
headers = call_fixture_to_headers("some_integration", "complex_fixture")
self.assertEqual(headers, {"key": "value"})
def test_call_fixture_to_headers_for_non_existent_integration(self) -> None:
headers = call_fixture_to_headers("some_random_nonexistent_integration", "fixture_name")
self.assertEqual(headers, {})
@patch("zerver.lib.webhooks.common.importlib.import_module")
def test_call_fixture_to_headers_with_no_fixtures_to_headers_function(
self,
import_module_mock: MagicMock,
) -> None:
fake_module = SimpleNamespace()
import_module_mock.return_value = fake_module
self.assertEqual(
call_fixture_to_headers("some_integration", "simple_fixture"),
{},
)
def test_standardize_headers(self) -> None:
self.assertEqual(standardize_headers({}), {})
raw_headers = {"Content-Type": "text/plain", "X-Event-Type": "ping"}
djangoified_headers = standardize_headers(raw_headers)
expected_djangoified_headers = {"CONTENT_TYPE": "text/plain", "HTTP_X_EVENT_TYPE": "ping"}
self.assertEqual(djangoified_headers, expected_djangoified_headers)
@override_settings(VERIFY_WEBHOOK_SIGNATURES=True)
def test_validate_webhook_signature(self) -> None:
request = HostRequestMock()
request.GET = QueryDict("", mutable=True)
# Valid signature
webhook_secret = "test_secret"
payload = '{"key": "value"}'
signature = hmac.new(
force_bytes(webhook_secret), force_bytes(payload), hashlib.sha256
).hexdigest()
request.GET.update({"webhook_secret": webhook_secret})
validate_webhook_signature(request, payload, signature)
# Invalid signature
invalid_signature = "invalid_signature"
with self.assertRaisesRegex(
JsonableError,
"Webhook signature verification failed.",
):
validate_webhook_signature(request, payload, invalid_signature)
# No webhook_secret parameter
request.GET.clear()
with self.assertRaisesRegex(
JsonableError,
"The webhook secret is missing. Please set the webhook_secret while generating the URL.",
):
validate_webhook_signature(request, payload, signature)
def test_check_send_webhook_message_returns_id(self) -> None:
webhook_bot = get_user("webhook-bot@zulip.com", get_realm("zulip"))
stream = self.make_stream("test_stream")
self.subscribe(webhook_bot, stream.name)
request = HostRequestMock()
request.user = webhook_bot
client = Client.objects.get_or_create(name="TestClient")[0]
RequestNotes.get_notes(request).client = client
message_id = check_send_webhook_message(
request,
webhook_bot,
"Test topic",
"Test message content",
stream=stream.name,
)
self.assertIsInstance(message_id, int)
assert message_id is not None
msg = Message.objects.get(id=message_id)
self.assertEqual(msg.topic_name(), "Test topic")
class TestGuessZulipUserFromExternalAccount(ZulipTestCase):
@override
def setUp(self) -> None:
super().setUp()
self.realm = get_realm("zulip")
self.hamlet = self.example_user("hamlet")
self.othello = self.example_user("othello")
self.github_name = "GitHub username"
def get_github_field(self) -> CustomProfileField:
return CustomProfileField.objects.get(
realm=self.realm,
name=self.github_name,
)
def set_user_external_account(
self, user: UserProfile, field: CustomProfileField, value: str
) -> None:
self.set_user_custom_profile_data(user, [{"id": field.id, "value": value}])
def test_single_exact_match(self) -> None:
github_field = self.get_github_field()
self.set_user_external_account(self.hamlet, github_field, "octocat")
result = guess_zulip_user_from_external_account(
self.realm, "octocat", self.github_name, external_username_case_insensitive=True
)
self.assertEqual(result, self.hamlet)
# Test case-sensitive account names
result = guess_zulip_user_from_external_account(
self.realm, "ocTOcat", self.github_name, external_username_case_insensitive=False
)
self.assertIsNone(result)
# Test multiple matching field values found
self.set_user_external_account(self.othello, github_field, "octocat")
result = guess_zulip_user_from_external_account(
self.realm, "octocat", self.github_name, external_username_case_insensitive=True
)
self.assertIsNone(result)
def test_no_matches_found(self) -> None:
# Test no matching field
result = guess_zulip_user_from_external_account(
self.realm, "someuser", "gitlab", external_username_case_insensitive=True
)
self.assertIsNone(result)
# test no matching field value
result = guess_zulip_user_from_external_account(
self.realm, "nonexistentuser", self.github_name, external_username_case_insensitive=True
)
self.assertIsNone(result)
def test_matches_from_custom_external_account_field(self) -> None:
external_account_field_name = "Otherhub username"
external_account_field = try_add_realm_custom_profile_field(
self.realm,
external_account_field_name,
CustomProfileField.EXTERNAL_ACCOUNT,
field_data={"subtype": "custom", "url_pattern": "https://other.com/%(username)s"},
)
self.set_user_external_account(self.hamlet, external_account_field, "octocat")
result = guess_zulip_user_from_external_account(
self.realm,
"octocat",
external_account_field_name,
external_username_case_insensitive=True,
)
self.assertEqual(result, self.hamlet)
class WebhookURLConfigurationTestCase(WebhookTestCase):
WEBHOOK_DIR_NAME = "helloworld"
@override
def setUp(self) -> None:
super().setUp()
stream = self.subscribe(self.test_user, self.channel_name)
# In actual webhook tests, we will not need to use stream id.
# We assign stream id to channel_name for testing URL configuration only.
self.channel_name = str(stream.id)
do_rename_stream(stream, "helloworld_renamed", self.test_user)
self.url = self.build_webhook_url()
def test_trigger_stream_message_by_id(self) -> None:
# check_webhook cannot be used here as it
# subscribes the test user to self.channel_name
payload = self.get_body("hello")
self.send_webhook_payload(
self.test_user, self.url, payload, content_type="application/json"
)
expected_topic_name = "Hello World"
expected_message = "Hello! I am happy to be here! :smile:\nThe Wikipedia featured article for today is **[Marilyn Monroe](https://en.wikipedia.org/wiki/Marilyn_Monroe)**"
msg = self.get_last_message()
self.assert_channel_message(
message=msg,
channel_name="helloworld_renamed",
topic_name=expected_topic_name,
content=expected_message,
)
class MissingEventHeaderTestCase(WebhookTestCase):
URL_TEMPLATE = "/api/v1/external/groove?stream={stream}&api_key={api_key}"
WEBHOOK_DIR_NAME = "groove"
# This tests the get_event_header function with an actual webhook,
# instead of just making a mock
def test_missing_event_header(self) -> None:
self.subscribe(self.test_user, self.channel_name)
with self.assertNoLogs("zulip.zerver.webhooks.anomalous", level="INFO"):
result = self.client_post(
self.url,
self.get_body("ticket_state_changed"),
content_type="application/x-www-form-urlencoded",
)
self.assert_json_error(result, "Missing the HTTP event header 'X-Groove-Event'")
realm = get_realm("zulip")
webhook_bot = get_user("webhook-bot@zulip.com", realm)
webhook_bot.last_reminder = None
notification_bot = self.notification_bot(realm)
msg = self.get_last_message()
expected_message = MISSING_EVENT_HEADER_MESSAGE.format(
bot_name=webhook_bot.full_name,
request_path="/api/v1/external/groove",
header_name="X-Groove-Event",
integration_name="Groove",
support_email=FromAddress.SUPPORT,
).rstrip()
if msg.sender.id != notification_bot.id: # nocoverage
# This block seems to fire occasionally; debug output:
print(msg)
print(msg.content)
self.assertEqual(msg.sender.id, notification_bot.id)
self.assertEqual(msg.content, expected_message)
@override
def get_body(self, fixture_name: str) -> str:
return self.webhook_fixture_data("groove", fixture_name, file_type="json")
class TestGetServiceApiData(ZulipTestCase):
@override
def setUp(self) -> None:
self.url = "https://api.example.com/users/123"
self.integration_name = "TestIntegration"
self.params = {"id": "123"}
self.headers = {"Authorization": "Bearer token"}
self.mock_response = MagicMock()
self.mock_response.status_code = 200
self.mock_response.json.return_value = {"name": "Test User"}
@patch("zerver.lib.webhooks.common.OutgoingSession")
def test_get_service_api_data_success(self, mock_session_class: MagicMock) -> None:
mock_session_instance = mock_session_class.return_value
mock_session_instance.request.return_value = self.mock_response
test_cases = [
{
"method": "get",
"expected_method": "GET",
"json": None,
"params": self.params,
},
{
"method": "POST",
"expected_method": "POST",
"json": self.params,
"params": None,
},
]
for case in test_cases:
with self.subTest(method=case["method"]):
mock_session_class.reset_mock()
mock_session_instance.request.reset_mock()
with self.assertNoLogs("zerver.lib.webhooks.common", level="WARNING"):
result = get_service_api_data(
self.url,
integration_name=self.integration_name,
headers=self.headers,
params=self.params,
method=str(case["method"]),
timeout=10.0,
)
mock_session_class.assert_called_once_with(
role="webhook_fetch",
headers={
"User-Agent": f"ZulipWebhookFetch/{ZULIP_VERSION}",
**self.headers,
},
timeout=10.0,
max_retries=3,
)
mock_session_instance.request.assert_called_once_with(
method=case["expected_method"],
url=self.url,
json=case["json"],
params=case["params"],
)
self.assertEqual(result.json(), {"name": "Test User"})
@patch("zerver.lib.webhooks.common.OutgoingSession.request")
def test_get_service_api_data_http_error(self, mock_request: MagicMock) -> None:
mock_request.return_value.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
with (
self.assertLogs("zerver.lib.webhooks.common", level="WARNING") as cm,
self.assertRaises(requests.HTTPError),
):
get_service_api_data(self.url, integration_name=self.integration_name)
self.assertEqual(
cm.output[0],
"WARNING:zerver.lib.webhooks.common:Failed to fetch data from "
"https://api.example.com/users/123 for "
"TestIntegration integration: 404 Not Found",
)
@patch("zerver.lib.webhooks.common.OutgoingSession.request")
def test_get_service_api_data_request_exception(self, mock_request: MagicMock) -> None:
mock_request.side_effect = requests.ConnectionError("Connection failed")
with (
self.assertLogs("zerver.lib.webhooks.common", level="WARNING") as cm,
self.assertRaises(requests.ConnectionError),
):
get_service_api_data(self.url, integration_name=self.integration_name)
self.assertEqual(
cm.output[0],
"WARNING:zerver.lib.webhooks.common:Failed to fetch data from "
"https://api.example.com/users/123 for "
"TestIntegration integration: Connection failed",
)