mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
2384 lines
108 KiB
Python
2384 lines
108 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
import tarfile
|
|
import tempfile
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import urllib3.exceptions
|
|
from cryptography.fernet import Fernet
|
|
from cryptography.hazmat.backends import default_backend
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
from django.db import connections, router
|
|
from django.db.models import Model
|
|
from django.utils import timezone
|
|
|
|
from sentry.backup.crypto import LocalFileDecryptor
|
|
from sentry.backup.dependencies import NormalizedModelName, dependencies, get_model, get_model_name
|
|
from sentry.backup.helpers import ImportFlags
|
|
from sentry.backup.imports import (
|
|
ImportingError,
|
|
import_in_config_scope,
|
|
import_in_global_scope,
|
|
import_in_organization_scope,
|
|
import_in_user_scope,
|
|
)
|
|
from sentry.backup.scopes import ExportScope, ImportScope, RelocationScope
|
|
from sentry.incidents.models import AlertRule, AlertRuleThresholdType
|
|
from sentry.models.actor import ACTOR_TYPES, Actor
|
|
from sentry.models.apitoken import DEFAULT_EXPIRATION, ApiToken, generate_token
|
|
from sentry.models.authenticator import Authenticator
|
|
from sentry.models.email import Email
|
|
from sentry.models.importchunk import (
|
|
ControlImportChunk,
|
|
ControlImportChunkReplica,
|
|
RegionImportChunk,
|
|
)
|
|
from sentry.models.lostpasswordhash import LostPasswordHash
|
|
from sentry.models.options.option import ControlOption, Option
|
|
from sentry.models.options.project_option import ProjectOption
|
|
from sentry.models.organization import Organization
|
|
from sentry.models.organizationmapping import OrganizationMapping
|
|
from sentry.models.organizationmember import OrganizationMember
|
|
from sentry.models.organizationmembermapping import OrganizationMemberMapping
|
|
from sentry.models.organizationslugreservation import (
|
|
OrganizationSlugReservation,
|
|
OrganizationSlugReservationType,
|
|
)
|
|
from sentry.models.orgauthtoken import OrgAuthToken
|
|
from sentry.models.project import Project
|
|
from sentry.models.projectkey import ProjectKey
|
|
from sentry.models.relay import Relay, RelayUsage
|
|
from sentry.models.savedsearch import SavedSearch, Visibility
|
|
from sentry.models.team import Team
|
|
from sentry.models.user import User
|
|
from sentry.models.useremail import UserEmail
|
|
from sentry.models.userip import UserIP
|
|
from sentry.models.userpermission import UserPermission
|
|
from sentry.models.userrole import UserRole, UserRoleUser
|
|
from sentry.monitors.models import Monitor
|
|
from sentry.receivers import create_default_projects
|
|
from sentry.services.hybrid_cloud.import_export.model import RpcImportErrorKind
|
|
from sentry.silo.base import SiloMode
|
|
from sentry.snuba.dataset import Dataset
|
|
from sentry.snuba.models import QuerySubscription, SnubaQuery, SnubaQueryEventType
|
|
from sentry.snuba.subscriptions import create_snuba_query
|
|
from sentry.testutils.factories import get_fixture_path
|
|
from sentry.testutils.helpers import override_options
|
|
from sentry.testutils.helpers.backups import (
|
|
NOOP_PRINTER,
|
|
BackupTestCase,
|
|
clear_database,
|
|
export_to_file,
|
|
generate_rsa_key_pair,
|
|
is_control_model,
|
|
)
|
|
from sentry.testutils.hybrid_cloud import use_split_dbs
|
|
from sentry.testutils.silo import assume_test_silo_mode, region_silo_test
|
|
from sentry.utils import json
|
|
from tests.sentry.backup import (
|
|
expect_models,
|
|
get_matching_exportable_models,
|
|
verify_models_in_output,
|
|
)
|
|
|
|
|
|
class ImportTestCase(BackupTestCase):
|
|
def export_to_tmp_file_and_clear_database(self, tmp_dir) -> Path:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
export_to_file(tmp_path, ExportScope.Global)
|
|
clear_database()
|
|
return tmp_path
|
|
|
|
|
|
@region_silo_test
|
|
class SanitizationTests(ImportTestCase):
|
|
"""
|
|
Ensure that potentially damaging data is properly scrubbed at import time.
|
|
"""
|
|
|
|
def test_users_sanitized_in_user_scope(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
self.generate_tmp_users_json_file(tmp_path)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_user_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 4
|
|
assert (
|
|
User.objects.filter(is_managed=False, is_staff=False, is_superuser=False).count()
|
|
== 4
|
|
)
|
|
|
|
# Every user except `max_user` shares an email.
|
|
assert Email.objects.count() == 2
|
|
|
|
# All `UserEmail`s must have their verification status reset in this scope.
|
|
assert UserEmail.objects.count() == 4
|
|
assert UserEmail.objects.filter(is_verified=True).count() == 0
|
|
assert (
|
|
UserEmail.objects.filter(date_hash_added__lt=datetime(2023, 7, 1, 0, 0)).count()
|
|
== 0
|
|
)
|
|
assert (
|
|
UserEmail.objects.filter(validation_hash="mCnWesSVvYQcq7qXQ36AZHwosAd6cghE").count()
|
|
== 0
|
|
)
|
|
|
|
assert User.objects.filter(is_unclaimed=True).count() == 4
|
|
assert LostPasswordHash.objects.count() == 4
|
|
assert User.objects.filter(is_managed=True).count() == 0
|
|
assert User.objects.filter(is_staff=True).count() == 0
|
|
assert User.objects.filter(is_superuser=True).count() == 0
|
|
assert Authenticator.objects.count() == 0
|
|
assert UserPermission.objects.count() == 0
|
|
assert UserRole.objects.count() == 0
|
|
assert UserRoleUser.objects.count() == 0
|
|
|
|
def test_users_sanitized_in_organization_scope(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
self.generate_tmp_users_json_file(tmp_path)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 4
|
|
assert (
|
|
User.objects.filter(is_managed=False, is_staff=False, is_superuser=False).count()
|
|
== 4
|
|
)
|
|
|
|
# Every user except `max_user` shares an email.
|
|
assert Email.objects.count() == 2
|
|
|
|
# All `UserEmail`s must have their verification status reset in this scope.
|
|
assert UserEmail.objects.count() == 4
|
|
assert UserEmail.objects.filter(is_verified=True).count() == 0
|
|
assert (
|
|
UserEmail.objects.filter(date_hash_added__lt=datetime(2023, 7, 1, 0, 0)).count()
|
|
== 0
|
|
)
|
|
assert (
|
|
UserEmail.objects.filter(validation_hash="mCnWesSVvYQcq7qXQ36AZHwosAd6cghE").count()
|
|
== 0
|
|
)
|
|
|
|
assert User.objects.filter(is_unclaimed=True).count() == 4
|
|
assert LostPasswordHash.objects.count() == 4
|
|
assert User.objects.filter(is_managed=True).count() == 0
|
|
assert User.objects.filter(is_staff=True).count() == 0
|
|
assert User.objects.filter(is_superuser=True).count() == 0
|
|
assert Authenticator.objects.count() == 0
|
|
assert UserPermission.objects.count() == 0
|
|
assert UserRole.objects.count() == 0
|
|
assert UserRoleUser.objects.count() == 0
|
|
|
|
def test_users_unsanitized_in_config_scope(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
self.generate_tmp_users_json_file(tmp_path)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_config_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 4
|
|
assert User.objects.filter(is_unclaimed=True).count() == 4
|
|
assert LostPasswordHash.objects.count() == 4
|
|
assert User.objects.filter(is_managed=True).count() == 1
|
|
assert User.objects.filter(is_staff=True).count() == 2
|
|
assert User.objects.filter(is_superuser=True).count() == 2
|
|
assert (
|
|
User.objects.filter(is_managed=False, is_staff=False, is_superuser=False).count()
|
|
== 2
|
|
)
|
|
assert UserEmail.objects.count() == 4
|
|
|
|
# Unlike the "global" scope, we do not keep authentication information for the "config"
|
|
# scope.
|
|
assert Authenticator.objects.count() == 0
|
|
|
|
# Every user except `max_user` shares an email.
|
|
assert Email.objects.count() == 2
|
|
|
|
# All `UserEmail`s must have their verification status reset in this scope.
|
|
assert UserEmail.objects.count() == 4
|
|
assert UserEmail.objects.filter(is_verified=True).count() == 0
|
|
assert (
|
|
UserEmail.objects.filter(date_hash_added__lt=datetime(2023, 7, 1, 0, 0)).count()
|
|
== 0
|
|
)
|
|
assert (
|
|
UserEmail.objects.filter(validation_hash="mCnWesSVvYQcq7qXQ36AZHwosAd6cghE").count()
|
|
== 0
|
|
)
|
|
|
|
# 1 from `max_user`, 1 from `permission_user`.
|
|
assert UserPermission.objects.count() == 2
|
|
|
|
# 1 from `max_user`.
|
|
assert UserRole.objects.count() == 1
|
|
assert UserRoleUser.objects.count() == 2
|
|
|
|
def test_users_unsanitized_in_global_scope(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
self.generate_tmp_users_json_file(tmp_path)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_global_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 4
|
|
# We don't mark `Global`ly imported `User`s unclaimed.
|
|
assert User.objects.filter(is_unclaimed=True).count() == 0
|
|
assert LostPasswordHash.objects.count() == 0
|
|
assert User.objects.filter(is_managed=True).count() == 1
|
|
assert User.objects.filter(is_staff=True).count() == 2
|
|
assert User.objects.filter(is_superuser=True).count() == 2
|
|
assert (
|
|
User.objects.filter(is_managed=False, is_staff=False, is_superuser=False).count()
|
|
== 2
|
|
)
|
|
assert UserEmail.objects.count() == 4
|
|
|
|
# Unlike the "config" scope, we keep authentication information for the "global" scope.
|
|
assert Authenticator.objects.count() == 4
|
|
|
|
# Every user except `max_user` shares an email.
|
|
assert Email.objects.count() == 2
|
|
|
|
# All `UserEmail`s must have their imported verification status reset in this scope.
|
|
assert UserEmail.objects.count() == 4
|
|
assert UserEmail.objects.filter(is_verified=True).count() == 4
|
|
assert (
|
|
UserEmail.objects.filter(date_hash_added__lt=datetime(2023, 7, 1, 0, 0)).count()
|
|
== 4
|
|
)
|
|
assert (
|
|
UserEmail.objects.filter(validation_hash="mCnWesSVvYQcq7qXQ36AZHwosAd6cghE").count()
|
|
== 4
|
|
)
|
|
|
|
# 1 from `max_user`, 1 from `permission_user`.
|
|
assert UserPermission.objects.count() == 2
|
|
|
|
# 1 from `max_user`.
|
|
assert UserRole.objects.count() == 1
|
|
assert UserRoleUser.objects.count() == 2
|
|
|
|
def test_generate_suffix_for_already_taken_organization(self):
|
|
owner = self.create_user(email="[email protected]")
|
|
self.create_organization(name="some-org", owner=owner)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
|
|
# Note that we have created an organization with the same name as one we are about to
|
|
# import.
|
|
existing_org = self.create_organization(owner=self.user, name="some-org")
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
assert Organization.objects.count() == 2
|
|
assert Organization.objects.filter(slug__icontains="some-org").count() == 2
|
|
assert Organization.objects.filter(slug__iexact="some-org").count() == 1
|
|
|
|
imported_organization = Organization.objects.get(slug__icontains="some-org-")
|
|
assert imported_organization.id != existing_org.id
|
|
|
|
org_chunk = RegionImportChunk.objects.get(
|
|
model="sentry.organization", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(org_chunk.inserted_map) == 1
|
|
assert len(org_chunk.inserted_identifiers) == 1
|
|
for slug in org_chunk.inserted_identifiers.values():
|
|
assert slug.startswith("some-org-")
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert (
|
|
OrganizationSlugReservation.objects.filter(
|
|
slug__icontains="some-org",
|
|
reservation_type=OrganizationSlugReservationType.PRIMARY,
|
|
).count()
|
|
== 2
|
|
)
|
|
|
|
assert OrganizationSlugReservation.objects.filter(slug__iexact="some-org").count() == 1
|
|
# Assert that the slug update RPC has completed and generated a valid matching primary
|
|
# slug reservation.
|
|
slug_reservation = OrganizationSlugReservation.objects.filter(
|
|
slug__icontains="some-org-",
|
|
reservation_type=OrganizationSlugReservationType.PRIMARY,
|
|
).get()
|
|
|
|
assert OrganizationMapping.objects.count() == 2
|
|
assert OrganizationMapping.objects.filter(slug__icontains="some-org").count() == 2
|
|
assert OrganizationMapping.objects.filter(slug__iexact="some-org").count() == 1
|
|
org_mapping = OrganizationMapping.objects.get(slug__icontains="some-org-")
|
|
assert org_mapping.slug == slug_reservation.slug == imported_organization.slug
|
|
assert (
|
|
org_mapping.organization_id
|
|
== slug_reservation.organization_id
|
|
== imported_organization.id
|
|
)
|
|
|
|
def test_generate_suffix_for_already_taken_organization_with_control_option(self):
|
|
with override_options({"hybrid_cloud.control-organization-provisioning": True}):
|
|
self.test_generate_suffix_for_already_taken_organization()
|
|
|
|
def test_generate_suffix_for_already_taken_username(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
self.create_user("min_user")
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
with open(tmp_path, "w+") as tmp_file:
|
|
models = self.json_of_exhaustive_user_with_minimum_privileges()
|
|
json.dump(
|
|
self.sort_in_memory_json(models),
|
|
tmp_file,
|
|
)
|
|
|
|
# Import twice, to check that new suffixes are assigned both times.
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_user_scope(tmp_file, printer=NOOP_PRINTER)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_user_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 3
|
|
assert (
|
|
User.objects.filter(username__icontains="min_user")
|
|
.values("username")
|
|
.distinct()
|
|
.count()
|
|
== 3
|
|
)
|
|
assert User.objects.filter(username__iexact="min_user").count() == 1
|
|
assert User.objects.filter(username__icontains="min_user-").count() == 2
|
|
|
|
def test_bad_invalid_user(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
with open(tmp_path, "w+") as tmp_file:
|
|
models = self.json_of_exhaustive_user_with_minimum_privileges()
|
|
|
|
# Modify all username to be longer than 128 characters.
|
|
for model in models:
|
|
if model["model"] == "sentry.user":
|
|
model["fields"]["username"] = "x" * 129
|
|
json.dump(models, tmp_file)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
with pytest.raises(ImportingError) as err:
|
|
import_in_user_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
assert err.value.context.get_kind() == RpcImportErrorKind.ValidationError
|
|
assert err.value.context.on.model == "sentry.user"
|
|
|
|
@patch("sentry.models.userip.geo_by_addr")
|
|
def test_good_regional_user_ip_in_user_scope(self, mock_geo_by_addr):
|
|
mock_geo_by_addr.return_value = {
|
|
"country_code": "US",
|
|
"region": "CA",
|
|
"subdivision": "San Francisco",
|
|
}
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
with open(tmp_path, "w+") as tmp_file:
|
|
models = self.json_of_exhaustive_user_with_minimum_privileges()
|
|
|
|
# Modify the UserIP to be in California, USA.
|
|
for model in models:
|
|
if model["model"] == "sentry.userip":
|
|
model["fields"]["ip_address"] = "8.8.8.8"
|
|
json.dump(models, tmp_file)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_user_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert UserIP.objects.count() == 1
|
|
assert UserIP.objects.filter(ip_address="8.8.8.8").exists()
|
|
assert UserIP.objects.filter(country_code="US").exists()
|
|
assert UserIP.objects.filter(region_code="CA").exists()
|
|
|
|
# Unlike global scope, this time must be reset.
|
|
assert UserIP.objects.filter(last_seen__gt=datetime(2023, 7, 1, 0, 0)).exists()
|
|
assert UserIP.objects.filter(first_seen__gt=datetime(2023, 7, 1, 0, 0)).exists()
|
|
|
|
@patch("sentry.models.userip.geo_by_addr")
|
|
def test_good_regional_user_ip_in_global_scope(self, mock_geo_by_addr):
|
|
mock_geo_by_addr.return_value = {
|
|
"country_code": "US",
|
|
"region": "CA",
|
|
"subdivision": "San Francisco",
|
|
}
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
with open(tmp_path, "w+") as tmp_file:
|
|
models = self.json_of_exhaustive_user_with_minimum_privileges()
|
|
|
|
# Modify the UserIP to be in California, USA.
|
|
for model in models:
|
|
if model["model"] == "sentry.userip":
|
|
model["fields"]["ip_address"] = "8.8.8.8"
|
|
json.dump(models, tmp_file)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_global_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert UserIP.objects.count() == 1
|
|
assert UserIP.objects.filter(ip_address="8.8.8.8").exists()
|
|
assert UserIP.objects.filter(country_code="US").exists()
|
|
assert UserIP.objects.filter(region_code="CA").exists()
|
|
|
|
# Unlike org/user scope, this must NOT be reset.
|
|
assert not UserIP.objects.filter(last_seen__gt=datetime(2023, 7, 1, 0, 0)).exists()
|
|
assert not UserIP.objects.filter(first_seen__gt=datetime(2023, 7, 1, 0, 0)).exists()
|
|
|
|
# Regression test for getsentry/self-hosted#2468.
|
|
@patch("sentry.models.userip.geo_by_addr")
|
|
def test_good_multiple_user_ips_per_user_in_global_scope(self, mock_geo_by_addr):
|
|
mock_geo_by_addr.return_value = {
|
|
"country_code": "US",
|
|
"region": "CA",
|
|
"subdivision": "San Francisco",
|
|
}
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
with open(tmp_path, "w+") as tmp_file:
|
|
models = self.json_of_exhaustive_user_with_minimum_privileges()
|
|
|
|
# Modify the UserIP to be in California, USA.
|
|
for model in models:
|
|
if model["model"] == "sentry.userip":
|
|
model["fields"]["ip_address"] = "8.8.8.8"
|
|
|
|
# Add a two copies of the same IP - so the user now has 2 `UserIP` models for the IP
|
|
# `8.8.8.9`, 1 for `8.8.8.8`, and 1 for `8.8.8.7`. After import, we would expect to
|
|
# only see one model for each IP.
|
|
models.append(
|
|
{
|
|
"model": "sentry.userip",
|
|
"pk": 3,
|
|
"fields": {
|
|
"user": 2,
|
|
"ip_address": "8.8.8.9",
|
|
"country_code": "US",
|
|
"region_code": "CA",
|
|
"first_seen": "2013-04-05T03:29:45.000Z",
|
|
"last_seen": "2013-04-05T03:29:45.000Z",
|
|
},
|
|
}
|
|
)
|
|
models.append(
|
|
{
|
|
"model": "sentry.userip",
|
|
"pk": 4,
|
|
"fields": {
|
|
"user": 2,
|
|
"ip_address": "8.8.8.9",
|
|
"country_code": "CA", # Incorrect value - importing should fix this.
|
|
"region_code": "BC", # Incorrect value - importing should fix this.
|
|
"first_seen": "2014-04-05T03:29:45.000Z",
|
|
"last_seen": "2014-04-05T03:29:45.000Z",
|
|
},
|
|
}
|
|
)
|
|
models.append(
|
|
{
|
|
"model": "sentry.userip",
|
|
"pk": 4,
|
|
"fields": {
|
|
"user": 2,
|
|
"ip_address": "8.8.8.7",
|
|
"country_code": None, # Unknown value - importing should fix this.
|
|
"region_code": None, # Unknown value - importing should fix this.
|
|
"first_seen": "2014-04-05T03:29:45.000Z",
|
|
"last_seen": "2014-04-05T03:29:45.000Z",
|
|
},
|
|
}
|
|
)
|
|
|
|
json.dump(self.sort_in_memory_json(models), tmp_file)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_global_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert UserIP.objects.count() == 3
|
|
assert UserIP.objects.filter(ip_address="8.8.8.9").count() == 1
|
|
assert UserIP.objects.filter(ip_address="8.8.8.8").count() == 1
|
|
assert UserIP.objects.filter(ip_address="8.8.8.7").count() == 1
|
|
assert UserIP.objects.filter(country_code="US").count() == 3
|
|
assert UserIP.objects.filter(region_code="CA").count() == 3
|
|
|
|
def test_bad_invalid_user_ip(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
with open(tmp_path, "w+") as tmp_file:
|
|
models = self.json_of_exhaustive_user_with_minimum_privileges()
|
|
|
|
# Modify the IP address to be in invalid.
|
|
for m in models:
|
|
if m["model"] == "sentry.userip":
|
|
m["fields"]["ip_address"] = "0.1.2.3.4.5.6.7.8.9.abc.def"
|
|
json.dump(list(models), tmp_file)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
with pytest.raises(ImportingError) as err:
|
|
import_in_user_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
assert err.value.context.get_kind() == RpcImportErrorKind.ValidationError
|
|
assert err.value.context.on.model == "sentry.userip"
|
|
|
|
# Regression test for getsentry/self-hosted#2571.
|
|
def test_good_multiple_useremails_per_user_in_user_scope(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
with open(tmp_path, "w+") as tmp_file:
|
|
models = self.json_of_exhaustive_user_with_minimum_privileges()
|
|
|
|
# Add two copies (1 verified, 1 not) of the same `UserEmail` - so the user now has 3
|
|
# `UserEmail` models, the latter of which have no corresponding `Email` entry.
|
|
models.append(
|
|
{
|
|
"model": "sentry.useremail",
|
|
"pk": 100,
|
|
"fields": {
|
|
"user": 2,
|
|
"email": "[email protected]",
|
|
"validation_hash": "7jvwev0oc8sFyEyEwfvDAwxidtGzpAov",
|
|
"date_hash_added": "2023-06-22T22:59:56.521Z",
|
|
"is_verified": True,
|
|
},
|
|
}
|
|
)
|
|
models.append(
|
|
{
|
|
"model": "sentry.useremail",
|
|
"pk": 101,
|
|
"fields": {
|
|
"user": 2,
|
|
"email": "[email protected]",
|
|
"validation_hash": "",
|
|
"date_hash_added": "2023-06-22T22:59:57.521Z",
|
|
"is_verified": False,
|
|
},
|
|
}
|
|
)
|
|
|
|
json.dump(self.sort_in_memory_json(models), tmp_file)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_user_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert UserEmail.objects.count() == 3
|
|
assert UserEmail.objects.values("user").distinct().count() == 1
|
|
assert UserEmail.objects.filter(email="[email protected]").exists()
|
|
assert UserEmail.objects.filter(email="[email protected]").exists()
|
|
assert UserEmail.objects.filter(email="[email protected]").exists()
|
|
|
|
# Validations are scrubbed and regenerated in non-global scopes.
|
|
assert UserEmail.objects.filter(validation_hash="").count() == 0
|
|
assert UserEmail.objects.filter(is_verified=True).count() == 0
|
|
|
|
# Regression test for getsentry/self-hosted#2571.
|
|
def test_good_multiple_useremails_per_user_in_global_scope(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
with open(tmp_path, "w+") as tmp_file:
|
|
models = self.json_of_exhaustive_user_with_minimum_privileges()
|
|
|
|
# Add two copies (1 verified, 1 not) of the same `UserEmail` - so the user now has 3
|
|
# `UserEmail` models, the latter of which have no corresponding `Email` entry.
|
|
models.append(
|
|
{
|
|
"model": "sentry.useremail",
|
|
"pk": 100,
|
|
"fields": {
|
|
"user": 2,
|
|
"email": "[email protected]",
|
|
"validation_hash": "7jvwev0oc8sFyEyEwfvDAwxidtGzpAov",
|
|
"date_hash_added": "2023-06-22T22:59:56.521Z",
|
|
"is_verified": True,
|
|
},
|
|
}
|
|
)
|
|
models.append(
|
|
{
|
|
"model": "sentry.useremail",
|
|
"pk": 101,
|
|
"fields": {
|
|
"user": 2,
|
|
"email": "[email protected]",
|
|
"validation_hash": "",
|
|
"date_hash_added": "2023-06-22T22:59:57.521Z",
|
|
"is_verified": False,
|
|
},
|
|
}
|
|
)
|
|
|
|
json.dump(self.sort_in_memory_json(models), tmp_file)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_global_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert UserEmail.objects.count() == 3
|
|
assert UserEmail.objects.values("user").distinct().count() == 1
|
|
assert UserEmail.objects.filter(email="[email protected]").exists()
|
|
assert UserEmail.objects.filter(email="[email protected]").exists()
|
|
assert UserEmail.objects.filter(email="[email protected]").exists()
|
|
|
|
# Validation hashes are not touched in the global scope.
|
|
assert UserEmail.objects.filter(validation_hash="").count() == 1
|
|
assert UserEmail.objects.filter(is_verified=True).count() == 2
|
|
|
|
def test_bad_invalid_user_option(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
with open(tmp_path, "w+") as tmp_file:
|
|
models = self.json_of_exhaustive_user_with_minimum_privileges()
|
|
|
|
# Modify the `timezone` option to be in invalid.
|
|
for m in models:
|
|
if m["model"] == "sentry.useroption" and m["fields"]["key"] == "timezone":
|
|
m["fields"]["value"] = '"MiddleEarth/Gondor"'
|
|
json.dump(list(models), tmp_file)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
with pytest.raises(ImportingError) as err:
|
|
import_in_user_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
assert err.value.context.get_kind() == RpcImportErrorKind.ValidationError
|
|
assert err.value.context.on.model == "sentry.useroption"
|
|
|
|
|
|
@region_silo_test
|
|
class SignalingTests(ImportTestCase):
|
|
"""
|
|
Some models are automatically created via signals and similar automagic from related models. We
|
|
test that behavior here. Specifically, we test the following:
|
|
- That `Email` and `UserEmail` are automatically created when `User` is.
|
|
- That `OrganizationMapping` and `OrganizationMemberMapping` are automatically created when
|
|
`Organization is.
|
|
- That `ProjectKey` and `ProjectOption` instances are automatically created when `Project`
|
|
is.
|
|
"""
|
|
|
|
def test_import_signaling_user(self):
|
|
self.create_exhaustive_user("user", email="[email protected]")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_user_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 1
|
|
assert User.objects.filter(email="[email protected]").exists()
|
|
|
|
assert UserEmail.objects.count() == 1
|
|
assert UserEmail.objects.filter(email="[email protected]").exists()
|
|
|
|
assert Email.objects.count() == 1
|
|
assert Email.objects.filter(email="[email protected]").exists()
|
|
|
|
def test_import_signaling_organization(self):
|
|
owner = self.create_exhaustive_user("owner")
|
|
invited = self.create_exhaustive_user("invited")
|
|
member = self.create_exhaustive_user("member")
|
|
self.create_exhaustive_organization("some-org", owner, invited, [member])
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
# There should only be 1 organization at this point
|
|
imported_organization = Organization.objects.get()
|
|
assert imported_organization.slug == "some-org"
|
|
|
|
assert OrganizationMember.objects.count() == 3
|
|
|
|
# The exhaustive org has 2 projects which automatically get 1 key and 3 options each.
|
|
assert Project.objects.count() == 2
|
|
assert Project.objects.filter(name="project-some-org").exists()
|
|
assert Project.objects.filter(name="other-project-some-org").exists()
|
|
|
|
assert ProjectKey.objects.count() == 2
|
|
assert ProjectOption.objects.count() == 6
|
|
assert ProjectOption.objects.filter(key="sentry:relay-rev").exists()
|
|
assert ProjectOption.objects.filter(key="sentry:relay-rev-lastchange").exists()
|
|
assert ProjectOption.objects.filter(key="sentry:option-epoch").exists()
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
# An organization slug reservation with a valid primary reservation type
|
|
# signals that we've synchronously resolved the slug update RPC correctly.
|
|
assert OrganizationSlugReservation.objects.filter(
|
|
organization_id=imported_organization.id,
|
|
slug="some-org",
|
|
reservation_type=OrganizationSlugReservationType.PRIMARY,
|
|
).exists()
|
|
assert OrganizationMapping.objects.count() == 1
|
|
assert OrganizationMapping.objects.filter(
|
|
organization_id=imported_organization.id, slug="some-org"
|
|
).exists()
|
|
assert OrganizationMemberMapping.objects.count() == 3
|
|
|
|
def test_import_signaling_organization_with_control_provisioning_option(self):
|
|
with override_options({"hybrid_cloud.control-organization-provisioning": True}):
|
|
self.test_import_signaling_organization()
|
|
|
|
|
|
@region_silo_test
|
|
class ScopingTests(ImportTestCase):
|
|
"""
|
|
Ensures that only models with the allowed relocation scopes are actually imported.
|
|
"""
|
|
|
|
@staticmethod
|
|
def verify_model_inclusion(scope: ImportScope):
|
|
"""
|
|
Ensure all in-scope models are included, and that no out-of-scope models are included.
|
|
Additionally, we verify that each such model had an appropriate `*ImportChunk` written out
|
|
atomically alongside it.
|
|
"""
|
|
included_models = get_matching_exportable_models(
|
|
lambda mr: len(mr.get_possible_relocation_scopes() & scope.value) > 0
|
|
)
|
|
excluded_models = get_matching_exportable_models(
|
|
lambda mr: mr.get_possible_relocation_scopes() != {RelocationScope.Excluded}
|
|
and not (mr.get_possible_relocation_scopes() & scope.value)
|
|
)
|
|
|
|
for model in included_models:
|
|
model_name_str = str(get_model_name(model))
|
|
if is_control_model(model):
|
|
replica = ControlImportChunkReplica.objects.filter(model=model_name_str).first()
|
|
assert replica is not None
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert model.objects.count() > 0
|
|
|
|
control = ControlImportChunk.objects.filter(model=model_name_str).first()
|
|
assert control is not None
|
|
|
|
# Ensure that the region-silo replica and the control-silo original are
|
|
# identical.
|
|
common_fields = {f.name for f in ControlImportChunk._meta.get_fields()} - {
|
|
"id",
|
|
"date_added",
|
|
"date_updated",
|
|
}
|
|
for field in common_fields:
|
|
assert getattr(replica, field, None) == getattr(control, field, None)
|
|
else:
|
|
assert model.objects.count() > 0
|
|
assert RegionImportChunk.objects.filter(model=model_name_str).count() == 1
|
|
|
|
for model in excluded_models:
|
|
if is_control_model(model):
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert model.objects.count() == 0
|
|
else:
|
|
assert model.objects.count() == 0
|
|
|
|
def test_user_import_scoping(self):
|
|
self.create_exhaustive_instance(is_superadmin=True)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_user_scope(tmp_file, printer=NOOP_PRINTER)
|
|
self.verify_model_inclusion(ImportScope.User)
|
|
|
|
# Test that the import UUID is auto-assigned properly.
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert ControlImportChunk.objects.values("import_uuid").distinct().count() == 1
|
|
|
|
assert ControlImportChunkReplica.objects.values("import_uuid").distinct().count() == 1
|
|
|
|
def test_organization_import_scoping(self):
|
|
self.create_exhaustive_instance(is_superadmin=True)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(tmp_file, printer=NOOP_PRINTER)
|
|
self.verify_model_inclusion(ImportScope.Organization)
|
|
|
|
# Test that the import UUID is auto-assigned properly.
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert ControlImportChunk.objects.values("import_uuid").distinct().count() == 1
|
|
|
|
assert ControlImportChunkReplica.objects.values("import_uuid").distinct().count() == 1
|
|
assert RegionImportChunk.objects.values("import_uuid").distinct().count() == 1
|
|
assert (
|
|
ControlImportChunkReplica.objects.values("import_uuid").first()
|
|
== RegionImportChunk.objects.values("import_uuid").first()
|
|
)
|
|
|
|
def test_config_import_scoping(self):
|
|
self.create_exhaustive_instance(is_superadmin=True)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_config_scope(tmp_file, printer=NOOP_PRINTER)
|
|
self.verify_model_inclusion(ImportScope.Config)
|
|
|
|
# Test that the import UUID is auto-assigned properly.
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert ControlImportChunk.objects.values("import_uuid").distinct().count() == 1
|
|
|
|
assert ControlImportChunkReplica.objects.values("import_uuid").distinct().count() == 1
|
|
assert RegionImportChunk.objects.values("import_uuid").distinct().count() == 1
|
|
assert (
|
|
ControlImportChunkReplica.objects.values("import_uuid").first()
|
|
== RegionImportChunk.objects.values("import_uuid").first()
|
|
)
|
|
|
|
def test_global_import_scoping(self):
|
|
self.create_exhaustive_instance(is_superadmin=True)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_global_scope(tmp_file, printer=NOOP_PRINTER)
|
|
self.verify_model_inclusion(ImportScope.Global)
|
|
|
|
# Test that the import UUID is auto-assigned properly.
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert ControlImportChunk.objects.values("import_uuid").distinct().count() == 1
|
|
|
|
assert ControlImportChunkReplica.objects.values("import_uuid").distinct().count() == 1
|
|
assert RegionImportChunk.objects.values("import_uuid").distinct().count() == 1
|
|
assert (
|
|
ControlImportChunkReplica.objects.values("import_uuid").first()
|
|
== RegionImportChunk.objects.values("import_uuid").first()
|
|
)
|
|
|
|
|
|
@region_silo_test
|
|
class DatabaseResetTests(ImportTestCase):
|
|
"""
|
|
Ensure that database resets work as intended in different import scopes.
|
|
"""
|
|
|
|
def import_empty_backup_file(self, import_fn):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_empty_file_path = tmp_dir + "empty_backup.json"
|
|
with open(tmp_empty_file_path, "w") as tmp_file:
|
|
json.dump([], tmp_file)
|
|
with open(tmp_empty_file_path, "rb") as empty_backup_json:
|
|
import_fn(empty_backup_json, printer=NOOP_PRINTER)
|
|
|
|
@pytest.mark.skipif(
|
|
os.environ.get("SENTRY_USE_MONOLITH_DBS", "0") == "0",
|
|
reason="only run when in `SENTRY_USE_MONOLITH_DBS=1` env variable is set",
|
|
)
|
|
def test_clears_existing_models_in_global_scope(self):
|
|
create_default_projects()
|
|
self.import_empty_backup_file(import_in_global_scope)
|
|
|
|
for dependency in dependencies():
|
|
model = get_model(dependency)
|
|
assert model is not None
|
|
assert model.objects.count() == 0
|
|
with connections[router.db_for_read(model)].cursor() as cursor:
|
|
cursor.execute(f"SELECT MAX(id) FROM {model._meta.db_table}")
|
|
sequence_number = cursor.fetchone()[0]
|
|
assert sequence_number == 1 or sequence_number is None
|
|
|
|
# During the setup of a fresh Sentry instance, there are a couple of models that are
|
|
# automatically created: the Sentry org, a Sentry team, and an internal project. During a
|
|
# global import, we want to avoid persisting these default models and start from scratch.
|
|
# These explicit assertions are here just to double check that these models have been wiped.
|
|
assert Project.objects.count() == 0
|
|
assert ProjectKey.objects.count() == 0
|
|
assert Organization.objects.count() == 0
|
|
assert OrganizationMember.objects.count() == 0
|
|
assert Team.objects.count() == 0
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
|
|
export_to_file(path, ExportScope.Global)
|
|
with open(path) as tmp_file:
|
|
assert tmp_file.read() == "[]"
|
|
|
|
def test_persist_existing_models_in_user_scope(self):
|
|
owner = self.create_exhaustive_user("owner", email="[email protected]")
|
|
user = self.create_exhaustive_user("user", email="[email protected]")
|
|
self.create_exhaustive_organization("neworg", owner, user, None)
|
|
assert Organization.objects.count() == 1
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 3
|
|
self.import_empty_backup_file(import_in_user_scope)
|
|
assert Organization.objects.count() == 1
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 3
|
|
|
|
def test_persist_existing_models_in_config_scope(self):
|
|
owner = self.create_exhaustive_user("owner", email="[email protected]")
|
|
user = self.create_exhaustive_user("user", email="[email protected]")
|
|
self.create_exhaustive_organization("neworg", owner, user, None)
|
|
assert Organization.objects.count() == 1
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 3
|
|
self.import_empty_backup_file(import_in_config_scope)
|
|
assert Organization.objects.count() == 1
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 3
|
|
|
|
def test_persist_existing_models_in_organization_scope(self):
|
|
owner = self.create_exhaustive_user("owner", email="[email protected]")
|
|
user = self.create_exhaustive_user("user", email="[email protected]")
|
|
self.create_exhaustive_organization("neworg", owner, user, None)
|
|
assert Organization.objects.count() == 1
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 3
|
|
self.import_empty_backup_file(import_in_organization_scope)
|
|
assert Organization.objects.count() == 1
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 3
|
|
|
|
|
|
# Filters should work identically in both silo and monolith modes, so no need to repeat the tests
|
|
# here.
|
|
@region_silo_test
|
|
class DecryptionTests(ImportTestCase):
|
|
"""
|
|
Ensures that decryption actually works. We only test one model for each scope, because it's
|
|
extremely unlikely that a failed decryption will leave only part of the data unmangled.
|
|
"""
|
|
|
|
@staticmethod
|
|
def encrypt_json_fixture(tmp_dir) -> tuple[Path, Path]:
|
|
good_file_path = get_fixture_path("backup", "fresh-install.json")
|
|
(priv_key_pem, pub_key_pem) = generate_rsa_key_pair()
|
|
|
|
tmp_priv_key_path = Path(tmp_dir).joinpath("key")
|
|
with open(tmp_priv_key_path, "wb") as f:
|
|
f.write(priv_key_pem)
|
|
|
|
tmp_pub_key_path = Path(tmp_dir).joinpath("key.pub")
|
|
with open(tmp_pub_key_path, "wb") as f:
|
|
f.write(pub_key_pem)
|
|
|
|
with open(good_file_path) as f:
|
|
json_data = json.load(f)
|
|
|
|
tmp_tarball_path = Path(tmp_dir).joinpath("input.tar")
|
|
with open(tmp_tarball_path, "wb") as i, open(tmp_pub_key_path, "rb") as p:
|
|
pem = p.read()
|
|
data_encryption_key = Fernet.generate_key()
|
|
backup_encryptor = Fernet(data_encryption_key)
|
|
encrypted_json_export = backup_encryptor.encrypt(json.dumps(json_data).encode("utf-8"))
|
|
|
|
dek_encryption_key = serialization.load_pem_public_key(pem, default_backend())
|
|
sha256 = hashes.SHA256()
|
|
mgf = padding.MGF1(algorithm=sha256)
|
|
oaep_padding = padding.OAEP(mgf=mgf, algorithm=sha256, label=None)
|
|
encrypted_dek = dek_encryption_key.encrypt(data_encryption_key, oaep_padding) # type: ignore
|
|
|
|
tar_buffer = io.BytesIO()
|
|
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
|
|
json_info = tarfile.TarInfo("export.json")
|
|
json_info.size = len(encrypted_json_export)
|
|
tar.addfile(json_info, fileobj=io.BytesIO(encrypted_json_export))
|
|
key_info = tarfile.TarInfo("data.key")
|
|
key_info.size = len(encrypted_dek)
|
|
tar.addfile(key_info, fileobj=io.BytesIO(encrypted_dek))
|
|
pub_info = tarfile.TarInfo("key.pub")
|
|
pub_info.size = len(pem)
|
|
tar.addfile(pub_info, fileobj=io.BytesIO(pem))
|
|
|
|
i.write(tar_buffer.getvalue())
|
|
|
|
return (tmp_tarball_path, tmp_priv_key_path)
|
|
|
|
def test_user_import_decryption(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
(tmp_tarball_path, tmp_priv_key_path) = self.encrypt_json_fixture(tmp_dir)
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 0
|
|
|
|
with open(tmp_tarball_path, "rb") as tmp_tarball_file, open(
|
|
tmp_priv_key_path, "rb"
|
|
) as tmp_priv_key_file:
|
|
import_in_user_scope(
|
|
tmp_tarball_file,
|
|
decryptor=LocalFileDecryptor(tmp_priv_key_file),
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() > 0
|
|
|
|
def test_organization_import_decryption(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
(tmp_tarball_path, tmp_priv_key_path) = self.encrypt_json_fixture(tmp_dir)
|
|
assert Organization.objects.count() == 0
|
|
|
|
with open(tmp_tarball_path, "rb") as tmp_tarball_file, open(
|
|
tmp_priv_key_path, "rb"
|
|
) as tmp_priv_key_file:
|
|
import_in_organization_scope(
|
|
tmp_tarball_file,
|
|
decryptor=LocalFileDecryptor(tmp_priv_key_file),
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
assert Organization.objects.count() > 0
|
|
|
|
def test_config_import_decryption(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
(tmp_tarball_path, tmp_priv_key_path) = self.encrypt_json_fixture(tmp_dir)
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert UserRole.objects.count() == 0
|
|
|
|
with open(tmp_tarball_path, "rb") as tmp_tarball_file, open(
|
|
tmp_priv_key_path, "rb"
|
|
) as tmp_priv_key_file:
|
|
import_in_config_scope(
|
|
tmp_tarball_file,
|
|
decryptor=LocalFileDecryptor(tmp_priv_key_file),
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert UserRole.objects.count() > 0
|
|
|
|
def test_global_import_decryption(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
(tmp_tarball_path, tmp_priv_key_path) = self.encrypt_json_fixture(tmp_dir)
|
|
assert Organization.objects.count() == 0
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 0
|
|
assert UserRole.objects.count() == 0
|
|
|
|
with open(tmp_tarball_path, "rb") as tmp_tarball_file, open(
|
|
tmp_priv_key_path, "rb"
|
|
) as tmp_priv_key_file:
|
|
import_in_global_scope(
|
|
tmp_tarball_file,
|
|
decryptor=LocalFileDecryptor(tmp_priv_key_file),
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
assert Organization.objects.count() > 0
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() > 0
|
|
assert UserRole.objects.count() > 0
|
|
|
|
|
|
# Filters should work identically in both silo and monolith modes, so no need to repeat the tests
|
|
# here.
|
|
@region_silo_test
|
|
class FilterTests(ImportTestCase):
|
|
"""
|
|
Ensures that filtering operations include the correct models.
|
|
"""
|
|
|
|
def test_import_filter_users(self):
|
|
self.create_exhaustive_user("user_1")
|
|
self.create_exhaustive_user("user_2")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_user_scope(tmp_file, user_filter={"user_2"}, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
# Count users, but also count a random model naively derived from just `User` alone,
|
|
# like `UserIP`. Because `Email` and `UserEmail` have some automagic going on that
|
|
# causes them to be created when a `User` is, we explicitly check to ensure that they
|
|
# are behaving correctly as well.
|
|
assert User.objects.count() == 1
|
|
assert UserIP.objects.count() == 1
|
|
assert UserEmail.objects.count() == 1
|
|
assert Email.objects.count() == 1
|
|
|
|
assert (
|
|
ControlImportChunk.objects.filter(
|
|
model="sentry.user", min_ordinal=1, max_ordinal=1
|
|
).count()
|
|
== 1
|
|
)
|
|
assert (
|
|
ControlImportChunk.objects.filter(
|
|
model="sentry.userip", min_ordinal=1, max_ordinal=1
|
|
).count()
|
|
== 1
|
|
)
|
|
assert (
|
|
ControlImportChunk.objects.filter(
|
|
model="sentry.useremail", min_ordinal=1, max_ordinal=1
|
|
).count()
|
|
== 1
|
|
)
|
|
assert (
|
|
ControlImportChunk.objects.filter(
|
|
model="sentry.email", min_ordinal=1, max_ordinal=1
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
assert not User.objects.filter(username="user_1").exists()
|
|
assert User.objects.filter(username="user_2").exists()
|
|
|
|
def test_export_filter_users_shared_email(self):
|
|
self.create_exhaustive_user("user_1", email="[email protected]")
|
|
self.create_exhaustive_user("user_2", email="[email protected]")
|
|
self.create_exhaustive_user("user_3", email="[email protected]")
|
|
self.create_exhaustive_user("user_4", email="[email protected]")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_user_scope(
|
|
tmp_file, user_filter={"user_1", "user_2", "user_3"}, printer=NOOP_PRINTER
|
|
)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 3
|
|
assert UserIP.objects.count() == 3
|
|
assert UserEmail.objects.count() == 3
|
|
assert Email.objects.count() == 2 # Lower due to shared emails
|
|
|
|
assert (
|
|
ControlImportChunk.objects.filter(
|
|
model="sentry.user", min_ordinal=1, max_ordinal=3
|
|
).count()
|
|
== 1
|
|
)
|
|
assert (
|
|
ControlImportChunk.objects.filter(
|
|
model="sentry.userip", min_ordinal=1, max_ordinal=3
|
|
).count()
|
|
== 1
|
|
)
|
|
assert (
|
|
ControlImportChunk.objects.filter(
|
|
model="sentry.useremail", min_ordinal=1, max_ordinal=3
|
|
).count()
|
|
== 1
|
|
)
|
|
assert (
|
|
ControlImportChunk.objects.filter(
|
|
model="sentry.email", min_ordinal=1, max_ordinal=2
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
assert User.objects.filter(username="user_1").exists()
|
|
assert User.objects.filter(username="user_2").exists()
|
|
assert User.objects.filter(username="user_3").exists()
|
|
assert not User.objects.filter(username="user_4").exists()
|
|
|
|
def test_import_filter_users_empty(self):
|
|
self.create_exhaustive_user("user_1")
|
|
self.create_exhaustive_user("user_2")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_user_scope(tmp_file, user_filter=set(), printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 0
|
|
assert UserIP.objects.count() == 0
|
|
assert UserEmail.objects.count() == 0
|
|
assert Email.objects.count() == 0
|
|
|
|
def test_import_filter_orgs_single(self):
|
|
a = self.create_exhaustive_user("user_a_only", email="[email protected]")
|
|
b = self.create_exhaustive_user("user_b_only", email="[email protected]")
|
|
c = self.create_exhaustive_user("user_c_only", email="[email protected]")
|
|
a_b = self.create_exhaustive_user("user_a_and_b")
|
|
b_c = self.create_exhaustive_user("user_b_and_c")
|
|
a_b_c = self.create_exhaustive_user("user_all", email="[email protected]")
|
|
self.create_exhaustive_organization("org-a", a, a_b, [a_b_c])
|
|
self.create_exhaustive_organization("org-b", b_c, a_b_c, [b, a_b])
|
|
self.create_exhaustive_organization("org-c", a_b_c, b_c, [c])
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(tmp_file, org_filter={"org-b"}, printer=NOOP_PRINTER)
|
|
|
|
assert Organization.objects.count() == 1
|
|
assert (
|
|
RegionImportChunk.objects.filter(
|
|
model="sentry.organization", min_ordinal=1, max_ordinal=1
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
assert not Organization.objects.filter(slug="org-a").exists()
|
|
assert Organization.objects.filter(slug="org-b").exists()
|
|
assert not Organization.objects.filter(slug="org-c").exists()
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert OrgAuthToken.objects.count() == 1
|
|
|
|
assert User.objects.count() == 4
|
|
assert UserIP.objects.count() == 4
|
|
assert UserEmail.objects.count() == 4
|
|
assert Email.objects.count() == 3 # Lower due to `[email protected]`
|
|
|
|
assert not User.objects.filter(username="user_a_only").exists()
|
|
assert User.objects.filter(username="user_b_only").exists()
|
|
assert not User.objects.filter(username="user_c_only").exists()
|
|
assert User.objects.filter(username="user_a_and_b").exists()
|
|
assert User.objects.filter(username="user_b_and_c").exists()
|
|
assert User.objects.filter(username="user_all").exists()
|
|
|
|
def test_import_filter_orgs_multiple(self):
|
|
a = self.create_exhaustive_user("user_a_only", email="[email protected]")
|
|
b = self.create_exhaustive_user("user_b_only", email="[email protected]")
|
|
c = self.create_exhaustive_user("user_c_only", email="[email protected]")
|
|
a_b = self.create_exhaustive_user("user_a_and_b")
|
|
b_c = self.create_exhaustive_user("user_b_and_c")
|
|
a_b_c = self.create_exhaustive_user("user_all", email="[email protected]")
|
|
self.create_exhaustive_organization("org-a", a, a_b, [a_b_c])
|
|
self.create_exhaustive_organization("org-b", b_c, a_b_c, [b, a_b])
|
|
self.create_exhaustive_organization("org-c", a_b_c, b_c, [c])
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(
|
|
tmp_file, org_filter={"org-a", "org-c"}, printer=NOOP_PRINTER
|
|
)
|
|
|
|
assert Organization.objects.count() == 2
|
|
assert (
|
|
RegionImportChunk.objects.filter(
|
|
model="sentry.organization", min_ordinal=1, max_ordinal=2
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
assert Organization.objects.filter(slug="org-a").exists()
|
|
assert not Organization.objects.filter(slug="org-b").exists()
|
|
assert Organization.objects.filter(slug="org-c").exists()
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert OrgAuthToken.objects.count() == 2
|
|
assert (
|
|
ControlImportChunk.objects.filter(
|
|
model="sentry.orgauthtoken", min_ordinal=1, max_ordinal=2
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
assert User.objects.count() == 5
|
|
assert UserIP.objects.count() == 5
|
|
assert UserEmail.objects.count() == 5
|
|
assert Email.objects.count() == 3 # Lower due to `[email protected]`
|
|
|
|
assert User.objects.filter(username="user_a_only").exists()
|
|
assert not User.objects.filter(username="user_b_only").exists()
|
|
assert User.objects.filter(username="user_c_only").exists()
|
|
assert User.objects.filter(username="user_a_and_b").exists()
|
|
assert User.objects.filter(username="user_b_and_c").exists()
|
|
assert User.objects.filter(username="user_all").exists()
|
|
|
|
def test_import_filter_orgs_empty(self):
|
|
a = self.create_exhaustive_user("user_a_only")
|
|
b = self.create_exhaustive_user("user_b_only")
|
|
c = self.create_exhaustive_user("user_c_only")
|
|
a_b = self.create_exhaustive_user("user_a_and_b")
|
|
b_c = self.create_exhaustive_user("user_b_and_c")
|
|
a_b_c = self.create_exhaustive_user("user_all")
|
|
self.create_exhaustive_organization("org-a", a, a_b, [a_b_c])
|
|
self.create_exhaustive_organization("org-b", b_c, a_b_c, [b, a_b])
|
|
self.create_exhaustive_organization("org-c", a_b_c, b_c, [c])
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(tmp_file, org_filter=set(), printer=NOOP_PRINTER)
|
|
|
|
assert Organization.objects.count() == 0
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert OrgAuthToken.objects.count() == 0
|
|
|
|
assert User.objects.count() == 0
|
|
assert UserIP.objects.count() == 0
|
|
assert UserEmail.objects.count() == 0
|
|
assert Email.objects.count() == 0
|
|
|
|
|
|
COLLISION_TESTED: set[NormalizedModelName] = set()
|
|
|
|
|
|
@region_silo_test
|
|
class CollisionTests(ImportTestCase):
|
|
"""
|
|
Ensure that collisions are properly handled in different flag modes.
|
|
"""
|
|
|
|
@expect_models(COLLISION_TESTED, ApiToken)
|
|
def test_colliding_api_token(self, expected_models: list[type[Model]]):
|
|
owner = self.create_exhaustive_user("owner")
|
|
expires_at = timezone.now() + DEFAULT_EXPIRATION
|
|
|
|
# Take note of the `ApiTokens` that were created by the exhaustive organization - this is
|
|
# the one we'll be importing.
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
colliding_no_refresh_set = ApiToken.objects.create(user=owner, token=generate_token())
|
|
colliding_no_refresh_set.refresh_token = None
|
|
colliding_no_refresh_set.expires_at = None
|
|
colliding_no_refresh_set.save()
|
|
|
|
colliding_same_refresh_only = ApiToken.objects.create(
|
|
user=owner,
|
|
token=generate_token(),
|
|
refresh_token=generate_token(),
|
|
expires_at=expires_at,
|
|
)
|
|
colliding_same_token_only = ApiToken.objects.create(
|
|
user=owner,
|
|
token=generate_token(),
|
|
refresh_token=generate_token(),
|
|
expires_at=expires_at,
|
|
)
|
|
colliding_same_both = ApiToken.objects.create(
|
|
user=owner,
|
|
token=generate_token(),
|
|
refresh_token=generate_token(),
|
|
expires_at=expires_at,
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
owner = self.create_exhaustive_user(username="owner")
|
|
|
|
# Re-insert colliding tokens, pointed at the new user.
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
colliding_no_refresh_set.user_id = owner.id
|
|
colliding_no_refresh_set.save()
|
|
|
|
colliding_same_refresh_only.token = generate_token()
|
|
colliding_same_refresh_only.user_id = owner.id
|
|
colliding_same_refresh_only.save()
|
|
|
|
colliding_same_token_only.refresh_token = generate_token()
|
|
colliding_same_token_only.user_id = owner.id
|
|
colliding_same_token_only.save()
|
|
|
|
colliding_same_both.user_id = owner.id
|
|
colliding_same_both.save()
|
|
assert ApiToken.objects.count() == 4
|
|
assert (
|
|
ApiToken.objects.filter(
|
|
token=colliding_no_refresh_set.token,
|
|
refresh_token__isnull=True,
|
|
expires_at__isnull=True,
|
|
).count()
|
|
== 1
|
|
)
|
|
assert (
|
|
ApiToken.objects.filter(
|
|
refresh_token=colliding_same_refresh_only.refresh_token
|
|
).count()
|
|
== 1
|
|
)
|
|
assert ApiToken.objects.filter(token=colliding_same_token_only.token).count() == 1
|
|
assert (
|
|
ApiToken.objects.filter(
|
|
token=colliding_same_both.token,
|
|
refresh_token=colliding_same_both.refresh_token,
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_config_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
# Ensure that old tokens have not been mutated.
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert ApiToken.objects.count() == 8
|
|
assert (
|
|
ApiToken.objects.filter(
|
|
token=colliding_no_refresh_set.token,
|
|
refresh_token__isnull=True,
|
|
expires_at__isnull=True,
|
|
).count()
|
|
== 1
|
|
)
|
|
assert (
|
|
ApiToken.objects.filter(
|
|
refresh_token=colliding_same_refresh_only.refresh_token
|
|
).count()
|
|
== 1
|
|
)
|
|
assert ApiToken.objects.filter(token=colliding_same_token_only.token).count() == 1
|
|
assert (
|
|
ApiToken.objects.filter(
|
|
token=colliding_same_both.token,
|
|
refresh_token=colliding_same_both.refresh_token,
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
# Ensure newly added entries with nulled `refresh_token` and/or `expires_at` have
|
|
# kept those fields nulled.
|
|
assert (
|
|
ApiToken.objects.filter(
|
|
refresh_token__isnull=True,
|
|
expires_at__isnull=True,
|
|
).count()
|
|
== 2
|
|
)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(COLLISION_TESTED, Monitor)
|
|
def test_colliding_monitor(self, expected_models: list[type[Model]]):
|
|
owner = self.create_exhaustive_user("owner")
|
|
invited = self.create_exhaustive_user("invited")
|
|
self.create_exhaustive_organization("some-org", owner, invited)
|
|
|
|
# Take note of a `Monitor` that was created by the exhaustive organization - this is the
|
|
# one we'll be importing.
|
|
colliding = Monitor.objects.filter().first()
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
|
|
# After exporting and clearing the database, insert a copy of the same `Monitor` as
|
|
# the one found in the import.
|
|
colliding.organization_id = self.create_organization().id
|
|
colliding.project_id = self.create_project().id
|
|
colliding.save()
|
|
|
|
assert Monitor.objects.count() == 1
|
|
assert Monitor.objects.filter(guid=colliding.guid).count() == 1
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
assert Monitor.objects.count() == 2
|
|
assert Monitor.objects.filter(guid=colliding.guid).count() == 1
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(COLLISION_TESTED, OrgAuthToken)
|
|
def test_colliding_org_auth_token(self, expected_models: list[type[Model]]):
|
|
owner = self.create_exhaustive_user("owner")
|
|
invited = self.create_exhaustive_user("invited")
|
|
member = self.create_exhaustive_user("member")
|
|
self.create_exhaustive_organization("some-org", owner, invited, [member])
|
|
|
|
# Take note of the `OrgAuthToken` that was created by the exhaustive organization - this is
|
|
# the one we'll be importing.
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
colliding = OrgAuthToken.objects.filter().first()
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
|
|
# After exporting and clearing the database, insert a copy of the same `OrgAuthToken` as
|
|
# the one found in the import.
|
|
org = self.create_organization()
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
colliding.organization_id = org.id
|
|
colliding.project_last_used_id = self.create_project(organization=org).id
|
|
colliding.save()
|
|
|
|
assert OrgAuthToken.objects.count() == 1
|
|
assert OrgAuthToken.objects.filter(token_hashed=colliding.token_hashed).count() == 1
|
|
assert (
|
|
OrgAuthToken.objects.filter(
|
|
token_last_characters=colliding.token_last_characters
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert OrgAuthToken.objects.count() == 2
|
|
assert OrgAuthToken.objects.filter(token_hashed=colliding.token_hashed).count() == 1
|
|
assert (
|
|
OrgAuthToken.objects.filter(
|
|
token_last_characters=colliding.token_last_characters
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(COLLISION_TESTED, ProjectKey)
|
|
def test_colliding_project_key(self, expected_models: list[type[Model]]):
|
|
owner = self.create_exhaustive_user("owner")
|
|
invited = self.create_exhaustive_user("invited")
|
|
member = self.create_exhaustive_user("member")
|
|
self.create_exhaustive_organization("some-org", owner, invited, [member])
|
|
|
|
# Take note of a `ProjectKey` that was created by the exhaustive organization - this is the
|
|
# one we'll be importing.
|
|
colliding = ProjectKey.objects.filter().first()
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
|
|
# After exporting and clearing the database, insert a copy of the same `ProjectKey` as
|
|
# the one found in the import.
|
|
colliding.project = self.create_project()
|
|
colliding.save()
|
|
|
|
assert ProjectKey.objects.count() < 4
|
|
assert ProjectKey.objects.filter(public_key=colliding.public_key).count() == 1
|
|
assert ProjectKey.objects.filter(secret_key=colliding.secret_key).count() == 1
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
assert ProjectKey.objects.count() == 4
|
|
assert ProjectKey.objects.filter(public_key=colliding.public_key).count() == 1
|
|
assert ProjectKey.objects.filter(secret_key=colliding.secret_key).count() == 1
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@pytest.mark.xfail(
|
|
not use_split_dbs(),
|
|
reason="Preexisting failure: getsentry/team-ospo#206",
|
|
raises=urllib3.exceptions.MaxRetryError,
|
|
strict=True,
|
|
)
|
|
@expect_models(COLLISION_TESTED, QuerySubscription)
|
|
def test_colliding_query_subscription(self, expected_models: list[type[Model]]):
|
|
# We need a celery task running to properly test the `subscription_id` assignment, otherwise
|
|
# its value just defaults to `None`.
|
|
with self.tasks():
|
|
owner = self.create_exhaustive_user("owner")
|
|
invited = self.create_exhaustive_user("invited")
|
|
member = self.create_exhaustive_user("member")
|
|
self.create_exhaustive_organization("some-org", owner, invited, [member])
|
|
|
|
# Take note of the `QuerySubscription` that was created by the exhaustive organization -
|
|
# this is the one we'll be importing.
|
|
colliding_snuba_query = SnubaQuery.objects.all().first()
|
|
colliding_query_subscription = QuerySubscription.objects.filter(
|
|
snuba_query=colliding_snuba_query
|
|
).first()
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
|
|
# After exporting and clearing the database, insert a copy of the same
|
|
# `QuerySubscription.subscription_id` as the one found in the import.
|
|
colliding_snuba_query.save()
|
|
colliding_query_subscription.project = self.create_project()
|
|
colliding_query_subscription.snuba_query = colliding_snuba_query
|
|
colliding_query_subscription.save()
|
|
|
|
assert SnubaQuery.objects.count() == 1
|
|
assert QuerySubscription.objects.count() == 1
|
|
assert (
|
|
QuerySubscription.objects.filter(
|
|
subscription_id=colliding_query_subscription.subscription_id
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
assert SnubaQuery.objects.count() > 1
|
|
assert QuerySubscription.objects.count() > 1
|
|
assert (
|
|
QuerySubscription.objects.filter(
|
|
subscription_id=colliding_query_subscription.subscription_id
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(COLLISION_TESTED, SavedSearch)
|
|
def test_colliding_saved_search(self, expected_models: list[type[Model]]):
|
|
self.create_organization("some-org", owner=self.user)
|
|
SavedSearch.objects.create(
|
|
name="Global Search",
|
|
query="saved query",
|
|
is_global=True,
|
|
visibility=Visibility.ORGANIZATION,
|
|
)
|
|
assert SavedSearch.objects.count() == 1
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
assert SavedSearch.objects.count() == 0
|
|
|
|
# Allow `is_global` searches for `ImportScope.Global` imports.
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_global_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
assert SavedSearch.objects.count() == 1
|
|
|
|
# Disallow `is_global` searches for `ImportScope.Organization` imports.
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(tmp_file, printer=NOOP_PRINTER)
|
|
|
|
assert SavedSearch.objects.count() == 1
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(COLLISION_TESTED, ControlOption, Option, Relay, RelayUsage, UserRole)
|
|
def test_colliding_configs_overwrite_configs_enabled_in_config_scope(
|
|
self, expected_models: list[type[Model]]
|
|
):
|
|
owner = self.create_exhaustive_user("owner", is_admin=True)
|
|
self.create_exhaustive_global_configs(owner)
|
|
|
|
# Take note of the configs we want to track - this is the one we'll be importing.
|
|
colliding_option = Option.objects.all().first()
|
|
colliding_relay = Relay.objects.all().first()
|
|
colliding_relay_usage = RelayUsage.objects.all().first()
|
|
|
|
old_relay_public_key = colliding_relay.public_key
|
|
old_relay_usage_public_key = colliding_relay_usage.public_key
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
colliding_control_option = ControlOption.objects.all().first()
|
|
colliding_user_role = UserRole.objects.all().first()
|
|
old_user_role_permissions = colliding_user_role.permissions
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
|
|
colliding_option.value = "y"
|
|
colliding_option.save()
|
|
|
|
colliding_relay.public_key = "invalid"
|
|
colliding_relay.save()
|
|
|
|
colliding_relay_usage.public_key = "invalid"
|
|
colliding_relay_usage.save()
|
|
|
|
assert Option.objects.count() == 1
|
|
assert Relay.objects.count() == 1
|
|
assert RelayUsage.objects.count() == 1
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
colliding_control_option.value = "z"
|
|
colliding_control_option.save()
|
|
|
|
colliding_user_role.permissions = ["other.admin"]
|
|
colliding_user_role.save()
|
|
|
|
assert ControlOption.objects.count() == 1
|
|
assert UserRole.objects.count() == 1
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_config_scope(
|
|
tmp_file, flags=ImportFlags(overwrite_configs=True), printer=NOOP_PRINTER
|
|
)
|
|
|
|
option_chunk = RegionImportChunk.objects.get(
|
|
model="sentry.option", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(option_chunk.inserted_map) == 0
|
|
assert len(option_chunk.existing_map) == 0
|
|
assert len(option_chunk.overwrite_map) == 1
|
|
assert Option.objects.count() == 1
|
|
assert Option.objects.filter(value__exact="a").exists()
|
|
|
|
relay_chunk = RegionImportChunk.objects.get(
|
|
model="sentry.relay", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(relay_chunk.inserted_map) == 0
|
|
assert len(relay_chunk.existing_map) == 0
|
|
assert len(relay_chunk.overwrite_map) == 1
|
|
assert Relay.objects.count() == 1
|
|
assert Relay.objects.filter(public_key__exact=old_relay_public_key).exists()
|
|
|
|
relay_usage_chunk = RegionImportChunk.objects.get(
|
|
model="sentry.relayusage", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(relay_usage_chunk.inserted_map) == 0
|
|
assert len(relay_usage_chunk.existing_map) == 0
|
|
assert len(relay_usage_chunk.overwrite_map) == 1
|
|
assert RelayUsage.objects.count() == 1
|
|
assert RelayUsage.objects.filter(public_key__exact=old_relay_usage_public_key).exists()
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
control_option_chunk = ControlImportChunk.objects.get(
|
|
model="sentry.controloption", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(control_option_chunk.inserted_map) == 0
|
|
assert len(control_option_chunk.existing_map) == 0
|
|
assert len(control_option_chunk.overwrite_map) == 1
|
|
assert ControlOption.objects.count() == 1
|
|
assert ControlOption.objects.filter(value__exact="b").exists()
|
|
|
|
actual_user_role = UserRole.objects.first()
|
|
assert len(actual_user_role.permissions) == len(old_user_role_permissions)
|
|
for i, actual_permission in enumerate(actual_user_role.permissions):
|
|
assert actual_permission == old_user_role_permissions[i]
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(COLLISION_TESTED, ControlOption, Option, Relay, RelayUsage, UserRole)
|
|
def test_colliding_configs_overwrite_configs_disabled_in_config_scope(
|
|
self, expected_models: list[type[Model]]
|
|
):
|
|
owner = self.create_exhaustive_user("owner", is_admin=True)
|
|
self.create_exhaustive_global_configs(owner)
|
|
|
|
# Take note of the configs we want to track - this is the one we'll be importing.
|
|
colliding_option = Option.objects.all().first()
|
|
colliding_relay = Relay.objects.all().first()
|
|
colliding_relay_usage = RelayUsage.objects.all().first()
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
colliding_control_option = ControlOption.objects.all().first()
|
|
colliding_user_role = UserRole.objects.all().first()
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
|
|
colliding_option.value = "y"
|
|
colliding_option.save()
|
|
|
|
colliding_relay.public_key = "invalid"
|
|
colliding_relay.save()
|
|
|
|
colliding_relay_usage.public_key = "invalid"
|
|
colliding_relay_usage.save()
|
|
|
|
assert Option.objects.count() == 1
|
|
assert Relay.objects.count() == 1
|
|
assert RelayUsage.objects.count() == 1
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
colliding_control_option.value = "z"
|
|
colliding_control_option.save()
|
|
|
|
colliding_user_role.permissions = ["other.admin"]
|
|
colliding_user_role.save()
|
|
|
|
assert ControlOption.objects.count() == 1
|
|
assert UserRole.objects.count() == 1
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_config_scope(
|
|
tmp_file, flags=ImportFlags(overwrite_configs=False), printer=NOOP_PRINTER
|
|
)
|
|
|
|
option_chunk = RegionImportChunk.objects.get(
|
|
model="sentry.option", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(option_chunk.inserted_map) == 0
|
|
assert len(option_chunk.existing_map) == 1
|
|
assert len(option_chunk.overwrite_map) == 0
|
|
assert Option.objects.count() == 1
|
|
assert Option.objects.filter(value__exact="y").exists()
|
|
|
|
relay_chunk = RegionImportChunk.objects.get(
|
|
model="sentry.relay", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(relay_chunk.inserted_map) == 0
|
|
assert len(relay_chunk.existing_map) == 1
|
|
assert len(relay_chunk.overwrite_map) == 0
|
|
assert Relay.objects.count() == 1
|
|
assert Relay.objects.filter(public_key__exact="invalid").exists()
|
|
|
|
relay_usage_chunk = RegionImportChunk.objects.get(
|
|
model="sentry.relayusage", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(relay_usage_chunk.inserted_map) == 0
|
|
assert len(relay_usage_chunk.existing_map) == 1
|
|
assert len(relay_usage_chunk.overwrite_map) == 0
|
|
assert RelayUsage.objects.count() == 1
|
|
assert RelayUsage.objects.filter(public_key__exact="invalid").exists()
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
control_option_chunk = ControlImportChunk.objects.get(
|
|
model="sentry.controloption", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(control_option_chunk.inserted_map) == 0
|
|
assert len(control_option_chunk.existing_map) == 1
|
|
assert len(control_option_chunk.overwrite_map) == 0
|
|
assert ControlOption.objects.count() == 1
|
|
assert ControlOption.objects.filter(value__exact="z").exists()
|
|
|
|
assert UserRole.objects.count() == 1
|
|
actual_user_role = UserRole.objects.first()
|
|
assert len(actual_user_role.permissions) == 1
|
|
assert actual_user_role.permissions[0] == "other.admin"
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(COLLISION_TESTED, Email, User, UserEmail, UserIP)
|
|
def test_colliding_user_with_merging_enabled_in_user_scope(
|
|
self, expected_models: list[type[Model]]
|
|
):
|
|
self.create_exhaustive_user(username="owner", email="[email protected]")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
self.create_exhaustive_user(username="owner", email="[email protected]")
|
|
import_in_user_scope(
|
|
tmp_file,
|
|
flags=ImportFlags(merge_users=True),
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 1
|
|
assert UserEmail.objects.count() == 1 # Keep only original when merging.
|
|
assert UserIP.objects.count() == 1 # Keep only original when merging.
|
|
assert Authenticator.objects.count() == 1
|
|
assert Email.objects.count() == 2
|
|
|
|
user_chunk = ControlImportChunk.objects.get(
|
|
model="sentry.user", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(user_chunk.inserted_map) == 0
|
|
assert len(user_chunk.existing_map) == 1
|
|
assert User.objects.filter(username__iexact="owner").exists()
|
|
assert not User.objects.filter(username__iexact="owner-").exists()
|
|
|
|
assert User.objects.filter(is_unclaimed=True).count() == 0
|
|
assert LostPasswordHash.objects.count() == 0
|
|
assert User.objects.filter(is_unclaimed=False).count() == 1
|
|
assert UserEmail.objects.filter(email__icontains="existing@").exists()
|
|
assert not UserEmail.objects.filter(email__icontains="importing@").exists()
|
|
|
|
# Incoming `UserEmail`s, `UserPermissions`, and `UserIP`s for imported users are
|
|
# completely scrubbed when merging is enabled.
|
|
assert not ControlImportChunk.objects.filter(model="sentry.useremail").exists()
|
|
assert not ControlImportChunk.objects.filter(model="sentry.userip").exists()
|
|
assert not ControlImportChunk.objects.filter(model="sentry.userpermission").exists()
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(COLLISION_TESTED, Email, User, UserEmail, UserIP)
|
|
def test_colliding_user_with_merging_disabled_in_user_scope(
|
|
self, expected_models: list[type[Model]]
|
|
):
|
|
self.create_exhaustive_user(username="owner", email="[email protected]")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
self.create_exhaustive_user(username="owner", email="[email protected]")
|
|
import_in_user_scope(
|
|
tmp_file,
|
|
flags=ImportFlags(merge_users=False),
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 2
|
|
assert UserIP.objects.count() == 2
|
|
assert UserEmail.objects.count() == 2
|
|
assert Authenticator.objects.count() == 1 # Only imported in global scope
|
|
assert Email.objects.count() == 2
|
|
|
|
user_chunk = ControlImportChunk.objects.get(
|
|
model="sentry.user", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(user_chunk.inserted_map) == 1
|
|
assert len(user_chunk.existing_map) == 0
|
|
assert User.objects.filter(username__iexact="owner").exists()
|
|
assert User.objects.filter(username__icontains="owner-").exists()
|
|
|
|
assert User.objects.filter(is_unclaimed=True).count() == 1
|
|
assert LostPasswordHash.objects.count() == 1
|
|
assert User.objects.filter(is_unclaimed=False).count() == 1
|
|
|
|
useremail_chunk = ControlImportChunk.objects.get(
|
|
model="sentry.useremail", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(useremail_chunk.inserted_map) == 1
|
|
assert len(useremail_chunk.existing_map) == 0
|
|
assert UserEmail.objects.filter(email__icontains="existing@").exists()
|
|
assert UserEmail.objects.filter(email__icontains="importing@").exists()
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(
|
|
COLLISION_TESTED, Email, Organization, OrganizationMember, User, UserEmail, UserIP
|
|
)
|
|
def test_colliding_user_with_merging_enabled_in_organization_scope(
|
|
self, expected_models: list[type[Model]]
|
|
):
|
|
owner = self.create_exhaustive_user(username="owner", email="[email protected]")
|
|
self.create_organization("some-org", owner=owner)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
owner = self.create_exhaustive_user(username="owner", email="[email protected]")
|
|
self.create_organization("some-org", owner=owner)
|
|
import_in_organization_scope(
|
|
tmp_file,
|
|
flags=ImportFlags(merge_users=True),
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
user = User.objects.get(username="owner")
|
|
|
|
assert User.objects.count() == 1
|
|
assert UserEmail.objects.count() == 1 # Keep only original when merging.
|
|
assert UserIP.objects.count() == 1 # Keep only original when merging.
|
|
assert Authenticator.objects.count() == 1 # Only imported in global scope
|
|
assert Email.objects.count() == 2
|
|
|
|
user_chunk = ControlImportChunk.objects.get(
|
|
model="sentry.user", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(user_chunk.inserted_map) == 0
|
|
assert len(user_chunk.existing_map) == 1
|
|
assert User.objects.filter(username__iexact="owner").exists()
|
|
assert not User.objects.filter(username__icontains="owner-").exists()
|
|
|
|
assert User.objects.filter(is_unclaimed=True).count() == 0
|
|
assert LostPasswordHash.objects.count() == 0
|
|
assert User.objects.filter(is_unclaimed=False).count() == 1
|
|
|
|
assert UserEmail.objects.filter(email__icontains="existing@").exists()
|
|
assert not UserEmail.objects.filter(email__icontains="importing@").exists()
|
|
|
|
# Incoming `UserEmail`s, `UserPermissions`, and `UserIP`s for imported users are
|
|
# completely dropped when merging is enabled.
|
|
assert not ControlImportChunk.objects.filter(model="sentry.useremail").exists()
|
|
assert not ControlImportChunk.objects.filter(model="sentry.userip").exists()
|
|
assert not ControlImportChunk.objects.filter(model="sentry.userpermission").exists()
|
|
|
|
assert Organization.objects.count() == 2
|
|
assert OrganizationMember.objects.count() == 2 # Same user in both orgs
|
|
|
|
existing = Organization.objects.get(slug="some-org")
|
|
imported = Organization.objects.filter(slug__icontains="some-org-").first()
|
|
assert (
|
|
OrganizationMember.objects.filter(user_id=user.id, organization=existing).count()
|
|
== 1
|
|
)
|
|
assert (
|
|
OrganizationMember.objects.filter(user_id=user.id, organization=imported).count()
|
|
== 1
|
|
)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert OrganizationMapping.objects.count() == 2
|
|
assert OrganizationMemberMapping.objects.count() == 2 # Same user in both orgs
|
|
assert (
|
|
OrganizationMemberMapping.objects.filter(
|
|
user=user, organization_id=existing.id
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(
|
|
COLLISION_TESTED, Email, Organization, OrganizationMember, User, UserEmail, UserIP
|
|
)
|
|
def test_colliding_user_with_merging_disabled_in_organization_scope(
|
|
self, expected_models: list[type[Model]]
|
|
):
|
|
owner = self.create_exhaustive_user(username="owner", email="[email protected]")
|
|
self.create_organization("some-org", owner=owner)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
owner = self.create_exhaustive_user(username="owner", email="[email protected]")
|
|
self.create_organization("some-org", owner=owner)
|
|
import_in_organization_scope(
|
|
tmp_file,
|
|
flags=ImportFlags(merge_users=False),
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
existing_user = User.objects.get(username="owner")
|
|
imported_user = User.objects.get(username__icontains="owner-")
|
|
|
|
assert User.objects.count() == 2
|
|
assert UserIP.objects.count() == 2
|
|
assert UserEmail.objects.count() == 2
|
|
assert Authenticator.objects.count() == 1 # Only imported in global scope
|
|
assert Email.objects.count() == 2
|
|
|
|
user_chunk = ControlImportChunk.objects.get(
|
|
model="sentry.user", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(user_chunk.inserted_map) == 1
|
|
assert len(user_chunk.existing_map) == 0
|
|
assert User.objects.filter(username__iexact="owner").exists()
|
|
assert User.objects.filter(username__icontains="owner-").exists()
|
|
|
|
assert User.objects.filter(is_unclaimed=True).count() == 1
|
|
assert LostPasswordHash.objects.count() == 1
|
|
assert User.objects.filter(is_unclaimed=False).count() == 1
|
|
|
|
useremail_chunk = ControlImportChunk.objects.get(
|
|
model="sentry.useremail", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(useremail_chunk.inserted_map) == 1
|
|
assert len(useremail_chunk.existing_map) == 0
|
|
assert UserEmail.objects.filter(email__icontains="existing@").exists()
|
|
assert UserEmail.objects.filter(email__icontains="importing@").exists()
|
|
|
|
assert Organization.objects.count() == 2
|
|
assert OrganizationMember.objects.count() == 2
|
|
|
|
existing_org = Organization.objects.get(slug="some-org")
|
|
imported_org = Organization.objects.filter(slug__icontains="some-org-").first()
|
|
assert (
|
|
OrganizationMember.objects.filter(
|
|
user_id=existing_user.id, organization=existing_org
|
|
).count()
|
|
== 1
|
|
)
|
|
assert (
|
|
OrganizationMember.objects.filter(
|
|
user_id=imported_user.id, organization=imported_org
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert OrganizationMapping.objects.count() == 2
|
|
assert OrganizationMemberMapping.objects.count() == 2
|
|
assert (
|
|
OrganizationMemberMapping.objects.filter(
|
|
user=existing_user, organization_id=existing_org.id
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(COLLISION_TESTED, Email, User, UserEmail, UserIP, UserPermission)
|
|
def test_colliding_user_with_merging_enabled_in_config_scope(
|
|
self, expected_models: list[type[Model]]
|
|
):
|
|
self.create_exhaustive_user(username="owner", email="[email protected]", is_admin=True)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
self.create_exhaustive_user(
|
|
username="owner", email="[email protected]", is_admin=True
|
|
)
|
|
import_in_config_scope(
|
|
tmp_file,
|
|
flags=ImportFlags(merge_users=True),
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 1
|
|
assert UserEmail.objects.count() == 1 # Keep only original when merging.
|
|
assert UserIP.objects.count() == 1 # Keep only original when merging.
|
|
assert UserPermission.objects.count() == 1 # Keep only original when merging.
|
|
assert Authenticator.objects.count() == 1
|
|
assert Email.objects.count() == 2
|
|
|
|
user_chunk = ControlImportChunk.objects.get(
|
|
model="sentry.user", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(user_chunk.inserted_map) == 0
|
|
assert len(user_chunk.existing_map) == 1
|
|
assert User.objects.filter(username__iexact="owner").exists()
|
|
assert not User.objects.filter(username__iexact="owner-").exists()
|
|
|
|
assert User.objects.filter(is_unclaimed=True).count() == 0
|
|
assert LostPasswordHash.objects.count() == 0
|
|
assert User.objects.filter(is_unclaimed=False).count() == 1
|
|
|
|
assert UserEmail.objects.filter(email__icontains="existing@").exists()
|
|
assert not UserEmail.objects.filter(email__icontains="importing@").exists()
|
|
|
|
# Incoming `UserEmail`s, `UserPermissions`, and `UserIP`s for imported users are
|
|
# completely dropped when merging is enabled.
|
|
assert not ControlImportChunk.objects.filter(model="sentry.useremail").exists()
|
|
assert not ControlImportChunk.objects.filter(model="sentry.userip").exists()
|
|
assert not ControlImportChunk.objects.filter(model="sentry.userpermission").exists()
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(COLLISION_TESTED, Email, User, UserEmail, UserIP, UserPermission)
|
|
def test_colliding_user_with_merging_disabled_in_config_scope(
|
|
self, expected_models: list[type[Model]]
|
|
):
|
|
self.create_exhaustive_user(username="owner", email="[email protected]", is_admin=True)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
self.create_exhaustive_user(
|
|
username="owner", email="[email protected]", is_admin=True
|
|
)
|
|
import_in_config_scope(
|
|
tmp_file,
|
|
flags=ImportFlags(merge_users=False),
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
with assume_test_silo_mode(SiloMode.CONTROL):
|
|
assert User.objects.count() == 2
|
|
assert UserIP.objects.count() == 2
|
|
assert UserEmail.objects.count() == 2
|
|
assert UserPermission.objects.count() == 2
|
|
assert Authenticator.objects.count() == 1 # Only imported in global scope
|
|
assert Email.objects.count() == 2
|
|
|
|
user_chunk = ControlImportChunk.objects.get(
|
|
model="sentry.user", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(user_chunk.inserted_map) == 1
|
|
assert len(user_chunk.existing_map) == 0
|
|
assert User.objects.filter(username__iexact="owner").exists()
|
|
assert User.objects.filter(username__icontains="owner-").exists()
|
|
|
|
assert User.objects.filter(is_unclaimed=True).count() == 1
|
|
assert LostPasswordHash.objects.count() == 1
|
|
assert User.objects.filter(is_unclaimed=False).count() == 1
|
|
|
|
useremail_chunk = ControlImportChunk.objects.get(
|
|
model="sentry.useremail", min_ordinal=1, max_ordinal=1
|
|
)
|
|
assert len(useremail_chunk.inserted_map) == 1
|
|
assert len(useremail_chunk.existing_map) == 0
|
|
assert UserEmail.objects.filter(email__icontains="existing@").exists()
|
|
assert UserEmail.objects.filter(email__icontains="importing@").exists()
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
|
|
CUSTOM_IMPORT_BEHAVIOR_TESTED: set[NormalizedModelName] = set()
|
|
|
|
|
|
# There is no need to in both monolith and region mode for model-level unit tests - region mode
|
|
# testing along should suffice.
|
|
@region_silo_test
|
|
class CustomImportBehaviorTests(ImportTestCase):
|
|
"""
|
|
Test bespoke, per-model behavior. Since these tests are relatively expensive to set up and tear
|
|
down (think on the order of 5-10 seconds per test case), we encourage combining model test cases
|
|
as much as reasonably possible.
|
|
"""
|
|
|
|
# TODO(hybrid-cloud): actor refactor. Remove this test case when done.
|
|
@expect_models(CUSTOM_IMPORT_BEHAVIOR_TESTED, Actor, AlertRule)
|
|
def test_alert_rule_with_owner_id(self, expected_models: list[type[Model]]):
|
|
user = self.create_user()
|
|
org = self.create_organization(name="test-org", owner=user)
|
|
team = self.create_team(name="test-team", organization=org)
|
|
|
|
def create_fake_snuba_query() -> SnubaQuery:
|
|
return create_snuba_query(
|
|
query_type=SnubaQuery.Type.ERROR,
|
|
dataset=Dataset.Events,
|
|
query="level:error",
|
|
aggregate="count()",
|
|
time_window=timedelta(minutes=10),
|
|
resolution=timedelta(minutes=1),
|
|
environment=None,
|
|
event_types=[SnubaQueryEventType.EventType.ERROR],
|
|
)
|
|
|
|
# Create four `AlertRule`. Both of them fell through the `owner_id` migration, and therefore
|
|
# DO have an `owner_id`, but have NEITHER a `team_id` nor `user_id`.
|
|
#
|
|
# For the first two `AlertRule` rules, we'll include an `Actor` model with the correct data,
|
|
# meaning we just have to do a DB lookup, but the model import should go ahead as if the
|
|
# migration had been successful. For the third `AlertRule`, the `Actor` will also have both
|
|
# `team` and `user` set to null. Finally, the last instance will have no `Actor` at all.
|
|
#
|
|
# The expected result is that the first two instances succeed, while the last two are
|
|
# ignored.]
|
|
common_alert_rule_args = {
|
|
"organization": org,
|
|
"threshold_type": AlertRuleThresholdType.ABOVE.value,
|
|
"resolve_threshold": 10,
|
|
"threshold_period": 1,
|
|
"include_all_projects": False,
|
|
"comparison_delta": None,
|
|
}
|
|
|
|
# Use `bulk_create` to avoid the `.save()` checks that catch some otherwise invalid data -
|
|
# the whole point of this test is to ensure we gracefully recover when importing such data!
|
|
AlertRule.objects.bulk_create(
|
|
[
|
|
AlertRule(
|
|
name="user-alert-rule",
|
|
owner=Actor.objects.create(user_id=user.id, type=ACTOR_TYPES["user"]),
|
|
snuba_query=create_fake_snuba_query(),
|
|
**common_alert_rule_args,
|
|
),
|
|
AlertRule(
|
|
name="team-alert-rule",
|
|
owner=Actor.objects.get(team=team, type=ACTOR_TYPES["team"]),
|
|
snuba_query=create_fake_snuba_query(),
|
|
**common_alert_rule_args,
|
|
),
|
|
AlertRule(
|
|
name="null-alert-rule",
|
|
owner=Actor.objects.create(team=None, user_id=None, type=ACTOR_TYPES["team"]),
|
|
snuba_query=create_fake_snuba_query(),
|
|
**common_alert_rule_args,
|
|
),
|
|
AlertRule(
|
|
name="unowned-alert-rule",
|
|
owner=None,
|
|
snuba_query=create_fake_snuba_query(),
|
|
**common_alert_rule_args,
|
|
),
|
|
]
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(
|
|
tmp_file,
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
user_alert_rule: AlertRule = AlertRule.objects.get(name="user-alert-rule")
|
|
user_actor: Actor = Actor.objects.get(id=user_alert_rule.owner_id)
|
|
assert user_alert_rule.owner is not None
|
|
assert user_alert_rule.user_id == user_actor.user_id
|
|
assert user_alert_rule.team is None
|
|
assert user_actor.team is None
|
|
|
|
team_alert_rule: AlertRule = AlertRule.objects.get(name="team-alert-rule")
|
|
team_actor: Actor = Actor.objects.get(id=team_alert_rule.owner_id)
|
|
assert team_alert_rule.owner is not None
|
|
assert team_alert_rule.team == team_actor.team
|
|
assert team_alert_rule.user_id is None
|
|
assert team_actor.user_id is None
|
|
|
|
null_alert_rule: AlertRule = AlertRule.objects.get(name="null-alert-rule")
|
|
unowned_alert_rule: AlertRule = AlertRule.objects.get(name="unowned-alert-rule")
|
|
assert null_alert_rule.owner is None
|
|
assert unowned_alert_rule.owner is None
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
@expect_models(CUSTOM_IMPORT_BEHAVIOR_TESTED, OrganizationMember)
|
|
def test_organization_member_inviter_id(self, expected_models: list[type[Model]]):
|
|
admin = self.create_exhaustive_user("admin", email="[email protected]", is_superuser=True)
|
|
owner = self.create_exhaustive_user("owner", email="[email protected]")
|
|
member = self.create_exhaustive_user("member", email="[email protected]")
|
|
org = self.create_exhaustive_organization(
|
|
slug="test-org",
|
|
owner=owner,
|
|
member=member,
|
|
invites={
|
|
admin: "[email protected]",
|
|
owner: "[email protected]",
|
|
},
|
|
)
|
|
|
|
# Give each member an inviter that is not in the organization itself (the "admin"), meaning
|
|
# they will not be imported if we only filter down to `test-org`. The desired outcome is
|
|
# that the inviter is nulled out.
|
|
for org_member in OrganizationMember.objects.filter(organization=org):
|
|
if not org_member.inviter_id:
|
|
org_member.inviter_id = admin.id
|
|
org_member.save()
|
|
assert (
|
|
OrganizationMember.objects.filter(organization=org.id, inviter_id__isnull=False).count()
|
|
== 4
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = self.export_to_tmp_file_and_clear_database(tmp_dir)
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
import_in_organization_scope(
|
|
tmp_file,
|
|
org_filter={"test-org"},
|
|
printer=NOOP_PRINTER,
|
|
)
|
|
|
|
# `owner` and `member` should both have had their `inviter_id` scrubbed.
|
|
org_id = Organization.objects.get(slug="test-org").id
|
|
assert OrganizationMember.objects.filter(
|
|
organization=org_id,
|
|
user_email="[email protected]",
|
|
email__isnull=True,
|
|
inviter_id__isnull=True,
|
|
).exists()
|
|
assert OrganizationMember.objects.filter(
|
|
organization=org_id,
|
|
user_email="[email protected]",
|
|
email__isnull=True,
|
|
inviter_id__isnull=True,
|
|
).exists()
|
|
|
|
# The invitee invited by the not-imported `admin` should lose their `inviter_id`, but
|
|
# the one invited by `owner` should keep it.
|
|
assert OrganizationMember.objects.filter(
|
|
organization=org_id,
|
|
email="[email protected]",
|
|
inviter_id__isnull=True,
|
|
).exists()
|
|
assert OrganizationMember.objects.filter(
|
|
organization=org_id,
|
|
email="[email protected]",
|
|
inviter_id__isnull=False,
|
|
).exists()
|
|
|
|
with open(tmp_path, "rb") as tmp_file:
|
|
verify_models_in_output(expected_models, json.load(tmp_file))
|
|
|
|
|
|
@pytest.mark.skipif(reason="not legacy")
|
|
class TestLegacyTestSuite:
|
|
def test_deleteme(self):
|
|
"""
|
|
The monolith-dbs test suite should only exist until relocation code
|
|
handles monolith- and hybrid-database modes with the same code path,
|
|
which is planned work.
|
|
"""
|
|
assert date.today() <= date(
|
|
2023, 11, 11
|
|
), "Please delete the monolith-dbs test suite!" # or else bump the date
|