Files
computer-playbook/tests/unit/python/utils/roles/applications/services/test_sso.py
T
kevinveenbirkenbachandClaude Opus 5 9d0f618e23 refactor(sso): compute the app logout target once in the sso lookup
The previous commit wrote the same proxy-aware logout expression into
four javascript.js.j2 templates. The rule it encodes - sign out of
oauth2-proxy first when the app is proxy-gated, else go straight to the
provider - belongs where proxy-gating is already decided.

utils/roles/applications/services/sso.py gains logout_url(url,
is_proxy_gated) next to get_sso_config, with OAUTH2_SIGN_OUT_PATH as the
path constant; the rd value is quoted exactly as Jinja's urlencode
filter does it. The sso lookup exposes it as the logout_url term: it
renders OIDC through the templar, takes is_proxy_gated from the same
resolution, and refuses a context without OIDC. Prometheus, Checkmk,
Baserow and Jitsi now render lookup('sso', application_id, 'logout_url').

Behaviour is unchanged: the resolver tests pin the rd encoding against
jinja2's do_urlencode, and the lookup tests cover a proxy-gated app, an
OIDC-native app, a disabled SSO and a missing OIDC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 13:06:19 +02:00

230 lines
8.1 KiB
Python

from __future__ import annotations
import unittest
from jinja2.filters import do_urlencode
from utils.roles.applications.services.sso import (
get_sso_config,
is_potentially_enabled,
logout_url,
)
KEYCLOAK_LOGOUT = "https://auth.example.org/realms/r/protocol/openid-connect/logout"
def _apps(
*,
enabled=False,
shared=False,
flavor=None,
oauth2_origin_host=None,
oauth2_origin_port=None,
oauth2_acl=None,
oauth2_allowed_groups=None,
) -> dict:
sso: dict = {"enabled": enabled, "shared": shared}
if flavor is not None:
sso["flavor"] = flavor
oauth2_sub: dict = {}
if oauth2_origin_host is not None or oauth2_origin_port is not None:
oauth2_sub["origin"] = {
**({"host": oauth2_origin_host} if oauth2_origin_host is not None else {}),
**({"port": oauth2_origin_port} if oauth2_origin_port is not None else {}),
}
if oauth2_acl is not None:
oauth2_sub["acl"] = oauth2_acl
if oauth2_allowed_groups is not None:
oauth2_sub["allowed_groups"] = oauth2_allowed_groups
if oauth2_sub:
sso["oauth2"] = oauth2_sub
return {"web-app-x": {"services": {"sso": sso}}}
class GetSsoConfigTests(unittest.TestCase):
def test_defaults_when_app_missing(self):
res = get_sso_config({}, "web-app-x")
self.assertEqual(res["enabled"], False)
self.assertEqual(res["shared"], False)
self.assertEqual(res["flavor"], "oidc")
self.assertFalse(res["is_enabled"])
self.assertFalse(res["is_proxy_gated"])
self.assertFalse(res["is_oidc_native"])
self.assertFalse(res["is_saml"])
def test_disabled_collapses_all_predicates(self):
res = get_sso_config(_apps(enabled=False, flavor="oauth2"), "web-app-x")
self.assertFalse(res["is_enabled"])
self.assertFalse(res["is_proxy_gated"])
self.assertFalse(res["is_oidc_native"])
def test_enabled_oidc_default_flavor(self):
res = get_sso_config(_apps(enabled=True), "web-app-x")
self.assertTrue(res["is_enabled"])
self.assertTrue(res["is_oidc_native"])
self.assertFalse(res["is_proxy_gated"])
self.assertEqual(res["flavor"], "oidc")
def test_enabled_oauth2_flavor_is_proxy_gated(self):
res = get_sso_config(_apps(enabled=True, flavor="oauth2"), "web-app-x")
self.assertTrue(res["is_enabled"])
self.assertTrue(res["is_proxy_gated"])
self.assertFalse(res["is_oidc_native"])
self.assertFalse(res["is_saml"])
def test_enabled_saml_flavor(self):
res = get_sso_config(_apps(enabled=True, flavor="saml"), "web-app-x")
self.assertTrue(res["is_saml"])
self.assertFalse(res["is_proxy_gated"])
self.assertFalse(res["is_oidc_native"])
def test_unknown_flavor_falls_back_to_oidc(self):
res = get_sso_config(_apps(enabled=True, flavor="bogus"), "web-app-x")
self.assertEqual(res["flavor"], "oidc")
self.assertTrue(res["is_oidc_native"])
def test_non_string_flavor_falls_back_to_oidc(self):
res = get_sso_config(_apps(enabled=True, flavor=42), "web-app-x")
self.assertEqual(res["flavor"], "oidc")
def test_shared_field_round_trips(self):
res = get_sso_config(_apps(enabled=True, shared=True), "web-app-x")
self.assertTrue(res["shared"])
def test_predicate_keys_present_when_app_has_partial_block(self):
apps = {"web-app-x": {"services": {"sso": {"enabled": True}}}}
res = get_sso_config(apps, "web-app-x")
for key in (
"enabled",
"shared",
"flavor",
"is_enabled",
"is_proxy_gated",
"is_oidc_native",
"is_saml",
):
self.assertIn(key, res, f"missing key {key}")
class Oauth2SubPropertiesTests(unittest.TestCase):
def test_defaults_when_oauth2_block_missing(self):
res = get_sso_config(_apps(enabled=True, flavor="oauth2"), "web-app-x")
self.assertEqual(res["oauth2_origin_host"], "")
self.assertEqual(res["oauth2_origin_port"], "")
self.assertEqual(res["oauth2_acl"], {})
self.assertEqual(res["oauth2_allowed_groups"], [])
def test_origin_host_and_port_pass_through(self):
res = get_sso_config(
_apps(
enabled=True,
flavor="oauth2",
oauth2_origin_host="application",
oauth2_origin_port=8000,
),
"web-app-x",
)
self.assertEqual(res["oauth2_origin_host"], "application")
self.assertEqual(res["oauth2_origin_port"], "8000") # stringified
def test_origin_port_string_pass_through(self):
res = get_sso_config(
_apps(enabled=True, flavor="oauth2", oauth2_origin_port="9090"),
"web-app-x",
)
self.assertEqual(res["oauth2_origin_port"], "9090")
def test_acl_dict_pass_through(self):
res = get_sso_config(
_apps(
enabled=True,
flavor="oauth2",
oauth2_acl={"whitelist": ["/api/", "/static/"]},
),
"web-app-x",
)
self.assertEqual(res["oauth2_acl"], {"whitelist": ["/api/", "/static/"]})
def test_acl_non_dict_falls_back_to_empty(self):
res = get_sso_config(
_apps(enabled=True, flavor="oauth2", oauth2_acl="bogus"),
"web-app-x",
)
self.assertEqual(res["oauth2_acl"], {})
def test_allowed_groups_list_pass_through(self):
groups = ["/roles/web-app-x/administrator", "/roles/web-app-x/biber"]
res = get_sso_config(
_apps(enabled=True, flavor="oauth2", oauth2_allowed_groups=groups),
"web-app-x",
)
self.assertEqual(res["oauth2_allowed_groups"], groups)
def test_allowed_groups_non_list_falls_back_to_empty(self):
res = get_sso_config(
_apps(enabled=True, flavor="oauth2", oauth2_allowed_groups="bogus"),
"web-app-x",
)
self.assertEqual(res["oauth2_allowed_groups"], [])
def test_oauth2_sub_keys_exposed_even_when_flavor_is_oidc(self):
res = get_sso_config(
_apps(
enabled=True,
flavor="oidc",
oauth2_origin_host="application",
oauth2_origin_port=8080,
),
"web-app-x",
)
self.assertEqual(res["oauth2_origin_host"], "application")
self.assertFalse(res["is_proxy_gated"]) # still oidc
class IsPotentiallyEnabledTests(unittest.TestCase):
def test_none_is_not_potentially_enabled(self):
self.assertFalse(is_potentially_enabled(None))
def test_literal_false_is_not_potentially_enabled(self):
self.assertFalse(is_potentially_enabled(False))
def test_literal_true_is_potentially_enabled(self):
self.assertTrue(is_potentially_enabled(True))
def test_string_false_case_insensitive_is_not_potentially_enabled(self):
for v in ("false", "False", "FALSE", " false "):
with self.subTest(value=v):
self.assertFalse(is_potentially_enabled(v))
def test_string_true_is_potentially_enabled(self):
self.assertTrue(is_potentially_enabled("true"))
def test_jinja_template_string_is_potentially_enabled(self):
self.assertTrue(
is_potentially_enabled("{{ 'web-app-keycloak' in group_names }}")
)
def test_empty_string_is_potentially_enabled(self):
self.assertTrue(is_potentially_enabled(""))
class TestLogoutUrl(unittest.TestCase):
def test_an_app_without_the_proxy_logs_out_at_the_provider(self):
self.assertEqual(logout_url(KEYCLOAK_LOGOUT, False), KEYCLOAK_LOGOUT)
def test_a_proxy_gated_app_signs_out_of_oauth2_proxy_first(self):
self.assertEqual(
logout_url(KEYCLOAK_LOGOUT, True),
"/oauth2/sign_out?rd=https%3A//auth.example.org/realms/r/protocol/openid-connect/logout",
)
def test_the_rd_encoding_matches_the_jinja_urlencode_filter(self):
self.assertEqual(
logout_url(KEYCLOAK_LOGOUT, True),
f"/oauth2/sign_out?rd={do_urlencode(KEYCLOAK_LOGOUT)}",
)
if __name__ == "__main__":
unittest.main()