push_notification: Add support to send E2EE test push notification.

This commit adds an endpoint `/mobile_push/e2ee/test_notification`
to send an end-to-end encrypted test push notification to the user's
selected mobile device or all of their mobile devices.
This commit is contained in:
Prakhar Pratyush
2025-08-07 18:35:13 +05:30
committed by Tim Abbott
parent f034a6c3b4
commit 3cbf0e70a2
14 changed files with 506 additions and 13 deletions

View File

@@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 11.0 ## Changes in Zulip 11.0
**Feature level 420**
* [`POST /mobile_push/e2ee/test_notification`](/api/e2ee-test-notify):
Added a new endpoint to send an end-to-end encrypted test push notification
to the user's selected mobile device or all of their mobile devices.
**Feature level 419** **Feature level 419**
* [`POST /register`](/api/register-queue): Added `simplified_presence_events` * [`POST /register`](/api/register-queue): Added `simplified_presence_events`

View File

@@ -157,9 +157,10 @@
* [Fetch an API key (production)](/api/fetch-api-key) * [Fetch an API key (production)](/api/fetch-api-key)
* [Fetch an API key (development only)](/api/dev-fetch-api-key) * [Fetch an API key (development only)](/api/dev-fetch-api-key)
* [Send a test notification to mobile device(s)](/api/test-notify) * [Send an E2EE test notification to mobile device(s)](/api/e2ee-test-notify)
* [Register E2EE push device](/api/register-push-device) * [Register E2EE push device](/api/register-push-device)
* [Mobile notifications](/api/mobile-notifications) * [Mobile notifications](/api/mobile-notifications)
* [Send a test notification to mobile device(s)](/api/test-notify)
* [Add an APNs device token](/api/add-apns-token) * [Add an APNs device token](/api/add-apns-token)
* [Remove an APNs device token](/api/remove-apns-token) * [Remove an APNs device token](/api/remove-apns-token)
* [Add an FCM registration token](/api/add-fcm-token) * [Add an FCM registration token](/api/add-fcm-token)

View File

@@ -59,7 +59,7 @@ Sample JSON data that gets encrypted:
{ {
"content": "test content", "content": "test content",
"message_id": 46, "message_id": 46,
"pm_users": "6,10,12,15" "pm_users": "6,10,12,15",
"realm_name": "Zulip Dev", "realm_name": "Zulip Dev",
"realm_url": "http://zulip.testserver", "realm_url": "http://zulip.testserver",
"recipient_type": "direct", "recipient_type": "direct",
@@ -106,6 +106,24 @@ Sample JSON data that gets encrypted:
**Changes**: New in Zulip 11.0 (feature level 413). **Changes**: New in Zulip 11.0 (feature level 413).
### Test push notification
A user can trigger [sending an E2EE test push notification](/api/e2ee-test-notify)
to the user's selected mobile device or all of their mobile devices.
Sample JSON data that gets encrypted:
```json
{
"realm_name": "Zulip Dev",
"realm_url": "http://zulip.testserver",
"time": 1754577820,
"type": "test",
"user_id": 10
}
```
**Changes**: New in Zulip 11.0 (feature level 420).
## Future work ## Future work
This page will eventually also document the formats of the APNs and This page will eventually also document the formats of the APNs and

View File

@@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 419 API_FEATURE_LEVEL = 420
# Bump the minor PROVISION_VERSION to indicate that folks should provision # Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump # only when going from an old version of the code to a newer version. Bump

View File

@@ -63,6 +63,10 @@ class ErrorCode(Enum):
INVALID_BOUNCER_PUBLIC_KEY = auto() INVALID_BOUNCER_PUBLIC_KEY = auto()
REQUEST_EXPIRED = auto() REQUEST_EXPIRED = auto()
PUSH_SERVICE_NOT_CONFIGURED = auto() PUSH_SERVICE_NOT_CONFIGURED = auto()
NO_ACTIVE_PUSH_DEVICE = auto()
FAILED_TO_CONNECT_BOUNCER = auto()
INTERNAL_SERVER_ERROR_ON_BOUNCER = auto()
ADMIN_ACTION_REQUIRED = auto()
class JsonableError(Exception): class JsonableError(Exception):

View File

@@ -15,7 +15,7 @@ import orjson
from aioapns.common import NotificationResult, PushType from aioapns.common import NotificationResult, PushType
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q, QuerySet
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@@ -42,6 +42,9 @@ from zerver.lib.exceptions import ErrorCode, JsonableError, MissingRemoteRealmEr
from zerver.lib.message import access_message_and_usermessage, direct_message_group_users from zerver.lib.message import access_message_and_usermessage, direct_message_group_users
from zerver.lib.notification_data import get_mentioned_user_group from zerver.lib.notification_data import get_mentioned_user_group
from zerver.lib.remote_server import ( from zerver.lib.remote_server import (
PushNotificationBouncerError,
PushNotificationBouncerRetryLaterError,
PushNotificationBouncerServerError,
record_push_notifications_recently_working, record_push_notifications_recently_working,
send_json_to_push_bouncer, send_json_to_push_bouncer,
send_server_data_to_push_bouncer, send_server_data_to_push_bouncer,
@@ -1463,9 +1466,14 @@ def get_encrypted_data(payload_data_to_encrypt: dict[str, Any], public_key_str:
def send_push_notifications( def send_push_notifications(
user_profile: UserProfile, user_profile: UserProfile,
payload_data_to_encrypt: dict[str, Any], payload_data_to_encrypt: dict[str, Any],
test_notification_to_push_devices: QuerySet[PushDevice] | None = None,
) -> None: ) -> None:
# Uses 'zerver_pushdevice_user_bouncer_device_id_idx' index. if test_notification_to_push_devices is not None:
push_devices = PushDevice.objects.filter(user=user_profile, bouncer_device_id__isnull=False) assert len(test_notification_to_push_devices) != 0
push_devices = test_notification_to_push_devices
else:
# Uses 'zerver_pushdevice_user_bouncer_device_id_idx' index.
push_devices = PushDevice.objects.filter(user=user_profile, bouncer_device_id__isnull=False)
if len(push_devices) == 0: if len(push_devices) == 0:
logger.info( logger.info(
@@ -1475,6 +1483,7 @@ def send_push_notifications(
return return
is_removal = payload_data_to_encrypt["type"] == "remove" is_removal = payload_data_to_encrypt["type"] == "remove"
is_test_notification = payload_data_to_encrypt["type"] == "test"
# Note: The "Final" qualifier serves as a shorthand # Note: The "Final" qualifier serves as a shorthand
# for declaring that a variable is effectively Literal. # for declaring that a variable is effectively Literal.
@@ -1549,6 +1558,12 @@ def send_push_notifications(
acting_user=None, acting_user=None,
) )
do_set_push_notifications_enabled_end_timestamp(user_profile.realm, None, acting_user=None) do_set_push_notifications_enabled_end_timestamp(user_profile.realm, None, acting_user=None)
if is_test_notification:
# Propagate the exception to the caller to notify the client
# about the error while attempting to send test push notification.
raise e
return return
# Handle success response data # Handle success response data
@@ -1597,6 +1612,12 @@ def send_push_notifications(
if can_push: if can_push:
record_push_notifications_recently_working() record_push_notifications_recently_working()
if is_test_notification and len(push_requests) == len(delete_device_ids):
# While sending test push notification, the bouncer reported
# that there's no active registered push device. Inform the
# same to the client.
raise NoActivePushDeviceError
def handle_push_notification(user_profile_id: int, missed_message: dict[str, Any]) -> None: def handle_push_notification(user_profile_id: int, missed_message: dict[str, Any]) -> None:
""" """
@@ -1797,6 +1818,39 @@ def send_test_push_notification(user_profile: UserProfile, devices: list[PushDev
) )
def send_e2ee_test_push_notification(
user_profile: UserProfile, push_devices: QuerySet[PushDevice]
) -> None:
payload_data_to_encrypt = get_base_payload(user_profile, for_legacy_clients=False)
payload_data_to_encrypt["type"] = "test"
payload_data_to_encrypt["time"] = datetime_to_timestamp(timezone_now())
logger.info("Sending E2EE test push notification for user %s", user_profile.id)
try:
send_push_notifications(
user_profile, payload_data_to_encrypt, test_notification_to_push_devices=push_devices
)
except PushNotificationBouncerServerError:
# 5xx error response from bouncer server
raise InternalBouncerServerError
except PushNotificationBouncerRetryLaterError:
# Network error
raise FailedToConnectBouncerError
except (
# Need to resubmit realm info - `manage.py register_server`
MissingRemoteRealmError,
# Invalid credentials or unexpected status code
PushNotificationBouncerError,
# Plan doesn't allow sending push notifications
PushNotificationsDisallowedByBouncerError,
):
# Server admins need to fix these set of errors, report them.
error_msg = f"Sending E2EE test push notification for user_id={user_profile.id} failed."
logger.error(error_msg)
raise PushNotificationAdminActionRequiredError
class InvalidPushDeviceTokenError(JsonableError): class InvalidPushDeviceTokenError(JsonableError):
code = ErrorCode.INVALID_PUSH_DEVICE_TOKEN code = ErrorCode.INVALID_PUSH_DEVICE_TOKEN
@@ -1858,3 +1912,56 @@ def get_push_devices(user_profile: UserProfile) -> dict[str, PushDeviceInfoDict]
str(row.push_account_id): {"status": row.status, "error_code": row.error_code} str(row.push_account_id): {"status": row.status, "error_code": row.error_code}
for row in rows for row in rows
} }
class NoActivePushDeviceError(JsonableError):
code = ErrorCode.NO_ACTIVE_PUSH_DEVICE
def __init__(self) -> None:
pass
@staticmethod
@override
def msg_format() -> str:
return _("No active registered push device")
class FailedToConnectBouncerError(JsonableError):
http_status_code = 502
code = ErrorCode.FAILED_TO_CONNECT_BOUNCER
def __init__(self) -> None:
pass
@staticmethod
@override
def msg_format() -> str:
return _("Network error while connecting to Zulip push notification service.")
class InternalBouncerServerError(JsonableError):
http_status_code = 502
code = ErrorCode.INTERNAL_SERVER_ERROR_ON_BOUNCER
def __init__(self) -> None:
pass
@staticmethod
@override
def msg_format() -> str:
return _("Internal server error on Zulip push notification service, retry later.")
class PushNotificationAdminActionRequiredError(JsonableError):
http_status_code = 403
code = ErrorCode.ADMIN_ACTION_REQUIRED
def __init__(self) -> None:
pass
@staticmethod
@override
def msg_format() -> str:
return _(
"Push notification configuration issue on server, contact the server administrator or retry later."
)

View File

@@ -32,6 +32,7 @@ from zerver.lib.debug import maybe_tracemalloc_listen
from zerver.lib.exceptions import ErrorCode, JsonableError, MissingAuthenticationError, WebhookError from zerver.lib.exceptions import ErrorCode, JsonableError, MissingAuthenticationError, WebhookError
from zerver.lib.markdown import get_markdown_requests, get_markdown_time from zerver.lib.markdown import get_markdown_requests, get_markdown_time
from zerver.lib.per_request_cache import flush_per_request_caches from zerver.lib.per_request_cache import flush_per_request_caches
from zerver.lib.push_notifications import FailedToConnectBouncerError, InternalBouncerServerError
from zerver.lib.rate_limiter import RateLimitResult from zerver.lib.rate_limiter import RateLimitResult
from zerver.lib.request import RequestNotes from zerver.lib.request import RequestNotes
from zerver.lib.response import ( from zerver.lib.response import (
@@ -397,10 +398,16 @@ class JsonErrorHandler(MiddlewareMixin):
if isinstance(exception, JsonableError): if isinstance(exception, JsonableError):
response = json_response_from_error(exception) response = json_response_from_error(exception)
if response.status_code < 500 or isinstance(exception, WebhookError): if response.status_code < 500 or isinstance(
exception, (FailedToConnectBouncerError | InternalBouncerServerError | WebhookError)
):
# Webhook errors are handled in # Webhook errors are handled in
# authenticated_rest_api_view / webhook_view, so we # authenticated_rest_api_view / webhook_view, so we
# just return the response without logging further. # just return the response without logging further.
#
# Return the response when `FailedToConnectBouncerError` or
# `InternalBouncerServerError` raised (status code 502) as
# it helps the client to show the user a more accurate error message.
return response return response
elif RequestNotes.get_notes(request).error_format == "JSON" and not settings.TEST_SUITE: elif RequestNotes.get_notes(request).error_format == "JSON" and not settings.TEST_SUITE:
response = json_response( response = json_response(

View File

@@ -119,15 +119,15 @@ class PushDevice(AbstractPushDevice):
# We treat (user, push_account_id) as a unique registration. # We treat (user, push_account_id) as a unique registration.
# #
# Also, the unique index created is used by queries in `get_push_accounts`, # Also, the unique index created is used by queries in `get_push_accounts`,
# `register_push_device`, and `handle_register_push_device_to_bouncer`. # `register_push_device`, `handle_register_push_device_to_bouncer`, and
# `send_e2ee_test_push_notification_api`.
fields=["user", "push_account_id"], fields=["user", "push_account_id"],
name="unique_push_device_user_push_account_id", name="unique_push_device_user_push_account_id",
), ),
] ]
indexes = [ indexes = [
models.Index( models.Index(
# Used in 'send_push_notifications' function, # Used in 'send_push_notifications' and `send_e2ee_test_push_notification_api`.
# in 'zerver/lib/push_notifications'.
fields=["user", "bouncer_device_id"], fields=["user", "bouncer_device_id"],
condition=Q(bouncer_device_id__isnull=False), condition=Q(bouncer_device_id__isnull=False),
name="zerver_pushdevice_user_bouncer_device_id_idx", name="zerver_pushdevice_user_bouncer_device_id_idx",

View File

@@ -470,7 +470,7 @@ def validate_test_response(request: Request, response: Response) -> bool:
return True return True
# Code is not declared but appears in various 400 responses. If # Code is not declared but appears in various 400 responses. If
# common, it can be added to 400 response schema # common, it can be added to 400 response schema
if status_code.startswith("4"): if status_code.startswith("4") or status_code == "502":
# This return statement should ideally be not here. But since # This return statement should ideally be not here. But since
# we have not defined 400 responses for various paths this has # we have not defined 400 responses for various paths this has
# been added as all 400 have the same schema. When all 400 # been added as all 400 have the same schema. When all 400

View File

@@ -26,7 +26,8 @@ from zerver.openapi.openapi import get_endpoint_from_operationid
UNTESTED_GENERATED_CURL_EXAMPLES = { UNTESTED_GENERATED_CURL_EXAMPLES = {
# Would need push notification bouncer set up to test the # Would need push notification bouncer set up to test the
# generated curl example for this endpoint. # generated curl example for the following two endpoints.
"e2ee-test-notify",
"test-notify", "test-notify",
# Having a message for a specific user available to test this endpoint # Having a message for a specific user available to test this endpoint
# is tricky for testing. # is tricky for testing.

View File

@@ -12636,6 +12636,69 @@ paths:
oneOf: oneOf:
- $ref: "#/components/schemas/InvalidPushDeviceTokenError" - $ref: "#/components/schemas/InvalidPushDeviceTokenError"
- $ref: "#/components/schemas/InvalidRemotePushDeviceTokenError" - $ref: "#/components/schemas/InvalidRemotePushDeviceTokenError"
/mobile_push/e2ee/test_notification:
post:
operationId: e2ee-test-notify
summary: Send an E2EE test notification to mobile device(s)
tags: ["mobile"]
description: |
Trigger sending an end-to-end encrypted (E2EE) test push notification
to the user's selected mobile device or all of their mobile devices.
**Changes**: New in Zulip 11.0 (feature level 420).
requestBody:
required: false
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
push_account_id:
description: |
The push account ID for the device to which to send the test notification.
If this parameter is not submitted, the E2EE test notification will
be sent to all of the user's devices registered on the server.
A mobile client should pass this parameter, to avoid triggering a test
notification for other clients.
See [`POST /mobile_push/register`](/api/register-push-device)
for details on push account IDs.
type: integer
example: 111222
responses:
"200":
description: Success.
content:
application/json:
schema:
$ref: "#/components/schemas/JsonSuccess"
"400":
description: |
Bad request.
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/NoActivePushDeviceError"
"403":
description: |
Forbidden.
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/PushNotificationAdminActionRequiredError"
"502":
description: |
Bad gateway.
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/FailedToConnectBouncerError"
- $ref: "#/components/schemas/InternalBouncerServerError"
/mobile_push/register: /mobile_push/register:
post: post:
operationId: register-push-device operationId: register-push-device
@@ -28459,6 +28522,62 @@ components:
A typical failed JSON response for when the push device token is not recognized A typical failed JSON response for when the push device token is not recognized
by the push notification bouncer: by the push notification bouncer:
NoActivePushDeviceError:
allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"code": "NO_ACTIVE_PUSH_DEVICE",
"msg": "No active registered push device",
"result": "error",
}
description: |
## No active push device
A typical failed JSON response for when no registered device is available
for the user (and `push_account_id`) to receive a push notification.
FailedToConnectBouncerError:
allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"code": "FAILED_TO_CONNECT_BOUNCER",
"msg": "Network error while connecting to Zulip push notification service.",
"result": "error",
}
description: |
## Failed to connect bouncer
A typical failed JSON response for when a network error occurs
while the server attempts to connect to the bouncer server.
InternalBouncerServerError:
allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"code": "INTERNAL_SERVER_ERROR_ON_BOUNCER",
"msg": "Internal server error on Zulip push notification service, retry later.",
"result": "error",
}
description: |
## Internal bouncer server error
A typical failed JSON response for when a 5xx error occurs on the bouncer server.
PushNotificationAdminActionRequiredError:
allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"code": "ADMIN_ACTION_REQUIRED",
"msg": "Push notification configuration issue on server, contact the server administrator or retry later.",
"result": "error",
}
description: |
## Admin action required
A typical failed JSON response for when there is a push notification
configuration issue on the server, such as invalid credentials,
an expired plan, or an unregistered organization. Admin action is required.
Event_types: Event_types:
description: | description: |
A JSON-encoded array indicating which types of events you're interested A JSON-encoded array indicating which types of events you're interested

View File

@@ -11,7 +11,17 @@ from firebase_admin.messaging import UnregisteredError
from analytics.models import RealmCount from analytics.models import RealmCount
from zerver.actions.user_groups import check_add_user_group from zerver.actions.user_groups import check_add_user_group
from zerver.lib.avatar import absolute_avatar_url from zerver.lib.avatar import absolute_avatar_url
from zerver.lib.push_notifications import handle_push_notification, handle_remove_push_notification from zerver.lib.exceptions import MissingRemoteRealmError
from zerver.lib.push_notifications import (
PushNotificationsDisallowedByBouncerError,
handle_push_notification,
handle_remove_push_notification,
)
from zerver.lib.remote_server import (
PushNotificationBouncerError,
PushNotificationBouncerRetryLaterError,
PushNotificationBouncerServerError,
)
from zerver.lib.test_classes import E2EEPushNotificationTestCase from zerver.lib.test_classes import E2EEPushNotificationTestCase
from zerver.lib.test_helpers import activate_push_notification_service from zerver.lib.test_helpers import activate_push_notification_service
from zerver.lib.timestamp import datetime_to_timestamp from zerver.lib.timestamp import datetime_to_timestamp
@@ -864,3 +874,195 @@ class RequireE2EEPushNotificationsSettingTest(E2EEPushNotificationTestCase):
self.assertEqual(send_apple.call_args.args[2]["alert"]["body"], "New message") self.assertEqual(send_apple.call_args.args[2]["alert"]["body"], "New message")
self.assertEqual(send_android.call_args.args[2]["content"], "New message") self.assertEqual(send_android.call_args.args[2]["content"], "New message")
class SendTestPushNotificationTest(E2EEPushNotificationTestCase):
def test_success_cloud(self) -> None:
hamlet = self.example_user("hamlet")
unused, registered_device_android = self.register_push_devices_for_notification()
with (
self.mock_fcm() as mock_fcm_messaging,
self.mock_apns() as send_notification,
self.assertLogs("zilencer.lib.push_notifications", level="INFO"),
self.assertLogs("zerver.lib.push_notifications", level="INFO") as zerver_logger,
):
mock_fcm_messaging.send_each.return_value = self.make_fcm_success_response()
send_notification.return_value.is_successful = True
# Send test notification to all of the registered mobile devices.
result = self.api_post(
hamlet, "/api/v1/mobile_push/e2ee/test_notification", subdomain="zulip"
)
self.assert_json_success(result)
mock_fcm_messaging.send_each.assert_called_once()
send_notification.assert_called_once()
self.assertEqual(
"INFO:zerver.lib.push_notifications:"
f"Sending E2EE test push notification for user {hamlet.id}",
zerver_logger.output[0],
)
self.assertEqual(
"INFO:zerver.lib.push_notifications:"
f"Sent E2EE mobile push notifications for user {hamlet.id}: 1 via FCM, 1 via APNs",
zerver_logger.output[-1],
)
# Send test notification to a selected mobile device.
result = self.api_post(
hamlet,
"/api/v1/mobile_push/e2ee/test_notification",
{"push_account_id": registered_device_android.push_account_id},
subdomain="zulip",
)
self.assert_json_success(result)
self.assertEqual(
"INFO:zerver.lib.push_notifications:"
f"Sending E2EE test push notification for user {hamlet.id}",
zerver_logger.output[0],
)
self.assertEqual(
"INFO:zerver.lib.push_notifications:"
f"Sent E2EE mobile push notifications for user {hamlet.id}: 1 via FCM, 0 via APNs",
zerver_logger.output[-1],
)
@responses.activate
@override_settings(ZILENCER_ENABLED=False)
def test_success_self_hosted(self) -> None:
self.add_mock_response()
hamlet = self.example_user("hamlet")
self.register_push_devices_for_notification(is_server_self_hosted=True)
with (
self.mock_fcm() as mock_fcm_messaging,
self.mock_apns() as send_notification,
mock.patch(
"corporate.lib.stripe.RemoteRealmBillingSession.current_count_for_billed_licenses",
return_value=10,
),
self.assertLogs("zilencer.lib.push_notifications", level="INFO"),
self.assertLogs("zerver.lib.push_notifications", level="INFO") as zerver_logger,
):
mock_fcm_messaging.send_each.return_value = self.make_fcm_success_response()
send_notification.return_value.is_successful = True
# Send test notification to all of the registered mobile devices.
result = self.api_post(
hamlet, "/api/v1/mobile_push/e2ee/test_notification", subdomain="zulip"
)
self.assert_json_success(result)
mock_fcm_messaging.send_each.assert_called_once()
send_notification.assert_called_once()
self.assertEqual(
"INFO:zerver.lib.push_notifications:"
f"Sending E2EE test push notification for user {hamlet.id}",
zerver_logger.output[0],
)
self.assertEqual(
"INFO:zerver.lib.push_notifications:"
f"Sent E2EE mobile push notifications for user {hamlet.id}: 1 via FCM, 1 via APNs",
zerver_logger.output[-1],
)
@responses.activate
@override_settings(ZILENCER_ENABLED=False)
def test_error_responses(self) -> None:
self.add_mock_response()
hamlet = self.example_user("hamlet")
# No registered device to send to.
result = self.api_post(
hamlet, "/api/v1/mobile_push/e2ee/test_notification", subdomain="zulip"
)
self.assert_json_error(result, "No active registered push device", 400)
# Verify errors propagated to the client.
registered_device_apple, registered_device_android = (
self.register_push_devices_for_notification(is_server_self_hosted=True)
)
def assert_error_response(msg: str, http_status_code: int) -> None:
with self.assertLogs("zerver.lib.push_notifications", level="INFO") as zerver_logger:
result = self.api_post(
hamlet, "/api/v1/mobile_push/e2ee/test_notification", subdomain="zulip"
)
self.assert_json_error(result, msg, http_status_code)
self.assertEqual(
"INFO:zerver.lib.push_notifications:"
f"Sending E2EE test push notification for user {hamlet.id}",
zerver_logger.output[0],
)
with (
mock.patch(
"zerver.lib.remote_server.send_to_push_bouncer",
side_effect=PushNotificationBouncerRetryLaterError("network error"),
),
self.assertLogs(level="ERROR") as error_logs,
):
assert_error_response(
"Network error while connecting to Zulip push notification service.", 502
)
self.assertEqual(
"ERROR:django.request:Bad Gateway: /api/v1/mobile_push/e2ee/test_notification",
error_logs.output[0],
)
with (
mock.patch(
"zerver.lib.remote_server.send_to_push_bouncer",
side_effect=PushNotificationBouncerServerError("server error"),
),
self.assertLogs(level="ERROR") as error_logs,
):
assert_error_response(
"Internal server error on Zulip push notification service, retry later.", 502
)
self.assertEqual(
"ERROR:django.request:Bad Gateway: /api/v1/mobile_push/e2ee/test_notification",
error_logs.output[0],
)
with mock.patch(
"zerver.lib.remote_server.send_to_push_bouncer", side_effect=MissingRemoteRealmError
):
assert_error_response(
"Push notification configuration issue on server, contact the server administrator or retry later.",
403,
)
with mock.patch(
"zerver.lib.remote_server.send_to_push_bouncer",
side_effect=PushNotificationBouncerError,
):
assert_error_response(
"Push notification configuration issue on server, contact the server administrator or retry later.",
403,
)
with mock.patch(
"zerver.lib.remote_server.send_to_push_bouncer",
side_effect=PushNotificationsDisallowedByBouncerError("plan expired"),
):
assert_error_response(
"Push notification configuration issue on server, contact the server administrator or retry later.",
403,
)
# Device marked expired on bouncer (not on server).
registered_device_apple.delete()
registered_device_android.delete()
with mock.patch(
"corporate.lib.stripe.RemoteRealmBillingSession.current_count_for_billed_licenses",
return_value=10,
):
assert_error_response("No active registered push device", 400)

View File

@@ -21,8 +21,10 @@ from zerver.lib.exceptions import (
) )
from zerver.lib.push_notifications import ( from zerver.lib.push_notifications import (
InvalidPushDeviceTokenError, InvalidPushDeviceTokenError,
NoActivePushDeviceError,
add_push_device_token, add_push_device_token,
remove_push_device_token, remove_push_device_token,
send_e2ee_test_push_notification,
send_test_push_notification, send_test_push_notification,
uses_notification_bouncer, uses_notification_bouncer,
validate_token, validate_token,
@@ -110,6 +112,30 @@ def send_test_push_notification_api(
return json_success(request) return json_success(request)
@human_users_only
@typed_endpoint
def send_e2ee_test_push_notification_api(
request: HttpRequest, user_profile: UserProfile, *, push_account_id: Json[int] | None = None
) -> HttpResponse:
# We skip push devices with `bouncer_device_id` set to `null` as they are
# not yet registered with the bouncer to send mobile push notifications.
if push_account_id is not None:
# Uses unique index created for 'unique_push_device_user_push_account_id' constraint.
push_devices = PushDevice.objects.filter(
user=user_profile, push_account_id=push_account_id, bouncer_device_id__isnull=False
)
else:
# Uses 'zerver_pushdevice_user_bouncer_device_id_idx' index.
push_devices = PushDevice.objects.filter(user=user_profile, bouncer_device_id__isnull=False)
if len(push_devices) == 0:
raise NoActivePushDeviceError
send_e2ee_test_push_notification(user_profile, push_devices)
return json_success(request)
def self_hosting_auth_view_common( def self_hosting_auth_view_common(
request: HttpRequest, user_profile: UserProfile, next_page: str | None = None request: HttpRequest, user_profile: UserProfile, next_page: str | None = None
) -> str: ) -> str:

View File

@@ -118,6 +118,7 @@ from zerver.views.push_notifications import (
self_hosting_auth_not_configured, self_hosting_auth_not_configured,
self_hosting_auth_redirect_endpoint, self_hosting_auth_redirect_endpoint,
self_hosting_registration_transfer_challenge_verify, self_hosting_registration_transfer_challenge_verify,
send_e2ee_test_push_notification_api,
send_test_push_notification_api, send_test_push_notification_api,
) )
from zerver.views.reactions import add_reaction, remove_reaction from zerver.views.reactions import add_reaction, remove_reaction
@@ -462,6 +463,7 @@ v1_api_and_json_patterns = [
), ),
rest_path("users/me/android_gcm_reg_id", POST=add_android_reg_id, DELETE=remove_android_reg_id), rest_path("users/me/android_gcm_reg_id", POST=add_android_reg_id, DELETE=remove_android_reg_id),
rest_path("mobile_push/test_notification", POST=send_test_push_notification_api), rest_path("mobile_push/test_notification", POST=send_test_push_notification_api),
rest_path("mobile_push/e2ee/test_notification", POST=send_e2ee_test_push_notification_api),
rest_path("mobile_push/register", POST=register_push_device), rest_path("mobile_push/register", POST=register_push_device),
# users/*/presence => zerver.views.presence. # users/*/presence => zerver.views.presence.
rest_path( rest_path(