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
Bug
introduced
by
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 |