1import logging
2import sys
3from pathlib import Path
4
5import pytest
6
7logging.basicConfig(
8 level=logging.INFO,
9 format="%(asctime)s test %(levelname)s: %(message)s",
10 datefmt="%Y-%m-%d %H:%M:%S",
11)
12
13logger = logging.getLogger("ambassador")
14
15from ambassador import IR, Config
16from ambassador.fetch import ResourceFetcher
17from ambassador.ir.irerrorresponse import IRErrorResponse
18from ambassador.utils import NullSecretHandler
19
20
21def _status_code_filter_eq_obj(status_code):
22 return {
23 "status_code_filter": {
24 "comparison": {
25 "op": "EQ",
26 "value": {"default_value": f"{status_code}", "runtime_key": "_donotsetthiskey"},
27 }
28 }
29 }
30
31
32def _json_format_obj(json_format, content_type=None):
33 return {"json_format": json_format}
34
35
36def _text_format_obj(text, content_type=None):
37 obj = {"text_format": f"{text}"}
38 if content_type is not None:
39 obj["content_type"] = content_type
40 return obj
41
42
43def _text_format_source_obj(filename, content_type=None):
44 obj = {"text_format_source": {"filename": filename}}
45 if content_type is not None:
46 obj["content_type"] = content_type
47 return obj
48
49
50def _ambassador_module_config():
51 return """
52---
53apiVersion: getambassador.io/v3alpha1
54kind: Module
55name: ambassador
56config:
57"""
58
59
60def _ambassador_module_onemapper(status_code, body_kind, body_value, content_type=None):
61 mod = (
62 _ambassador_module_config()
63 + f"""
64 error_response_overrides:
65 - on_status_code: "{status_code}"
66 body:
67"""
68 )
69 if body_kind == "text_format_source":
70 mod = (
71 mod
72 + f"""
73 {body_kind}:
74 filename: "{body_value}"
75"""
76 )
77 elif body_kind == "json_format":
78 mod = (
79 mod
80 + f"""
81 {body_kind}: {body_value}
82"""
83 )
84 else:
85 mod = (
86 mod
87 + f"""
88 {body_kind}: "{body_value}"
89"""
90 )
91 if content_type is not None:
92 mod = (
93 mod
94 + f"""
95 content_type: "{content_type}"
96"""
97 )
98 return mod
99
100
101def _test_errorresponse(yaml, expectations, expect_fail=False):
102 aconf = Config()
103
104 fetcher = ResourceFetcher(logger, aconf)
105 fetcher.parse_yaml(yaml)
106
107 aconf.load_all(fetcher.sorted())
108
109 secret_handler = NullSecretHandler(logger, None, None, "0")
110
111 ir = IR(aconf, file_checker=lambda path: True, secret_handler=secret_handler)
112
113 error_response = IRErrorResponse(
114 ir, aconf, ir.ambassador_module.get("error_response_overrides", None), ir.ambassador_module
115 )
116
117 error_response.setup(ir, aconf)
118 if aconf.errors:
119 print("errors: %s" % repr(aconf.errors))
120
121 ir_conf = error_response.config()
122 if expect_fail:
123 assert ir_conf is None
124 return
125 assert ir_conf
126
127 # There should be no default body format override
128 body_format = ir_conf.get("body_format", None)
129 assert body_format is None
130
131 mappers = ir_conf.get("mappers", None)
132 assert mappers
133 assert len(mappers) == len(
134 expectations
135 ), f"unexpected len(mappers) {len(expectations)} != len(expectations) {len(expectations)}"
136
137 for i in range(len(expectations)):
138 expected_filter, expected_body_format_override = expectations[i]
139 m = mappers[i]
140
141 print(
142 "checking with expected_body_format_override %s and expected_filter %s"
143 % (expected_body_format_override, expected_filter)
144 )
145 print("checking m: ", m)
146 actual_filter = m["filter"]
147 assert m["filter"] == expected_filter
148 if expected_body_format_override:
149 actual_body_format_override = m["body_format_override"]
150 assert actual_body_format_override == expected_body_format_override
151
152
153def _test_errorresponse_onemapper(yaml, expected_filter, expected_body_format_override, fail=False):
154 return _test_errorresponse(
155 yaml, [(expected_filter, expected_body_format_override)], expect_fail=fail
156 )
157
158
159def _test_errorresponse_twomappers(yaml, expectation1, expectation2, fail=False):
160 return _test_errorresponse(yaml, [expectation1, expectation2], expect_fail=fail)
161
162
163def _test_errorresponse_onemapper_onstatuscode_textformat(status_code, text_format, fail=False):
164 _test_errorresponse_onemapper(
165 _ambassador_module_onemapper(status_code, "text_format", text_format),
166 _status_code_filter_eq_obj(status_code),
167 _text_format_obj(text_format),
168 fail=fail,
169 )
170
171
172def _test_errorresponse_onemapper_onstatuscode_textformat_contenttype(
173 status_code, text_format, content_type
174):
175 _test_errorresponse_onemapper(
176 _ambassador_module_onemapper(
177 status_code, "text_format", text_format, content_type=content_type
178 ),
179 _status_code_filter_eq_obj(status_code),
180 _text_format_obj(text_format, content_type=content_type),
181 )
182
183
184def _test_errorresponse_onemapper_onstatuscode_textformat_datasource(
185 status_code, text_format, source, content_type
186):
187
188 # in order for tests to pass the files (all located in /tmp) need to exist
189 try:
190 open(source, "x").close()
191 except OSError:
192 # the file already exists
193 pass
194
195 _test_errorresponse_onemapper(
196 _ambassador_module_onemapper(
197 status_code, "text_format_source", source, content_type=content_type
198 ),
199 _status_code_filter_eq_obj(status_code),
200 _text_format_source_obj(source, content_type=content_type),
201 )
202
203
204def _sanitize_json(json_format):
205 sanitized = {}
206 for k, v in json_format.items():
207 if isinstance(v, bool):
208 sanitized[k] = str(v).lower()
209 else:
210 sanitized[k] = str(v)
211 return sanitized
212
213
214def _test_errorresponse_onemapper_onstatuscode_jsonformat(status_code, json_format):
215 _test_errorresponse_onemapper(
216 _ambassador_module_onemapper(status_code, "json_format", json_format),
217 _status_code_filter_eq_obj(status_code),
218 # We expect the output json to be sanitized and contain the string representation
219 # of every value. We provide a basic implementation of string sanitizatino in this
220 # test, `sanitize_json`.
221 _json_format_obj(_sanitize_json(json_format)),
222 )
223
224
225def _test_errorresponse_twomappers_onstatuscode_textformat(code1, text1, code2, text2, fail=False):
226 _test_errorresponse_twomappers(
227 f"""
228---
229apiVersion: getambassador.io/v3alpha1
230kind: Module
231name: ambassador
232config:
233 error_response_overrides:
234 - on_status_code: "{code1}"
235 body:
236 text_format: {text1}
237 - on_status_code: {code2}
238 body:
239 text_format: {text2}
240""",
241 (_status_code_filter_eq_obj(code1), _text_format_obj(text1)),
242 (_status_code_filter_eq_obj(code2), _text_format_obj(text2)),
243 fail=fail,
244 )
245
246
247def _test_errorresponse_invalid_configs(yaml):
248 _test_errorresponse(yaml, list(), expect_fail=True)
249
250
251@pytest.mark.compilertest
252def test_errorresponse_twomappers_onstatuscode_textformat():
253 _test_errorresponse_twomappers_onstatuscode_textformat(
254 "400", "bad request my friend", "504", "waited too long for an upstream resonse"
255 )
256 _test_errorresponse_twomappers_onstatuscode_textformat("503", "boom", "403", "go away")
257
258
259@pytest.mark.compilertest
260def test_errorresponse_onemapper_onstatuscode_textformat():
261 _test_errorresponse_onemapper_onstatuscode_textformat(429, "429 the int")
262 _test_errorresponse_onemapper_onstatuscode_textformat("501", "five oh one")
263 _test_errorresponse_onemapper_onstatuscode_textformat("400", "bad req")
264 _test_errorresponse_onemapper_onstatuscode_textformat(
265 "429", "too fast, too furious on host %REQ(:authority)%"
266 )
267
268
269@pytest.mark.compilertest
270def test_errorresponse_invalid_envoy_operator():
271 _test_errorresponse_onemapper_onstatuscode_textformat(404, "%FAILME%", fail=True)
272
273
274@pytest.mark.compilertest
275def test_errorresponse_onemapper_onstatuscode_textformat_contenttype():
276 _test_errorresponse_onemapper_onstatuscode_textformat_contenttype("503", "oops", "text/what")
277 _test_errorresponse_onemapper_onstatuscode_textformat_contenttype(
278 "429", "<html>too fast, too furious on host %REQ(:authority)%</html>", "text/html"
279 )
280 _test_errorresponse_onemapper_onstatuscode_textformat_contenttype(
281 "404", "{'error':'notfound'}", "application/json"
282 )
283
284
285@pytest.mark.compilertest
286def test_errorresponse_onemapper_onstatuscode_jsonformat():
287 _test_errorresponse_onemapper_onstatuscode_jsonformat(
288 "501",
289 {
290 "response_code": "%RESPONSE_CODE%",
291 "upstream_cluster": "%UPSTREAM_CLUSTER%",
292 "badness": "yup",
293 },
294 )
295 # Test both a JSON object whose Python type has non-string primitives...
296 _test_errorresponse_onemapper_onstatuscode_jsonformat(
297 "401",
298 {
299 "unauthorized": "yeah",
300 "your_address": "%DOWNSTREAM_REMOTE_ADDRESS%",
301 "security_level": 9000,
302 "awesome": True,
303 "floaty": 0.75,
304 },
305 )
306 # ...and a JSON object where the Python type already has strings
307 _test_errorresponse_onemapper_onstatuscode_jsonformat(
308 "403",
309 {
310 "whoareyou": "dunno",
311 "your_address": "%DOWNSTREAM_REMOTE_ADDRESS%",
312 "security_level": "11000",
313 "awesome": "false",
314 "floaty": "0.95",
315 },
316 )
317
318
319@pytest.mark.compilertest
320def test_errorresponse_onemapper_onstatuscode_textformatsource(tmp_path: Path):
321 _test_errorresponse_onemapper_onstatuscode_textformat_datasource(
322 "400", "badness", str(tmp_path / "badness"), "text/plain"
323 )
324 _test_errorresponse_onemapper_onstatuscode_textformat_datasource(
325 "404", "badness", str(tmp_path / "notfound.dat"), "application/specialsauce"
326 )
327 _test_errorresponse_onemapper_onstatuscode_textformat_datasource(
328 "429", "2fast", str(tmp_path / "2fast.html"), "text/html"
329 )
330 _test_errorresponse_onemapper_onstatuscode_textformat_datasource(
331 "503", "something went wrong", str(tmp_path / "503.html"), "text/html; charset=UTF-8"
332 )
333
334
335@pytest.mark.compilertest
336def test_errorresponse_invalid_configs():
337 # status code must be an int
338 _test_errorresponse_invalid_configs(
339 _ambassador_module_config()
340 + f"""
341 error_response_overrides:
342 - on_status_code: bad
343 body:
344 text_format: 'good'
345"""
346 )
347 # cannot match on code < 400 nor >= 600
348 _test_errorresponse_invalid_configs(
349 _ambassador_module_config()
350 + f"""
351 error_response_overrides:
352 - on_status_code: 200
353 body:
354 text_format: 'good'
355"""
356 )
357 _test_errorresponse_invalid_configs(
358 _ambassador_module_config()
359 + f"""
360 error_response_overrides:
361 - on_status_code: 399
362 body:
363 text_format: 'good'
364"""
365 )
366 _test_errorresponse_invalid_configs(
367 _ambassador_module_config()
368 + f"""
369 error_response_overrides:
370 - on_status_code: 600
371 body:
372 text_format: 'good'
373"""
374 )
375 # body must be a dict
376 _test_errorresponse_invalid_configs(
377 _ambassador_module_config()
378 + f"""
379 error_response_overrides:
380 - on_status_code: 401
381 body: 'bad'
382"""
383 )
384 # body requires a valid format field
385 _test_errorresponse_invalid_configs(
386 _ambassador_module_config()
387 + f"""
388 error_response_overrides:
389 - on_status_code: 401
390 body:
391 bad: 'good'
392"""
393 )
394 # body field must be present
395 _test_errorresponse_invalid_configs(
396 _ambassador_module_config()
397 + f"""
398 error_response_overrides:
399 - on_status_code: 501
400 bad:
401 text_format: 'good'
402"""
403 )
404 # body field cannot be an empty dict
405 _test_errorresponse_invalid_configs(
406 _ambassador_module_config()
407 + f"""
408 error_response_overrides:
409 - on_status_code: 501
410 body: {{}}
411"""
412 )
413 # response override must be a non-empty array
414 _test_errorresponse_invalid_configs(
415 _ambassador_module_config()
416 + f"""
417 error_response_overrides: []
418"""
419 )
420 _test_errorresponse_invalid_configs(
421 _ambassador_module_config()
422 + f"""
423 error_response_overrides: 'great sadness'
424"""
425 )
426 # (not an array, bad)
427 _test_errorresponse_invalid_configs(
428 _ambassador_module_config()
429 + f"""
430 error_response_overrides:
431 on_status_code: 401
432 body:
433 text_format: 'good'
434"""
435 )
436 # text_format_source must have a single string 'filename'
437 _test_errorresponse_invalid_configs(
438 _ambassador_module_config()
439 + f"""
440 error_response_overrides:
441 - on_status_code: 401
442 body:
443 text_format_source: "this obviously cannot be a string"
444"""
445 )
446 _test_errorresponse_invalid_configs(
447 _ambassador_module_config()
448 + f"""
449 error_response_overrides:
450 - on_status_code: 401
451 body:
452 text_format_source:
453 filename: []
454"""
455 )
456 _test_errorresponse_invalid_configs(
457 _ambassador_module_config()
458 + f"""
459 error_response_overrides:
460 - on_status_code: 401
461 body:
462 text_format_source:
463 notfilename: "/tmp/good"
464"""
465 )
466 # json_format field must be an object field
467 _test_errorresponse_invalid_configs(
468 _ambassador_module_config()
469 + f"""
470 error_response_overrides:
471 - on_status_code: 401
472 body:
473 json_format: "this also cannot be a string"
474"""
475 )
476 # json_format cannot have values that do not cast to string trivially
477 _test_errorresponse_invalid_configs(
478 _ambassador_module_config()
479 + f"""
480 error_response_overrides:
481 - on_status_code: 401
482 body:
483 json_format:
484 "x":
485 "yo": 1
486 "field": "good"
487"""
488 )
489 _test_errorresponse_invalid_configs(
490 _ambassador_module_config()
491 + f"""
492 error_response_overrides:
493 - on_status_code: 401
494 body:
495 json_format:
496 "a": []
497 "x": true
498"""
499 )
500 # content type, if it exists, must be a string
501 _test_errorresponse_invalid_configs(
502 _ambassador_module_config()
503 + f"""
504 error_response_overrides:
505 - on_status_code: 401
506 body:
507 text_format: "good"
508 content_type: []
509"""
510 )
511 _test_errorresponse_invalid_configs(
512 _ambassador_module_config()
513 + f"""
514 error_response_overrides:
515 - on_status_code: 401
516 body:
517 text_format: "good"
518 content_type: 4.2
519"""
520 )
521 # only one of text_format, json_format, or text_format_source may be set
522 _test_errorresponse_invalid_configs(
523 _ambassador_module_config()
524 + f"""
525 error_response_overrides:
526 - on_status_code: 401
527 body:
528 text_format: "bad"
529 json_format:
530 "bad": 1
531 "invalid": "bad"
532"""
533 )
534 _test_errorresponse_invalid_configs(
535 _ambassador_module_config()
536 + f"""
537 error_response_overrides:
538 - on_status_code: 401
539 body:
540 text_format: "goodgood"
541 text_format_source:
542 filename: "/etc/issue"
543"""
544 )
545
546
547if __name__ == "__main__":
548 pytest.main(sys.argv)
View as plain text