test_oauth.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. """Tests for SwitchBot OAuth helpers."""
  2. from __future__ import annotations
  3. import logging
  4. from typing import Any
  5. from unittest.mock import ANY, AsyncMock, MagicMock
  6. from urllib.parse import parse_qs, urlparse
  7. import aiohttp
  8. import pytest
  9. from switchbot import (
  10. OAUTH_AUTHORIZE_URL,
  11. OAUTH_SCOPE,
  12. OAUTH_TOKEN_URL,
  13. SwitchbotAccountConnectionError,
  14. SwitchbotApiError,
  15. SwitchbotAuthenticationError,
  16. build_oauth_authorize_url,
  17. exchange_oauth_code,
  18. )
  19. CLIENT_ID = "client-id"
  20. REDIRECT_URI = "https://example.com/oauth/callback"
  21. STATE = "oauth-state"
  22. def _mock_session(
  23. *,
  24. status: int = 200,
  25. json_data: Any = None,
  26. json_exception: Exception | None = None,
  27. ) -> MagicMock:
  28. """Create a mocked client session and response."""
  29. response = MagicMock()
  30. response.status = status
  31. response.headers = {"x-request-id": "oauth-request-id"}
  32. response.json = AsyncMock(return_value=json_data)
  33. if json_exception is not None:
  34. response.json.side_effect = json_exception
  35. session = MagicMock(spec=aiohttp.ClientSession)
  36. session.post.return_value.__aenter__ = AsyncMock(return_value=response)
  37. session.post.return_value.__aexit__ = AsyncMock(return_value=None)
  38. return session
  39. def test_oauth_production_endpoints() -> None:
  40. """Test OAuth uses the SwitchBot production endpoints."""
  41. assert OAUTH_AUTHORIZE_URL == "https://sp.oauth.switchbot.net"
  42. assert (
  43. OAUTH_TOKEN_URL == "https://account.api.switchbot.net/merchant/v1/oauth/token"
  44. )
  45. assert OAUTH_SCOPE == "api_login"
  46. def test_build_oauth_authorize_url() -> None:
  47. """Test authorization URL generation."""
  48. url = build_oauth_authorize_url(CLIENT_ID, REDIRECT_URI, STATE)
  49. parsed = urlparse(url)
  50. assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == OAUTH_AUTHORIZE_URL
  51. assert parse_qs(parsed.query) == {
  52. "client_id": [CLIENT_ID],
  53. "redirect_uri": [REDIRECT_URI],
  54. "response_type": ["code"],
  55. "scope": [OAUTH_SCOPE],
  56. "state": [STATE],
  57. }
  58. assert "client_secret" not in parsed.query
  59. assert "code_challenge" not in parsed.query
  60. @pytest.mark.asyncio
  61. async def test_exchange_oauth_code() -> None:
  62. """Test authorization code exchange."""
  63. token = {
  64. "access_token": "access-token",
  65. "refresh_token": "refresh-token",
  66. "token_type": "Bearer",
  67. "expires_in": 3600,
  68. "refresh_expires_in": 2592000,
  69. }
  70. session = _mock_session(json_data=token)
  71. result = await exchange_oauth_code(
  72. session,
  73. CLIENT_ID,
  74. REDIRECT_URI,
  75. "authorization-code",
  76. )
  77. assert result == token
  78. session.post.assert_called_once_with(
  79. OAUTH_TOKEN_URL,
  80. data={
  81. "code": "authorization-code",
  82. "client_id": CLIENT_ID,
  83. "grant_type": "authorization_code",
  84. "redirect_uri": REDIRECT_URI,
  85. },
  86. timeout=ANY,
  87. )
  88. assert "client_secret" not in session.post.call_args.kwargs["data"]
  89. assert "code_verifier" not in session.post.call_args.kwargs["data"]
  90. @pytest.mark.asyncio
  91. async def test_exchange_oauth_code_accepts_string_expiry() -> None:
  92. """Test a numeric string expiry returned by the token endpoint."""
  93. token = {
  94. "access_token": "access-token",
  95. "expires_in": "3600",
  96. }
  97. session = _mock_session(json_data=token)
  98. result = await exchange_oauth_code(
  99. session,
  100. CLIENT_ID,
  101. REDIRECT_URI,
  102. "authorization-code",
  103. )
  104. assert result == {"access_token": "access-token", "expires_in": 3600}
  105. assert token["expires_in"] == "3600"
  106. @pytest.mark.asyncio
  107. @pytest.mark.parametrize(
  108. "token",
  109. [
  110. pytest.param({"expires_in": 3600}, id="missing-access-token"),
  111. pytest.param({"access_token": "access-token"}, id="missing-expires-in"),
  112. pytest.param(
  113. {"access_token": "access-token", "expires_in": "invalid"},
  114. id="invalid-expires-in",
  115. ),
  116. pytest.param(
  117. {"access_token": "access-token", "expires_in": True},
  118. id="boolean-expires-in",
  119. ),
  120. ],
  121. )
  122. async def test_exchange_oauth_code_invalid_token(token: dict[str, Any]) -> None:
  123. """Test invalid token response data."""
  124. session = _mock_session(json_data=token)
  125. with pytest.raises(SwitchbotApiError, match="Invalid token data"):
  126. await exchange_oauth_code(
  127. session,
  128. CLIENT_ID,
  129. REDIRECT_URI,
  130. "authorization-code",
  131. )
  132. @pytest.mark.asyncio
  133. @pytest.mark.parametrize(
  134. ("status", "error"),
  135. [
  136. pytest.param(400, "invalid_grant", id="invalid-grant"),
  137. pytest.param(400, "invalid_client", id="invalid-client"),
  138. pytest.param(401, "invalid_request", id="unauthorized"),
  139. pytest.param(403, "access_denied", id="forbidden"),
  140. ],
  141. )
  142. async def test_exchange_oauth_code_authentication_error(
  143. status: int, error: str
  144. ) -> None:
  145. """Test a rejected authorization code."""
  146. session = _mock_session(
  147. status=status,
  148. json_data={
  149. "error": error,
  150. "error_description": "Authorization code expired",
  151. },
  152. )
  153. with pytest.raises(SwitchbotAuthenticationError, match=error):
  154. await exchange_oauth_code(
  155. session,
  156. CLIENT_ID,
  157. REDIRECT_URI,
  158. "authorization-code",
  159. )
  160. @pytest.mark.asyncio
  161. @pytest.mark.parametrize(
  162. ("status", "error"),
  163. [
  164. pytest.param(400, "invalid_request", id="invalid-request"),
  165. pytest.param(404, "not_found", id="not-found"),
  166. pytest.param(499, "invalid_grant", id="other-client-error"),
  167. ],
  168. )
  169. async def test_exchange_oauth_code_api_error(status: int, error: str) -> None:
  170. """Test OAuth configuration and endpoint errors."""
  171. session = _mock_session(status=status, json_data={"error": error})
  172. with pytest.raises(SwitchbotApiError, match=error):
  173. await exchange_oauth_code(
  174. session,
  175. CLIENT_ID,
  176. REDIRECT_URI,
  177. "authorization-code",
  178. )
  179. @pytest.mark.asyncio
  180. @pytest.mark.parametrize("status", [429, 500, 503])
  181. async def test_exchange_oauth_code_transient_error(status: int) -> None:
  182. """Test transient token service errors."""
  183. session = _mock_session(
  184. status=status,
  185. json_data={"error": "temporarily_unavailable"},
  186. )
  187. with pytest.raises(
  188. SwitchbotAccountConnectionError, match="temporarily_unavailable"
  189. ):
  190. await exchange_oauth_code(
  191. session,
  192. CLIENT_ID,
  193. REDIRECT_URI,
  194. "authorization-code",
  195. )
  196. @pytest.mark.asyncio
  197. async def test_exchange_oauth_code_connection_error() -> None:
  198. """Test token service connection errors."""
  199. session = MagicMock(spec=aiohttp.ClientSession)
  200. session.post.side_effect = aiohttp.ClientError("connection failed")
  201. with pytest.raises(SwitchbotAccountConnectionError, match="connection failed"):
  202. await exchange_oauth_code(
  203. session,
  204. CLIENT_ID,
  205. REDIRECT_URI,
  206. "authorization-code",
  207. )
  208. @pytest.mark.asyncio
  209. async def test_exchange_oauth_code_unparsable_error_response(
  210. caplog: pytest.LogCaptureFixture,
  211. ) -> None:
  212. """Test an unparsable OAuth error body is not exposed."""
  213. session = _mock_session(
  214. status=400,
  215. json_exception=ValueError("sensitive-provider-error"),
  216. )
  217. caplog.set_level(logging.DEBUG, logger="switchbot.oauth")
  218. with pytest.raises(SwitchbotApiError, match="400"):
  219. await exchange_oauth_code(
  220. session, CLIENT_ID, REDIRECT_URI, "authorization-code"
  221. )
  222. assert "error=unavailable" in caplog.text
  223. assert "error_type=ValueError" in caplog.text
  224. assert "sensitive-provider-error" not in caplog.text
  225. @pytest.mark.asyncio
  226. async def test_exchange_oauth_code_invalid_json() -> None:
  227. """Test an invalid token service response."""
  228. session = _mock_session(json_exception=ValueError("invalid json"))
  229. with pytest.raises(SwitchbotApiError, match="Invalid response"):
  230. await exchange_oauth_code(
  231. session,
  232. CLIENT_ID,
  233. REDIRECT_URI,
  234. "authorization-code",
  235. )
  236. @pytest.mark.asyncio
  237. async def test_exchange_oauth_code_invalid_json_shape() -> None:
  238. """Test a non-object token service response."""
  239. session = _mock_session(json_data=[])
  240. with pytest.raises(SwitchbotApiError, match="Invalid response"):
  241. await exchange_oauth_code(
  242. session,
  243. CLIENT_ID,
  244. REDIRECT_URI,
  245. "authorization-code",
  246. )
  247. @pytest.mark.asyncio
  248. async def test_oauth_debug_logs_exclude_sensitive_values(
  249. caplog: pytest.LogCaptureFixture,
  250. ) -> None:
  251. """Test OAuth debug logs contain stages but no credential values."""
  252. token = {
  253. "access_token": "sensitive-access-token",
  254. "refresh_token": "sensitive-refresh-token",
  255. "token_type": "Bearer",
  256. "expires_in": 3600,
  257. "refresh_expires_in": 2592000,
  258. }
  259. session = _mock_session(json_data=token)
  260. caplog.set_level(logging.DEBUG, logger="switchbot.oauth")
  261. build_oauth_authorize_url(CLIENT_ID, REDIRECT_URI, STATE)
  262. await exchange_oauth_code(
  263. session,
  264. CLIENT_ID,
  265. REDIRECT_URI,
  266. "sensitive-authorization-code",
  267. )
  268. assert "sp.oauth.switchbot.net" in caplog.text
  269. assert "example.com" in caplog.text
  270. assert "duration_ms=" in caplog.text
  271. assert "request_id=oauth-request-id" in caplog.text
  272. assert "access_token" in caplog.text
  273. assert "expires_in" in caplog.text
  274. for sensitive_value in (
  275. "sensitive-authorization-code",
  276. "sensitive-access-token",
  277. "sensitive-refresh-token",
  278. "2592000",
  279. STATE,
  280. ):
  281. assert sensitive_value not in caplog.text
  282. @pytest.mark.asyncio
  283. async def test_oauth_error_logs_include_safe_fields_only(
  284. caplog: pytest.LogCaptureFixture,
  285. ) -> None:
  286. """Test only bounded, redacted OAuth error fields are logged."""
  287. session = _mock_session(
  288. status=400,
  289. json_data={
  290. "error": "invalid_grant",
  291. "error_description": (
  292. "Authorization code sensitive-authorization-code expired"
  293. ),
  294. "ignored": "sensitive-provider-error",
  295. },
  296. )
  297. caplog.set_level(logging.DEBUG, logger="switchbot.oauth")
  298. with pytest.raises(SwitchbotAuthenticationError, match="invalid_grant"):
  299. await exchange_oauth_code(
  300. session,
  301. CLIENT_ID,
  302. REDIRECT_URI,
  303. "sensitive-authorization-code",
  304. )
  305. assert "invalid_grant" in caplog.text
  306. assert "Authorization code <redacted> expired" in caplog.text
  307. assert "<redacted>" in caplog.text
  308. assert "sensitive-provider-error" not in caplog.text
  309. assert "sensitive-authorization-code" not in caplog.text