diff --git a/tests/unit/test_hub_ca_bundle.py b/tests/unit/test_hub_ca_bundle.py new file mode 100644 index 0000000..1be9654 --- /dev/null +++ b/tests/unit/test_hub_ca_bundle.py @@ -0,0 +1,227 @@ +"""Structural tests for the hub-side enterprise CA bundle wiring in values.yaml. + +The singleuser side is covered by ``test_spawner_ca_bundle.py`` (runtime Python +in ``01-spawner.py``). The hub-side wiring lives in the chart's static +``values.yaml`` — z2jh renders the hub Deployment from those values, so +there is no Python to unit-test. Instead we assert the shape of the values +file itself: the required volumes, mounts, init container, and env vars +are present under ``jupyterhub.hub.*`` with the expected shape. These +assertions pin the contract described in the ``trust-bundle-enabled`` +docstring so a future edit that accidentally drops one of them fails CI +instead of silently breaking outbound HTTPS from the hub. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +VALUES_YAML = REPO_ROOT / "values.yaml" + +MERGED = "/etc/ssl/certs-extra/ca-bundle.crt" + + +def _hub_values(): + """Load the ``jupyterhub.hub`` block from the chart's values.yaml.""" + with VALUES_YAML.open() as f: + values = yaml.safe_load(f) + return values["jupyterhub"]["hub"] + + +# --------------------------------------------------------------------------- +# extraVolumes — org-ca (optional ConfigMap) + ca-merged (emptyDir) +# --------------------------------------------------------------------------- + +def test_extra_volumes_contains_org_ca_optional_configmap(): + """org-ca must be an OPTIONAL ConfigMap volume — missing trust-manager + is a no-op, not a spawn/pod-start failure.""" + volumes = _hub_values()["extraVolumes"] + org_ca = next((v for v in volumes if v["name"] == "org-ca"), None) + assert org_ca is not None, "missing 'org-ca' entry in hub.extraVolumes" + assert "configMap" in org_ca, "org-ca must be a ConfigMap volume" + assert org_ca["configMap"].get("optional") is True, ( + "org-ca ConfigMap must be optional so hub startup does not depend on " + "trust-manager being installed" + ) + + +def test_extra_volumes_contains_ca_merged_emptydir(): + """ca-merged is the emptyDir the merge init container writes into.""" + volumes = _hub_values()["extraVolumes"] + merged = next((v for v in volumes if v["name"] == "ca-merged"), None) + assert merged is not None, "missing 'ca-merged' entry in hub.extraVolumes" + assert "emptyDir" in merged, "ca-merged must be an emptyDir volume" + + +def test_extra_volumes_preserves_pre_existing_chart_entries(): + """The two chart defaults (custom-config, oauth-client) must stay in the + list so this edit does not regress the existing wiring.""" + names = {v["name"] for v in _hub_values()["extraVolumes"]} + assert {"custom-config", "oauth-client"}.issubset(names) + + +# --------------------------------------------------------------------------- +# extraVolumeMounts — the merged bundle must be visible in the hub container +# --------------------------------------------------------------------------- + +def test_extra_volume_mounts_exposes_ca_merged_at_expected_path(): + mounts = _hub_values()["extraVolumeMounts"] + ca_merged = next((m for m in mounts if m["name"] == "ca-merged"), None) + assert ca_merged is not None, "missing 'ca-merged' mount in hub.extraVolumeMounts" + assert ca_merged["mountPath"] == "/etc/ssl/certs-extra", ( + "hub-side must use the same merged-bundle mount path as the " + "singleuser side for path consistency" + ) + + +def test_extra_volume_mounts_preserves_pre_existing_chart_entries(): + names = {m["name"] for m in _hub_values()["extraVolumeMounts"]} + assert {"custom-config", "oauth-client"}.issubset(names) + + +# --------------------------------------------------------------------------- +# initContainers — merge-ca-bundle produces /etc/ssl/certs-extra/ca-bundle.crt +# --------------------------------------------------------------------------- + +def test_init_container_merge_ca_bundle_present(): + init_containers = _hub_values().get("initContainers", []) + merge = next( + (c for c in init_containers if c["name"] == "merge-ca-bundle"), None, + ) + assert merge is not None, ( + "missing 'merge-ca-bundle' init container — hub cannot reach TLS-" + "inspected external endpoints (Keycloak external URL, etc.) without it" + ) + + +def test_init_container_uses_hub_image(): + """The init container must read the SAME system CA the main container + has. Using the hub image guarantees that; a generic busybox would not. + + Kept as a soft assertion (contains 'jupyterhub' in name) so a maintainer + who bumps the tag doesn't have to update this test in lockstep with the + hub.image field. If the image reference is refactored to a chart-side + helper later, this assertion can be removed. + """ + init_containers = _hub_values().get("initContainers", []) + merge = next(c for c in init_containers if c["name"] == "merge-ca-bundle") + hub_image = _hub_values()["image"] + assert merge["image"] == f'{hub_image["name"]}:{hub_image["tag"]}', ( + "merge-ca-bundle image must match hub.image so the system CA store " + "the init container reads matches what the hub container has at " + "runtime; if you bump hub.image, bump the init container image too" + ) + + +def test_init_container_appends_org_ca_conditionally(): + """The shell command must: + 1. Copy the image's system CA into /merged/ca-bundle.crt (always) + 2. Append org CA IFF the mounted file exists (so missing trust-manager + is a no-op — merged bundle == system bundle) + """ + init_containers = _hub_values().get("initContainers", []) + merge = next(c for c in init_containers if c["name"] == "merge-ca-bundle") + # command list is ['/bin/sh', '-c', '