mirror of
https://github.com/zulip/zulip.git
synced 2025-10-23 04:52:12 +00:00
This commit moves `AnalyticsBouncerTest` to a new `test_zilencer_analytics.py` file. It helps in making it easier to work with `test_push_notifications.py` which was 5000+ lines of code.
1466 lines
60 KiB
Python
1466 lines
60 KiB
Python
import logging
|
|
import uuid
|
|
from collections.abc import Mapping
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
from unittest import mock
|
|
|
|
import orjson
|
|
import responses
|
|
import time_machine
|
|
from django.conf import settings
|
|
from django.db.models import F
|
|
from django.utils.timezone import now
|
|
from requests.exceptions import ConnectionError
|
|
from typing_extensions import override
|
|
|
|
from analytics.lib.counts import CountStat, LoggingCountStat
|
|
from analytics.models import InstallationCount, RealmCount, UserCount
|
|
from corporate.lib.stripe import RemoteRealmBillingSession
|
|
from corporate.models.plans import CustomerPlan
|
|
from version import ZULIP_VERSION
|
|
from zerver.actions.create_realm import do_create_realm
|
|
from zerver.actions.realm_settings import (
|
|
do_change_realm_org_type,
|
|
do_deactivate_realm,
|
|
do_set_realm_authentication_methods,
|
|
)
|
|
from zerver.lib import redis_utils
|
|
from zerver.lib.remote_server import (
|
|
PUSH_NOTIFICATIONS_RECENTLY_WORKING_REDIS_KEY,
|
|
AnalyticsRequest,
|
|
PushNotificationBouncerRetryLaterError,
|
|
build_analytics_data,
|
|
get_realms_info_for_push_bouncer,
|
|
record_push_notifications_recently_working,
|
|
redis_client,
|
|
send_server_data_to_push_bouncer,
|
|
send_to_push_bouncer,
|
|
)
|
|
from zerver.lib.test_classes import BouncerTestCase
|
|
from zerver.lib.test_helpers import activate_push_notification_service
|
|
from zerver.lib.types import AnalyticsDataUploadLevel
|
|
from zerver.lib.user_counts import realm_user_count_by_role
|
|
from zerver.models import Realm, RealmAuditLog
|
|
from zerver.models.realm_audit_logs import AuditLogEventType
|
|
from zerver.models.realms import get_realm
|
|
from zilencer.lib.remote_counts import MissingDataError
|
|
|
|
if settings.ZILENCER_ENABLED:
|
|
from zilencer.models import (
|
|
RemoteInstallationCount,
|
|
RemoteRealm,
|
|
RemoteRealmAuditLog,
|
|
RemoteRealmCount,
|
|
RemoteZulipServer,
|
|
)
|
|
|
|
|
|
class AnalyticsBouncerTest(BouncerTestCase):
|
|
TIME_ZERO = datetime(1988, 3, 14, tzinfo=timezone.utc)
|
|
|
|
def assertPushNotificationsAre(self, should_be: bool) -> None:
|
|
self.assertEqual(
|
|
{should_be},
|
|
set(
|
|
Realm.objects.all().distinct().values_list("push_notifications_enabled", flat=True)
|
|
),
|
|
)
|
|
|
|
@override
|
|
def setUp(self) -> None:
|
|
redis_client.delete(PUSH_NOTIFICATIONS_RECENTLY_WORKING_REDIS_KEY)
|
|
|
|
return super().setUp()
|
|
|
|
@activate_push_notification_service()
|
|
@responses.activate
|
|
def test_analytics_failure_api(self) -> None:
|
|
assert settings.ZULIP_SERVICES_URL is not None
|
|
ANALYTICS_URL = settings.ZULIP_SERVICES_URL + "/api/v1/remotes/server/analytics"
|
|
ANALYTICS_STATUS_URL = ANALYTICS_URL + "/status"
|
|
|
|
with (
|
|
responses.RequestsMock() as resp,
|
|
self.assertLogs("zulip.analytics", level="WARNING") as mock_warning,
|
|
):
|
|
resp.add(responses.GET, ANALYTICS_STATUS_URL, body=ConnectionError())
|
|
Realm.objects.all().update(push_notifications_enabled=True)
|
|
send_server_data_to_push_bouncer()
|
|
self.assertEqual(
|
|
"WARNING:zulip.analytics:ConnectionError while trying to connect to push notification bouncer",
|
|
mock_warning.output[0],
|
|
)
|
|
self.assertTrue(resp.assert_call_count(ANALYTICS_STATUS_URL, 1))
|
|
self.assertPushNotificationsAre(False)
|
|
|
|
# Simulate ConnectionError again, but this time with a redis record indicating
|
|
# that push notifications have recently worked fine.
|
|
with (
|
|
responses.RequestsMock() as resp,
|
|
self.assertLogs("zulip.analytics", level="WARNING") as mock_warning,
|
|
):
|
|
resp.add(responses.GET, ANALYTICS_STATUS_URL, body=ConnectionError())
|
|
Realm.objects.all().update(push_notifications_enabled=True)
|
|
record_push_notifications_recently_working()
|
|
|
|
send_server_data_to_push_bouncer()
|
|
self.assertEqual(
|
|
"WARNING:zulip.analytics:ConnectionError while trying to connect to push notification bouncer",
|
|
mock_warning.output[0],
|
|
)
|
|
self.assertTrue(resp.assert_call_count(ANALYTICS_STATUS_URL, 1))
|
|
# push_notifications_enabled shouldn't get set to False, because this is treated
|
|
# as a transient error.
|
|
self.assertPushNotificationsAre(True)
|
|
|
|
# However after an hour has passed without seeing push notifications
|
|
# working, we take the error seriously.
|
|
with time_machine.travel(now() + timedelta(minutes=61), tick=False):
|
|
send_server_data_to_push_bouncer()
|
|
self.assertEqual(
|
|
"WARNING:zulip.analytics:ConnectionError while trying to connect to push notification bouncer",
|
|
mock_warning.output[1],
|
|
)
|
|
self.assertTrue(resp.assert_call_count(ANALYTICS_STATUS_URL, 2))
|
|
self.assertPushNotificationsAre(False)
|
|
|
|
redis_client.delete(
|
|
redis_utils.REDIS_KEY_PREFIX + PUSH_NOTIFICATIONS_RECENTLY_WORKING_REDIS_KEY
|
|
)
|
|
|
|
with (
|
|
responses.RequestsMock() as resp,
|
|
self.assertLogs("zulip.analytics", level="WARNING") as mock_warning,
|
|
):
|
|
resp.add(responses.GET, ANALYTICS_STATUS_URL, body="This is not JSON")
|
|
Realm.objects.all().update(push_notifications_enabled=True)
|
|
send_server_data_to_push_bouncer()
|
|
self.assertTrue(
|
|
mock_warning.output[0].startswith(
|
|
f"ERROR:zulip.analytics:Exception communicating with {settings.ZULIP_SERVICES_URL}\nTraceback",
|
|
)
|
|
)
|
|
self.assertTrue(resp.assert_call_count(ANALYTICS_STATUS_URL, 1))
|
|
self.assertPushNotificationsAre(False)
|
|
|
|
with responses.RequestsMock() as resp, self.assertLogs("", level="WARNING") as mock_warning:
|
|
resp.add(responses.GET, ANALYTICS_STATUS_URL, body="Server error", status=502)
|
|
Realm.objects.all().update(push_notifications_enabled=True)
|
|
send_server_data_to_push_bouncer()
|
|
self.assertEqual(
|
|
"WARNING:root:Received 502 from push notification bouncer",
|
|
mock_warning.output[0],
|
|
)
|
|
self.assertTrue(resp.assert_call_count(ANALYTICS_STATUS_URL, 1))
|
|
self.assertPushNotificationsAre(True)
|
|
|
|
with (
|
|
responses.RequestsMock() as resp,
|
|
self.assertLogs("zulip.analytics", level="WARNING") as mock_warning,
|
|
):
|
|
Realm.objects.all().update(push_notifications_enabled=True)
|
|
resp.add(
|
|
responses.GET,
|
|
ANALYTICS_STATUS_URL,
|
|
status=401,
|
|
json={"CODE": "UNAUTHORIZED", "msg": "Some problem", "result": "error"},
|
|
)
|
|
send_server_data_to_push_bouncer()
|
|
self.assertIn(
|
|
"WARNING:zulip.analytics:Some problem",
|
|
mock_warning.output[0],
|
|
)
|
|
self.assertTrue(resp.assert_call_count(ANALYTICS_STATUS_URL, 1))
|
|
self.assertPushNotificationsAre(False)
|
|
|
|
with (
|
|
responses.RequestsMock() as resp,
|
|
self.assertLogs("zulip.analytics", level="WARNING") as mock_warning,
|
|
):
|
|
Realm.objects.all().update(push_notifications_enabled=True)
|
|
resp.add(
|
|
responses.GET,
|
|
ANALYTICS_STATUS_URL,
|
|
json={
|
|
"last_realm_count_id": 0,
|
|
"last_installation_count_id": 0,
|
|
"last_realmauditlog_id": 0,
|
|
},
|
|
)
|
|
resp.add(
|
|
responses.POST,
|
|
ANALYTICS_URL,
|
|
status=401,
|
|
json={"CODE": "UNAUTHORIZED", "msg": "Some problem", "result": "error"},
|
|
)
|
|
send_server_data_to_push_bouncer()
|
|
self.assertIn(
|
|
"WARNING:zulip.analytics:Some problem",
|
|
mock_warning.output[0],
|
|
)
|
|
self.assertTrue(resp.assert_call_count(ANALYTICS_URL, 1))
|
|
self.assertPushNotificationsAre(False)
|
|
|
|
@activate_push_notification_service(submit_usage_statistics=True)
|
|
@responses.activate
|
|
def test_analytics_api(self) -> None:
|
|
"""This is a variant of the below test_push_api, but using the full
|
|
push notification bouncer flow
|
|
"""
|
|
assert settings.ZULIP_SERVICES_URL is not None
|
|
ANALYTICS_URL = settings.ZULIP_SERVICES_URL + "/api/v1/remotes/server/analytics"
|
|
ANALYTICS_STATUS_URL = ANALYTICS_URL + "/status"
|
|
user = self.example_user("hamlet")
|
|
end_time = self.TIME_ZERO
|
|
|
|
self.add_mock_response()
|
|
# Send any existing data over, so that we can start the test with a "clean" slate
|
|
remote_server = self.server
|
|
assert remote_server is not None
|
|
assert remote_server.last_version is None
|
|
|
|
send_server_data_to_push_bouncer()
|
|
self.assertTrue(responses.assert_call_count(ANALYTICS_STATUS_URL, 1))
|
|
|
|
audit_log = RealmAuditLog.objects.all().order_by("id").last()
|
|
assert audit_log is not None
|
|
audit_log_max_id = audit_log.id
|
|
|
|
remote_server.refresh_from_db()
|
|
assert remote_server.last_version == ZULIP_VERSION
|
|
|
|
remote_audit_log_count = RemoteRealmAuditLog.objects.count()
|
|
|
|
self.assertEqual(RemoteRealmCount.objects.count(), 0)
|
|
self.assertEqual(RemoteInstallationCount.objects.count(), 0)
|
|
|
|
def check_counts(
|
|
analytics_status_mock_request_call_count: int,
|
|
analytics_mock_request_call_count: int,
|
|
remote_realm_count: int,
|
|
remote_installation_count: int,
|
|
remote_realm_audit_log: int,
|
|
) -> None:
|
|
self.assertTrue(
|
|
responses.assert_call_count(
|
|
ANALYTICS_STATUS_URL, analytics_status_mock_request_call_count
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
responses.assert_call_count(ANALYTICS_URL, analytics_mock_request_call_count)
|
|
)
|
|
self.assertEqual(RemoteRealmCount.objects.count(), remote_realm_count)
|
|
self.assertEqual(RemoteInstallationCount.objects.count(), remote_installation_count)
|
|
self.assertEqual(
|
|
RemoteRealmAuditLog.objects.count(), remote_audit_log_count + remote_realm_audit_log
|
|
)
|
|
|
|
# Create some rows we'll send to remote server
|
|
# LoggingCountStat that should be included;
|
|
# i.e. not in LOGGING_COUNT_STAT_PROPERTIES_NOT_SENT_TO_BOUNCER
|
|
messages_read_logging_stat = LoggingCountStat(
|
|
"messages_read::hour", UserCount, CountStat.HOUR
|
|
)
|
|
RealmCount.objects.create(
|
|
realm=user.realm,
|
|
property=messages_read_logging_stat.property,
|
|
end_time=end_time,
|
|
value=5,
|
|
)
|
|
InstallationCount.objects.create(
|
|
property=messages_read_logging_stat.property,
|
|
end_time=end_time,
|
|
value=5,
|
|
)
|
|
# LoggingCountStat that should not be included;
|
|
# i.e. in LOGGING_COUNT_STAT_PROPERTIES_NOT_SENT_TO_BOUNCER
|
|
invites_sent_logging_stat = LoggingCountStat("invites_sent::day", RealmCount, CountStat.DAY)
|
|
RealmCount.objects.create(
|
|
realm=user.realm,
|
|
property=invites_sent_logging_stat.property,
|
|
end_time=end_time,
|
|
value=5,
|
|
)
|
|
InstallationCount.objects.create(
|
|
property=invites_sent_logging_stat.property,
|
|
end_time=end_time,
|
|
value=5,
|
|
)
|
|
# Event type in SYNCED_BILLING_EVENTS -- should be included
|
|
RealmAuditLog.objects.create(
|
|
realm=user.realm,
|
|
modified_user=user,
|
|
event_type=AuditLogEventType.USER_CREATED,
|
|
event_time=end_time,
|
|
extra_data=orjson.dumps(
|
|
{
|
|
RealmAuditLog.ROLE_COUNT: realm_user_count_by_role(user.realm),
|
|
}
|
|
).decode(),
|
|
)
|
|
# Event type not in SYNCED_BILLING_EVENTS -- should not be included
|
|
RealmAuditLog.objects.create(
|
|
realm=user.realm,
|
|
modified_user=user,
|
|
event_type=AuditLogEventType.REALM_LOGO_CHANGED,
|
|
event_time=end_time,
|
|
extra_data=orjson.dumps({"foo": "bar"}).decode(),
|
|
)
|
|
self.assertEqual(RealmCount.objects.count(), 2)
|
|
self.assertEqual(InstallationCount.objects.count(), 2)
|
|
self.assertEqual(RealmAuditLog.objects.filter(id__gt=audit_log_max_id).count(), 2)
|
|
|
|
with self.settings(ANALYTICS_DATA_UPLOAD_LEVEL=AnalyticsDataUploadLevel.BILLING):
|
|
# With this setting, we don't send RealmCounts and InstallationCounts.
|
|
send_server_data_to_push_bouncer()
|
|
check_counts(2, 2, 0, 0, 1)
|
|
|
|
with self.settings(ANALYTICS_DATA_UPLOAD_LEVEL=AnalyticsDataUploadLevel.ALL):
|
|
# With ALL data upload enabled, but 'consider_usage_statistics=False',
|
|
# we don't send RealmCount and InstallationCounts.
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
check_counts(3, 3, 0, 0, 1)
|
|
|
|
send_server_data_to_push_bouncer()
|
|
check_counts(4, 4, 1, 1, 1)
|
|
|
|
self.assertEqual(
|
|
list(
|
|
RemoteRealm.objects.order_by("id").values(
|
|
"server_id",
|
|
"uuid",
|
|
"uuid_owner_secret",
|
|
"host",
|
|
"name",
|
|
"org_type",
|
|
"authentication_methods",
|
|
"realm_date_created",
|
|
"registration_deactivated",
|
|
"realm_deactivated",
|
|
"plan_type",
|
|
"is_system_bot_realm",
|
|
)
|
|
),
|
|
[
|
|
{
|
|
"server_id": self.server.id,
|
|
"uuid": realm.uuid,
|
|
"uuid_owner_secret": realm.uuid_owner_secret,
|
|
"host": realm.host,
|
|
"name": realm.name,
|
|
"org_type": realm.org_type,
|
|
"authentication_methods": realm.authentication_methods_dict(),
|
|
"realm_date_created": realm.date_created,
|
|
"registration_deactivated": False,
|
|
"realm_deactivated": False,
|
|
"plan_type": RemoteRealm.PLAN_TYPE_SELF_MANAGED,
|
|
"is_system_bot_realm": realm.string_id == "zulipinternal",
|
|
}
|
|
for realm in Realm.objects.order_by("id")
|
|
],
|
|
)
|
|
|
|
# Modify a realm and verify the remote realm data that should get updated, get updated.
|
|
zephyr_realm = get_realm("zephyr")
|
|
zephyr_original_host = zephyr_realm.host
|
|
zephyr_realm.string_id = "zephyr2"
|
|
|
|
zephyr_original_name = zephyr_realm.name
|
|
zephyr_realm.name = "Zephyr2"
|
|
|
|
zephyr_original_org_type = zephyr_realm.org_type
|
|
self.assertEqual(zephyr_realm.org_type, Realm.ORG_TYPES["business"]["id"])
|
|
do_change_realm_org_type(
|
|
zephyr_realm, Realm.ORG_TYPES["government"]["id"], acting_user=user
|
|
)
|
|
|
|
# date_created can't be updated.
|
|
original_date_created = zephyr_realm.date_created
|
|
zephyr_realm.date_created = now()
|
|
zephyr_realm.save()
|
|
|
|
zephyr_original_authentication_methods = zephyr_realm.authentication_methods_dict()
|
|
# Sanity check to make sure the set up is how we think.
|
|
self.assertEqual(zephyr_original_authentication_methods["Email"], True)
|
|
|
|
new_auth_method_dict = {
|
|
"Google": False,
|
|
"Email": False,
|
|
"GitHub": False,
|
|
"Apple": False,
|
|
"Dev": True,
|
|
"SAML": True,
|
|
"GitLab": False,
|
|
"OpenID Connect": False,
|
|
}
|
|
do_set_realm_authentication_methods(zephyr_realm, new_auth_method_dict, acting_user=user)
|
|
|
|
# Deactivation is synced.
|
|
do_deactivate_realm(
|
|
zephyr_realm, acting_user=None, deactivation_reason="owner_request", email_owners=False
|
|
)
|
|
|
|
send_server_data_to_push_bouncer()
|
|
check_counts(5, 5, 1, 1, 7)
|
|
|
|
zephyr_remote_realm = RemoteRealm.objects.get(uuid=zephyr_realm.uuid)
|
|
self.assertEqual(zephyr_remote_realm.host, zephyr_realm.host)
|
|
self.assertEqual(zephyr_remote_realm.realm_date_created, original_date_created)
|
|
self.assertEqual(zephyr_remote_realm.realm_deactivated, True)
|
|
self.assertEqual(zephyr_remote_realm.name, zephyr_realm.name)
|
|
self.assertEqual(zephyr_remote_realm.authentication_methods, new_auth_method_dict)
|
|
self.assertEqual(zephyr_remote_realm.org_type, Realm.ORG_TYPES["government"]["id"])
|
|
|
|
# Verify the RemoteRealmAuditLog entries created.
|
|
remote_audit_logs = (
|
|
RemoteRealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.REMOTE_REALM_VALUE_UPDATED,
|
|
remote_realm=zephyr_remote_realm,
|
|
)
|
|
.order_by("id")
|
|
.values("event_type", "remote_id", "realm_id", "extra_data")
|
|
)
|
|
|
|
self.assertEqual(
|
|
list(remote_audit_logs),
|
|
[
|
|
dict(
|
|
event_type=AuditLogEventType.REMOTE_REALM_VALUE_UPDATED,
|
|
remote_id=None,
|
|
realm_id=zephyr_realm.id,
|
|
extra_data={
|
|
"attr_name": "host",
|
|
"old_value": zephyr_original_host,
|
|
"new_value": zephyr_realm.host,
|
|
},
|
|
),
|
|
dict(
|
|
event_type=AuditLogEventType.REMOTE_REALM_VALUE_UPDATED,
|
|
remote_id=None,
|
|
realm_id=zephyr_realm.id,
|
|
extra_data={
|
|
"attr_name": "org_type",
|
|
"old_value": zephyr_original_org_type,
|
|
"new_value": zephyr_realm.org_type,
|
|
},
|
|
),
|
|
dict(
|
|
event_type=AuditLogEventType.REMOTE_REALM_VALUE_UPDATED,
|
|
remote_id=None,
|
|
realm_id=zephyr_realm.id,
|
|
extra_data={
|
|
"attr_name": "name",
|
|
"old_value": zephyr_original_name,
|
|
"new_value": zephyr_realm.name,
|
|
},
|
|
),
|
|
dict(
|
|
event_type=AuditLogEventType.REMOTE_REALM_VALUE_UPDATED,
|
|
remote_id=None,
|
|
realm_id=zephyr_realm.id,
|
|
extra_data={
|
|
"attr_name": "authentication_methods",
|
|
"old_value": zephyr_original_authentication_methods,
|
|
"new_value": new_auth_method_dict,
|
|
},
|
|
),
|
|
dict(
|
|
event_type=AuditLogEventType.REMOTE_REALM_VALUE_UPDATED,
|
|
remote_id=None,
|
|
realm_id=zephyr_realm.id,
|
|
extra_data={
|
|
"attr_name": "realm_deactivated",
|
|
"old_value": False,
|
|
"new_value": True,
|
|
},
|
|
),
|
|
],
|
|
)
|
|
|
|
# Test having no new rows
|
|
send_server_data_to_push_bouncer()
|
|
check_counts(6, 6, 1, 1, 7)
|
|
|
|
# Test only having new RealmCount rows
|
|
RealmCount.objects.create(
|
|
realm=user.realm,
|
|
property=messages_read_logging_stat.property,
|
|
end_time=end_time + timedelta(days=1),
|
|
value=6,
|
|
)
|
|
RealmCount.objects.create(
|
|
realm=user.realm,
|
|
property=messages_read_logging_stat.property,
|
|
end_time=end_time + timedelta(days=2),
|
|
value=9,
|
|
)
|
|
send_server_data_to_push_bouncer()
|
|
check_counts(7, 7, 3, 1, 7)
|
|
|
|
# Test only having new InstallationCount rows
|
|
InstallationCount.objects.create(
|
|
property=messages_read_logging_stat.property,
|
|
end_time=end_time + timedelta(days=1),
|
|
value=6,
|
|
)
|
|
send_server_data_to_push_bouncer()
|
|
check_counts(8, 8, 3, 2, 7)
|
|
|
|
# Test only having new RealmAuditLog rows
|
|
# Non-synced event
|
|
RealmAuditLog.objects.create(
|
|
realm=user.realm,
|
|
modified_user=user,
|
|
event_type=AuditLogEventType.REALM_LOGO_CHANGED,
|
|
event_time=end_time,
|
|
extra_data={"data": "foo"},
|
|
)
|
|
send_server_data_to_push_bouncer()
|
|
check_counts(9, 9, 3, 2, 7)
|
|
# Synced event
|
|
RealmAuditLog.objects.create(
|
|
realm=user.realm,
|
|
modified_user=user,
|
|
event_type=AuditLogEventType.USER_REACTIVATED,
|
|
event_time=end_time,
|
|
extra_data={
|
|
RealmAuditLog.ROLE_COUNT: realm_user_count_by_role(user.realm),
|
|
},
|
|
)
|
|
with self.settings(ANALYTICS_DATA_UPLOAD_LEVEL=AnalyticsDataUploadLevel.BASIC):
|
|
# With the BASIC level, RealmAuditLog rows are not sent.
|
|
send_server_data_to_push_bouncer()
|
|
check_counts(10, 10, 3, 2, 7)
|
|
|
|
# Now, with ANALYTICS_DATA_UPLOAD_LEVEL back to the baseline for this test,
|
|
# the new RealmAuditLog event will be sent.
|
|
send_server_data_to_push_bouncer()
|
|
check_counts(11, 11, 3, 2, 8)
|
|
|
|
# Now create an InstallationCount with a property that's not supposed
|
|
# to be tracked by the remote server - since the bouncer itself tracks
|
|
# the RemoteInstallationCount with this property. We want to verify
|
|
# that the remote server will fail at sending analytics to the bouncer
|
|
# with such an InstallationCount - since syncing it should not be allowed.
|
|
forbidden_installation_count = InstallationCount.objects.create(
|
|
property="mobile_pushes_received::day",
|
|
end_time=end_time,
|
|
value=5,
|
|
)
|
|
with self.assertLogs("zulip.analytics", level="WARNING") as warn_log:
|
|
send_server_data_to_push_bouncer()
|
|
self.assertEqual(
|
|
warn_log.output,
|
|
["WARNING:zulip.analytics:Invalid property mobile_pushes_received::day"],
|
|
)
|
|
# The analytics endpoint call counts increase by 1, but the actual RemoteCounts remain unchanged,
|
|
# since syncing the data failed.
|
|
check_counts(12, 12, 3, 2, 8)
|
|
forbidden_installation_count.delete()
|
|
|
|
(realm_count_data, installation_count_data, realmauditlog_data) = build_analytics_data(
|
|
RealmCount.objects.all(), InstallationCount.objects.all(), RealmAuditLog.objects.all()
|
|
)
|
|
request = AnalyticsRequest.model_construct(
|
|
realm_counts=realm_count_data,
|
|
installation_counts=installation_count_data,
|
|
realmauditlog_rows=realmauditlog_data,
|
|
realms=[],
|
|
version=None,
|
|
merge_base=None,
|
|
api_feature_level=None,
|
|
)
|
|
result = self.uuid_post(
|
|
self.server_uuid,
|
|
"/api/v1/remotes/server/analytics",
|
|
request.model_dump(
|
|
round_trip=True, exclude={"realms", "version", "merge_base", "api_feature_level"}
|
|
),
|
|
subdomain="",
|
|
)
|
|
self.assert_json_error(result, "Data is out of order.")
|
|
|
|
# Adjust the id of all existing rows so that they get re-sent.
|
|
# This is equivalent to running `./manage.py clear_analytics_tables`
|
|
RealmCount.objects.all().update(id=F("id") + RealmCount.objects.latest("id").id)
|
|
InstallationCount.objects.all().update(
|
|
id=F("id") + InstallationCount.objects.latest("id").id
|
|
)
|
|
with self.assertLogs(level="WARNING") as warn_log:
|
|
send_server_data_to_push_bouncer()
|
|
self.assertEqual(
|
|
warn_log.output,
|
|
[
|
|
f"WARNING:root:Dropped 3 duplicated rows while saving 3 rows of zilencer_remoterealmcount for server demo.example.com/{self.server_uuid}",
|
|
f"WARNING:root:Dropped 2 duplicated rows while saving 2 rows of zilencer_remoteinstallationcount for server demo.example.com/{self.server_uuid}",
|
|
],
|
|
)
|
|
# Only the request counts go up -- all of the other rows' duplicates are dropped
|
|
check_counts(13, 13, 3, 2, 8)
|
|
|
|
# Test that only valid org_type values are accepted - integers defined in OrgTypeEnum.
|
|
realms_data = get_realms_info_for_push_bouncer()
|
|
# Not a valid org_type value:
|
|
realms_data[0].org_type = 11
|
|
|
|
request = AnalyticsRequest.model_construct(
|
|
realm_counts=[],
|
|
installation_counts=[],
|
|
realmauditlog_rows=[],
|
|
realms=realms_data,
|
|
version=None,
|
|
merge_base=None,
|
|
api_feature_level=None,
|
|
)
|
|
result = self.uuid_post(
|
|
self.server_uuid,
|
|
"/api/v1/remotes/server/analytics",
|
|
request.model_dump(
|
|
round_trip=True, exclude={"version", "merge_base", "api_feature_level"}
|
|
),
|
|
subdomain="",
|
|
)
|
|
self.assert_json_error(
|
|
result, 'Invalid realms[0]["org_type"]: Value error, Not a valid org_type value'
|
|
)
|
|
|
|
@activate_push_notification_service(submit_usage_statistics=True)
|
|
@responses.activate
|
|
def test_analytics_api_foreign_keys_to_remote_realm(self) -> None:
|
|
self.add_mock_response()
|
|
|
|
user = self.example_user("hamlet")
|
|
end_time = self.TIME_ZERO
|
|
|
|
# Create some rows we'll send to remote server
|
|
messages_read_logging_stat = LoggingCountStat(
|
|
"messages_read::hour", UserCount, CountStat.HOUR
|
|
)
|
|
realm_count = RealmCount.objects.create(
|
|
realm=user.realm,
|
|
property=messages_read_logging_stat.property,
|
|
end_time=end_time,
|
|
value=5,
|
|
)
|
|
installation_count = InstallationCount.objects.create(
|
|
property=messages_read_logging_stat.property,
|
|
end_time=end_time,
|
|
value=5,
|
|
)
|
|
realm_audit_log = RealmAuditLog.objects.create(
|
|
realm=user.realm,
|
|
modified_user=user,
|
|
event_type=AuditLogEventType.USER_CREATED,
|
|
event_time=end_time,
|
|
extra_data=orjson.dumps(
|
|
{
|
|
RealmAuditLog.ROLE_COUNT: realm_user_count_by_role(user.realm),
|
|
}
|
|
).decode(),
|
|
)
|
|
realm_count_data, installation_count_data, realmauditlog_data = build_analytics_data(
|
|
RealmCount.objects.all(), InstallationCount.objects.all(), RealmAuditLog.objects.all()
|
|
)
|
|
|
|
# This first post should fail because of excessive audit log event types.
|
|
request = AnalyticsRequest.model_construct(
|
|
realm_counts=realm_count_data,
|
|
installation_counts=installation_count_data,
|
|
realmauditlog_rows=realmauditlog_data,
|
|
realms=[],
|
|
version=None,
|
|
merge_base=None,
|
|
api_feature_level=None,
|
|
)
|
|
result = self.uuid_post(
|
|
self.server_uuid,
|
|
"/api/v1/remotes/server/analytics",
|
|
request.model_dump(
|
|
round_trip=True, exclude={"version", "merge_base", "api_feature_level"}
|
|
),
|
|
subdomain="",
|
|
)
|
|
self.assert_json_error(result, "Invalid event type.")
|
|
|
|
# Start again only using synced billing events.
|
|
realm_count_data, installation_count_data, realmauditlog_data = build_analytics_data(
|
|
RealmCount.objects.all(),
|
|
InstallationCount.objects.all(),
|
|
RealmAuditLog.objects.filter(event_type__in=RemoteRealmAuditLog.SYNCED_BILLING_EVENTS),
|
|
)
|
|
|
|
# Send the data to the bouncer without any realms data. This should lead
|
|
# to successful saving of the data, but with the remote_realm foreign key
|
|
# set to NULL.
|
|
request = AnalyticsRequest.model_construct(
|
|
realm_counts=realm_count_data,
|
|
installation_counts=installation_count_data,
|
|
realmauditlog_rows=realmauditlog_data,
|
|
realms=[],
|
|
version=None,
|
|
merge_base=None,
|
|
api_feature_level=None,
|
|
)
|
|
result = self.uuid_post(
|
|
self.server_uuid,
|
|
"/api/v1/remotes/server/analytics",
|
|
request.model_dump(
|
|
round_trip=True, exclude={"version", "merge_base", "api_feature_level"}
|
|
),
|
|
subdomain="",
|
|
)
|
|
self.assert_json_success(result)
|
|
remote_realm_count = RemoteRealmCount.objects.latest("id")
|
|
remote_installation_count = RemoteInstallationCount.objects.latest("id")
|
|
remote_realm_audit_log = RemoteRealmAuditLog.objects.latest("id")
|
|
|
|
self.assertEqual(remote_realm_count.remote_id, realm_count.id)
|
|
self.assertEqual(remote_realm_count.remote_realm, None)
|
|
self.assertEqual(remote_installation_count.remote_id, installation_count.id)
|
|
# InstallationCount/RemoteInstallationCount don't have realm/remote_realm foreign
|
|
# keys, because they're aggregated over all realms.
|
|
|
|
self.assertEqual(remote_realm_audit_log.remote_id, realm_audit_log.id)
|
|
self.assertEqual(remote_realm_audit_log.remote_realm, None)
|
|
|
|
send_server_data_to_push_bouncer()
|
|
|
|
remote_realm_count.refresh_from_db()
|
|
remote_installation_count.refresh_from_db()
|
|
remote_realm_audit_log.refresh_from_db()
|
|
|
|
remote_realm = RemoteRealm.objects.get(uuid=user.realm.uuid)
|
|
|
|
self.assertEqual(remote_realm_count.remote_realm, remote_realm)
|
|
self.assertEqual(remote_realm_audit_log.remote_realm, remote_realm)
|
|
|
|
current_remote_realm_count_amount = RemoteRealmCount.objects.count()
|
|
current_remote_realm_audit_log_amount = RemoteRealmAuditLog.objects.count()
|
|
|
|
# Now create and send new data (including realm info) and verify it has .remote_realm
|
|
# set as it should.
|
|
RealmCount.objects.create(
|
|
realm=user.realm,
|
|
property=messages_read_logging_stat.property,
|
|
end_time=end_time + timedelta(days=1),
|
|
value=6,
|
|
)
|
|
InstallationCount.objects.create(
|
|
property=messages_read_logging_stat.property,
|
|
end_time=end_time + timedelta(days=1),
|
|
value=6,
|
|
)
|
|
RealmAuditLog.objects.create(
|
|
realm=user.realm,
|
|
modified_user=user,
|
|
event_type=AuditLogEventType.USER_CREATED,
|
|
event_time=end_time,
|
|
extra_data={
|
|
RealmAuditLog.ROLE_COUNT: realm_user_count_by_role(user.realm),
|
|
},
|
|
)
|
|
send_server_data_to_push_bouncer()
|
|
|
|
# Make sure new data was created, so that we're actually testing what we think.
|
|
self.assertEqual(RemoteRealmCount.objects.count(), current_remote_realm_count_amount + 1)
|
|
self.assertEqual(
|
|
RemoteRealmAuditLog.objects.count(), current_remote_realm_audit_log_amount + 1
|
|
)
|
|
|
|
for remote_realm_count in RemoteRealmCount.objects.filter(realm_id=user.realm.id):
|
|
self.assertEqual(remote_realm_count.remote_realm, remote_realm)
|
|
for remote_realm_audit_log in RemoteRealmAuditLog.objects.filter(realm_id=user.realm.id):
|
|
self.assertEqual(remote_realm_audit_log.remote_realm, remote_realm)
|
|
|
|
@activate_push_notification_service(submit_usage_statistics=True)
|
|
@responses.activate
|
|
def test_analytics_api_invalid(self) -> None:
|
|
"""This is a variant of the below test_push_api, but using the full
|
|
push notification bouncer flow
|
|
"""
|
|
self.add_mock_response()
|
|
user = self.example_user("hamlet")
|
|
end_time = self.TIME_ZERO
|
|
|
|
realm_stat = LoggingCountStat("invalid count stat", RealmCount, CountStat.DAY)
|
|
RealmCount.objects.create(
|
|
realm=user.realm, property=realm_stat.property, end_time=end_time, value=5
|
|
)
|
|
|
|
self.assertEqual(RealmCount.objects.count(), 1)
|
|
|
|
self.assertEqual(RemoteRealmCount.objects.count(), 0)
|
|
with self.assertLogs("zulip.analytics", level="WARNING") as m:
|
|
send_server_data_to_push_bouncer()
|
|
self.assertEqual(m.output, ["WARNING:zulip.analytics:Invalid property invalid count stat"])
|
|
self.assertEqual(RemoteRealmCount.objects.count(), 0)
|
|
|
|
@activate_push_notification_service()
|
|
@responses.activate
|
|
def test_remote_realm_duplicate_uuid(self) -> None:
|
|
"""
|
|
Tests for a case where a RemoteRealm with a certain uuid is already registered for one server,
|
|
and then another server tries to register the same uuid. This generally shouldn't happen,
|
|
because export->import of a realm should re-generate the uuid, but we should have error
|
|
handling for this edge case nonetheless.
|
|
"""
|
|
|
|
original_server = RemoteZulipServer.objects.get(uuid=self.server.uuid)
|
|
# Start by deleting existing registration, to have a clean slate.
|
|
RemoteRealm.objects.all().delete()
|
|
|
|
second_server = RemoteZulipServer.objects.create(
|
|
uuid=uuid.uuid4(),
|
|
api_key="magic_secret_api_key2",
|
|
hostname="demo2.example.com",
|
|
last_updated=now(),
|
|
)
|
|
|
|
self.add_mock_response()
|
|
user = self.example_user("hamlet")
|
|
realm = user.realm
|
|
|
|
RemoteRealm.objects.create(
|
|
server=second_server,
|
|
uuid=realm.uuid,
|
|
uuid_owner_secret=realm.uuid_owner_secret,
|
|
host=realm.host,
|
|
realm_date_created=realm.date_created,
|
|
registration_deactivated=False,
|
|
realm_deactivated=False,
|
|
plan_type=RemoteRealm.PLAN_TYPE_SELF_MANAGED,
|
|
)
|
|
|
|
with (
|
|
self.assertLogs("zulip.analytics", level="WARNING") as mock_log_host,
|
|
self.assertLogs("zilencer.views") as mock_log_bouncer,
|
|
):
|
|
send_server_data_to_push_bouncer()
|
|
self.assertEqual(
|
|
mock_log_host.output, ["WARNING:zulip.analytics:Duplicate registration detected."]
|
|
)
|
|
self.assertIn(
|
|
"INFO:zilencer.views:"
|
|
f"update_remote_realm_data_for_server:server:{original_server.id}:IntegrityError creating RemoteRealm rows:",
|
|
mock_log_bouncer.output[0],
|
|
)
|
|
|
|
# Servers on Zulip 2.0.6 and earlier only send realm_counts and installation_counts data,
|
|
# and don't send realmauditlog_rows. Make sure that continues to work.
|
|
@activate_push_notification_service()
|
|
@responses.activate
|
|
def test_old_two_table_format(self) -> None:
|
|
self.add_mock_response()
|
|
# Send fixture generated with Zulip 2.0 code
|
|
send_to_push_bouncer(
|
|
"POST",
|
|
"server/analytics",
|
|
{
|
|
"realm_counts": '[{"id":1,"property":"messages_sent:is_bot:hour","subgroup":"false","end_time":574300800.0,"value":5,"realm":2}]',
|
|
"installation_counts": "[]",
|
|
"version": '"2.0.6+git"',
|
|
},
|
|
)
|
|
assert settings.ZULIP_SERVICES_URL is not None
|
|
ANALYTICS_URL = settings.ZULIP_SERVICES_URL + "/api/v1/remotes/server/analytics"
|
|
self.assertTrue(responses.assert_call_count(ANALYTICS_URL, 1))
|
|
self.assertEqual(RemoteRealmCount.objects.count(), 1)
|
|
self.assertEqual(RemoteInstallationCount.objects.count(), 0)
|
|
self.assertEqual(RemoteRealmAuditLog.objects.count(), 0)
|
|
|
|
# Make sure we aren't sending data we don't mean to, even if we don't store it.
|
|
@activate_push_notification_service()
|
|
@responses.activate
|
|
def test_only_sending_intended_realmauditlog_data(self) -> None:
|
|
self.add_mock_response()
|
|
user = self.example_user("hamlet")
|
|
# Event type in SYNCED_BILLING_EVENTS -- should be included
|
|
RealmAuditLog.objects.create(
|
|
realm=user.realm,
|
|
modified_user=user,
|
|
event_type=AuditLogEventType.USER_REACTIVATED,
|
|
event_time=self.TIME_ZERO,
|
|
extra_data={
|
|
RealmAuditLog.ROLE_COUNT: realm_user_count_by_role(user.realm),
|
|
},
|
|
)
|
|
# Event type not in SYNCED_BILLING_EVENTS -- should not be included
|
|
RealmAuditLog.objects.create(
|
|
realm=user.realm,
|
|
modified_user=user,
|
|
event_type=AuditLogEventType.REALM_LOGO_CHANGED,
|
|
event_time=self.TIME_ZERO,
|
|
extra_data=orjson.dumps({"foo": "bar"}).decode(),
|
|
)
|
|
|
|
# send_server_data_to_push_bouncer calls send_to_push_bouncer twice.
|
|
# We need to distinguish the first and second calls.
|
|
first_call = True
|
|
|
|
def check_for_unwanted_data(*args: Any) -> Any:
|
|
nonlocal first_call
|
|
if first_call:
|
|
first_call = False
|
|
else:
|
|
# Test that we're respecting SYNCED_BILLING_EVENTS
|
|
self.assertIn(f'"event_type":{AuditLogEventType.USER_REACTIVATED}', str(args))
|
|
self.assertNotIn(f'"event_type":{AuditLogEventType.REALM_LOGO_CHANGED}', str(args))
|
|
# Test that we're respecting REALMAUDITLOG_PUSHED_FIELDS
|
|
self.assertIn("backfilled", str(args))
|
|
self.assertNotIn("modified_user", str(args))
|
|
return send_to_push_bouncer(*args)
|
|
|
|
with mock.patch(
|
|
"zerver.lib.remote_server.send_to_push_bouncer", side_effect=check_for_unwanted_data
|
|
):
|
|
send_server_data_to_push_bouncer()
|
|
|
|
@activate_push_notification_service()
|
|
@responses.activate
|
|
def test_realmauditlog_data_mapping(self) -> None:
|
|
self.add_mock_response()
|
|
user = self.example_user("hamlet")
|
|
user_count = realm_user_count_by_role(user.realm)
|
|
log_entry = RealmAuditLog.objects.create(
|
|
realm=user.realm,
|
|
modified_user=user,
|
|
backfilled=True,
|
|
event_type=AuditLogEventType.USER_REACTIVATED,
|
|
event_time=self.TIME_ZERO,
|
|
extra_data=orjson.dumps({RealmAuditLog.ROLE_COUNT: user_count}).decode(),
|
|
)
|
|
send_server_data_to_push_bouncer()
|
|
remote_log_entry = RemoteRealmAuditLog.objects.order_by("id").last()
|
|
assert remote_log_entry is not None
|
|
self.assertEqual(str(remote_log_entry.server.uuid), self.server_uuid)
|
|
self.assertEqual(remote_log_entry.remote_id, log_entry.id)
|
|
self.assertEqual(remote_log_entry.event_time, self.TIME_ZERO)
|
|
self.assertEqual(remote_log_entry.backfilled, True)
|
|
assert remote_log_entry.extra_data is not None
|
|
self.assertEqual(remote_log_entry.extra_data, {RealmAuditLog.ROLE_COUNT: user_count})
|
|
self.assertEqual(remote_log_entry.event_type, AuditLogEventType.USER_REACTIVATED)
|
|
|
|
# This verifies that the bouncer is backwards-compatible with remote servers using
|
|
# TextField to store extra_data.
|
|
@activate_push_notification_service()
|
|
@responses.activate
|
|
def test_realmauditlog_string_extra_data(self) -> None:
|
|
self.add_mock_response()
|
|
|
|
def verify_request_with_overridden_extra_data(
|
|
request_extra_data: object,
|
|
*,
|
|
expected_extra_data: object = None,
|
|
skip_audit_log_check: bool = False,
|
|
) -> None:
|
|
user = self.example_user("hamlet")
|
|
log_entry = RealmAuditLog.objects.create(
|
|
realm=user.realm,
|
|
modified_user=user,
|
|
event_type=AuditLogEventType.USER_REACTIVATED,
|
|
event_time=self.TIME_ZERO,
|
|
extra_data=orjson.dumps(
|
|
{
|
|
RealmAuditLog.ROLE_COUNT: {
|
|
RealmAuditLog.ROLE_COUNT_HUMANS: {},
|
|
}
|
|
}
|
|
).decode(),
|
|
)
|
|
|
|
# We use this to patch send_to_push_bouncer so that extra_data in the
|
|
# legacy format gets sent to the bouncer.
|
|
def transform_realmauditlog_extra_data(
|
|
method: str,
|
|
endpoint: str,
|
|
post_data: bytes | Mapping[str, str | int | None | bytes],
|
|
extra_headers: Mapping[str, str] = {},
|
|
) -> dict[str, Any]:
|
|
if endpoint == "server/analytics":
|
|
assert isinstance(post_data, dict)
|
|
assert isinstance(post_data["realmauditlog_rows"], str)
|
|
original_data = orjson.loads(post_data["realmauditlog_rows"])
|
|
# We replace the extra_data with another fake example to verify that
|
|
# the bouncer actually gets requested with extra_data being string
|
|
new_data = [{**row, "extra_data": request_extra_data} for row in original_data]
|
|
post_data["realmauditlog_rows"] = orjson.dumps(new_data).decode()
|
|
return send_to_push_bouncer(method, endpoint, post_data, extra_headers)
|
|
|
|
with mock.patch(
|
|
"zerver.lib.remote_server.send_to_push_bouncer",
|
|
side_effect=transform_realmauditlog_extra_data,
|
|
):
|
|
send_server_data_to_push_bouncer()
|
|
|
|
if skip_audit_log_check:
|
|
return
|
|
|
|
remote_log_entry = RemoteRealmAuditLog.objects.order_by("id").last()
|
|
assert remote_log_entry is not None
|
|
self.assertEqual(str(remote_log_entry.server.uuid), self.server_uuid)
|
|
self.assertEqual(remote_log_entry.remote_id, log_entry.id)
|
|
self.assertEqual(remote_log_entry.event_time, self.TIME_ZERO)
|
|
self.assertEqual(remote_log_entry.extra_data, expected_extra_data)
|
|
|
|
# Pre-migration extra_data
|
|
verify_request_with_overridden_extra_data(
|
|
request_extra_data=orjson.dumps(
|
|
{
|
|
RealmAuditLog.ROLE_COUNT: {
|
|
RealmAuditLog.ROLE_COUNT_HUMANS: {},
|
|
}
|
|
}
|
|
).decode(),
|
|
expected_extra_data={
|
|
RealmAuditLog.ROLE_COUNT: {
|
|
RealmAuditLog.ROLE_COUNT_HUMANS: {},
|
|
}
|
|
},
|
|
)
|
|
verify_request_with_overridden_extra_data(request_extra_data=None, expected_extra_data={})
|
|
# Post-migration extra_data
|
|
verify_request_with_overridden_extra_data(
|
|
request_extra_data={
|
|
RealmAuditLog.ROLE_COUNT: {
|
|
RealmAuditLog.ROLE_COUNT_HUMANS: {},
|
|
}
|
|
},
|
|
expected_extra_data={
|
|
RealmAuditLog.ROLE_COUNT: {
|
|
RealmAuditLog.ROLE_COUNT_HUMANS: {},
|
|
}
|
|
},
|
|
)
|
|
verify_request_with_overridden_extra_data(
|
|
request_extra_data={},
|
|
expected_extra_data={},
|
|
)
|
|
# Invalid extra_data
|
|
with self.assertLogs("zulip.analytics", level="WARNING") as m:
|
|
verify_request_with_overridden_extra_data(
|
|
request_extra_data="{malformedjson:",
|
|
skip_audit_log_check=True,
|
|
)
|
|
self.assertIn("Malformed audit log data", m.output[0])
|
|
|
|
@activate_push_notification_service()
|
|
@responses.activate
|
|
def test_realm_properties_after_send_analytics(self) -> None:
|
|
self.add_mock_response()
|
|
|
|
with (
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_customer", return_value=None
|
|
) as m,
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteServerBillingSession.current_count_for_billed_licenses",
|
|
return_value=10,
|
|
),
|
|
):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
m.assert_called()
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertEqual(realm.push_notifications_enabled, True)
|
|
self.assertEqual(realm.push_notifications_enabled_end_timestamp, None)
|
|
|
|
with (
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_customer", return_value=None
|
|
) as m,
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.current_count_for_billed_licenses",
|
|
return_value=11,
|
|
),
|
|
):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
m.assert_called()
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertEqual(realm.push_notifications_enabled, False)
|
|
self.assertEqual(realm.push_notifications_enabled_end_timestamp, None)
|
|
|
|
dummy_customer = mock.MagicMock()
|
|
with (
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_customer",
|
|
return_value=dummy_customer,
|
|
),
|
|
mock.patch("corporate.lib.stripe.get_current_plan_by_customer", return_value=None) as m,
|
|
):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
m.assert_called()
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertEqual(realm.push_notifications_enabled, True)
|
|
self.assertEqual(realm.push_notifications_enabled_end_timestamp, None)
|
|
|
|
dummy_customer = mock.MagicMock()
|
|
with (
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_customer",
|
|
return_value=dummy_customer,
|
|
),
|
|
mock.patch("corporate.lib.stripe.get_current_plan_by_customer", return_value=None) as m,
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.current_count_for_billed_licenses",
|
|
return_value=11,
|
|
),
|
|
):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
m.assert_called()
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertEqual(realm.push_notifications_enabled, False)
|
|
self.assertEqual(realm.push_notifications_enabled_end_timestamp, None)
|
|
|
|
RemoteRealm.objects.filter(server=self.server).update(
|
|
plan_type=RemoteRealm.PLAN_TYPE_COMMUNITY
|
|
)
|
|
|
|
with (
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_customer",
|
|
return_value=dummy_customer,
|
|
),
|
|
mock.patch("corporate.lib.stripe.get_current_plan_by_customer", return_value=None),
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.current_count_for_billed_licenses"
|
|
) as m,
|
|
):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
m.assert_not_called()
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertEqual(realm.push_notifications_enabled, True)
|
|
self.assertEqual(realm.push_notifications_enabled_end_timestamp, None)
|
|
|
|
# Reset the plan type to test remaining cases.
|
|
RemoteRealm.objects.filter(server=self.server).update(
|
|
plan_type=RemoteRealm.PLAN_TYPE_SELF_MANAGED
|
|
)
|
|
|
|
dummy_customer_plan = mock.MagicMock()
|
|
dummy_customer_plan.status = CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
|
|
dummy_date = datetime(year=2023, month=12, day=3, tzinfo=timezone.utc)
|
|
with (
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_customer",
|
|
return_value=dummy_customer,
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.get_current_plan_by_customer",
|
|
return_value=dummy_customer_plan,
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.current_count_for_billed_licenses",
|
|
return_value=11,
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_next_billing_cycle",
|
|
return_value=dummy_date,
|
|
) as m,
|
|
self.assertLogs("zulip.analytics", level="INFO") as info_log,
|
|
):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
m.assert_called()
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertEqual(realm.push_notifications_enabled, True)
|
|
self.assertEqual(
|
|
realm.push_notifications_enabled_end_timestamp,
|
|
dummy_date,
|
|
)
|
|
self.assertIn(
|
|
"INFO:zulip.analytics:Reported 0 records",
|
|
info_log.output[0],
|
|
)
|
|
|
|
with (
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_customer",
|
|
return_value=dummy_customer,
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.get_current_plan_by_customer",
|
|
return_value=dummy_customer_plan,
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.current_count_for_billed_licenses",
|
|
side_effect=MissingDataError,
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_next_billing_cycle",
|
|
return_value=dummy_date,
|
|
) as m,
|
|
self.assertLogs("zulip.analytics", level="INFO") as info_log,
|
|
):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
m.assert_called()
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertEqual(realm.push_notifications_enabled, True)
|
|
self.assertEqual(
|
|
realm.push_notifications_enabled_end_timestamp,
|
|
dummy_date,
|
|
)
|
|
self.assertIn(
|
|
"INFO:zulip.analytics:Reported 0 records",
|
|
info_log.output[0],
|
|
)
|
|
|
|
with (
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_customer",
|
|
return_value=dummy_customer,
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.get_current_plan_by_customer",
|
|
return_value=dummy_customer_plan,
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.current_count_for_billed_licenses",
|
|
return_value=10,
|
|
),
|
|
):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
m.assert_called()
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertEqual(realm.push_notifications_enabled, True)
|
|
self.assertEqual(
|
|
realm.push_notifications_enabled_end_timestamp,
|
|
None,
|
|
)
|
|
|
|
dummy_customer_plan = mock.MagicMock()
|
|
dummy_customer_plan.status = CustomerPlan.ACTIVE
|
|
with (
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_customer",
|
|
return_value=dummy_customer,
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.get_current_plan_by_customer",
|
|
return_value=dummy_customer_plan,
|
|
),
|
|
self.assertLogs("zulip.analytics", level="INFO") as info_log,
|
|
):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
m.assert_called()
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertEqual(realm.push_notifications_enabled, True)
|
|
self.assertEqual(
|
|
realm.push_notifications_enabled_end_timestamp,
|
|
None,
|
|
)
|
|
self.assertIn(
|
|
"INFO:zulip.analytics:Reported 0 records",
|
|
info_log.output[0],
|
|
)
|
|
|
|
# Remote realm is on an inactive plan. Remote server on active plan.
|
|
# ACTIVE plan takes precedence.
|
|
dummy_remote_realm_customer = mock.MagicMock()
|
|
dummy_remote_server_customer = mock.MagicMock()
|
|
dummy_remote_server_customer_plan = mock.MagicMock()
|
|
dummy_remote_server_customer_plan.status = CustomerPlan.ACTIVE
|
|
|
|
def get_current_plan_by_customer(customer: mock.MagicMock) -> mock.MagicMock | None:
|
|
assert customer in [dummy_remote_realm_customer, dummy_remote_server_customer]
|
|
if customer == dummy_remote_server_customer:
|
|
return dummy_remote_server_customer_plan
|
|
return None
|
|
|
|
with (
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.get_customer",
|
|
return_value=dummy_remote_realm_customer,
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteServerBillingSession.get_customer",
|
|
return_value=dummy_remote_server_customer,
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteServerBillingSession.sync_license_ledger_if_needed"
|
|
),
|
|
mock.patch(
|
|
"corporate.lib.stripe.get_current_plan_by_customer",
|
|
side_effect=get_current_plan_by_customer,
|
|
) as m,
|
|
):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
m.assert_called()
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertEqual(realm.push_notifications_enabled, True)
|
|
self.assertEqual(
|
|
realm.push_notifications_enabled_end_timestamp,
|
|
None,
|
|
)
|
|
|
|
with (
|
|
mock.patch("zerver.lib.remote_server.send_to_push_bouncer") as m,
|
|
self.assertLogs("zulip.analytics", level="WARNING") as exception_log,
|
|
):
|
|
get_response = {
|
|
"last_realm_count_id": 0,
|
|
"last_installation_count_id": 0,
|
|
"last_realmauditlog_id": 0,
|
|
}
|
|
|
|
def mock_send_to_push_bouncer_response(method: str, *args: Any) -> dict[str, int]:
|
|
if method == "POST":
|
|
raise PushNotificationBouncerRetryLaterError("Some problem")
|
|
return get_response
|
|
|
|
m.side_effect = mock_send_to_push_bouncer_response
|
|
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertFalse(realm.push_notifications_enabled)
|
|
self.assertEqual(
|
|
exception_log.output,
|
|
["WARNING:zulip.analytics:Some problem"],
|
|
)
|
|
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertEqual(
|
|
list(
|
|
RemoteRealm.objects.order_by("id").values(
|
|
"server_id",
|
|
"uuid",
|
|
"uuid_owner_secret",
|
|
"host",
|
|
"realm_date_created",
|
|
"registration_deactivated",
|
|
"realm_deactivated",
|
|
"plan_type",
|
|
)
|
|
),
|
|
[
|
|
{
|
|
"server_id": self.server.id,
|
|
"uuid": realm.uuid,
|
|
"uuid_owner_secret": realm.uuid_owner_secret,
|
|
"host": realm.host,
|
|
"realm_date_created": realm.date_created,
|
|
"registration_deactivated": False,
|
|
"realm_deactivated": False,
|
|
"plan_type": RemoteRealm.PLAN_TYPE_SELF_MANAGED,
|
|
}
|
|
for realm in Realm.objects.order_by("id")
|
|
],
|
|
)
|
|
|
|
@activate_push_notification_service()
|
|
@responses.activate
|
|
def test_deleted_realm(self) -> None:
|
|
self.add_mock_response()
|
|
logger = logging.getLogger("zulip.analytics")
|
|
|
|
realm_info = get_realms_info_for_push_bouncer()
|
|
|
|
# Hard-delete a realm to test the non existent realm uuid case.
|
|
zephyr_realm = get_realm("zephyr")
|
|
assert zephyr_realm is not None
|
|
deleted_realm_uuid = zephyr_realm.uuid
|
|
zephyr_realm.delete()
|
|
|
|
# This mock causes us to still send data to the bouncer as if the realm existed,
|
|
# causing the bouncer to include its corresponding info in the response. Through
|
|
# that, we're testing our graceful handling of seeing a non-existent realm uuid
|
|
# in that response.
|
|
with (
|
|
mock.patch(
|
|
"zerver.lib.remote_server.get_realms_info_for_push_bouncer", return_value=realm_info
|
|
) as m,
|
|
self.assertLogs(logger, level="WARNING") as analytics_logger,
|
|
):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
m.assert_called()
|
|
realms = Realm.objects.all()
|
|
for realm in realms:
|
|
self.assertEqual(realm.push_notifications_enabled, True)
|
|
self.assertEqual(realm.push_notifications_enabled_end_timestamp, None)
|
|
|
|
self.assertEqual(
|
|
analytics_logger.output,
|
|
[
|
|
"WARNING:zulip.analytics:"
|
|
f"Received unexpected realm UUID from bouncer {deleted_realm_uuid}"
|
|
],
|
|
)
|
|
|
|
# Now we want to test the other side of this - bouncer's handling
|
|
# of a deleted realm.
|
|
with (
|
|
self.captureOnCommitCallbacks(execute=True),
|
|
self.assertLogs(logger, level="WARNING") as analytics_logger,
|
|
mock.patch(
|
|
"corporate.lib.stripe.RemoteRealmBillingSession.on_paid_plan", return_value=True
|
|
),
|
|
):
|
|
# This time the logger shouldn't get triggered - because the bouncer doesn't
|
|
# include .realm_locally_deleted realms in its response.
|
|
# Note: This is hacky, because until Python 3.10 we don't have access to
|
|
# assertNoLogs - and regular assertLogs demands that the logger gets triggered.
|
|
# So we do a dummy warning ourselves here, to satisfy it.
|
|
# TODO: Replace this with assertNoLogs once we fully upgrade to Python 3.10.
|
|
logger.warning("Dummy warning")
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
remote_realm_for_deleted_realm = RemoteRealm.objects.get(uuid=deleted_realm_uuid)
|
|
|
|
self.assertEqual(remote_realm_for_deleted_realm.registration_deactivated, False)
|
|
self.assertEqual(remote_realm_for_deleted_realm.realm_locally_deleted, True)
|
|
self.assertEqual(analytics_logger.output, ["WARNING:zulip.analytics:Dummy warning"])
|
|
|
|
audit_log = RemoteRealmAuditLog.objects.latest("id")
|
|
self.assertEqual(audit_log.event_type, AuditLogEventType.REMOTE_REALM_LOCALLY_DELETED)
|
|
self.assertEqual(audit_log.remote_realm, remote_realm_for_deleted_realm)
|
|
|
|
from django.core.mail import outbox
|
|
|
|
email = outbox[-1]
|
|
self.assert_length(email.to, 1)
|
|
self.assertEqual(email.to[0], "sales@zulip.com")
|
|
|
|
billing_session = RemoteRealmBillingSession(remote_realm=remote_realm_for_deleted_realm)
|
|
self.assertIn(
|
|
f"Support URL: {billing_session.support_url()}",
|
|
email.body,
|
|
)
|
|
self.assertIn(
|
|
f"Internal billing notice for {billing_session.billing_entity_display_name}.",
|
|
email.body,
|
|
)
|
|
self.assertIn(
|
|
"Investigate why remote realm is marked as locally deleted when it's on a paid plan.",
|
|
email.body,
|
|
)
|
|
self.assertEqual(
|
|
f"{billing_session.billing_entity_display_name} on paid plan marked as locally deleted",
|
|
email.subject,
|
|
)
|
|
|
|
# Restore the deleted realm to verify that the bouncer correctly handles that
|
|
# by toggling off .realm_locally_deleted.
|
|
restored_zephyr_realm = do_create_realm("zephyr", "Zephyr")
|
|
restored_zephyr_realm.uuid = deleted_realm_uuid
|
|
restored_zephyr_realm.save()
|
|
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
remote_realm_for_deleted_realm.refresh_from_db()
|
|
self.assertEqual(remote_realm_for_deleted_realm.realm_locally_deleted, False)
|
|
|
|
audit_log = RemoteRealmAuditLog.objects.latest("id")
|
|
self.assertEqual(
|
|
audit_log.event_type, AuditLogEventType.REMOTE_REALM_LOCALLY_DELETED_RESTORED
|
|
)
|
|
self.assertEqual(audit_log.remote_realm, remote_realm_for_deleted_realm)
|