test_notification_provider_secrets.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. """Notification provider secrets are write-only.
  2. Provider responses return every secret field as a fixed placeholder. Sending
  3. the placeholder back keeps the stored value, an empty value clears it, and a
  4. stored secret only goes to the destination it was entered for: changing the
  5. destination needs the secret entered again. Testing an edited provider fills
  6. in the stored secrets for those who may edit it.
  7. """
  8. from __future__ import annotations
  9. import json
  10. from unittest.mock import AsyncMock, patch
  11. import pytest
  12. from httpx import AsyncClient
  13. from sqlalchemy import select
  14. from backend.app.models.notification import NotificationProvider
  15. from backend.app.services.secret_fields import SECRET_PLACEHOLDER
  16. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  17. M = SECRET_PLACEHOLDER
  18. # provider type -> (config with every secret set, its secret fields)
  19. CONFIGS = {
  20. "callmebot": ({"phone": "+100", "apikey": "cmb-key"}, ["apikey"]),
  21. "ntfy": ({"server": "https://ntfy.example", "topic": "t", "auth_token": "ntfy-tok"}, ["auth_token"]),
  22. "pushover": ({"user_key": "po-user", "app_token": "po-app", "priority": "0"}, ["user_key", "app_token"]),
  23. "telegram": ({"bot_token": "123:tg", "chat_id": "42"}, ["bot_token"]),
  24. "email": (
  25. {
  26. "smtp_server": "smtp.example",
  27. "smtp_port": "587",
  28. "security": "starttls",
  29. "auth_enabled": "true",
  30. "username": "u",
  31. "password": "mail-pw",
  32. "from_email": "a@example.com",
  33. "to_email": "b@example.com",
  34. },
  35. ["password"],
  36. ),
  37. "discord": ({"webhook_url": "https://discord.example/api/webhooks/1/abc"}, ["webhook_url"]),
  38. "webhook": (
  39. {"webhook_url": "https://hooks.example/x?key=k", "auth_header": "Bearer wh", "payload_format": "generic"},
  40. ["webhook_url", "auth_header"],
  41. ),
  42. "bark": ({"device_key": "bark-key", "server": "https://bark.example"}, ["device_key"]),
  43. "gotify": ({"server": "https://gotify.example", "app_token": "go-tok"}, ["app_token"]),
  44. "notify": ({"device_id": "IO12345678901234", "token": "notify-tok"}, ["token"]),
  45. "homeassistant": ({"service": "notify.mobile"}, []),
  46. }
  47. async def _admin(client: AsyncClient) -> dict[str, str]:
  48. await client.post(
  49. "/api/v1/auth/setup",
  50. json={"auth_enabled": True, "admin_username": "nsadmin", "admin_password": "AdminPass1!"},
  51. )
  52. login = await client.post("/api/v1/auth/login", json={"username": "nsadmin", "password": "AdminPass1!"})
  53. assert login.status_code == 200, login.text
  54. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  55. async def _user(client: AsyncClient, admin: dict[str, str], name: str, permissions: list[str]) -> dict[str, str]:
  56. group = await client.post("/api/v1/groups/", headers=admin, json={"name": f"g_{name}", "permissions": permissions})
  57. assert group.status_code in (200, 201), group.text
  58. created = await client.post(
  59. "/api/v1/users/",
  60. headers=admin,
  61. json={"username": name, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
  62. )
  63. assert created.status_code in (200, 201), created.text
  64. login = await client.post("/api/v1/auth/login", json={"username": name, "password": "UserPass1!"})
  65. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  66. async def _create(client: AsyncClient, headers: dict[str, str], provider_type: str, config: dict) -> dict:
  67. with (
  68. patch("backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock),
  69. patch("backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock),
  70. ):
  71. response = await client.post(
  72. "/api/v1/notifications/",
  73. headers=headers,
  74. json={"name": provider_type, "provider_type": provider_type, "config": config},
  75. )
  76. assert response.status_code == 200, response.text
  77. return response.json()
  78. async def _stored(db_session, provider_id: int) -> dict:
  79. db_session.expire_all()
  80. provider = await db_session.scalar(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  81. return json.loads(provider.config)
  82. async def _patch(client: AsyncClient, headers: dict[str, str], provider_id: int, body: dict):
  83. with (
  84. patch(
  85. "backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock
  86. ) as live_cleanup,
  87. patch("backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock),
  88. ):
  89. response = await client.patch(f"/api/v1/notifications/{provider_id}", headers=headers, json=body)
  90. return response, live_cleanup
  91. @pytest.mark.parametrize("provider_type", sorted(CONFIGS))
  92. async def test_a_reader_gets_placeholders_for_every_secret(async_client: AsyncClient, provider_type):
  93. config, secrets = CONFIGS[provider_type]
  94. admin = await _admin(async_client)
  95. viewer = await _user(async_client, admin, "viewer", ["notifications:read"])
  96. created = await _create(async_client, admin, provider_type, config)
  97. listed = (await async_client.get("/api/v1/notifications/", headers=viewer)).json()
  98. single = (await async_client.get(f"/api/v1/notifications/{created['id']}", headers=viewer)).json()
  99. expected = {key: (M if key in secrets else value) for key, value in config.items()}
  100. assert created["config"] == expected
  101. assert listed[0]["config"] == expected
  102. assert single["config"] == expected
  103. async def test_an_unset_secret_is_not_shown_as_set(async_client: AsyncClient):
  104. admin = await _admin(async_client)
  105. created = await _create(
  106. async_client, admin, "ntfy", {"server": "https://ntfy.example", "topic": "t", "auth_token": ""}
  107. )
  108. assert created["config"]["auth_token"] == ""
  109. async def test_sending_the_placeholder_back_keeps_the_secret(async_client: AsyncClient, db_session):
  110. admin = await _admin(async_client)
  111. created = await _create(async_client, admin, "telegram", CONFIGS["telegram"][0])
  112. response, _ = await _patch(async_client, admin, created["id"], {"config": {**created["config"], "chat_id": "43"}})
  113. assert response.status_code == 200, response.text
  114. assert response.json()["config"] == {"bot_token": M, "chat_id": "43"}
  115. assert await _stored(db_session, created["id"]) == {"bot_token": "123:tg", "chat_id": "43"}
  116. @pytest.mark.parametrize("provider_type", sorted(CONFIGS))
  117. async def test_every_type_saved_back_as_shown_stays_as_it_was(async_client: AsyncClient, db_session, provider_type):
  118. """The edit dialog sends the config back as it was shown."""
  119. admin = await _admin(async_client)
  120. created = await _create(async_client, admin, provider_type, CONFIGS[provider_type][0])
  121. response, _ = await _patch(async_client, admin, created["id"], {"config": created["config"]})
  122. assert response.status_code == 200, response.text
  123. assert await _stored(db_session, created["id"]) == CONFIGS[provider_type][0]
  124. async def test_the_placeholder_cannot_stand_for_a_secret_never_set(async_client: AsyncClient, db_session):
  125. admin = await _admin(async_client)
  126. created = await _create(
  127. async_client, admin, "ntfy", {"server": "https://ntfy.example", "topic": "t", "auth_token": ""}
  128. )
  129. response, _ = await _patch(async_client, admin, created["id"], {"config": {**created["config"], "auth_token": M}})
  130. assert response.status_code == 400, response.text
  131. assert (await _stored(db_session, created["id"]))["auth_token"] == ""
  132. async def test_an_empty_secret_clears_it_and_a_new_one_replaces_it(async_client: AsyncClient, db_session):
  133. admin = await _admin(async_client)
  134. created = await _create(async_client, admin, "ntfy", CONFIGS["ntfy"][0])
  135. await _patch(async_client, admin, created["id"], {"config": {**created["config"], "auth_token": ""}})
  136. assert (await _stored(db_session, created["id"]))["auth_token"] == ""
  137. await _patch(async_client, admin, created["id"], {"config": {**created["config"], "auth_token": "fresh"}})
  138. assert (await _stored(db_session, created["id"]))["auth_token"] == "fresh"
  139. @pytest.mark.parametrize(
  140. ("provider_type", "change"),
  141. [
  142. ("email", {"smtp_server": "smtp.elsewhere.example"}),
  143. ("email", {"smtp_port": "25"}),
  144. ("email", {"security": "none"}),
  145. ("ntfy", {"server": "https://ntfy.elsewhere.example"}),
  146. ("gotify", {"server": "https://gotify.elsewhere.example"}),
  147. ("bark", {"server": "https://bark.elsewhere.example"}),
  148. ("webhook", {"webhook_url": "https://elsewhere.example/hook"}),
  149. ],
  150. )
  151. async def test_a_kept_secret_does_not_follow_a_new_destination(
  152. async_client: AsyncClient, db_session, provider_type, change
  153. ):
  154. admin = await _admin(async_client)
  155. created = await _create(async_client, admin, provider_type, CONFIGS[provider_type][0])
  156. response, _ = await _patch(async_client, admin, created["id"], {"config": {**created["config"], **change}})
  157. assert response.status_code == 400, response.text
  158. assert await _stored(db_session, created["id"]) == CONFIGS[provider_type][0]
  159. async def test_a_new_destination_with_its_secret_entered_again_is_saved(async_client: AsyncClient, db_session):
  160. admin = await _admin(async_client)
  161. created = await _create(async_client, admin, "email", CONFIGS["email"][0])
  162. response, _ = await _patch(
  163. async_client,
  164. admin,
  165. created["id"],
  166. {"config": {**created["config"], "smtp_server": "smtp.new.example", "password": "new-pw"}},
  167. )
  168. assert response.status_code == 200, response.text
  169. stored = await _stored(db_session, created["id"])
  170. assert stored["smtp_server"] == "smtp.new.example" and stored["password"] == "new-pw"
  171. async def test_a_kept_secret_does_not_follow_a_new_provider_type(async_client: AsyncClient, db_session):
  172. admin = await _admin(async_client)
  173. created = await _create(async_client, admin, "webhook", CONFIGS["webhook"][0])
  174. response, _ = await _patch(
  175. async_client,
  176. admin,
  177. created["id"],
  178. {"provider_type": "discord", "config": {"webhook_url": M}},
  179. )
  180. assert response.status_code == 400, response.text
  181. assert await _stored(db_session, created["id"]) == CONFIGS["webhook"][0]
  182. async def test_the_placeholder_is_not_a_secret_of_its_own(async_client: AsyncClient):
  183. admin = await _admin(async_client)
  184. response = await async_client.post(
  185. "/api/v1/notifications/",
  186. headers=admin,
  187. json={"name": "tg", "provider_type": "telegram", "config": {"bot_token": M, "chat_id": "42"}},
  188. )
  189. assert response.status_code == 400, response.text
  190. async def test_a_notify_provider_kept_as_is_is_not_cleaned_up(async_client: AsyncClient):
  191. """Its live activities and widgets are only cleaned up when its device or
  192. token changed; the placeholder in the response is not a change."""
  193. admin = await _admin(async_client)
  194. created = await _create(async_client, admin, "notify", CONFIGS["notify"][0])
  195. response, live_cleanup = await _patch(async_client, admin, created["id"], {"config": created["config"]})
  196. assert response.status_code == 200, response.text
  197. live_cleanup.assert_not_awaited()
  198. # --- testing an edited provider ---
  199. async def _test_config(client: AsyncClient, headers: dict[str, str], body: dict):
  200. with patch(
  201. "backend.app.api.routes.notifications.notification_service.send_test_notification",
  202. new_callable=AsyncMock,
  203. return_value=(True, "ok"),
  204. ) as send:
  205. response = await client.post("/api/v1/notifications/test-config", headers=headers, json=body)
  206. return response, send
  207. async def test_an_editor_tests_with_the_stored_secret(async_client: AsyncClient):
  208. admin = await _admin(async_client)
  209. editor = await _user(
  210. async_client, admin, "editor", ["notifications:read", "notifications:create", "notifications:update"]
  211. )
  212. created = await _create(async_client, admin, "telegram", CONFIGS["telegram"][0])
  213. response, send = await _test_config(
  214. async_client,
  215. editor,
  216. {"provider_type": "telegram", "config": created["config"], "provider_id": created["id"]},
  217. )
  218. assert response.status_code == 200, response.text
  219. assert send.await_args.args[1] == {"bot_token": "123:tg", "chat_id": "42"}
  220. async def test_someone_who_may_only_create_cannot_test_with_a_stored_secret(async_client: AsyncClient):
  221. admin = await _admin(async_client)
  222. creator = await _user(async_client, admin, "creator", ["notifications:read", "notifications:create"])
  223. created = await _create(async_client, admin, "telegram", CONFIGS["telegram"][0])
  224. response, send = await _test_config(
  225. async_client,
  226. creator,
  227. {"provider_type": "telegram", "config": created["config"], "provider_id": created["id"]},
  228. )
  229. assert response.status_code == 403, response.text
  230. send.assert_not_awaited()
  231. async def test_a_test_does_not_send_a_stored_secret_to_a_new_destination(async_client: AsyncClient):
  232. admin = await _admin(async_client)
  233. created = await _create(async_client, admin, "gotify", CONFIGS["gotify"][0])
  234. response, send = await _test_config(
  235. async_client,
  236. admin,
  237. {
  238. "provider_type": "gotify",
  239. "config": {**created["config"], "server": "https://gotify.elsewhere.example"},
  240. "provider_id": created["id"],
  241. },
  242. )
  243. assert response.status_code == 400, response.text
  244. send.assert_not_awaited()
  245. async def test_a_test_without_a_provider_does_not_send_the_placeholder(async_client: AsyncClient):
  246. admin = await _admin(async_client)
  247. response, send = await _test_config(
  248. async_client, admin, {"provider_type": "telegram", "config": {"bot_token": M, "chat_id": "42"}}
  249. )
  250. assert response.status_code == 400, response.text
  251. send.assert_not_awaited()
  252. async def test_a_new_provider_is_tested_as_entered(async_client: AsyncClient):
  253. admin = await _admin(async_client)
  254. response, send = await _test_config(
  255. async_client, admin, {"provider_type": "telegram", "config": {"bot_token": "123:tg", "chat_id": "42"}}
  256. )
  257. assert response.status_code == 200, response.text
  258. assert send.await_args.args[1] == {"bot_token": "123:tg", "chat_id": "42"}
  259. async def test_a_type_change_needs_a_config_of_its_own(async_client: AsyncClient, db_session):
  260. """Changing only the type would keep the stored secrets under a type that
  261. neither masks nor guards them."""
  262. admin = await _admin(async_client)
  263. created = await _create(async_client, admin, "email", CONFIGS["email"][0])
  264. response, _ = await _patch(async_client, admin, created["id"], {"provider_type": "homeassistant"})
  265. assert response.status_code == 400, response.text
  266. db_session.expire_all()
  267. provider = await db_session.get(NotificationProvider, created["id"])
  268. assert provider.provider_type == "email"
  269. async def test_a_secret_named_for_another_type_is_masked_too(async_client: AsyncClient, db_session):
  270. """Leftover keys a provider's type does not use are still not shown, and
  271. saving them back as shown keeps them."""
  272. admin = await _admin(async_client)
  273. created = await _create(async_client, admin, "homeassistant", {"service": "notify.x", "password": "left-over"})
  274. assert created["config"] == {"service": "notify.x", "password": M}
  275. response, _ = await _patch(async_client, admin, created["id"], {"config": created["config"]})
  276. assert response.status_code == 200, response.text
  277. assert (await _stored(db_session, created["id"]))["password"] == "left-over"
  278. @pytest.mark.parametrize(
  279. ("stored", "sent"),
  280. [({"smtp_port": 587}, {"smtp_port": "587"}), ({"smtp_port": None}, {"smtp_port": ""})],
  281. )
  282. async def test_an_unchanged_destination_in_another_spelling_is_not_a_change(
  283. async_client: AsyncClient, db_session, stored, sent
  284. ):
  285. """The edit dialog sends every value back as text."""
  286. admin = await _admin(async_client)
  287. created = await _create(async_client, admin, "email", {**CONFIGS["email"][0], **stored})
  288. response, _ = await _patch(async_client, admin, created["id"], {"config": {**created["config"], **sent}})
  289. assert response.status_code == 200, response.text
  290. assert (await _stored(db_session, created["id"]))["password"] == "mail-pw"
  291. @pytest.mark.parametrize("security", ["", " starttls", "none"])
  292. async def test_a_kept_smtp_password_is_not_sent_with_weaker_security(async_client: AsyncClient, db_session, security):
  293. """With no security stored, the sender uses STARTTLS; anything else it reads
  294. as plain SMTP, which would send the stored password unencrypted."""
  295. admin = await _admin(async_client)
  296. config = {key: value for key, value in CONFIGS["email"][0].items() if key != "security"}
  297. created = await _create(async_client, admin, "email", config)
  298. response, _ = await _patch(
  299. async_client, admin, created["id"], {"config": {**created["config"], "security": security}}
  300. )
  301. assert response.status_code == 400, response.text
  302. assert await _stored(db_session, created["id"]) == config