mirror of
https://github.com/zulip/zulip.git
synced 2025-10-24 08:33:43 +00:00
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:
committed by
Tim Abbott
parent
f034a6c3b4
commit
3cbf0e70a2
@@ -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`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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."
|
||||||
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user