test_notification_app_message.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. """Messages from other applications through the notification channels.
  2. The contract these tests pin:
  3. ``POST /notifications/app-message`` hands an app's message to every enabled
  4. channel with ``on_app_message`` on, and to no other. An API key needs the
  5. opt-in ``can_send_notifications`` scope, and its owner the
  6. ``notifications:update`` permission (a key can't do what its owner may not).
  7. Users need ``notifications:update``. Text is plain, the link http(s) only,
  8. and each caller is rate-limited.
  9. """
  10. from unittest.mock import AsyncMock, patch
  11. import pytest
  12. from httpx import AsyncClient
  13. from sqlalchemy import select
  14. from sqlalchemy.ext.asyncio import AsyncSession
  15. from backend.app.api.routes import notifications as notification_routes
  16. from backend.app.core.auth import generate_api_key
  17. from backend.app.models.api_key import APIKey
  18. from backend.app.models.notification import NotificationProvider
  19. from backend.app.models.user import User
  20. URL = "/api/v1/notifications/app-message"
  21. @pytest.fixture(autouse=True)
  22. def _fresh_rate_limit():
  23. notification_routes._app_message_times.clear()
  24. yield
  25. notification_routes._app_message_times.clear()
  26. async def _admin_token(client: AsyncClient) -> str:
  27. await client.post(
  28. "/api/v1/auth/setup",
  29. json={
  30. "auth_enabled": True,
  31. "admin_username": "notifyadmin",
  32. "admin_password": "AdminPass1!", # pragma: allowlist secret
  33. },
  34. )
  35. login = await client.post(
  36. "/api/v1/auth/login",
  37. json={"username": "notifyadmin", "password": "AdminPass1!"}, # pragma: allowlist secret
  38. )
  39. return login.json()["access_token"]
  40. async def _user_id(db: AsyncSession, username: str) -> int:
  41. return (await db.execute(select(User).where(User.username == username))).scalar_one().id
  42. async def _key(db: AsyncSession, *, owner_id: int | None, allowed: bool, name: str = "Bambuddy Orders") -> str:
  43. full_key, key_hash, key_prefix = generate_api_key()
  44. db.add(
  45. APIKey(name=name, key_hash=key_hash, key_prefix=key_prefix, user_id=owner_id, can_send_notifications=allowed)
  46. )
  47. await db.commit()
  48. return full_key
  49. async def _channels(db: AsyncSession) -> None:
  50. for name, on, enabled in (
  51. ("Telegram", True, True),
  52. ("ntfy", True, True),
  53. ("Email", False, True),
  54. ("Old", True, False),
  55. ):
  56. db.add(NotificationProvider(name=name, provider_type="ntfy", enabled=enabled, config="{}", on_app_message=on))
  57. await db.commit()
  58. def _sent():
  59. return patch(
  60. "backend.app.services.notification_service.notification_service._send_to_provider",
  61. new=AsyncMock(return_value=(True, "")),
  62. )
  63. class TestAppMessage:
  64. @pytest.mark.asyncio
  65. @pytest.mark.integration
  66. async def test_goes_to_the_channels_that_accept_app_messages(self, async_client: AsyncClient, db_session):
  67. await _admin_token(async_client)
  68. await _channels(db_session)
  69. key = await _key(db_session, owner_id=await _user_id(db_session, "notifyadmin"), allowed=True)
  70. with _sent() as send:
  71. resp = await async_client.post(
  72. URL,
  73. headers={"X-API-Key": key},
  74. json={
  75. "title": "3 orders need you",
  76. "message": "#1004, #1009, #1010",
  77. "url": "http://orders.lan:8090/todo",
  78. },
  79. )
  80. assert resp.status_code == 200, resp.text
  81. assert resp.json() == {"channels": 2}
  82. names = sorted(call.args[0].name for call in send.await_args_list)
  83. assert names == ["Telegram", "ntfy"]
  84. _, title, body = send.await_args_list[0].args[:3]
  85. assert title == "3 orders need you"
  86. assert body == "#1004, #1009, #1010\nhttp://orders.lan:8090/todo"
  87. assert send.await_args_list[0].kwargs["event_type"] == "app:Bambuddy Orders"
  88. @pytest.mark.asyncio
  89. @pytest.mark.integration
  90. async def test_lists_the_channels_by_name(self, async_client: AsyncClient, db_session):
  91. await _admin_token(async_client)
  92. await _channels(db_session)
  93. key = await _key(db_session, owner_id=await _user_id(db_session, "notifyadmin"), allowed=True)
  94. resp = await async_client.get(f"{URL}/channels", headers={"X-API-Key": key})
  95. assert resp.status_code == 200
  96. assert resp.json() == [{"name": "Telegram", "provider_type": "ntfy"}, {"name": "ntfy", "provider_type": "ntfy"}]
  97. @pytest.mark.asyncio
  98. @pytest.mark.integration
  99. async def test_a_key_without_the_scope_is_refused(self, async_client: AsyncClient, db_session):
  100. await _admin_token(async_client)
  101. key = await _key(db_session, owner_id=await _user_id(db_session, "notifyadmin"), allowed=False)
  102. resp = await async_client.post(URL, headers={"X-API-Key": key}, json={"title": "t", "message": "m"})
  103. assert resp.status_code == 403
  104. assert "send_notifications" in resp.json()["detail"]
  105. @pytest.mark.asyncio
  106. @pytest.mark.integration
  107. async def test_a_key_cant_do_what_its_owner_may_not(self, async_client: AsyncClient, db_session):
  108. await _admin_token(async_client)
  109. db_session.add(User(username="nobody", password_hash="x", role="user", is_active=True))
  110. await db_session.commit()
  111. key = await _key(db_session, owner_id=await _user_id(db_session, "nobody"), allowed=True)
  112. resp = await async_client.post(URL, headers={"X-API-Key": key}, json={"title": "t", "message": "m"})
  113. assert resp.status_code == 403
  114. assert "notifications:update" in resp.json()["detail"]
  115. @pytest.mark.asyncio
  116. @pytest.mark.integration
  117. async def test_an_admin_user_may_send(self, async_client: AsyncClient, db_session):
  118. token = await _admin_token(async_client)
  119. with _sent():
  120. resp = await async_client.post(
  121. URL, headers={"Authorization": f"Bearer {token}"}, json={"title": "Test", "message": "Hello"}
  122. )
  123. assert resp.status_code == 200
  124. assert resp.json() == {"channels": 0}
  125. @pytest.mark.asyncio
  126. @pytest.mark.integration
  127. async def test_unauthenticated_is_refused(self, async_client: AsyncClient):
  128. await _admin_token(async_client)
  129. resp = await async_client.post(URL, json={"title": "t", "message": "m"})
  130. assert resp.status_code == 401
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. @pytest.mark.parametrize(
  134. "payload",
  135. [
  136. {"title": "", "message": "m"},
  137. {"title": "t", "message": " "},
  138. {"title": "t" * 121, "message": "m"},
  139. {"title": "t", "message": "m", "url": "javascript:alert(1)"},
  140. {"title": "t", "message": "m", "url": "http://a b"},
  141. ],
  142. )
  143. async def test_refuses_what_isnt_plain_text_or_a_web_link(self, async_client: AsyncClient, db_session, payload):
  144. await _admin_token(async_client)
  145. key = await _key(db_session, owner_id=await _user_id(db_session, "notifyadmin"), allowed=True)
  146. resp = await async_client.post(URL, headers={"X-API-Key": key}, json=payload)
  147. assert resp.status_code == 422
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_control_characters_are_dropped(self, async_client: AsyncClient, db_session):
  151. await _admin_token(async_client)
  152. await _channels(db_session)
  153. key = await _key(db_session, owner_id=await _user_id(db_session, "notifyadmin"), allowed=True)
  154. with _sent() as send:
  155. await async_client.post(URL, headers={"X-API-Key": key}, json={"title": "Hi\x07", "message": "a\nb\x1b"})
  156. assert send.await_args_list[0].args[1:3] == ("Hi", "a\nb")
  157. @pytest.mark.asyncio
  158. @pytest.mark.integration
  159. async def test_each_caller_is_rate_limited(self, async_client: AsyncClient, db_session):
  160. await _admin_token(async_client)
  161. owner = await _user_id(db_session, "notifyadmin")
  162. key = await _key(db_session, owner_id=owner, allowed=True)
  163. other = await _key(db_session, owner_id=owner, allowed=True, name="Other app")
  164. with _sent():
  165. codes = [
  166. (
  167. await async_client.post(URL, headers={"X-API-Key": key}, json={"title": "t", "message": "m"})
  168. ).status_code
  169. for _ in range(notification_routes.APP_MESSAGE_LIMIT + 1)
  170. ]
  171. other_code = (
  172. await async_client.post(URL, headers={"X-API-Key": other}, json={"title": "t", "message": "m"})
  173. ).status_code
  174. assert codes[:-1] == [200] * notification_routes.APP_MESSAGE_LIMIT
  175. assert codes[-1] == 429
  176. assert other_code == 200
  177. class TestProviderAndKeyFlags:
  178. @pytest.mark.asyncio
  179. @pytest.mark.integration
  180. async def test_the_channel_switch_round_trips(self, async_client: AsyncClient):
  181. resp = await async_client.post(
  182. "/api/v1/notifications/",
  183. json={"name": "Phone", "provider_type": "ntfy", "config": {"server": "https://ntfy.sh", "topic": "x"}},
  184. )
  185. assert resp.status_code == 200, resp.text
  186. provider = resp.json()
  187. assert provider["on_app_message"] is False
  188. resp = await async_client.patch(f"/api/v1/notifications/{provider['id']}", json={"on_app_message": True})
  189. assert resp.json()["on_app_message"] is True
  190. @pytest.mark.asyncio
  191. @pytest.mark.integration
  192. async def test_the_key_scope_round_trips_and_defaults_off(self, async_client: AsyncClient):
  193. token = await _admin_token(async_client)
  194. headers = {"Authorization": f"Bearer {token}"}
  195. resp = await async_client.post("/api/v1/api-keys/", headers=headers, json={"name": "orders"})
  196. assert resp.json()["can_send_notifications"] is False
  197. resp = await async_client.patch(
  198. f"/api/v1/api-keys/{resp.json()['id']}", headers=headers, json={"can_send_notifications": True}
  199. )
  200. assert resp.json()["can_send_notifications"] is True