...

Text file src/github.com/datawire/ambassador/v2/python/tests/integration/test_header_case_overrides.py

Documentation: github.com/datawire/ambassador/v2/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, version="V3"):
   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, version)
   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, version="V3"):
   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        version=version,
   264    )
   265
   266
   267# Test that we throw assertions for obviously wrong cases
   268@pytest.mark.compilertest
   269def test_testsanity():
   270    failed = False
   271    for version in ["V2", "V3"]:
   272        try:
   273            _test_headercaseoverrides_rules(["X-ABC"], expected=["X-Wrong"], version=version)
   274        except AssertionError as e:
   275            failed = True
   276        assert failed
   277
   278        failed = False
   279        try:
   280            _test_headercaseoverrides_rules([], expected=["X-Wrong"], version=version)
   281        except AssertionError as e:
   282            failed = True
   283    assert failed
   284
   285
   286# Test that we can parse a variety of header case override arrays.
   287@pytest.mark.compilertest
   288def test_headercaseoverrides_basic():
   289    for version in ["V2", "V3"]:
   290        _test_headercaseoverrides_rules([], expect_norules=True, version=version)
   291        _test_headercaseoverrides_rules([{}], expect_norules=True, version=version)
   292        _test_headercaseoverrides_rules([5], expect_norules=True, version=version)
   293        _test_headercaseoverrides_rules(["X-ABC"], version=version)
   294        _test_headercaseoverrides_rules(["X-foo", "X-ABC-Baz"], version=version)
   295        _test_headercaseoverrides_rules(["x-goOd", "X-alSo-good", "Authorization"], version=version)
   296        _test_headercaseoverrides_rules(["x-good", ["hello"]], expected=["x-good"], version=version)
   297        _test_headercaseoverrides_rules(
   298            ["X-ABC", "x-foo", 5, {}], expected=["X-ABC", "x-foo"], version=version
   299        )
   300
   301
   302# Test that we always omit header case overrides if proper case is set
   303@pytest.mark.compilertest
   304def test_headercaseoverrides_propercasefail():
   305    for version in ["V2", "V3"]:
   306        _test_headercaseoverrides(
   307            _ambassador_module_header_case_overrides(["My-OPINIONATED-CASING"], proper_case=True),
   308            [],
   309            expect_norules=True,
   310            version=version,
   311        )
   312        _test_headercaseoverrides(
   313            _ambassador_module_header_case_overrides([], proper_case=True),
   314            [],
   315            expect_norules=True,
   316            version=version,
   317        )
   318        _test_headercaseoverrides(
   319            _ambassador_module_header_case_overrides(
   320                [{"invalid": "true"}, "X-COOL"], proper_case=True
   321            ),
   322            [],
   323            expect_norules=True,
   324            version=version,
   325        )
   326
   327
   328class HeaderCaseOverridesTesting:
   329    def create_module(self, namespace):
   330        manifest = f"""
   331---
   332apiVersion: getambassador.io/v3alpha1
   333kind: Module
   334metadata:
   335  name: ambassador
   336spec:
   337  config:
   338    header_case_overrides:
   339    - X-HELLO
   340    - X-FOO-Bar
   341        """
   342
   343        apply_kube_artifacts(namespace=namespace, artifacts=manifest)
   344
   345    def create_listeners(self, namespace):
   346        manifest = f"""
   347---
   348apiVersion: getambassador.io/v3alpha1
   349kind: Listener
   350metadata:
   351  name: listener-8080
   352spec:
   353  port: 8080
   354  protocol: HTTP
   355  securityModel: INSECURE
   356  hostBinding:
   357    namespace:
   358      from: SELF
   359"""
   360
   361        apply_kube_artifacts(namespace=namespace, artifacts=manifest)
   362
   363    def test_header_case_overrides(self):
   364        # Is there any reason not to use the default namespace?
   365        namespace = "header-case-overrides"
   366
   367        # Install Ambassador
   368        install_ambassador(namespace=namespace)
   369
   370        # Install httpbin
   371        apply_kube_artifacts(namespace=namespace, artifacts=httpbin_manifests)
   372
   373        # Install headerecho
   374        apply_kube_artifacts(namespace=namespace, artifacts=headerecho_manifests)
   375
   376        # Install listeners.
   377        self.create_listeners(namespace)
   378
   379        # Install module
   380        self.create_module(namespace)
   381
   382        # Install httpbin mapping
   383        create_httpbin_mapping(namespace=namespace)
   384
   385        # Install headerecho mapping
   386        create_headerecho_mapping(namespace=namespace)
   387
   388        # Now let's wait for ambassador and httpbin pods to become ready
   389        run_and_assert(
   390            [
   391                "tools/bin/kubectl",
   392                "wait",
   393                "--timeout=90s",
   394                "--for=condition=Ready",
   395                "pod",
   396                "-l",
   397                "service=ambassador",
   398                "-n",
   399                namespace,
   400            ]
   401        )
   402        run_and_assert(
   403            [
   404                "tools/bin/kubectl",
   405                "wait",
   406                "--timeout=90s",
   407                "--for=condition=Ready",
   408                "pod",
   409                "-l",
   410                "service=httpbin",
   411                "-n",
   412                namespace,
   413            ]
   414        )
   415
   416        # Assume we can reach Ambassador through telepresence
   417        ambassador_host = "ambassador." + namespace
   418
   419        # Assert 200 OK at httpbin/status/200 endpoint
   420        ready = False
   421        httpbin_url = f"http://{ambassador_host}/httpbin/status/200"
   422        headerecho_url = f"http://{ambassador_host}/headerecho/"
   423
   424        loop_limit = 10
   425        while not ready:
   426            assert loop_limit > 0, "httpbin is not ready yet, aborting..."
   427            try:
   428                print(f"trying {httpbin_url}...")
   429                resp = requests.get(httpbin_url, timeout=5)
   430                code = resp.status_code
   431                assert code == 200, f"Expected 200 OK, got {code}"
   432                resp.close()
   433                print(f"{httpbin_url} is ready")
   434
   435                print(f"trying {headerecho_url}...")
   436                resp = requests.get(headerecho_url, timeout=5)
   437                code = resp.status_code
   438                assert code == 200, f"Expected 200 OK, got {code}"
   439                resp.close()
   440                print(f"{headerecho_url} is ready")
   441
   442                ready = True
   443
   444            except Exception as e:
   445                print(f"Error: {e}")
   446                print(f"{httpbin_url} not ready yet, trying again...")
   447                time.sleep(1)
   448                loop_limit -= 1
   449
   450        assert ready
   451
   452        httpbin_url = f"http://{ambassador_host}/httpbin/response-headers?x-Hello=1&X-foo-Bar=1&x-Lowercase1=1&x-lowercase2=1"
   453        resp = requests.get(httpbin_url, timeout=5)
   454        code = resp.status_code
   455        assert code == 200, f"Expected 200 OK, got {code}"
   456
   457        # First, test that the response headers have the correct case.
   458
   459        # Very important: this test relies on matching case sensitive header keys.
   460        # Fortunately it appears that we can convert resp.headers, a case insensitive
   461        # dictionary, into a list of case sensitive keys.
   462        keys = [h for h in resp.headers.keys()]
   463        for k in keys:
   464            print(f"header key: {k}")
   465
   466        assert "x-hello" not in keys
   467        assert "X-HELLO" in keys
   468        assert "x-foo-bar" not in keys
   469        assert "X-FOO-Bar" in keys
   470        assert "x-lowercase1" in keys
   471        assert "x-Lowercase1" not in keys
   472        assert "x-lowercase2" in keys
   473        resp.close()
   474
   475        # Second, test that the request headers sent to the headerecho server
   476        # have the correct case.
   477
   478        headers = {"x-Hello": "1", "X-foo-Bar": "1", "x-Lowercase1": "1", "x-lowercase2": "1"}
   479        resp = requests.get(headerecho_url, headers=headers, timeout=5)
   480        code = resp.status_code
   481        assert code == 200, f"Expected 200 OK, got {code}"
   482
   483        response_obj = json.loads(resp.text)
   484        print(f"response_obj = {response_obj}")
   485        assert response_obj
   486        assert "headers" in response_obj
   487
   488        hdrs = response_obj["headers"]
   489        assert "x-hello" not in hdrs
   490        assert "X-HELLO" in hdrs
   491        assert "x-foo-bar" not in hdrs
   492        assert "X-FOO-Bar" in hdrs
   493        assert "x-lowercase1" in hdrs
   494        assert "x-Lowercase1" not in hdrs
   495        assert "x-lowercase2" in hdrs
   496
   497
   498@pytest.mark.flaky(reruns=1, reruns_delay=10)
   499def test_ambassador_headercaseoverrides():
   500    t = HeaderCaseOverridesTesting()
   501    t.test_header_case_overrides()
   502
   503
   504if __name__ == "__main__":
   505    pytest.main(sys.argv)

View as plain text