Files
computer-playbook/tests/unit/python/plugins/lookup/test_sso_scope_domains.py
T
kevinveenbirkenbachandClaude Opus 5 14950b4319 fix(sso): serve the console's onion hosts and keep one session across them
Three failures of the same deploy, each uncovered by fixing the one before it.

web-app-seaweedfs-console read its two vhost domains through
lookup('config', ..., 'domains.canonical.*'), which resolves against the raw
applications tree. The onion substitution happens elsewhere -- in
get_merged_domains, where svc-net-tor's exclusive flag REPLACES the clearnet
value. On an onion node the role therefore rendered vhosts for clearnet names
while Keycloak's redirectUris, the CSP header and LOGOUT_DOMAINS all carried
the onion names, so the Filer and Master surfaces had no vhost at all and the
default server answered. Reading through lookup('domains', app).<key> is the
shape matrix and bluesky already use for named canonicals.

With the onion names in place the console's own nginx refused to start:
a server_name of ~81 characters overflows the stock bucket size of 64 and
nginx aborts with 'could not build server_names_hash'. The central openresty
has carried server_names_hash_bucket_size 128 since forever
(sys-svc-webserver-core/templates/nginx.conf.j2); the second nginx needs the
same.

With both surfaces served, an administrator still had to re-authenticate on
the Master host: cookie_domains was emitted only when the app had clearnet
domains, so an onion-exclusive node got no cookie scope and oauth2-proxy fell
back to a host-only cookie. whitelist_domains on the very next line already
handled both address families; cookie_domains now does too.

Both lists move out of the template into lookups of their own. The template
held six set-statements and two nested inline conditionals to build two JSON
arrays; the lookups take an application_id and return the list, which also
makes the behaviour testable without rendering the template. The template
structure assertion in test_cookie_scope.py went with them -- it measured
Jinja text, not behaviour -- replaced by thirteen cases over the two lookups:
single host, multi-host collapse, onion-only, dual-stack, no domains, no tor
node, and a missing DOMAIN_PRIMARY that now raises instead of silently
scoping nothing.

Evidence: make compose-playwright role=web-app-seaweedfs-console goes from
3 failed to 3 passed against the live stack. For the single-domain case the
change newly affects, web-app-prometheus keeps all four of its SSO specs
green with the scope applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 04:32:38 +02:00

134 lines
4.5 KiB
Python

import unittest
from unittest.mock import MagicMock, patch
from ansible.errors import AnsibleError
from plugins.lookup.sso_cookie_domains import LookupModule as CookieLookup
from plugins.lookup.sso_whitelist_domains import LookupModule as WhitelistLookup
ONION = "ocickgnpp2fqow4ku7juiprpdvwiq6ufv7j3jfykgnzvoe32jeasg5qd.onion"
CLEARNET = "infinito.test"
IDP = {"web-app-keycloak": ["auth." + CLEARNET]}
IDP_ONION = {"web-app-keycloak": ["auth." + ONION]}
def _loader(module):
"""Patch the lookup_loader of *module* so 'domains' and 'config' read the
scope variables the test supplies.
Args:
module: dotted path of the lookup module under test.
"""
def _get(name, *args, **kwargs):
plugin = MagicMock()
def _run(terms, variables=None, **_kwargs):
variables = variables or {}
if name == "domains":
return [variables.get("domains", {})]
return [variables.get("tor_node", "")]
plugin.run.side_effect = _run
return plugin
return patch(f"{module}.lookup_loader.get", side_effect=_get)
class TestSsoCookieDomains(unittest.TestCase):
def setUp(self):
self.lookup = CookieLookup()
patcher = _loader("plugins.lookup.sso_cookie_domains")
patcher.start()
self.addCleanup(patcher.stop)
def _run(self, app_id, domains):
return self.lookup.run([app_id], variables={"domains": domains})[0]
def test_single_clearnet_host_keeps_its_exact_name(self):
out = self._run("web-app-a", {"web-app-a": ["a." + CLEARNET], **IDP})
self.assertEqual(out, ["a." + CLEARNET, "auth." + CLEARNET])
def test_multi_host_app_collapses_to_the_shared_parent(self):
out = self._run(
"web-app-a",
{
"web-app-a": {
"filer": "filer.seaweedfs.s3." + CLEARNET,
"master": "master.seaweedfs.s3." + CLEARNET,
},
**IDP,
},
)
self.assertEqual(out, ["seaweedfs.s3." + CLEARNET, "auth." + CLEARNET])
def test_onion_only_app_scopes_the_onion_parent(self):
out = self._run(
"web-app-a",
{
"web-app-a": {
"filer": "filer.seaweedfs.s3." + ONION,
"master": "master.seaweedfs.s3." + ONION,
},
**IDP_ONION,
},
)
self.assertEqual(out, ["seaweedfs.s3." + ONION, "auth." + ONION])
def test_dual_stack_app_scopes_both_families(self):
out = self._run(
"web-app-a",
{"web-app-a": ["a." + CLEARNET, "a." + ONION], **IDP},
)
self.assertEqual(out, ["a." + CLEARNET, "a." + ONION, "auth." + CLEARNET])
def test_app_without_domains_yields_no_scope(self):
self.assertEqual(self._run("web-app-a", IDP), [])
def test_term_count_is_enforced(self):
with self.assertRaises(AnsibleError):
self.lookup.run([], variables={"domains": {}})
class TestSsoWhitelistDomains(unittest.TestCase):
def setUp(self):
self.lookup = WhitelistLookup()
patcher = _loader("plugins.lookup.sso_whitelist_domains")
patcher.start()
self.addCleanup(patcher.stop)
def _run(self, app_id, domains, tor_node=ONION, primary=CLEARNET):
return self.lookup.run(
[app_id],
variables={
"domains": domains,
"tor_node": tor_node,
"DOMAIN_PRIMARY": primary,
},
)[0]
def test_clearnet_app_whitelists_the_primary_domain(self):
out = self._run("web-app-a", {"web-app-a": ["a." + CLEARNET]})
self.assertEqual(out, ["." + CLEARNET])
def test_onion_app_whitelists_only_the_node_onion(self):
out = self._run("web-app-a", {"web-app-a": ["a." + ONION]})
self.assertEqual(out, ["." + ONION])
def test_dual_stack_app_whitelists_both(self):
out = self._run("web-app-a", {"web-app-a": ["a." + CLEARNET, "a." + ONION]})
self.assertEqual(out, ["." + CLEARNET, "." + ONION])
def test_onion_host_without_a_provisioned_node_is_not_whitelisted(self):
out = self._run("web-app-a", {"web-app-a": ["a." + ONION]}, tor_node="")
self.assertEqual(out, [])
def test_clearnet_host_without_a_primary_domain_raises(self):
with self.assertRaises(AnsibleError):
self._run("web-app-a", {"web-app-a": ["a." + CLEARNET]}, primary="")
if __name__ == "__main__": # pragma: no cover
unittest.main()