User Messages

For quite some time, OpenStack services have wanted to be able to send
messages to API end users (by user I do not mean the operator, but the
user that is interacting with the client).

This patch implements basic user messages with the following APIs.
GET /messages
GET /messages/<message_id>
DELETE /messages/<message_id>

Implements the basic /messages resource and tempest tests
The patch is aligned with related cinder patch where possible:
I8a635a07ed6ff93ccb71df8c404c927d1ecef005

DocImpact
APIImpact

Needed-By: I5ffb840a271c518f62ee1accfd8e20a97f45594d
Needed-By: I9ce096eebda3249687268e361b7141dea4032b57
Needed-By: Ic7d25a144905a39c56ababe8bd666b01bc0d0aef

Partially-implements: blueprint user-messages
Co-Authored-By: Jan Provaznik <jprovazn@redhat.com>
Change-Id: Ia0cc524e0bfb2ca5e495e575e17e9911c746690b
diff --git a/manila_tempest_tests/tests/api/admin/test_user_messages.py b/manila_tempest_tests/tests/api/admin/test_user_messages.py
new file mode 100644
index 0000000..1d23487
--- /dev/null
+++ b/manila_tempest_tests/tests/api/admin/test_user_messages.py
@@ -0,0 +1,103 @@
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from oslo_utils import timeutils
+from oslo_utils import uuidutils
+from tempest import config
+from tempest import test
+
+from manila_tempest_tests.tests.api import base
+
+CONF = config.CONF
+
+MICROVERSION = '2.37'
+MESSAGE_KEYS = (
+    'created_at',
+    'action_id',
+    'detail_id',
+    'expires_at',
+    'id',
+    'message_level',
+    'request_id',
+    'resource_type',
+    'resource_id',
+    'user_message',
+    'project_id',
+    'links',
+)
+
+
+@base.skip_if_microversion_lt(MICROVERSION)
+class UserMessageTest(base.BaseSharesAdminTest):
+
+    def setUp(self):
+        super(UserMessageTest, self).setUp()
+        self.message = self.create_user_message()
+
+    @test.attr(type=[base.TAG_POSITIVE, base.TAG_API])
+    def test_list_messages(self):
+        body = self.shares_v2_client.list_messages()
+        self.assertIsInstance(body, list)
+        self.assertTrue(self.message['id'], [x['id'] for x in body])
+        message = body[0]
+        self.assertEqual(set(MESSAGE_KEYS), set(message.keys()))
+
+    @test.attr(type=[base.TAG_POSITIVE, base.TAG_API])
+    def test_list_messages_sorted_and_paginated(self):
+        self.create_user_message()
+        self.create_user_message()
+        params = {'sort_key': 'resource_id', 'sort_dir': 'asc', 'limit': 2}
+        body = self.shares_v2_client.list_messages(params=params)
+        # tempest/lib/common/rest_client.py's _parse_resp checks
+        # for number of keys in response's dict, if there is only single
+        # key, it returns directly this key, otherwise it returns
+        # parsed body. If limit param is used, then API returns
+        # multiple keys in reponse ('messages' and 'message_links')
+        messages = body['messages']
+        self.assertIsInstance(messages, list)
+        ids = [x['resource_id'] for x in messages]
+        self.assertEqual(2, len(ids))
+        self.assertEqual(ids, sorted(ids))
+
+    @test.attr(type=[base.TAG_POSITIVE, base.TAG_API])
+    def test_list_messages_filtered(self):
+        self.create_user_message()
+        params = {'resource_id': self.message['resource_id']}
+        body = self.shares_v2_client.list_messages(params=params)
+        self.assertIsInstance(body, list)
+        ids = [x['id'] for x in body]
+        self.assertEqual([self.message['id']], ids)
+
+    @test.attr(type=[base.TAG_POSITIVE, base.TAG_API])
+    def test_show_message(self):
+        self.addCleanup(self.shares_v2_client.delete_message,
+                        self.message['id'])
+
+        message = self.shares_v2_client.get_message(self.message['id'])
+
+        self.assertEqual(set(MESSAGE_KEYS), set(message.keys()))
+        self.assertTrue(uuidutils.is_uuid_like(message['id']))
+        self.assertEqual('001', message['action_id'])
+        self.assertEqual('002', message['detail_id'])
+        self.assertEqual('SHARE', message['resource_type'])
+        self.assertTrue(uuidutils.is_uuid_like(message['resource_id']))
+        self.assertEqual('ERROR', message['message_level'])
+        created_at = timeutils.parse_strtime(message['created_at'])
+        expires_at = timeutils.parse_strtime(message['expires_at'])
+        self.assertGreater(expires_at, created_at)
+        self.assertEqual(set(MESSAGE_KEYS), set(message.keys()))
+
+    @test.attr(type=[base.TAG_POSITIVE, base.TAG_API])
+    def test_delete_message(self):
+        self.shares_v2_client.delete_message(self.message['id'])
+        self.shares_v2_client.wait_for_resource_deletion(
+            message_id=self.message['id'])
diff --git a/manila_tempest_tests/tests/api/admin/test_user_messages_negative.py b/manila_tempest_tests/tests/api/admin/test_user_messages_negative.py
new file mode 100644
index 0000000..47eed3b
--- /dev/null
+++ b/manila_tempest_tests/tests/api/admin/test_user_messages_negative.py
@@ -0,0 +1,58 @@
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+from oslo_utils import uuidutils
+import six
+from tempest import config
+from tempest.lib import exceptions as lib_exc
+from tempest import test
+
+from manila_tempest_tests.tests.api import base
+
+CONF = config.CONF
+
+MICROVERSION = '2.37'
+
+
+@base.skip_if_microversion_lt(MICROVERSION)
+class UserMessageNegativeTest(base.BaseSharesAdminTest):
+
+    def setUp(self):
+        super(UserMessageNegativeTest, self).setUp()
+        self.message = self.create_user_message()
+
+    @test.attr(type=[base.TAG_NEGATIVE, base.TAG_API])
+    def test_show_message_of_other_tenants(self):
+        isolated_client = self.get_client_with_isolated_creds(
+            type_of_creds='alt', client_version='2')
+        self.assertRaises(lib_exc.NotFound,
+                          isolated_client.get_message,
+                          self.message['id'])
+
+    @test.attr(type=[base.TAG_NEGATIVE, base.TAG_API])
+    def test_show_nonexistent_message(self):
+        self.assertRaises(lib_exc.NotFound,
+                          self.shares_v2_client.get_message,
+                          six.text_type(uuidutils.generate_uuid()))
+
+    @test.attr(type=[base.TAG_NEGATIVE, base.TAG_API])
+    def test_delete_message_of_other_tenants(self):
+        isolated_client = self.get_client_with_isolated_creds(
+            type_of_creds='alt', client_version='2')
+        self.assertRaises(lib_exc.NotFound,
+                          isolated_client.delete_message,
+                          self.message['id'])
+
+    @test.attr(type=[base.TAG_NEGATIVE, base.TAG_API])
+    def test_delete_nonexistent_message(self):
+        self.assertRaises(lib_exc.NotFound,
+                          self.shares_v2_client.delete_message,
+                          six.text_type(uuidutils.generate_uuid()))
diff --git a/manila_tempest_tests/tests/api/base.py b/manila_tempest_tests/tests/api/base.py
index 3df7153..764a852 100644
--- a/manila_tempest_tests/tests/api/base.py
+++ b/manila_tempest_tests/tests/api/base.py
@@ -998,6 +998,25 @@
                                     "d2value": d2value
                                 })
 
+    def create_user_message(self):
+        """Trigger a 'no valid host' situation to generate a message."""
+        extra_specs = {
+            'vendor_name': 'foobar',
+            'driver_handles_share_servers': CONF.share.multitenancy_enabled,
+        }
+        share_type_name = data_utils.rand_name("share-type")
+
+        bogus_type = self.create_share_type(
+            name=share_type_name,
+            extra_specs=extra_specs)['share_type']
+
+        params = {'share_type_id': bogus_type['id'],
+                  'share_network_id': self.shares_v2_client.share_network_id}
+        share = self.shares_v2_client.create_share(**params)
+        self.addCleanup(self.shares_v2_client.delete_share, share['id'])
+        self.shares_v2_client.wait_for_share_status(share['id'], "error")
+        return self.shares_v2_client.wait_for_message(share['id'])
+
 
 class BaseSharesAltTest(BaseSharesTest):
     """Base test case class for all Shares Alt API tests."""