Test Failed
Push — master ( d86747...44c495 )
by Tomaz
03:37 queued 10s
created

st2tests/st2tests/api.py (1 issue)

Labels
Severity
1
# Licensed to the StackStorm, Inc ('StackStorm') under one or more
2
# contributor license agreements.  See the NOTICE file distributed with
3
# this work for additional information regarding copyright ownership.
4
# The ASF licenses this file to You under the Apache License, Version 2.0
5
# (the "License"); you may not use this file except in compliance with
6
# the License.  You may obtain a copy of the License at
7
#
8
#     http://www.apache.org/licenses/LICENSE-2.0
9
#
10
# Unless required by applicable law or agreed to in writing, software
11
# distributed under the License is distributed on an "AS IS" BASIS,
12
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
# See the License for the specific language governing permissions and
14
# limitations under the License.
15
16
"""
17
Various base classes and test utility functions for API related tests.
18
"""
19
20
from __future__ import absolute_import
21
22
import json
23
24
import six
25
import webtest
26
import mock
27
28
from oslo_config import cfg
29
30
from st2common.router import Router
31
from st2common.bootstrap import runnersregistrar as runners_registrar
32
from st2tests.base import DbTestCase
33
from st2tests.base import CleanDbTestCase
34
from st2tests import config as tests_config
35
36
__all__ = [
37
    'BaseFunctionalTest',
38
39
    'FunctionalTest',
40
    'APIControllerWithIncludeAndExcludeFilterTestCase',
41
    'BaseInquiryControllerTestCase',
42
43
    'FakeResponse',
44
    'TestApp'
45
]
46
47
48
SUPER_SECRET_PARAMETER = 'SUPER_SECRET_PARAMETER_THAT_SHOULD_NEVER_APPEAR_IN_RESPONSES_OR_LOGS'
49
ANOTHER_SUPER_SECRET_PARAMETER = 'ANOTHER_SUPER_SECRET_PARAMETER_TO_TEST_OVERRIDING'
50
51
52
class ResponseValidationError(ValueError):
53
    pass
54
55
56
class ResponseLeakError(ValueError):
57
    pass
58
59
60
class TestApp(webtest.TestApp):
61
    def do_request(self, req, **kwargs):
62
        self.cookiejar.clear()
63
64
        if req.environ['REQUEST_METHOD'] != 'OPTIONS':
65
            # Making sure endpoint handles OPTIONS method properly
66
            self.options(req.environ['PATH_INFO'])
67
68
        res = super(TestApp, self).do_request(req, **kwargs)
69
70
        if res.headers.get('Warning', None):
71
            raise ResponseValidationError('Endpoint produced invalid response. Make sure the '
72
                                          'response matches OpenAPI scheme for the endpoint.')
73
74
        if not kwargs.get('expect_errors', None):
75
            try:
76
                body = res.body
77
            except AssertionError as e:
78
                if 'Iterator read after closed' in six.text_type(e):
79
                    body = b''
80
                else:
81
                    raise e
82
83
            if six.b(SUPER_SECRET_PARAMETER) in body or \
84
                    six.b(ANOTHER_SUPER_SECRET_PARAMETER) in body:
85
                raise ResponseLeakError('Endpoint response contains secret parameter. '
86
                                        'Find the leak.')
87
88
        if 'Access-Control-Allow-Origin' not in res.headers:
89
            raise ResponseValidationError('Response missing a required CORS header')
90
91
        return res
92
93
94
class BaseFunctionalTest(DbTestCase):
95
    """
96
    Base test case class for testing API controllers with auth and RBAC disabled.
97
    """
98
99
    # App used by the tests
100
    app_module = None
101
102
    # By default auth is disabled
103
    enable_auth = False
104
105
    register_runners = True
106
107
    @classmethod
108
    def setUpClass(cls):
109
        super(BaseFunctionalTest, cls).setUpClass()
110
        cls._do_setUpClass()
111
112
    def tearDown(self):
113
        super(BaseFunctionalTest, self).tearDown()
114
115
        # Reset mock context for API requests
116
        if getattr(self, 'request_context_mock', None):
117
            self.request_context_mock.stop()
118
119
            if hasattr(Router, 'mock_context'):
120
                del(Router.mock_context)
121
122
    @classmethod
123
    def _do_setUpClass(cls):
124
        tests_config.parse_args()
125
126
        cfg.CONF.set_default('enable', cls.enable_auth, group='auth')
127
128
        cfg.CONF.set_override(name='enable', override=False, group='rbac')
129
130
        # TODO(manas) : register action types here for now. RunnerType registration can be moved
131
        # to posting to /runnertypes but that implies implementing POST.
132
        if cls.register_runners:
133
            runners_registrar.register_runners()
134
135
        cls.app = TestApp(cls.app_module.setup_app())
136
137
    def use_user(self, user_db):
138
        """
139
        Select a user which is to be used by the HTTP request following this call.
140
        """
141
        if not user_db:
142
            raise ValueError('"user_db" is mandatory')
143
144
        mock_context = {
145
            'user': user_db,
146
            'auth_info': {
147
                'method': 'authentication token',
148
                'location': 'header'
149
            }
150
        }
151
        self.request_context_mock = mock.PropertyMock(return_value=mock_context)
152
        Router.mock_context = self.request_context_mock
153
154
155
class FunctionalTest(BaseFunctionalTest):
156
    from st2api import app
157
158
    app_module = app
159
160
161
# pylint: disable=no-member
162
class APIControllerWithIncludeAndExcludeFilterTestCase(object):
163
    """
164
    Base class which is to be inherited from the API controller test cases which support
165
    ?exclude_filters and ?include_filters query param filters.
166
    """
167
168
    # Controller get all path (e.g. "/v1/actions", /v1/rules, etc)
169
    get_all_path = None
170
171
    # API controller class
172
    controller_cls = None
173
174
    # Name of the model field to filter on
175
    include_attribute_field_name = None
176
177
    # Name of the model field to filter on
178
    exclude_attribute_field_name = None
179
180
    # True to assert that the object count in the response matches count returned by
181
    # _get_model_instance method method
182
    test_exact_object_count = True
183
184
    def test_get_all_exclude_attributes_and_include_attributes_are_mutually_exclusive(self):
185
        url = self.get_all_path + '?include_attributes=id&exclude_attributes=id'
186
        resp = self.app.get(url, expect_errors=True)
187
        self.assertEqual(resp.status_int, 400)
188
        expected_msg = ('exclude.*? and include.*? arguments are mutually exclusive. '
189
                        'You need to provide either one or another, but not both.')
190
        self.assertRegexpMatches(resp.json['faultstring'], expected_msg)
191
192
    def test_get_all_invalid_exclude_and_include_parameter(self):
193
        # 1. Invalid exclude_attributes field
194
        url = self.get_all_path + '?exclude_attributes=invalid_field'
195
        resp = self.app.get(url, expect_errors=True)
196
197
        expected_msg = ('Invalid or unsupported exclude attribute specified: .*invalid_field.*')
198
        self.assertEqual(resp.status_int, 400)
199
        self.assertRegexpMatches(resp.json['faultstring'], expected_msg)
200
201
        # 2. Invalid include_attributes field
202
        url = self.get_all_path + '?include_attributes=invalid_field'
203
        resp = self.app.get(url, expect_errors=True)
204
205
        expected_msg = ('Invalid or unsupported include attribute specified: .*invalid_field.*')
206
        self.assertEqual(resp.status_int, 400)
207
        self.assertRegexpMatches(resp.json['faultstring'], expected_msg)
208
209
    def test_get_all_include_attributes_filter(self):
210
        mandatory_include_fields = self.controller_cls.mandatory_include_fields_response
211
212
        # Create any resources needed by those tests (if not already created inside setUp /
213
        # setUpClass)
214
        object_ids = self._insert_mock_models()
215
216
        # Valid include attribute  - mandatory field which should always be included
217
        resp = self.app.get('%s?include_attributes=%s' % (self.get_all_path,
218
                                                          mandatory_include_fields[0]))
219
220
        self.assertEqual(resp.status_int, 200)
221
        self.assertTrue(len(resp.json) >= 1)
222
223
        if self.test_exact_object_count:
224
            self.assertEqual(len(resp.json), len(object_ids))
225
226
        self.assertEqual(len(resp.json[0].keys()), len(mandatory_include_fields))
227
228
        # Verify all mandatory fields are include
229
        for field in mandatory_include_fields:
230
            self.assertResponseObjectContainsField(resp.json[0], field)
231
232
        # Valid include attribute - not a mandatory field
233
        include_field = self.include_attribute_field_name
234
        assert include_field not in mandatory_include_fields
235
236
        resp = self.app.get('%s?include_attributes=%s' % (self.get_all_path, include_field))
237
238
        self.assertEqual(resp.status_int, 200)
239
        self.assertTrue(len(resp.json) >= 1)
240
241
        if self.test_exact_object_count:
242
            self.assertEqual(len(resp.json), len(object_ids))
243
244
        self.assertEqual(len(resp.json[0].keys()), len(mandatory_include_fields) + 1)
245
246
        for field in [include_field] + mandatory_include_fields:
247
            self.assertResponseObjectContainsField(resp.json[0], field)
248
249
        # Delete mock resources
250
        self._delete_mock_models(object_ids)
251
252
    def test_get_all_exclude_attributes_filter(self):
253
        # Create any resources needed by those tests (if not already created inside setUp /
254
        # setUpClass)
255
        object_ids = self._insert_mock_models()
256
257
        # Valid exclude attribute
258
259
        # 1. Verify attribute is present when no filter is provided
260
        exclude_attribute = self.exclude_attribute_field_name
261
        resp = self.app.get(self.get_all_path)
262
263
        self.assertEqual(resp.status_int, 200)
264
        self.assertTrue(len(resp.json) >= 1)
265
266
        if self.test_exact_object_count:
267
            self.assertEqual(len(resp.json), len(object_ids))
268
269
        self.assertTrue(exclude_attribute in resp.json[0])
270
271
        # 2. Verify attribute is excluded when filter is provided
272
        exclude_attribute = self.exclude_attribute_field_name
273
        resp = self.app.get('%s?exclude_attributes=%s' % (self.get_all_path,
274
                                                          exclude_attribute))
275
276
        self.assertEqual(resp.status_int, 200)
277
        self.assertTrue(len(resp.json) >= 1)
278
279
        if self.test_exact_object_count:
280
            self.assertEqual(len(resp.json), len(object_ids))
281
282
        self.assertFalse(exclude_attribute in resp.json[0])
283
284
        self._delete_mock_models(object_ids)
285
286
    def assertResponseObjectContainsField(self, resp_item, field):
287
        # Handle "." and nested fields
288
        if '.' in field:
289
            split = field.split('.')
290
291
            for index, field_part in enumerate(split):
292
                self.assertTrue(field_part in resp_item)
293
                resp_item = resp_item[field_part]
294
295
            # Additional safety check
296
            self.assertEqual(index, len(split) - 1)
0 ignored issues
show
The loop variable index might not be defined here.
Loading history...
297
        else:
298
            self.assertTrue(field in resp_item)
299
300
    def _insert_mock_models(self):
301
        """
302
        Insert mock models used for get all filter tests.
303
304
        If the test class inserts mock models inside setUp / setUpClass method, this function
305
        should just return the ids of inserted models.
306
        """
307
        return []
308
309
    def _delete_mock_models(self, object_ids):
310
        """
311
        Delete mock models / objects used by get all filter tests.
312
313
        If the test class inserts mock models inside setUp / setUpClass method, this method should
314
        be overridden and made a no-op.
315
        """
316
        for object_id in object_ids:
317
            self._do_delete(object_id)
318
319
    def _do_delete(self, object_id):
320
        pass
321
322
323
class FakeResponse(object):
324
325
    def __init__(self, text, status_code, reason):
326
        self.text = text
327
        self.status_code = status_code
328
        self.reason = reason
329
330
    def json(self):
331
        return json.loads(self.text)
332
333
    def raise_for_status(self):
334
        raise Exception(self.reason)
335
336
337
class BaseActionExecutionControllerTestCase(object):
338
    app = None
339
340
    @staticmethod
341
    def _get_actionexecution_id(resp):
342
        return resp.json['id']
343
344
    @staticmethod
345
    def _get_liveaction_id(resp):
346
        return resp.json['liveaction']['id']
347
348
    def _do_get_one(self, actionexecution_id, *args, **kwargs):
349
        return self.app.get('/v1/executions/%s' % actionexecution_id, *args, **kwargs)
350
351
    def _do_post(self, liveaction, *args, **kwargs):
352
        return self.app.post_json('/v1/executions', liveaction, *args, **kwargs)
353
354
    def _do_delete(self, actionexecution_id, expect_errors=False):
355
        return self.app.delete('/v1/executions/%s' % actionexecution_id,
356
                               expect_errors=expect_errors)
357
358
    def _do_put(self, actionexecution_id, updates, *args, **kwargs):
359
        return self.app.put_json('/v1/executions/%s' % actionexecution_id, updates, *args, **kwargs)
360
361
362
class BaseInquiryControllerTestCase(BaseFunctionalTest, CleanDbTestCase):
363
    """
364
    Base class for non-RBAC tests for Inquiry API
365
366
    Inherits from CleanDbTestCase to preserve atomicity between tests
367
    """
368
    from st2api import app
369
370
    enable_auth = False
371
    app_module = app
372
373
    @staticmethod
374
    def _get_inquiry_id(resp):
375
        return resp.json['id']
376
377
    def _do_get_execution(self, actionexecution_id, *args, **kwargs):
378
        return self.app.get('/v1/executions/%s' % actionexecution_id, *args, **kwargs)
379
380
    def _do_get_one(self, inquiry_id, *args, **kwargs):
381
        return self.app.get('/v1/inquiries/%s' % inquiry_id, *args, **kwargs)
382
383
    def _do_get_all(self, limit=50, *args, **kwargs):
384
        return self.app.get('/v1/inquiries/?limit=%s' % limit, *args, **kwargs)
385
386
    def _do_respond(self, inquiry_id, response, *args, **kwargs):
387
        payload = {
388
            "id": inquiry_id,
389
            "response": response
390
        }
391
        return self.app.put_json('/v1/inquiries/%s' % inquiry_id, payload, *args, **kwargs)
392
393
    def _do_create_inquiry(self, liveaction, result, status='pending', *args, **kwargs):
394
        post_resp = self.app.post_json('/v1/executions', liveaction, *args, **kwargs)
395
        inquiry_id = self._get_inquiry_id(post_resp)
396
        updates = {'status': status, 'result': result}
397
        return self.app.put_json('/v1/executions/%s' % inquiry_id, updates, *args, **kwargs)
398