Files
computer-playbook/tests/unit/python/roles/web-app-stalwart/test_roundcube_oauth_scheme.py
T
kevinveenbirkenbachandClaude Opus 5.5 3610e7b9ef fix(code-scanning): resolve the alerts that describe real code
Of the 76 open code scanning alerts, 39 point at code that can change.

svc-ai-mcp-adapter:
- A path argument that is empty or made only of dots is refused.
  Percent-encoding keeps a separator inside its segment, but "." and ".."
  need none, so an upstream that resolves dot segments could answer from a
  path the contract does not name.
- parse_duration splits on the units. The pattern it used before rescanned
  a long run of digits from every start position.

Tests and the README generators:
- Every Jinja environment built with autoescape=False and a "noqa: S701"
  uses select_autoescape now, as six test modules and
  utils/templating/jinja.py already did. It escapes templates named
  *.html, *.htm and *.xml, which none of these call sites loads.
- Dead code goes: a doubled "rel" block in the email lint, an unread
  _SKIP_DIRS, and a module that _resolve returned to no caller.
- Two "except ...: pass" blocks become contextlib.suppress and
  assertRaises.
- Nine test modules import unittest.mock one way instead of two, and one
  lookup test imports its module once.

Closes #807

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:18:00 +02:00

105 lines
3.7 KiB
Python

"""Whether Roundcube forces https on its generated URLs, per canonical scheme.
Roundcube derives the OAuth ``redirect_uri`` from the request scheme, and the
reverse proxy speaks plain HTTP to the container. Forcing https is therefore
right on a clearnet vhost and wrong on an onion one, which is served over HTTP
because Tor carries the encryption. Getting it wrong is not cosmetic: Keycloak
compares the ``redirect_uri`` against the client's registered list verbatim and
answers "Invalid parameter: redirect_uri" on a scheme mismatch, so the webmail
login dies before the form is ever drawn.
Both sides are built from ``canonical_url``, so the assertion here is that the
template follows that one source rather than a hard-coded scheme.
"""
from __future__ import annotations
import unittest
from jinja2 import Environment, StrictUndefined, select_autoescape
from utils.cache.files import read_text
from . import PROJECT_ROOT
TEMPLATE = (
PROJECT_ROOT
/ "roles"
/ "web-app-stalwart"
/ "templates"
/ "roundcube-oauth.inc.php.j2"
)
FORCE_HTTPS = "$_SERVER['HTTPS'] = 'on';"
ONION_BASE = (
"http://webmail.4dl6yijmuksgh5bfaf3csqoelji7zptyuo6wy5g5prjpnk6vdbzfwnad.onion"
)
CLEARNET_BASE = "https://webmail.infinito.example"
def _render(base_url: str, *, sso_enabled: bool = True, socks_proxy: str = "") -> str:
env = Environment(
trim_blocks=True,
lstrip_blocks=True,
undefined=StrictUndefined,
autoescape=select_autoescape(default_for_string=False),
)
env.filters["bool"] = bool
return env.from_string(read_text(TEMPLATE)).render(
STALWART_SSO_ENABLED=sso_enabled,
STALWART_WEBMAIL_BASE_URL=base_url,
STALWART_OIDC_SOCKS_PROXY=socks_proxy,
STALWART_WEBMAIL_OIDC_CLIENT_ID="roundcube",
OIDC={
"BUTTON_TEXT": "Sign in",
"ATTRIBUTES": {"USERNAME": "preferred_username"},
"CLIENT": {
"AUTHORIZE_URL": "https://auth.infinito.example/auth",
"TOKEN_URL": "https://auth.infinito.example/token",
"USER_INFO_URL": "https://auth.infinito.example/userinfo",
"LOGOUT_URL": "https://auth.infinito.example/logout",
},
},
)
class TestRoundcubeOauthScheme(unittest.TestCase):
def test_a_clearnet_vhost_still_forces_https(self):
self.assertIn(FORCE_HTTPS, _render(CLEARNET_BASE))
def test_an_onion_vhost_is_left_on_http(self):
self.assertNotIn(FORCE_HTTPS, _render(ONION_BASE))
def test_the_scheme_is_never_decided_by_the_onion_suffix_alone(self):
self.assertIn(FORCE_HTTPS, _render("https://webmail.example.onion"))
def test_an_onion_issuer_routes_the_back_channel_through_tor(self):
rendered = _render(
ONION_BASE, socks_proxy="socks5h://host.docker.internal:9050"
)
self.assertIn(
"$config['http_client'] = ['proxy' => 'socks5h://host.docker.internal:9050'];",
rendered,
)
def test_the_proxy_must_hand_the_name_to_tor_to_resolve(self):
"""socks5:// resolves locally, which is what cURL refuses to do for .onion."""
rendered = _render(
ONION_BASE, socks_proxy="socks5h://host.docker.internal:9050"
)
self.assertNotIn("'proxy' => 'socks5://", rendered)
def test_a_clearnet_issuer_gets_no_proxy_at_all(self):
self.assertNotIn("http_client", _render(CLEARNET_BASE))
def test_nothing_is_emitted_without_sso(self):
rendered = _render(
CLEARNET_BASE, sso_enabled=False, socks_proxy="socks5h://tor:9050"
)
self.assertNotIn(FORCE_HTTPS, rendered)
self.assertNotIn("oauth_client_id", rendered)
self.assertNotIn("http_client", rendered)
if __name__ == "__main__":
unittest.main()