206 lines
11 KiB
Python
206 lines
11 KiB
Python
"""Legacy deployment diagnostics must expose conflicts without changing data."""
|
|
import json
|
|
from importlib import import_module
|
|
from io import StringIO
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
from django.apps import apps
|
|
from django.contrib import admin
|
|
from django.core.management import call_command
|
|
from django.core.management.base import CommandError
|
|
from django.db import connection, transaction
|
|
from django.db.migrations.executor import MigrationExecutor
|
|
from django.test import TransactionTestCase
|
|
from django.test.utils import CaptureQueriesContext
|
|
|
|
from apps.cuser.cuser_model import CUser
|
|
from apps.customer.models import Customer, CustomerNote, MobileConflict
|
|
from apps.jwt_auth.test_regressions import RegressionFixtures
|
|
from apps.workspace.models import Workspace
|
|
from apps.shop.models import Order
|
|
|
|
|
|
def audit(*args):
|
|
output = StringIO()
|
|
call_command('audit_mobile_duplicates', *args, stdout=output)
|
|
return json.loads(output.getvalue())
|
|
|
|
|
|
def repair():
|
|
migration = import_module('apps.customer.migrations.0008_customer_mobile_unique')
|
|
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
|
|
|
|
|
|
class MobileAuditTests(RegressionFixtures):
|
|
def test_country_prefix_conflict_reports_all_members_and_dependencies_read_only(self):
|
|
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
|
|
note = CustomerNote.objects.create(customer=other, cuser_role=self.role,
|
|
desc='Keep this note', sys_ws=self.workspace)
|
|
before = list(Customer.objects.order_by('pk').values())
|
|
with CaptureQueriesContext(connection) as queries:
|
|
report = audit()
|
|
self.assertEqual(report['customer_duplicate_groups'], [{
|
|
'workspace_id': self.workspace.pk, 'canonical_mobile': self.customer.mobile,
|
|
'record_ids': [self.customer.pk, other.pk],
|
|
}])
|
|
self.assertEqual(report['duplicate_group_count'], 1)
|
|
self.assertEqual(report['customers'][1]['related_counts']['customer.CustomerNote.customer'], 1)
|
|
self.assertEqual(report['customers'][0]['panel_role']['user_id'], self.user.pk)
|
|
self.assertEqual(before, list(Customer.objects.order_by('pk').values()))
|
|
self.assertTrue(CustomerNote.objects.filter(pk=note.pk).exists())
|
|
writes = [query['sql'] for query in queries if query['sql'].lstrip().split()[0].upper()
|
|
in {'INSERT', 'UPDATE', 'DELETE', 'ALTER', 'DROP', 'CREATE'}]
|
|
self.assertEqual(writes, [])
|
|
|
|
def test_cross_workspace_numbers_and_multiple_nulls_are_allowed(self):
|
|
Customer.objects.create(name_family='Other tenant', mobile=989123456789, sys_ws=self.other_workspace)
|
|
Customer.objects.create(name_family='No number 1', mobile=None, sys_ws=self.workspace)
|
|
Customer.objects.create(name_family='No number 2', mobile=None, sys_ws=self.workspace)
|
|
self.assertEqual(audit()['duplicate_group_count'], 0)
|
|
|
|
def test_user_country_prefix_conflict_is_global_and_excludes_secrets(self):
|
|
user = CUser.objects.create(username='legacy', name_family='Legacy', mobile=989123456789)
|
|
report = audit()
|
|
self.assertEqual(report['user_duplicate_groups'][0]['record_ids'], [self.user.pk, user.pk])
|
|
self.assertEqual(report['duplicate_group_count'], 1)
|
|
self.assertNotIn('password', report['users'][0])
|
|
user.refresh_from_db()
|
|
self.assertEqual(user.mobile, 989123456789)
|
|
|
|
def test_requested_customer_details_and_missing_ids_without_duplicates(self):
|
|
report = audit('--customer-ids', str(self.customer.pk), '999999')
|
|
self.assertEqual(report['customers'][0]['id'], self.customer.pk)
|
|
self.assertEqual(report['missing_requested_customer_ids'], [999999])
|
|
self.assertEqual(report['duplicate_group_count'], 0)
|
|
|
|
def test_invalid_database_has_clear_error(self):
|
|
with self.assertRaisesMessage(CommandError, 'Unknown database alias'):
|
|
audit('--database', 'missing')
|
|
|
|
def test_automatic_repair_preserves_notes_orders_audit_and_blocks_old_panel_session(self):
|
|
tokens = self.use_role()
|
|
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
|
|
note = CustomerNote.objects.create(customer=self.customer, cuser_role=self.role,
|
|
desc='Keep this note', sys_ws=self.workspace)
|
|
order = Order.objects.create(customer=other, total_price=100, factor_type=1, sys_ws=self.workspace)
|
|
repair()
|
|
self.assertTrue(CustomerNote.objects.filter(pk=note.pk, customer=self.customer).exists())
|
|
self.assertTrue(Order.objects.filter(pk=order.pk, customer=other).exists())
|
|
entries = MobileConflict.objects.filter(source_model='customer.Customer')
|
|
self.assertEqual(entries.count(), 2)
|
|
archived = entries.get(record_id=self.customer.pk)
|
|
self.assertEqual(archived.snapshot['cuser_role_id'], self.role.pk)
|
|
self.assertTrue(archived.snapshot['roles'][0]['customer_panel'])
|
|
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
|
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
|
|
repair()
|
|
self.assertEqual(entries.count(), 2)
|
|
self.assertEqual(entries.get(record_id=self.customer.pk).snapshot, archived.snapshot)
|
|
|
|
def test_account_collision_disables_all_ambiguous_accounts_without_changing_their_numbers(self):
|
|
tokens = self.use_role()
|
|
other = CUser.objects.create(username='legacy', mobile=989123456789, is_active=True)
|
|
repair()
|
|
self.user.refresh_from_db()
|
|
other.refresh_from_db()
|
|
self.role.refresh_from_db()
|
|
self.operator.refresh_from_db()
|
|
self.assertFalse(self.user.is_active)
|
|
self.assertFalse(other.is_active)
|
|
self.assertFalse(self.role.is_active)
|
|
self.assertFalse(self.operator.is_active)
|
|
self.assertEqual(self.user.mobile, 9123456789)
|
|
self.assertEqual(other.mobile, 989123456789)
|
|
self.assertEqual(MobileConflict.objects.filter(source_model='cuser.CUser').count(), 2)
|
|
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
|
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
|
|
before = list(MobileConflict.objects.order_by('pk').values())
|
|
repair()
|
|
self.assertEqual(before, list(MobileConflict.objects.order_by('pk').values()))
|
|
|
|
def test_failed_archival_rolls_back_entire_repair(self):
|
|
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
|
|
Customer.objects.create(name_family='Second group', mobile=9333333333, sys_ws=self.workspace)
|
|
Customer.objects.create(name_family='Second group legacy', mobile=989333333333, sys_ws=self.workspace)
|
|
module = import_module('apps.customer.mobile_migration_repair')
|
|
archive = module._archive
|
|
calls = 0
|
|
|
|
def failing_archive(*args, **kwargs):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 3:
|
|
raise RuntimeError('Simulated audit storage failure')
|
|
return archive(*args, **kwargs)
|
|
|
|
with self.assertRaisesMessage(RuntimeError, 'Simulated audit storage failure'):
|
|
with transaction.atomic(), patch.object(module, '_archive', side_effect=failing_archive):
|
|
repair()
|
|
self.customer.refresh_from_db()
|
|
other.refresh_from_db()
|
|
self.assertEqual(self.customer.mobile, 9123456789)
|
|
self.assertEqual(other.mobile, 989123456789)
|
|
self.assertEqual(MobileConflict.objects.count(), 0)
|
|
|
|
def test_admin_audit_history_cannot_be_added_changed_or_deleted(self):
|
|
model_admin = admin.site._registry[MobileConflict]
|
|
request = Mock(user=Mock(is_superuser=True, is_active=True))
|
|
self.assertFalse(model_admin.has_add_permission(request))
|
|
self.assertFalse(model_admin.has_change_permission(request))
|
|
self.assertFalse(model_admin.has_delete_permission(request))
|
|
|
|
def test_postgres_migration_locks_legacy_writers_and_drains_deferred_checks(self):
|
|
cursor = MagicMock()
|
|
postgres = Mock(alias='default', vendor='postgresql', ops=connection.ops)
|
|
postgres.cursor.return_value.__enter__ = Mock(return_value=cursor)
|
|
postgres.cursor.return_value.__exit__ = Mock(return_value=False)
|
|
migration = import_module('apps.customer.migrations.0008_customer_mobile_unique')
|
|
migration.check_and_normalize_mobiles(apps, Mock(connection=postgres))
|
|
statements = [call.args[0] for call in cursor.execute.call_args_list]
|
|
self.assertTrue(statements[0].startswith('LOCK TABLE '))
|
|
self.assertIn('SHARE ROW EXCLUSIVE MODE', statements[0])
|
|
self.assertEqual(statements[-1], 'SET CONSTRAINTS ALL IMMEDIATE')
|
|
|
|
|
|
class LegacyMobileAuditTests(TransactionTestCase):
|
|
def test_exact_duplicates_before_migration_are_automatically_quarantined(self):
|
|
executor = MigrationExecutor(connection)
|
|
targets = executor.loader.graph.leaf_nodes()
|
|
executor.migrate([('customer', '0007_alter_customer_name_family')])
|
|
try:
|
|
# Real legacy databases do not have the new audit table yet.
|
|
with connection.schema_editor() as editor:
|
|
editor.delete_model(MobileConflict)
|
|
workspace = Workspace.objects.create(name='Legacy workspace', username='legacy-workspace')
|
|
first = Customer.objects.create(name_family='First', mobile=9123456789, sys_ws=workspace)
|
|
second = Customer.objects.create(name_family='Second', mobile=9123456789, sys_ws=workspace)
|
|
report = audit('--customer-ids', str(first.pk), str(second.pk))
|
|
self.assertEqual(report['customer_duplicate_groups'][0]['record_ids'], [first.pk, second.pk])
|
|
self.assertEqual(Customer.objects.filter(mobile=9123456789).count(), 2)
|
|
MigrationExecutor(connection).migrate(targets)
|
|
self.assertEqual(Customer.objects.filter(pk__in=[first.pk, second.pk]).count(), 2)
|
|
self.assertEqual(Customer.objects.filter(pk__in=[first.pk, second.pk], mobile=None).count(), 2)
|
|
self.assertEqual(MobileConflict.objects.filter(source_model='customer.Customer').count(), 2)
|
|
self.assertEqual(MobileConflict.objects.get(record_id=first.pk).original_mobile, 9123456789)
|
|
self.assertEqual(audit()['duplicate_group_count'], 0)
|
|
finally:
|
|
# Test data only: restore the normal schema for subsequent tests.
|
|
Customer.objects.all().delete()
|
|
MigrationExecutor(connection).migrate(targets)
|
|
|
|
def test_already_applied_0008_installation_creates_missing_audit_table(self):
|
|
executor = MigrationExecutor(connection)
|
|
targets = executor.loader.graph.leaf_nodes()
|
|
executor.migrate([('customer', '0008_customer_mobile_unique')])
|
|
try:
|
|
# Simulate an installation that applied the original 0008 before
|
|
# this automatic repair feature existed.
|
|
with connection.schema_editor() as editor:
|
|
editor.delete_model(MobileConflict)
|
|
self.assertNotIn(MobileConflict._meta.db_table, connection.introspection.table_names())
|
|
MigrationExecutor(connection).migrate(targets)
|
|
self.assertIn(MobileConflict._meta.db_table, connection.introspection.table_names())
|
|
self.assertEqual(MobileConflict.objects.count(), 0)
|
|
finally:
|
|
MigrationExecutor(connection).migrate(targets)
|