/
/
/
1"""
2Contract tests for ``provider.origins``.
3
4Two contracts are pinned here:
5
61. The public helper names exist on ``provider.origins``. The Connect Wizard
7 imports them directly (no ``importlib`` round-trip); a rename in the
8 origins module would silently break wizard mount with an opaque
9 ``RuntimeError`` if there were no test to catch it.
10
112. ``provider.http_bridge`` continues to re-export the historical
12 underscore-prefixed names so existing call sites and any external tests
13 keep working without churn.
14"""
15# mypy: disable-error-code="arg-type, no-untyped-def, type-arg, assignment, misc, attr-defined"
16
17from __future__ import annotations
18
19from typing import Any
20from unittest.mock import MagicMock
21
22from music_assistant.providers.fastmcp_server.origins import (
23 _is_origin_allowed,
24 _normalize_origin,
25 _port_from_base_url,
26 compute_origin_allowlist,
27 is_origin_allowed_for_request,
28)
29
30
31def test_public_names_exist() -> None:
32 """The two helpers the Connect Wizard imports must remain public."""
33 assert callable(compute_origin_allowlist)
34 assert callable(is_origin_allowed_for_request)
35
36
37def test_http_bridge_re_exports_legacy_names() -> None:
38 """
39 Historical names on ``provider.http_bridge`` keep working for back-compat.
40
41 The attribute-defined ignores below are deliberate: the re-exports use
42 underscore-prefixed aliases so mypy treats them as private. This test
43 is *exactly* the contract that asserts those aliases stay reachable.
44 """
45 from music_assistant.providers.fastmcp_server import http_bridge # noqa: PLC0415
46
47 assert http_bridge._compute_origin_allowlist is compute_origin_allowlist
48 assert http_bridge._is_origin_allowed_for_request is is_origin_allowed_for_request
49 assert http_bridge._normalize_origin is _normalize_origin
50 assert http_bridge._port_from_base_url is _port_from_base_url
51 assert http_bridge._is_origin_allowed is _is_origin_allowed
52
53
54def test_compute_origin_allowlist_includes_loopback() -> None:
55 """The allowlist always includes default-port loopback variants."""
56 mass = MagicMock()
57 mass.webserver.base_url = "http://localhost:8095"
58 mass.webserver.publish_ip = "127.0.0.1"
59
60 allow = compute_origin_allowlist(mass)
61 assert "http://localhost" in allow
62 assert "http://127.0.0.1" in allow
63 assert "http://[::1]" in allow
64
65
66def test_compute_origin_allowlist_adds_both_schemes_on_ma_port() -> None:
67 """
68 When MA runs on a non-default port, both http and https on that port are accepted.
69
70 Browsers serialize the port in Origin even for loopback connections, and
71 a TLS-terminating reverse proxy in front of MA produces https origins
72 that the bare http allowlist would otherwise reject.
73 """
74 mass = MagicMock()
75 mass.webserver.base_url = "http://localhost:8095"
76 mass.webserver.publish_ip = "192.168.1.42"
77
78 allow = compute_origin_allowlist(mass)
79 # Loopback x {http, https} x MA port
80 for host in ("localhost", "127.0.0.1", "[::1]"):
81 assert f"http://{host}:8095" in allow, host
82 assert f"https://{host}:8095" in allow, host
83 # Configured publish_ip x {http, https} x MA port
84 assert "http://192.168.1.42:8095" in allow
85 assert "https://192.168.1.42:8095" in allow
86 # base_url itself plus its https mirror
87 assert "http://localhost:8095" in allow
88 assert "https://localhost:8095" in allow
89
90
91def test_compute_origin_allowlist_picks_up_extra_origins_csv() -> None:
92 """Operator-supplied origins from config are normalised and added."""
93 mass = MagicMock()
94 mass.webserver.base_url = "http://localhost:8095"
95 mass.webserver.publish_ip = ""
96
97 allow = compute_origin_allowlist(
98 mass, extra_origins_csv="https://ha.example.com, https://other.example "
99 )
100 assert "https://ha.example.com" in allow
101 assert "https://other.example" in allow
102
103
104def test_https_base_url_does_not_get_http_mirror() -> None:
105 """An https base URL must NOT add an http downgrade to the allowlist."""
106 mass = MagicMock()
107 mass.webserver.base_url = "https://secure.example"
108 mass.webserver.publish_ip = ""
109
110 allow = compute_origin_allowlist(mass)
111 assert "https://secure.example" in allow
112 # No http mirror â attackers should not be able to downgrade.
113 assert "http://secure.example" not in allow
114
115
116def test_is_origin_allowed_for_request_passes_through_allowlist_hits() -> None:
117 """A standard allowlist hit returns True without entering the ingress fallback."""
118 mass = MagicMock()
119 mass.webserver.base_url = "http://localhost:8095"
120 mass.webserver.publish_ip = ""
121 allow = compute_origin_allowlist(mass)
122
123 request: Any = MagicMock()
124 request.headers = {"Origin": "http://localhost:8095"}
125 assert is_origin_allowed_for_request(request, allow) is True
126
127
128def test_is_origin_allowed_for_request_rejects_unknown_no_forwarded_host() -> None:
129 """An unknown origin without HA-ingress headers is rejected."""
130 mass = MagicMock()
131 mass.webserver.base_url = "http://localhost:8095"
132 mass.webserver.publish_ip = ""
133 allow = compute_origin_allowlist(mass)
134
135 request: Any = MagicMock()
136 request.headers = {"Origin": "https://evil.example"}
137 assert is_origin_allowed_for_request(request, allow) is False
138