diff --git a/.github/workflows/tests/common_utils.py b/.github/workflows/tests/common_utils.py index 41198b09e1..b5d4cd48b7 100644 --- a/.github/workflows/tests/common_utils.py +++ b/.github/workflows/tests/common_utils.py @@ -15,12 +15,14 @@ from __future__ import annotations +import json import os import unittest from typing import Any import requests import urllib3 +from requests.auth import HTTPBasicAuth # Default timeout for HTTP calls to the gateway (self-signed TLS, CI). KNOX_REQUEST_TIMEOUT = 30 @@ -28,9 +30,38 @@ HSTS_HEADER_NAME = "Strict-Transport-Security" HSTS_EXPECTED_VALUE = "max-age=300; includeSubDomains" +# Top-level keys in /gateway/{topology}/health/v1/metrics JSON (see Java GatewayHealthFuncTest). +METRICS_TOP_LEVEL_KEYS = frozenset( + {"timers", "histograms", "counters", "gauges", "version", "meters"} +) + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +def knox_ldap_guest_auth() -> HTTPBasicAuth: + """HTTP Basic auth for the demo LDAP `guest` user (CI Docker topologies).""" + + return HTTPBasicAuth("guest", "guest-password") + + +def knox_ldap_admin_auth() -> HTTPBasicAuth: + """HTTP Basic auth for the demo LDAP `admin` user (CI Docker topologies).""" + + return HTTPBasicAuth("admin", "admin-password") + + +def health_metrics_pretty_dict(gateway_base: str) -> dict[str, Any]: + """ + GET health metrics with ?pretty=true; raise AssertionError if not HTTP 200 or invalid JSON. + gateway_base must include a trailing slash (see gateway_base_url()). + """ + + r = knox_get(gateway_base + "gateway/health/v1/metrics?pretty=true") + if r.status_code != 200: + raise AssertionError(f"health metrics expected 200, got {r.status_code}") + return json.loads(r.text) + + def gateway_base_url() -> str: """Return KNOX_GATEWAY_URL with a trailing slash.""" url = os.environ.get("KNOX_GATEWAY_URL", "https://localhost:8443/") diff --git a/.github/workflows/tests/test_health.py b/.github/workflows/tests/test_health.py index a62e2f2bd5..eadbc64784 100644 --- a/.github/workflows/tests/test_health.py +++ b/.github/workflows/tests/test_health.py @@ -17,7 +17,14 @@ import requests -from common_utils import assert_hsts_header, gateway_base_url, knox_get +from common_utils import ( + METRICS_TOP_LEVEL_KEYS, + assert_hsts_header, + gateway_base_url, + health_metrics_pretty_dict, + knox_get, + knox_post, +) class TestKnoxHealth(unittest.TestCase): @@ -92,6 +99,112 @@ def test_health_ping_content_type_is_plain_text(self): content_type = response.headers.get("Content-Type", "") self.assertIn("text/plain", content_type) -if __name__ == '__main__': - unittest.main() +class TestHealthGatewayExtended(unittest.TestCase): + """Anonymous HEALTH topology: gateway-status, ping variants, metrics keys, routing.""" + + def setUp(self): + self.base_url = gateway_base_url() + + def test_health_gateway_status_returns_ok_or_pending_plain_text(self): + """gateway-status is 200 text/plain with body OK or PENDING.""" + url = self.base_url + "gateway/health/v1/gateway-status" + r = knox_get(url) + self.assertEqual(r.status_code, 200) + self.assertIn("text/plain", r.headers.get("Content-Type", "")) + self.assertIn(r.text.strip(), ("OK", "PENDING")) + + def test_health_ping_post_returns_ok(self): + """POST /v1/ping matches GET semantics for the health service.""" + url = self.base_url + "gateway/health/v1/ping" + r = knox_post(url) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text.strip(), "OK") + + def test_health_ping_sets_cache_control_no_store(self): + """Ping uses must-revalidate,no-cache,no-store (see PingResource).""" + url = self.base_url + "gateway/health/v1/ping" + r = knox_get(url) + self.assertEqual(r.status_code, 200) + cc = r.headers.get("Cache-Control", "") + self.assertIn("no-store", cc) + self.assertIn("no-cache", cc) + + def test_health_metrics_pretty_includes_all_core_top_level_keys(self): + """Pretty metrics JSON includes timers/histograms/counters/gauges/version/meters.""" + payload = health_metrics_pretty_dict(self.base_url) + self.assertTrue( + METRICS_TOP_LEVEL_KEYS.issubset(payload.keys()), + msg=f"Missing keys: {METRICS_TOP_LEVEL_KEYS - set(payload.keys())}", + ) + + def test_health_metrics_without_pretty_includes_same_top_level_keys(self): + """Metrics without ?pretty= still expose the same registry sections.""" + url = self.base_url + "gateway/health/v1/metrics" + r = knox_get(url) + self.assertEqual(r.status_code, 200) + payload = json.loads(r.text) + self.assertTrue(METRICS_TOP_LEVEL_KEYS.issubset(payload.keys())) + + def test_health_metrics_version_value_is_non_empty_string(self): + """The version entry in metrics JSON is a string.""" + payload = health_metrics_pretty_dict(self.base_url) + ver = payload.get("version") + self.assertIsInstance(ver, str) + self.assertTrue(len(ver) > 0) + + def test_unknown_topology_returns_404(self): + """Requests to an undeployed topology name fail with 404.""" + url = self.base_url + "gateway/not-a-deployed-topology/v1/ping" + r = knox_get(url) + self.assertEqual(r.status_code, 404) + + def test_health_gateway_status_includes_hsts(self): + """gateway-status uses the same global Strict-Transport-Security as other gateway responses.""" + url = self.base_url + "gateway/health/v1/gateway-status" + r = knox_get(url) + self.assertEqual(r.status_code, 200) + assert_hsts_header(self, r) + + def test_health_metrics_includes_hsts(self): + """Metrics JSON responses include the expected HSTS header.""" + url = self.base_url + "gateway/health/v1/metrics?pretty=true" + r = knox_get(url) + self.assertEqual(r.status_code, 200) + assert_hsts_header(self, r) + + def test_health_gateway_status_cache_control_no_store(self): + """gateway-status sets Cache-Control with no-cache/no-store like ping.""" + url = self.base_url + "gateway/health/v1/gateway-status" + r = knox_get(url) + self.assertEqual(r.status_code, 200) + cc = r.headers.get("Cache-Control", "") + self.assertIn("no-store", cc) + self.assertIn("no-cache", cc) + + +class TestHealthMetricsSectionShapes(unittest.TestCase): + """Dropwizard metric registry JSON: each major section is an object.""" + + @classmethod + def setUpClass(cls): + cls._payload = health_metrics_pretty_dict(gateway_base_url()) + + def test_metrics_timers_section_is_dict(self): + self.assertIsInstance(self._payload["timers"], dict) + + def test_metrics_histograms_section_is_dict(self): + self.assertIsInstance(self._payload["histograms"], dict) + + def test_metrics_counters_section_is_dict(self): + self.assertIsInstance(self._payload["counters"], dict) + + def test_metrics_gauges_section_is_dict(self): + self.assertIsInstance(self._payload["gauges"], dict) + + def test_metrics_meters_section_is_dict(self): + self.assertIsInstance(self._payload["meters"], dict) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/tests/test_knox_auth_service_and_LDAP.py b/.github/workflows/tests/test_knox_auth_service_and_LDAP.py index 51a42d84e4..539d4eb11f 100644 --- a/.github/workflows/tests/test_knox_auth_service_and_LDAP.py +++ b/.github/workflows/tests/test_knox_auth_service_and_LDAP.py @@ -12,10 +12,16 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import json import unittest -from requests.auth import HTTPBasicAuth -from common_utils import collect_actor_group_values, gateway_base_url, knox_get +from common_utils import ( + collect_actor_group_values, + gateway_base_url, + knox_get, + knox_ldap_admin_auth, + knox_ldap_guest_auth, +) ######################################################## # This test is verifying the behavior of the Knox Auth Service + LDAP authentication. @@ -40,7 +46,7 @@ def test_auth_service_guest(self): print(f"\nTesting guest authentication against {self.topology_url}") response = knox_get( self.topology_url, - auth=HTTPBasicAuth('guest', 'guest-password'), + auth=knox_ldap_guest_auth(), ) print(f"Status Code: {response.status_code}") @@ -60,7 +66,7 @@ def test_auth_service_admin_groups(self): print(f"\nTesting admin authentication against {self.topology_url}") response = knox_get( self.topology_url, - auth=HTTPBasicAuth('admin', 'admin-password'), + auth=knox_ldap_admin_auth(), ) print(f"Status Code: {response.status_code}") @@ -85,6 +91,84 @@ def test_auth_service_admin_groups(self): for group in expected_groups: self.assertIn(group, all_groups) + +class TestKnoxLdapKnoxToken(unittest.TestCase): + """KNOXTOKEN + JWKS under knoxldap topology (Shiro + LDAP).""" + + def setUp(self): + self.base_url = gateway_base_url() + self._token_prefix = self.base_url + "gateway/knoxldap/knoxtoken/api" + + def test_knoxldap_jwks_with_guest_returns_json_with_keys_array(self): + """JWKS document is JSON with a keys array (may be empty if no RSA key).""" + url = self._token_prefix + "/v1/jwks.json" + r = knox_get(url, auth=knox_ldap_guest_auth()) + self.assertEqual(r.status_code, 200) + self.assertIn("application/json", r.headers.get("Content-Type", "")) + body = json.loads(r.text) + self.assertIn("keys", body) + self.assertIsInstance(body["keys"], list) + + def test_knoxldap_jwks_without_credentials_returns_401(self): + """Shiro requires BASIC auth for knoxldap paths including JWKS.""" + url = self._token_prefix + "/v1/jwks.json" + r = knox_get(url) + self.assertEqual(r.status_code, 401) + + def test_knoxldap_token_v1_get_returns_access_token_json(self): + """GET knoxtoken v1 returns a JWT access_token for a valid LDAP user.""" + url = self._token_prefix + "/v1/token" + r = knox_get(url, auth=knox_ldap_guest_auth()) + self.assertEqual(r.status_code, 200) + self.assertIn("application/json", r.headers.get("Content-Type", "")) + body = json.loads(r.text) + self.assertIn("access_token", body) + self.assertIsInstance(body["access_token"], str) + self.assertTrue(len(body["access_token"]) > 0) + + def test_knoxldap_token_v2_get_returns_access_token_json(self): + """GET knoxtoken v2/token exposes access_token for basic acquisition.""" + url = self._token_prefix + "/v2/token" + r = knox_get(url, auth=knox_ldap_guest_auth()) + self.assertEqual(r.status_code, 200) + body = json.loads(r.text) + self.assertIn("access_token", body) + + def test_knoxldap_token_v1_without_credentials_returns_401(self): + url = self._token_prefix + "/v1/token" + r = knox_get(url) + self.assertEqual(r.status_code, 401) + + def test_knoxldap_token_v2_without_credentials_returns_401(self): + """Shiro requires auth for v2 token the same as v1.""" + url = self._token_prefix + "/v2/token" + r = knox_get(url) + self.assertEqual(r.status_code, 401) + + def test_knoxldap_access_token_string_is_jwt_shape(self): + """Issued access_token is a typical JWT (header.payload.sig segments).""" + url = self._token_prefix + "/v1/token" + r = knox_get(url, auth=knox_ldap_guest_auth()) + self.assertEqual(r.status_code, 200) + body = json.loads(r.text) + token = body.get("access_token", "") + parts = token.split(".") + self.assertGreaterEqual(len(parts), 3, msg="access_token should look like a JWT") + + +class TestKnoxLdapExtAuthzAuthn(unittest.TestCase): + """Unauthenticated access to knoxldap extauthz (Shiro).""" + + def setUp(self): + self.base_url = gateway_base_url() + self.extauthz = self.base_url + "gateway/knoxldap/auth/api/v1/extauthz" + + def test_knoxldap_extauthz_without_credentials_returns_401(self): + """extauthz requires BASIC auth like other knoxldap paths.""" + r = knox_get(self.extauthz) + self.assertEqual(r.status_code, 401) + + if __name__ == '__main__': unittest.main() diff --git a/.github/workflows/tests/test_knoxauth_preauth_and_paths.py b/.github/workflows/tests/test_knoxauth_preauth_and_paths.py index 45d51a5c82..54a1911453 100644 --- a/.github/workflows/tests/test_knoxauth_preauth_and_paths.py +++ b/.github/workflows/tests/test_knoxauth_preauth_and_paths.py @@ -16,7 +16,14 @@ from requests.auth import HTTPBasicAuth -from common_utils import collect_actor_group_values, gateway_base_url, knox_get, knox_post +from common_utils import ( + collect_actor_group_values, + gateway_base_url, + knox_get, + knox_ldap_admin_auth, + knox_ldap_guest_auth, + knox_post, +) class TestKnoxAuthServicePreAuthAndPaths(unittest.TestCase): @@ -44,7 +51,7 @@ def test_preauth_post_supported(self): """POST preauth with guest credentials returns 200 and x-knox-actor-username.""" response = knox_post( self.preauth_url, - auth=HTTPBasicAuth("guest", "guest-password"), + auth=knox_ldap_guest_auth(), ) self.assertEqual(response.status_code, 200) @@ -77,10 +84,22 @@ def test_extauthz_additional_path_not_ignored_in_knoxldap(self): """Unknown path under extauthz returns 404; extra segments are not ignored as success.""" response = knox_get( self.extauthz_url + "/does-not-exist", - auth=HTTPBasicAuth("guest", "guest-password"), + auth=knox_ldap_guest_auth(), ) self.assertEqual(response.status_code, 404) + def test_preauth_get_guest_returns_actor_username(self): + response = knox_get(self.preauth_url, auth=knox_ldap_guest_auth()) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get("x-knox-actor-username"), "guest") + + def test_preauth_get_admin_includes_long_group_names_three_and_four(self): + response = knox_get(self.preauth_url, auth=knox_ldap_admin_auth()) + self.assertEqual(response.status_code, 200) + groups = collect_actor_group_values(response, prefix="x-knox-actor-groups") + for name in ("longGroupName3", "longGroupName4"): + self.assertIn(name, groups) + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/tests/test_remote_auth.py b/.github/workflows/tests/test_remote_auth.py index ae87c53930..6f3a941d51 100644 --- a/.github/workflows/tests/test_remote_auth.py +++ b/.github/workflows/tests/test_remote_auth.py @@ -15,7 +15,14 @@ import unittest from requests.auth import HTTPBasicAuth -from common_utils import collect_actor_group_values, gateway_base_url, knox_get +from common_utils import ( + collect_actor_group_values, + gateway_base_url, + knox_get, + knox_post, + knox_ldap_admin_auth, + knox_ldap_guest_auth, +) ######################################################## # This test is verifying the behavior of the RemoteAuthProvider. @@ -41,7 +48,7 @@ def test_remote_auth_success(self): # knoxldap accepts guest:guest-password response = knox_get( self.topology_url, - auth=HTTPBasicAuth('guest', 'guest-password'), + auth=knox_ldap_guest_auth(), ) print(f"Status Code: {response.status_code}") print(f"Headers: {response.headers}") @@ -59,7 +66,7 @@ def test_remote_auth_admin_groups(self): print(f"\nTesting remote auth admin against {self.topology_url}") response = knox_get( self.topology_url, - auth=HTTPBasicAuth('admin', 'admin-password'), + auth=knox_ldap_admin_auth(), ) self.assertEqual(response.status_code, 200) self.assertEqual(response.headers['X-Knox-Actor-ID'], 'admin') @@ -74,6 +81,15 @@ def test_remote_auth_admin_groups(self): self.assertIn('longGroupName1', all_groups) self.assertIn('longGroupName2', all_groups) + def test_remote_auth_post_pre_with_guest_succeeds(self): + """POST is supported on the preauth resource the same as GET (delegates to knoxldap).""" + response = knox_post( + self.topology_url, + auth=knox_ldap_guest_auth(), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get("X-Knox-Actor-ID"), "guest") + def test_remote_auth_failure(self): """ Verify invalid credentials result in 401 @@ -87,5 +103,14 @@ def test_remote_auth_failure(self): # When remote auth fails (knoxldap returns 401), RemoteAuthFilter should return 401 self.assertEqual(response.status_code, 401) + def test_remoteauth_pre_without_credentials_is_server_error(self): + """ + No Authorization header: RemoteAuthFilter hits an exception path and returns 500, + not 401 (same idea as extauthz without credentials in test_remoteauth_extauthz_additional_path). + """ + response = knox_get(self.topology_url) + self.assertEqual(response.status_code, 500) + + if __name__ == '__main__': unittest.main() diff --git a/.github/workflows/tests/test_remoteauth_extauthz_additional_path.py b/.github/workflows/tests/test_remoteauth_extauthz_additional_path.py index 73a6f578a6..76468dabf1 100644 --- a/.github/workflows/tests/test_remoteauth_extauthz_additional_path.py +++ b/.github/workflows/tests/test_remoteauth_extauthz_additional_path.py @@ -16,7 +16,13 @@ from requests.auth import HTTPBasicAuth -from common_utils import gateway_base_url, knox_get +from common_utils import ( + collect_actor_group_values, + gateway_base_url, + knox_get, + knox_ldap_admin_auth, + knox_ldap_guest_auth, +) class TestRemoteAuthExtAuthzAdditionalPath(unittest.TestCase): @@ -27,7 +33,7 @@ def setUp(self): def test_extauthz_success(self): response = knox_get( self.extauthz_url, - auth=HTTPBasicAuth("guest", "guest-password"), + auth=knox_ldap_guest_auth(), ) self.assertEqual(response.status_code, 200) self.assertIn("X-Knox-Actor-ID", response.headers) @@ -36,12 +42,30 @@ def test_extauthz_success(self): def test_extauthz_additional_path_is_ignored(self): response = knox_get( self.extauthz_url + "/some/extra/path", - auth=HTTPBasicAuth("guest", "guest-password"), + auth=knox_ldap_guest_auth(), ) self.assertEqual(response.status_code, 200) self.assertIn("X-Knox-Actor-ID", response.headers) self.assertEqual(response.headers["X-Knox-Actor-ID"], "guest") + def test_extauthz_admin_user_actor_and_groups(self): + """Admin via remoteauth extauthz maps LDAP groups onto X-Knox-Actor-* headers.""" + response = knox_get(self.extauthz_url, auth=knox_ldap_admin_auth()) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get("X-Knox-Actor-ID"), "admin") + groups = collect_actor_group_values(response, prefix="X-Knox-Actor-Groups") + for name in ("longGroupName1", "longGroupName2"): + self.assertIn(name, groups) + + def test_extauthz_additional_deep_path_still_ignored(self): + """ignore.additional.path accepts longer arbitrary suffix segments.""" + response = knox_get( + self.extauthz_url + "/a/b/c/d/extra", + auth=knox_ldap_guest_auth(), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get("X-Knox-Actor-ID"), "guest") + def test_extauthz_bad_credentials_unauthorized(self): response = knox_get( self.extauthz_url,