Forráskód Böngészése

Add tests for `coriolis.api.v1.replicas` module

Cristian Matiut 2 éve
szülő
commit
9ad54992b3

+ 94 - 0
coriolis/tests/api/v1/data/replicas_get_merged_replica_values.yml

@@ -0,0 +1,94 @@
+
+- config:
+    replica:
+      origin_endpoint_id: "mock_origin_endpoint_id"
+      destination_endpoint_id: "mock_destination_endpoint_id"
+      source_environment: {'mock_source_key': 'mock_source_value'}
+      destination_environment:
+        storage_mappings: {'mock_destination_key': 'mock_destination_value'}
+      network_map: {'mock_network_key': 'mock_network_value'}
+      user_scripts: {'mock_scripts_key': 'mock_scripts_value'}
+      notes: "mock_notes"
+      origin_minion_pool_id: "mock_origin_minion_pool_id"
+      destination_minion_pool_id: "mock_destination_minion_pool_id"
+      instance_osmorphing_minion_pool_mappings:
+        mock_instance_1: "mock_pool_1"
+        mock_instance_2: "mock_pool_2"
+    updated_values: 
+      source_environment: {'mock_updated_source_key': 'mock_updated_source_value'}
+      destination_environment:
+        storage_mappings: {'mock_updated_destination_key': 'mock_updated_destination_value'}
+      network_map: {'mock_updated_network_key': 'mock_updated_network_value'}
+      user_scripts: {'mock_updated_scripts_key': 'mock_updated_scripts_value'}
+      notes: "mock_updated_notes"
+      origin_minion_pool_id: "mock_updated_origin_minion_pool_id"
+      destination_minion_pool_id: "mock_updated_destination_minion_pool_id"
+      instance_osmorphing_minion_pool_mappings:
+        mock_instance_1: "mock_updated_pool_1"
+        mock_instance_2: "mock_updated_pool_2"
+  expected_result:
+    source_environment:
+      mock_updated_source_key: 'mock_updated_source_value'
+      mock_source_key: 'mock_source_value'
+    destination_environment:
+      network_map:
+        mock_updated_network_key: 'mock_updated_network_value'
+        mock_network_key: 'mock_network_value'
+      storage_mappings:
+        mock_updated_destination_key: 'mock_updated_destination_value'
+    network_map:
+      mock_updated_network_key: 'mock_updated_network_value'
+      mock_network_key: 'mock_network_value'
+    notes: "mock_updated_notes"
+    origin_minion_pool_id: "mock_updated_origin_minion_pool_id"
+    destination_minion_pool_id: "mock_updated_destination_minion_pool_id"
+    instance_osmorphing_minion_pool_mappings:
+      mock_instance_1: "mock_updated_pool_1"
+      mock_instance_2: "mock_updated_pool_2"
+
+- config:
+    replica:
+      origin_endpoint_id: "mock_origin_endpoint_id"
+      destination_endpoint_id: "mock_destination_endpoint_id"
+      source_environment: {'mock_source_key': 'mock_source_value'}
+      destination_environment:
+        storage_mappings: {'mock_destination_key': 'mock_destination_value'}
+      network_map: {'mock_network_key': 'mock_network_value'}
+      user_scripts: {'mock_scripts_key': 'mock_scripts_value'}
+      notes: "mock_notes"
+      origin_minion_pool_id: "mock_origin_minion_pool_id"
+      destination_minion_pool_id: "mock_destination_minion_pool_id"
+      instance_osmorphing_minion_pool_mappings:
+        mock_instance_1: "mock_pool_1"
+        mock_instance_2: "mock_pool_2"
+    updated_values: {}
+  expected_result:
+    source_environment:
+      mock_source_key: 'mock_source_value'
+    destination_environment:
+      network_map:
+        mock_network_key: 'mock_network_value'
+      storage_mappings:
+        mock_destination_key: 'mock_destination_value'
+    network_map:
+      mock_network_key: 'mock_network_value'
+    notes: "mock_notes"
+
+- config:
+    replica:
+      origin_endpoint_id: "mock_origin_endpoint_id"
+      destination_endpoint_id: "mock_destination_endpoint_id"
+      user_scripts: {'mock_scripts_key': 'mock_scripts_value'}
+      notes: "mock_notes"
+      origin_minion_pool_id: "mock_origin_minion_pool_id"
+      destination_minion_pool_id: "mock_destination_minion_pool_id"
+      instance_osmorphing_minion_pool_mappings:
+        mock_instance_1: "mock_pool_1"
+        mock_instance_2: "mock_pool_2"
+    updated_values: {}
+  expected_result:
+    source_environment: {}
+    destination_environment: {}
+    network_map: {}
+    notes: "mock_notes"
+

+ 156 - 0
coriolis/tests/api/v1/data/replicas_update_storage_mappings.yml

@@ -0,0 +1,156 @@
+
+- config:
+    original_storage_mappings:
+      backend_mappings:
+        - source: "source_1"
+          destination: destination_1
+        - source: "source_2"
+          destination: destination_2
+      disk_mappings:
+        - disk_id: "disk_1"
+          destination: destination_1
+        - disk_id: "disk_2"
+          destination: destination_2
+    new_storage_mappings:
+      backend_mappings:
+        - source: "source_1"
+          destination: destination_3
+      disk_mappings:
+        - disk_id: "disk_1"
+          destination: destination_4
+  logs_expected: True
+  expected_result:
+    backend_mappings:
+      - source: "source_2"
+        destination: destination_2
+      - source: "source_1"
+        destination: destination_3
+    disk_mappings:
+      - disk_id: "disk_2"
+        destination: destination_2
+      - disk_id: "disk_1"
+        destination: destination_4
+
+- config:
+    original_storage_mappings:
+      backend_mappings:
+        - source: "source_1"
+          destination: destination_1
+        - source: "source_2"
+          destination: destination_2
+      disk_mappings:
+        - disk_id: "disk_1"
+          destination: destination_1
+        - disk_id: "disk_2"
+          destination: destination_2
+    new_storage_mappings:
+      backend_mappings:
+        - source: "source_1"
+          destination: destination_3
+  logs_expected: True
+  expected_result:
+    backend_mappings:
+      - source: "source_2"
+        destination: destination_2
+      - source: "source_1"
+        destination: destination_3
+    disk_mappings:
+      - disk_id: "disk_1"
+        destination: destination_1
+      - disk_id: "disk_2"
+        destination: destination_2
+
+- config:
+    original_storage_mappings:
+      backend_mappings:
+        - source: "source_1"
+          destination: destination_1
+        - source: "source_2"
+          destination: destination_2
+      disk_mappings:
+        - disk_id: "disk_1"
+          destination: destination_1
+        - disk_id: "disk_2"
+          destination: destination_2
+    new_storage_mappings:
+      disk_mappings:
+        - disk_id: "disk_1"
+          destination: destination_3
+  logs_expected: True
+  expected_result:
+    backend_mappings:
+      - source: "source_1"
+        destination: destination_1
+      - source: "source_2"
+        destination: destination_2
+    disk_mappings:
+      - disk_id: "disk_2"
+        destination: destination_2
+      - disk_id: "disk_1"
+        destination: destination_3
+
+- config:
+    original_storage_mappings:
+      backend_mappings:
+        - source: "source_1"
+          destination: destination_1
+        - source: "source_2"
+          destination: destination_2
+      disk_mappings:
+        - disk_id: "disk_1"
+          destination: destination_1
+        - disk_id: "disk_2"
+          destination: destination_2
+    new_storage_mappings: {}
+  logs_expected: False
+  expected_result:
+    backend_mappings:
+      - source: "source_1"
+        destination: destination_1
+      - source: "source_2"
+        destination: destination_2
+    disk_mappings:
+      - disk_id: "disk_1"
+        destination: destination_1
+      - disk_id: "disk_2"
+        destination: destination_2
+
+- config:
+    original_storage_mappings:
+      backend_mappings:
+        - source: "source_1"
+          destination: destination_1
+        - source: "source_2"
+          destination: destination_2
+      disk_mappings:
+        - disk_id: "disk_1"
+          destination: destination_1
+        - disk_id: "disk_2"
+          destination: destination_2
+      default: "mock_default_1"
+    new_storage_mappings:
+      backend_mappings:
+        - source: "source_3"
+          destination: destination_3
+      disk_mappings:
+        - disk_id: "disk_3"
+          destination: destination_3
+      default: "mock_default_2"
+  logs_expected: False
+  expected_result:
+    backend_mappings:
+      - source: "source_1"
+        destination: destination_1
+      - source: "source_2"
+        destination: destination_2
+      - source: "source_3"
+        destination: destination_3
+    disk_mappings:
+      - disk_id: "disk_1"
+        destination: destination_1
+      - disk_id: "disk_2"
+        destination: destination_2
+      - disk_id: "disk_3"
+        destination: destination_3
+    default: "mock_default_2"
+

+ 56 - 0
coriolis/tests/api/v1/data/replicas_validate_create_body.yml

@@ -0,0 +1,56 @@
+
+- config:
+    body:
+      replica:
+        origin_endpoint_id: "mock_origin_endpoint_id"
+        destination_endpoint_id: "mock_destination_endpoint_id"
+        source_environment: "mock_source_environment"
+        destination_environment:
+          network_map: "mock_network_map"
+        instances: ['mock_instance_1', 'mock_instance_2']
+        notes: "mock_notes"
+        origin_minion_pool_id: "mock_origin_minion_pool_id"
+        destination_minion_pool_id: "mock_destination_minion_pool_id"
+        instance_osmorphing_minion_pool_mappings:
+          mock_instance_1: "mock_pool"
+          mock_instance_2: "mock_pool"
+        network_map: "mock_network_map"
+        user_scripts: "mock_user_scripts"
+        storage_mappings: "mock_storage_mappings"
+  exception_raised: False
+  expected_result:
+    - mock_origin_endpoint_id
+    - mock_destination_endpoint_id
+    - mock_source_environment
+    - {'network_map': 'mock_network_map',
+      'storage_mappings': 'mock_storage_mappings'}
+    - ['mock_instance_1', 'mock_instance_2']
+    - mock_network_map
+    - mock_storage_mappings
+    - mock_notes
+    - mock_origin_minion_pool_id
+    - mock_destination_minion_pool_id
+    - {'mock_instance_1': 'mock_pool', 'mock_instance_2': 'mock_pool'}
+    - mock_user_scripts
+
+- config:
+    body:
+      replica:
+        origin_endpoint_id: "mock_origin_endpoint_id"
+        destination_endpoint_id: "mock_destination_endpoint_id"
+        source_environment: "mock_source_environment"
+        destination_environment:
+          network_map: "mock_network_map"
+        instances: ['mock_instance_1', 'mock_instance_2']
+        notes: "mock_notes"
+        origin_minion_pool_id: "mock_origin_minion_pool_id"
+        destination_minion_pool_id: "mock_destination_minion_pool_id"
+        instance_osmorphing_minion_pool_mappings:
+          mock_instance_1: "mock_pool"
+          mock_instance_3: "mock_pool"
+        network_map: "mock_network_map"
+        user_scripts: "mock_user_scripts"
+        storage_mappings: "mock_storage_mappings"
+  exception_raised: "One or more instance OSMorphing pool mappings were"
+  expected_result: 
+

+ 31 - 0
coriolis/tests/api/v1/data/replicas_validate_update_body.yml

@@ -0,0 +1,31 @@
+
+- config:
+    body:
+      replica:
+        source_environment: "mock_source_environment"
+        destination_environment: "mock_destination_environment"
+        storage_mappings: {'mock_updated_destination_key': 'mock_updated_destination_value'}
+        network_map: {'mock_updated_network_key': 'mock_updated_network_value'}
+        user_scripts: {'mock_updated_scripts_key': 'mock_updated_scripts_value'}
+        notes: "mock_updated_notes"
+        origin_minion_pool_id: "mock_updated_origin_minion_pool_id"
+        destination_minion_pool_id: "mock_updated_destination_minion_pool_id"
+        instance_osmorphing_minion_pool_mappings:
+          mock_instance_1: "mock_updated_pool_1"
+          mock_instance_2: "mock_updated_pool_2"
+    replica:
+      destination_endpoint_id: "mock_destination_endpoint_id"
+      origin_endpoint_id: "mock_origin_endpoint_id"
+      instances: "mock_instances"
+  expected_result:
+    source_environment: "mock_source_environment"
+    destination_environment: "mock_destination_environment"
+    storage_mappings: {'mock_updated_destination_key': 'mock_updated_destination_value'}
+    network_map: {'mock_updated_network_key': 'mock_updated_network_value'}
+    user_scripts: {'mock_updated_scripts_key': 'mock_updated_scripts_value'}
+    notes: "mock_updated_notes"
+    origin_minion_pool_id: "mock_updated_origin_minion_pool_id"
+    destination_minion_pool_id: "mock_updated_destination_minion_pool_id"
+    instance_osmorphing_minion_pool_mappings:
+      mock_instance_1: "mock_updated_pool_1"
+      mock_instance_2: "mock_updated_pool_2"

+ 13 - 0
coriolis/tests/api/v1/data/replicas_validate_update_body_raises.yml

@@ -0,0 +1,13 @@
+
+- body:
+    replica:
+      origin_endpoint_id: "mock_origin_endpoint_id"
+
+- body:
+    replica:
+      destination_endpoint_id: "mock_destination_endpoint_id"
+
+- body:
+    replica:
+      instances: "instances"
+

+ 541 - 0
coriolis/tests/api/v1/test_replicas.py

@@ -0,0 +1,541 @@
+# Copyright 2023 Cloudbase Solutions Srl
+# All Rights Reserved.
+
+from unittest import mock
+
+import ddt
+from webob import exc
+
+from coriolis.api.v1 import replicas
+from coriolis.api.v1 import utils as api_utils
+from coriolis.api.v1.views import replica_tasks_execution_view
+from coriolis.api.v1.views import replica_view
+from coriolis.endpoints import api as endpoints_api
+from coriolis import exception
+from coriolis.replicas import api
+from coriolis.tests import test_base
+from coriolis.tests import testutils
+
+
+@ddt.ddt
+class ReplicaControllerTestCase(test_base.CoriolisBaseTestCase):
+    """Test suite for the Coriolis Replica Controller v1 API"""
+
+    def setUp(self):
+        super(ReplicaControllerTestCase, self).setUp()
+        self.replicas = replicas.ReplicaController()
+
+    @mock.patch('coriolis.api.v1.replicas.CONF')
+    @mock.patch.object(replica_view, 'single')
+    @mock.patch.object(api.API, 'get_replica')
+    def test_show(
+        self,
+        mock_get_replica,
+        mock_single,
+        mock_conf
+    ):
+        mock_req = mock.Mock()
+        mock_context = mock.Mock()
+        mock_req.environ = {'coriolis.context': mock_context}
+        id = mock.sentinel.id
+        mock_conf.api.include_task_info_in_replicas_api = True
+
+        result = self.replicas.show(mock_req, id)
+
+        self.assertEqual(
+            mock_single.return_value,
+            result
+        )
+
+        mock_context.can.assert_called_once_with("migration:replicas:show")
+        mock_get_replica.assert_called_once_with(
+            mock_context, id, include_task_info=True)
+        mock_single.assert_called_once_with(mock_get_replica.return_value)
+
+    @mock.patch('coriolis.api.v1.replicas.CONF')
+    @mock.patch.object(replica_view, 'single')
+    @mock.patch.object(api.API, 'get_replica')
+    def test_show_no_replica(
+        self,
+        mock_get_replica,
+        mock_single,
+        mock_conf
+    ):
+        mock_req = mock.Mock()
+        mock_context = mock.Mock()
+        mock_req.environ = {'coriolis.context': mock_context}
+        id = mock.sentinel.id
+        mock_conf.api.include_task_info_in_replicas_api = True
+        mock_get_replica.return_value = None
+
+        self.assertRaises(
+            exc.HTTPNotFound,
+            self.replicas.show,
+            mock_req,
+            id
+        )
+
+        mock_context.can.assert_called_once_with("migration:replicas:show")
+        mock_get_replica.assert_called_once_with(
+            mock_context, id, include_task_info=True)
+        mock_single.assert_not_called()
+
+    @mock.patch('coriolis.api.v1.replicas.CONF')
+    @mock.patch.object(replica_view, 'collection')
+    @mock.patch.object(api.API, 'get_replicas')
+    @mock.patch.object(api_utils, '_get_show_deleted')
+    def test_list(
+        self,
+        mock_get_show_deleted,
+        mock_get_replicas,
+        mock_collection,
+        mock_conf
+    ):
+        mock_req = mock.Mock()
+        mock_context = mock.Mock()
+        mock_req.environ = {'coriolis.context': mock_context}
+
+        result = self.replicas._list(mock_req)
+
+        self.assertEqual(
+            mock_collection.return_value,
+            result
+        )
+
+        mock_get_show_deleted.assert_called_once_with(
+            mock_req.GET.get.return_value)
+        mock_context.can.assert_called_once_with("migration:replicas:list")
+        mock_get_replicas.assert_called_once_with(
+            mock_context,
+            include_tasks_executions=
+            mock_conf.api.include_task_info_in_replicas_api,
+            include_task_info=mock_conf.api.include_task_info_in_replicas_api
+        )
+        mock_collection.assert_called_once_with(mock_get_replicas.return_value)
+
+    @mock.patch.object(api_utils, 'validate_instances_list_for_transfer')
+    @mock.patch.object(endpoints_api.API, 'validate_source_environment')
+    @mock.patch.object(api_utils, 'validate_network_map')
+    @mock.patch.object(endpoints_api.API, 'validate_target_environment')
+    @mock.patch.object(api_utils, 'validate_user_scripts')
+    @mock.patch.object(api_utils, 'normalize_user_scripts')
+    @mock.patch.object(api_utils, 'validate_storage_mappings')
+    @ddt.file_data('data/replicas_validate_create_body.yml')
+    def test_validate_create_body(
+        self,
+        mock_validate_storage_mappings,
+        mock_normalize_user_scripts,
+        mock_validate_user_scripts,
+        mock_validate_target_environment,
+        mock_validate_network_map,
+        mock_validate_source_environment,
+        mock_validate_instances_list_for_transfer,
+        config,
+        exception_raised,
+        expected_result
+    ):
+        ctxt = {}
+        body = config["body"]
+        replica = body["replica"]
+        origin_endpoint_id = replica.get('origin_endpoint_id')
+        source_environment = replica.get('source_environment')
+        network_map = replica.get('network_map')
+        destination_endpoint_id = replica.get('destination_endpoint_id')
+        destination_environment = replica.get('destination_environment')
+        user_scripts = replica.get('user_scripts')
+        instances = replica.get('instances')
+        storage_mappings = replica.get('storage_mappings')
+        mock_validate_instances_list_for_transfer.return_value = instances
+        mock_normalize_user_scripts.return_value = user_scripts
+
+        if exception_raised:
+            self.assertRaisesRegex(
+                Exception,
+                exception_raised,
+                testutils.get_wrapped_function(
+                    self.replicas._validate_create_body),
+                self.replicas,
+                ctxt,
+                body
+            )
+
+            mock_validate_network_map.assert_not_called()
+        else:
+            result = testutils.get_wrapped_function(
+                self.replicas._validate_create_body)(
+                    self.replicas,
+                    ctxt,
+                    body,
+            )
+
+            self.assertEqual(
+                tuple(expected_result),
+                result
+            )
+
+            mock_validate_network_map.assert_called_once_with(network_map)
+            mock_validate_target_environment.assert_called_once_with(
+                ctxt, destination_endpoint_id, destination_environment)
+            mock_validate_user_scripts.assert_called_once_with(user_scripts)
+            mock_normalize_user_scripts.assert_called_once_with(
+                user_scripts, instances)
+            mock_validate_storage_mappings.assert_called_once_with(
+                storage_mappings)
+
+        mock_validate_source_environment.assert_called_once_with(
+            ctxt, origin_endpoint_id, source_environment)
+        mock_validate_instances_list_for_transfer.assert_called_once_with(
+            instances)
+
+    @mock.patch.object(replica_view, 'single')
+    @mock.patch.object(api.API, 'create')
+    @mock.patch.object(replicas.ReplicaController, '_validate_create_body')
+    def test_create(
+        self,
+        mock_validate_create_body,
+        mock_create,
+        mock_single
+    ):
+        mock_req = mock.Mock()
+        mock_context = mock.Mock()
+        mock_req.environ = {'coriolis.context': mock_context}
+        mock_body = {}
+        mock_validate_create_body.return_value = (mock.sentinel.value,) * 12
+
+        result = self.replicas.create(mock_req, mock_body)
+
+        self.assertEqual(
+            mock_single.return_value,
+            result
+        )
+
+        mock_context.can.assert_called_once_with(
+            "migration:replicas:create")
+        mock_validate_create_body.assert_called_once_with(
+            mock_context, mock_body)
+        mock_create.assert_called_once()
+        mock_single.assert_called_once_with(mock_create.return_value)
+
+    @mock.patch.object(api.API, 'delete')
+    def test_delete(
+        self,
+        mock_delete
+    ):
+        mock_req = mock.Mock()
+        mock_context = mock.Mock()
+        mock_req.environ = {'coriolis.context': mock_context}
+        id = mock.sentinel.id
+
+        self.assertRaises(
+            exc.HTTPNoContent,
+            self.replicas.delete,
+            mock_req,
+            id
+        )
+
+        mock_delete.assert_called_once_with(mock_context, id)
+
+    @mock.patch.object(api.API, 'delete')
+    def test_delete_not_found(
+        self,
+        mock_delete
+    ):
+        mock_req = mock.Mock()
+        mock_context = mock.Mock()
+        mock_req.environ = {'coriolis.context': mock_context}
+        id = mock.sentinel.id
+        mock_delete.side_effect = exception.NotFound()
+
+        self.assertRaises(
+            exc.HTTPNotFound,
+            self.replicas.delete,
+            mock_req,
+            id
+        )
+
+        mock_context.can.assert_called_once_with("migration:replicas:delete")
+        mock_delete.assert_called_once_with(mock_context, id)
+
+    @ddt.file_data('data/replicas_update_storage_mappings.yml')
+    def test_update_storage_mappings(
+        self,
+        config,
+        expected_result,
+        logs_expected
+    ):
+        original_storage_mappings = config['original_storage_mappings']
+        new_storage_mappings = config['new_storage_mappings']
+
+        if logs_expected:
+            with self.assertLogs('coriolis.api.v1.replicas', level='INFO'):
+                result = self.replicas._update_storage_mappings(
+                    original_storage_mappings, new_storage_mappings)
+        else:
+            result = self.replicas._update_storage_mappings(
+                original_storage_mappings, new_storage_mappings)
+
+        self.assertEqual(
+            expected_result,
+            result
+        )
+
+    def test_get_updated_user_scripts(
+        self,
+    ):
+        original_user_scripts = {
+            'global': {"mock_global_scripts_1": "mock_value",
+                       "mock_global_scripts_2": "mock_value"},
+            'instances': {"mock_instance_scripts": "mock_value"}
+        }
+        new_user_scripts = {
+            'global': {"mock_global_scripts_1": "mock_new_value"},
+            'instances': {"mock_instance_scripts": "mock_new_value"}
+        }
+        expected_result = {
+            'global': {"mock_global_scripts_1": "mock_new_value",
+                       "mock_global_scripts_2": "mock_value"},
+            'instances': {"mock_instance_scripts": "mock_new_value"}
+        }
+        result = self.replicas._get_updated_user_scripts(
+            original_user_scripts, new_user_scripts)
+
+        self.assertEqual(
+            expected_result,
+            result
+        )
+
+    def test_get_updated_user_scripts_new_user_scripts_empty(
+        self,
+    ):
+        original_user_scripts = {
+            'global': {"mock_global_scripts_1": "mock_value",
+                       "mock_global_scripts_2": "mock_value"},
+            'instances': {"mock_instance_scripts": "mock_value"}
+        }
+        new_user_scripts = {}
+
+        result = self.replicas._get_updated_user_scripts(
+            original_user_scripts, new_user_scripts)
+
+        self.assertEqual(
+            original_user_scripts,
+            result
+        )
+
+    @mock.patch.object(replicas.ReplicaController, '_get_updated_user_scripts')
+    @mock.patch.object(api_utils, 'validate_user_scripts')
+    @mock.patch.object(replicas.ReplicaController, '_update_storage_mappings')
+    @ddt.file_data('data/replicas_get_merged_replica_values.yml')
+    def test_get_merged_replica_values(
+        self,
+        mock_update_storage_mappings,
+        mock_validate_user_scripts,
+        mock_get_updated_user_scripts,
+        config,
+        expected_result
+    ):
+        replica = config['replica']
+        updated_values = config['updated_values']
+        original_storage_mapping = replica.get('storage_mappings', {})
+        replica_user_scripts = replica.get('user_scripts', {})
+        updated_user_scripts = updated_values.get('user_scripts', {})
+        new_storage_mappings = updated_values.get('storage_mappings', {})
+        expected_result['storage_mappings'] = \
+            mock_update_storage_mappings.return_value
+        expected_result['destination_environment'][
+            'storage_mappings'] = mock_update_storage_mappings.return_value
+        expected_result['user_scripts'] = \
+            mock_get_updated_user_scripts.return_value
+        mock_validate_user_scripts.side_effect = ["mock_scripts",
+                                                  "mock_new_scripts"]
+
+        result = self.replicas._get_merged_replica_values(
+            replica, updated_values)
+
+        self.assertEqual(
+            expected_result,
+            result
+        )
+
+        mock_update_storage_mappings.assert_called_once_with(
+            original_storage_mapping, new_storage_mappings)
+        mock_validate_user_scripts.assert_has_calls(
+            [mock.call(replica_user_scripts), mock.call(updated_user_scripts)])
+        mock_get_updated_user_scripts.assert_called_once_with(
+            "mock_scripts", "mock_new_scripts")
+
+    @mock.patch.object(api_utils, 'normalize_user_scripts')
+    @mock.patch.object(api_utils, 'validate_user_scripts')
+    @mock.patch.object(api_utils, 'validate_storage_mappings')
+    @mock.patch.object(api_utils, 'validate_network_map')
+    @mock.patch.object(endpoints_api.API, 'validate_target_environment')
+    @mock.patch.object(endpoints_api.API, 'validate_source_environment')
+    @mock.patch.object(replicas.ReplicaController,
+                       '_get_merged_replica_values')
+    @mock.patch.object(api.API, 'get_replica')
+    @ddt.file_data('data/replicas_validate_update_body.yml')
+    def test_validate_update_body(
+        self,
+        mock_get_replica,
+        mock_get_merged_replica_values,
+        mock_validate_source_environment,
+        mock_validate_target_environment,
+        mock_validate_network_map,
+        mock_validate_storage_mappings,
+        mock_validate_user_scripts,
+        mock_normalize_user_scripts,
+        config,
+        expected_result
+    ):
+        body = config['body']
+        replica = config['replica']
+        replica_body = body['replica']
+        context = mock.sentinel.context
+        id = mock.sentinel.id
+        mock_get_replica.return_value = replica
+        mock_get_merged_replica_values.return_value = replica_body
+        mock_normalize_user_scripts.return_value = replica_body['user_scripts']
+
+        result = testutils.get_wrapped_function(
+            self.replicas._validate_update_body)(
+            self.replicas,
+            id,
+            context,
+            body
+        )
+
+        self.assertEqual(
+            expected_result,
+            result
+        )
+
+        mock_get_replica.assert_called_once_with(context, id)
+        mock_get_merged_replica_values.assert_called_once_with(
+            replica, replica_body)
+        mock_validate_source_environment.assert_called_once_with(
+            context, replica['origin_endpoint_id'],
+            replica_body['source_environment'])
+        mock_validate_target_environment.assert_called_once_with(
+            context, replica['destination_endpoint_id'],
+            replica_body['destination_environment'])
+        mock_validate_network_map.assert_called_once_with(
+            replica_body['network_map'])
+        mock_validate_storage_mappings.assert_called_once_with(
+            replica_body['storage_mappings'])
+        mock_validate_user_scripts.assert_called_once_with(
+            replica_body['user_scripts'])
+        mock_normalize_user_scripts.assert_called_once_with(
+            replica_body['user_scripts'], replica['instances'])
+
+    @mock.patch.object(api.API, 'get_replica')
+    @ddt.file_data('data/replicas_validate_update_body_raises.yml')
+    def test_validate_update_body_raises(
+        self,
+        mock_get_replica,
+        body,
+    ):
+        context = mock.sentinel.context
+        id = mock.sentinel.id
+
+        self.assertRaises(
+            exc.HTTPBadRequest,
+            testutils.get_wrapped_function(
+                self.replicas._validate_update_body),
+            self.replicas,
+            id,
+            context,
+            body
+        )
+
+        mock_get_replica.assert_called_once_with(context, id)
+
+    @mock.patch.object(replica_tasks_execution_view, 'single')
+    @mock.patch.object(api.API, 'update')
+    @mock.patch.object(replicas.ReplicaController, '_validate_update_body')
+    def test_update(
+        self,
+        mock_validate_update_body,
+        mock_update,
+        mock_single
+    ):
+        mock_req = mock.Mock()
+        mock_context = mock.Mock()
+        mock_req.environ = {'coriolis.context': mock_context}
+        id = mock.sentinel.id
+        body = mock.sentinel.body
+
+        result = self.replicas.update(mock_req, id, body)
+
+        self.assertEqual(
+            mock_single.return_value,
+            result
+        )
+
+        mock_context.can.assert_called_once_with(
+            "migration:replicas:update")
+        mock_validate_update_body.assert_called_once_with(
+            id, mock_context, body)
+        mock_update.assert_called_once_with(
+            mock_context, id,
+            mock_validate_update_body.return_value)
+        mock_single.assert_called_once_with(mock_update.return_value)
+
+    @mock.patch.object(api.API, 'update')
+    @mock.patch.object(replicas.ReplicaController, '_validate_update_body')
+    def test_update_not_found(
+        self,
+        mock_validate_update_body,
+        mock_update
+    ):
+        mock_req = mock.Mock()
+        mock_context = mock.Mock()
+        mock_req.environ = {'coriolis.context': mock_context}
+        id = mock.sentinel.id
+        body = mock.sentinel.body
+        mock_update.side_effect = exception.NotFound()
+
+        self.assertRaises(
+            exc.HTTPNotFound,
+            self.replicas.update,
+            mock_req,
+            id,
+            body
+        )
+
+        mock_context.can.assert_called_once_with(
+            "migration:replicas:update")
+        mock_validate_update_body.assert_called_once_with(
+            id, mock_context, body)
+        mock_update.assert_called_once_with(
+            mock_context, id,
+            mock_validate_update_body.return_value)
+
+    @mock.patch.object(api.API, 'update')
+    @mock.patch.object(replicas.ReplicaController, '_validate_update_body')
+    def test_update_not_invalid_parameter_value(
+        self,
+        mock_validate_update_body,
+        mock_update
+    ):
+        mock_req = mock.Mock()
+        mock_context = mock.Mock()
+        mock_req.environ = {'coriolis.context': mock_context}
+        id = mock.sentinel.id
+        body = mock.sentinel.body
+        mock_update.side_effect = exception.InvalidParameterValue('err')
+
+        self.assertRaises(
+            exc.HTTPNotFound,
+            self.replicas.update,
+            mock_req,
+            id,
+            body
+        )
+
+        mock_context.can.assert_called_once_with(
+            "migration:replicas:update")
+        mock_validate_update_body.assert_called_once_with(
+            id, mock_context, body)
+        mock_update.assert_called_once_with(
+            mock_context, id,
+            mock_validate_update_body.return_value)