...

Text file src/github.com/emissary-ingress/emissary/v3/python/tests/integration/test_header_case_overrides.py

Documentation: github.com/emissary-ingress/emissary/v3/python/tests/integration

     1import json
     2import logging
     3import sys
     4import time
     5
     6import pytest
     7import requests
     8
     9from tests.integration.utils import create_httpbin_mapping, install_ambassador
    10from tests.kubeutils import apply_kube_artifacts
    11from tests.manifests import httpbin_manifests
    12from tests.runutils import run_and_assert
    13
    14logging.basicConfig(
    15    level=logging.INFO,
    16    format="%(asctime)s test %(levelname)s: %(message)s",
    17    datefmt="%Y-%m-%d %H:%M:%S",
    18)
    19
    20logger = logging.getLogger("ambassador")
    21
    22from ambassador import IR, Config
    23from ambassador.envoy import EnvoyConfig
    24from ambassador.fetch import ResourceFetcher
    25from ambassador.utils import NullSecretHandler
    26
    27headerecho_manifests = """
    28---
    29apiVersion: v1
    30kind: Service
    31metadata:
    32  name: headerecho
    33spec:
    34  type: ClusterIP
    35  selector:
    36    service: headerecho
    37  ports:
    38  - port: 80
    39    targetPort: http
    40---
    41apiVersion: apps/v1
    42kind: Deployment
    43metadata:
    44  name: headerecho
    45spec:
    46  replicas: 1
    47  selector:
    48    matchLabels:
    49      service: headerecho
    50  template:
    51    metadata:
    52      labels:
    53        service: headerecho
    54    spec:
    55      containers:
    56      - name: headerecho
    57        # We should find a better home for this image.
    58        image: johnesmet/simple-header-echo
    59        ports:
    60        - name: http
    61          containerPort: 8080
    62"""
    63
    64
    65def create_headerecho_mapping(namespace):
    66    headerecho_mapping = f"""
    67---
    68apiVersion: getambassador.io/v3alpha1
    69kind: Mapping
    70metadata:
    71  name:  headerecho-mapping
    72  namespace: {namespace}
    73spec:
    74  hostname: "*"
    75  prefix: /headerecho/
    76  rewrite: /
    77  service: headerecho
    78"""
    79
    80    apply_kube_artifacts(namespace=namespace, artifacts=headerecho_mapping)
    81
    82
    83def _ambassador_module_config():
    84    return """
    85---
    86apiVersion: getambassador.io/v3alpha1
    87kind: Module
    88metadata:
    89  name: ambassador
    90  namespace: default
    91spec:
    92  config:
    93"""
    94
    95
    96def _ambassador_module_header_case_overrides(overrides, proper_case=False):
    97    mod = _ambassador_module_config()
    98    if len(overrides) == 0:
    99        mod = (
   100            mod
   101            + """
   102    header_case_overrides: []
   103"""
   104        )
   105        return mod
   106
   107    mod = (
   108        mod
   109        + """
   110    header_case_overrides:
   111"""
   112    )
   113    for override in overrides:
   114        mod = (
   115            mod
   116            + f"""
   117    - {override}
   118"""
   119        )
   120    # proper case isn't valid if header_case_overrides are set, but we do
   121    # it here for tests that want to test that this is in fact invalid.
   122    if proper_case:
   123        mod = (
   124            mod
   125            + f"""
   126      proper_case: true
   127"""
   128        )
   129    return mod
   130
   131
   132def _test_headercaseoverrides(yaml, expectations, expect_norules=False):
   133    aconf = Config()
   134
   135    yaml = (
   136        yaml
   137        + """
   138---
   139apiVersion: getambassador.io/v3alpha1
   140kind: Listener
   141metadata:
   142  name: ambassador-listener-8080
   143  namespace: default
   144spec:
   145  port: 8080
   146  protocol: HTTPS
   147  securityModel: XFP
   148  hostBinding:
   149    namespace:
   150      from: ALL
   151
   152---
   153apiVersion: getambassador.io/v3alpha1
   154kind: Mapping
   155metadata:
   156  name: httpbin-mapping
   157  namespace: default
   158spec:
   159  service: httpbin
   160  hostname: "*"
   161  prefix: /httpbin/
   162"""
   163    )
   164
   165    fetcher = ResourceFetcher(logger, aconf)
   166    fetcher.parse_yaml(yaml, k8s=True)
   167
   168    aconf.load_all(fetcher.sorted())
   169
   170    secret_handler = NullSecretHandler(logger, None, None, "0")
   171
   172    ir = IR(aconf, file_checker=lambda path: True, secret_handler=secret_handler)
   173    assert ir
   174
   175    econf = EnvoyConfig.generate(ir)
   176    assert econf, "could not create an econf"
   177
   178    found_module_rules = False
   179    found_cluster_rules = False
   180    conf = econf.as_dict()
   181
   182    for listener in conf["static_resources"]["listeners"]:
   183        for filter_chain in listener["filter_chains"]:
   184            for f in filter_chain["filters"]:
   185                typed_config = f["typed_config"]
   186                if "http_protocol_options" not in typed_config:
   187                    continue
   188
   189                http_protocol_options = typed_config["http_protocol_options"]
   190                if expect_norules:
   191                    assert (
   192                        "header_key_format" not in http_protocol_options
   193                    ), f"'header_key_format' found unexpected typed_config {typed_config}"
   194                    continue
   195
   196                assert (
   197                    "header_key_format" in http_protocol_options
   198                ), f"'header_key_format' not found, typed_config {typed_config}"
   199
   200                header_key_format = http_protocol_options["header_key_format"]
   201                assert (
   202                    "custom" in header_key_format
   203                ), f"'custom' not found, typed_config {typed_config}"
   204
   205                rules = header_key_format["custom"]["rules"]
   206                assert len(rules) == len(expectations)
   207                for e in expectations:
   208                    hdr = e.lower()
   209                    assert hdr in rules
   210                    rule = rules[hdr]
   211                    assert rule == e, f"unexpected rule {rule} in {rules}"
   212                found_module_rules = True
   213
   214    for cluster in conf["static_resources"]["clusters"]:
   215        if "httpbin" not in cluster["name"]:
   216            continue
   217
   218        http_protocol_options = cluster.get("http_protocol_options", None)
   219        if not http_protocol_options:
   220            if expect_norules:
   221                continue
   222            assert (
   223                "http_protocol_options" in cluster
   224            ), f"'http_protocol_options' missing from cluster: {cluster}"
   225
   226        if expect_norules:
   227            assert (
   228                "header_key_format" not in http_protocol_options
   229            ), f"'header_key_format' found unexpected cluster: {cluster}"
   230            continue
   231
   232        assert (
   233            "header_key_format" in http_protocol_options
   234        ), f"'header_key_format' not found, cluster {cluster}"
   235
   236        header_key_format = http_protocol_options["header_key_format"]
   237        assert "custom" in header_key_format, f"'custom' not found, cluster {cluster}"
   238
   239        rules = header_key_format["custom"]["rules"]
   240        assert len(rules) == len(expectations)
   241        for e in expectations:
   242            hdr = e.lower()
   243            assert hdr in rules
   244            rule = rules[hdr]
   245            assert rule == e, f"unexpected rule {rule} in {rules}"
   246        found_cluster_rules = True
   247
   248    if expect_norules:
   249        assert not found_module_rules
   250        assert not found_cluster_rules
   251    else:
   252        assert found_module_rules
   253        assert found_cluster_rules
   254
   255
   256def _test_headercaseoverrides_rules(rules, expected=None, expect_norules=False):
   257    if not expected:
   258        expected = rules
   259    _test_headercaseoverrides(
   260        _ambassador_module_header_case_overrides(rules),
   261        expected,
   262        expect_norules=expect_norules,
   263    )
   264
   265
   266# Test that we throw assertions for obviously wrong cases
   267@pytest.mark.compilertest
   268def test_testsanity():
   269    failed = False
   270    try:
   271        _test_headercaseoverrides_rules(["X-ABC"], expected=["X-Wrong"])
   272    except AssertionError as e:
   273        failed = True
   274    assert failed
   275
   276    failed = False
   277    try:
   278        _test_headercaseoverrides_rules([], expected=["X-Wrong"])
   279    except AssertionError as e:
   280        failed = True
   281    assert failed
   282
   283
   284# Test that we can parse a variety of header case override arrays.
   285@pytest.mark.compilertest
   286def test_headercaseoverrides_basic():
   287    _test_headercaseoverrides_rules([], expect_norules=True)
   288    _test_headercaseoverrides_rules([{}], expect_norules=True)
   289    _test_headercaseoverrides_rules([5], expect_norules=True)
   290    _test_headercaseoverrides_rules(["X-ABC"])
   291    _test_headercaseoverrides_rules(["X-foo", "X-ABC-Baz"])
   292    _test_headercaseoverrides_rules(["x-goOd", "X-alSo-good", "Authorization"])
   293    _test_headercaseoverrides_rules(["x-good", ["hello"]], expected=["x-good"])
   294    _test_headercaseoverrides_rules(["X-ABC", "x-foo", 5, {}], expected=["X-ABC", "x-foo"])
   295
   296
   297# Test that we always omit header case overrides if proper case is set
   298@pytest.mark.compilertest
   299def test_headercaseoverrides_propercasefail():
   300    _test_headercaseoverrides(
   301        _ambassador_module_header_case_overrides(["My-OPINIONATED-CASING"], proper_case=True),
   302        [],
   303        expect_norules=True,
   304    )
   305    _test_headercaseoverrides(
   306        _ambassador_module_header_case_overrides([], proper_case=True),
   307        [],
   308        expect_norules=True,
   309    )
   310    _test_headercaseoverrides(
   311        _ambassador_module_header_case_overrides([{"invalid": "true"}, "X-COOL"], proper_case=True),
   312        [],
   313        expect_norules=True,
   314    )
   315
   316
   317class HeaderCaseOverridesTesting:
   318    def create_module(self, namespace):
   319        manifest = f"""
   320---
   321apiVersion: getambassador.io/v3alpha1
   322kind: Module
   323metadata:
   324  name: ambassador
   325spec:
   326  config:
   327    header_case_overrides:
   328    - X-HELLO
   329    - X-FOO-Bar
   330        """
   331
   332        apply_kube_artifacts(namespace=namespace, artifacts=manifest)
   333
   334    def create_listeners(self, namespace):
   335        manifest = f"""
   336---
   337apiVersion: getambassador.io/v3alpha1
   338kind: Listener
   339metadata:
   340  name: listener-8080
   341spec:
   342  port: 8080
   343  protocol: HTTP
   344  securityModel: INSECURE
   345  hostBinding:
   346    namespace:
   347      from: SELF
   348"""
   349
   350        apply_kube_artifacts(namespace=namespace, artifacts=manifest)
   351
   352    def test_header_case_overrides(self):
   353        # Is there any reason not to use the default namespace?
   354        namespace = "header-case-overrides"
   355
   356        # Install Ambassador
   357        install_ambassador(namespace=namespace)
   358
   359        # Install httpbin
   360        apply_kube_artifacts(namespace=namespace, artifacts=httpbin_manifests)
   361
   362        # Install headerecho
   363        apply_kube_artifacts(namespace=namespace, artifacts=headerecho_manifests)
   364
   365        # Install listeners.
   366        self.create_listeners(namespace)
   367
   368        # Install module
   369        self.create_module(namespace)
   370
   371        # Install httpbin mapping
   372        create_httpbin_mapping(namespace=namespace)
   373
   374        # Install headerecho mapping
   375        create_headerecho_mapping(namespace=namespace)
   376
   377        # Now let's wait for ambassador and httpbin pods to become ready
   378        run_and_assert(
   379            [
   380                "tools/bin/kubectl",
   381                "wait",
   382                "--timeout=90s",
   383                "--for=condition=Ready",
   384                "pod",
   385                "-l",
   386                "service=ambassador",
   387                "-n",
   388                namespace,
   389            ]
   390        )
   391        run_and_assert(
   392            [
   393                "tools/bin/kubectl",
   394                "wait",
   395                "--timeout=90s",
   396                "--for=condition=Ready",
   397                "pod",
   398                "-l",
   399                "service=httpbin",
   400                "-n",
   401                namespace,
   402            ]
   403        )
   404
   405        # Assume we can reach Ambassador through telepresence
   406        ambassador_host = "ambassador." + namespace
   407
   408        # Assert 200 OK at httpbin/status/200 endpoint
   409        ready = False
   410        httpbin_url = f"http://{ambassador_host}/httpbin/status/200"
   411        headerecho_url = f"http://{ambassador_host}/headerecho/"
   412
   413        loop_limit = 10
   414        while not ready:
   415            assert loop_limit > 0, "httpbin is not ready yet, aborting..."
   416            try:
   417                print(f"trying {httpbin_url}...")
   418                resp = requests.get(httpbin_url, timeout=5)
   419                code = resp.status_code
   420                assert code == 200, f"Expected 200 OK, got {code}"
   421                resp.close()
   422                print(f"{httpbin_url} is ready")
   423
   424                print(f"trying {headerecho_url}...")
   425                resp = requests.get(headerecho_url, timeout=5)
   426                code = resp.status_code
   427                assert code == 200, f"Expected 200 OK, got {code}"
   428                resp.close()
   429                print(f"{headerecho_url} is ready")
   430
   431                ready = True
   432
   433            except Exception as e:
   434                print(f"Error: {e}")
   435                print(f"{httpbin_url} not ready yet, trying again...")
   436                time.sleep(1)
   437                loop_limit -= 1
   438
   439        assert ready
   440
   441        httpbin_url = f"http://{ambassador_host}/httpbin/response-headers?x-Hello=1&X-foo-Bar=1&x-Lowercase1=1&x-lowercase2=1"
   442        resp = requests.get(httpbin_url, timeout=5)
   443        code = resp.status_code
   444        assert code == 200, f"Expected 200 OK, got {code}"
   445
   446        # First, test that the response headers have the correct case.
   447
   448        # Very important: this test relies on matching case sensitive header keys.
   449        # Fortunately it appears that we can convert resp.headers, a case insensitive
   450        # dictionary, into a list of case sensitive keys.
   451        keys = [h for h in resp.headers.keys()]
   452        for k in keys:
   453            print(f"header key: {k}")
   454
   455        assert "x-hello" not in keys
   456        assert "X-HELLO" in keys
   457        assert "x-foo-bar" not in keys
   458        assert "X-FOO-Bar" in keys
   459        assert "x-lowercase1" in keys
   460        assert "x-Lowercase1" not in keys
   461        assert "x-lowercase2" in keys
   462        resp.close()
   463
   464        # Second, test that the request headers sent to the headerecho server
   465        # have the correct case.
   466
   467        headers = {"x-Hello": "1", "X-foo-Bar": "1", "x-Lowercase1": "1", "x-lowercase2": "1"}
   468        resp = requests.get(headerecho_url, headers=headers, timeout=5)
   469        code = resp.status_code
   470        assert code == 200, f"Expected 200 OK, got {code}"
   471
   472        response_obj = json.loads(resp.text)
   473        print(f"response_obj = {response_obj}")
   474        assert response_obj
   475        assert "headers" in response_obj
   476
   477        hdrs = response_obj["headers"]
   478        assert "x-hello" not in hdrs
   479        assert "X-HELLO" in hdrs
   480        assert "x-foo-bar" not in hdrs
   481        assert "X-FOO-Bar" in hdrs
   482        assert "x-lowercase1" in hdrs
   483        assert "x-Lowercase1" not in hdrs
   484        assert "x-lowercase2" in hdrs
   485
   486
   487@pytest.mark.flaky(reruns=1, reruns_delay=10)
   488def test_ambassador_headercaseoverrides():
   489    t = HeaderCaseOverridesTesting()
   490    t.test_header_case_overrides()
   491
   492
   493if __name__ == "__main__":
   494    pytest.main(sys.argv)

View as plain text