test_notification_photo_attachments.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. """Unit tests for camera snapshot attachments on notifications.
  2. Covers the per-provider attach_photo opt-out, the fetched-URL providers (Home
  3. Assistant, Bark, Slack-format webhooks) and the on-disk store they fetch from,
  4. and the inline image on emails.
  5. """
  6. import json
  7. import os
  8. import time
  9. from types import SimpleNamespace
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from backend.app.schemas.notification_template import EVENT_VARIABLES, PHOTO_CAPABLE_EVENTS
  13. from backend.app.services.email_service import send_email
  14. from backend.app.services.notification_service import NotificationService
  15. from backend.app.utils import notification_photos
  16. def _client_returning(status_code=200, json_body=None):
  17. mock_response = MagicMock()
  18. mock_response.status_code = status_code
  19. mock_response.text = ""
  20. mock_response.json = MagicMock(return_value=json_body or {})
  21. mock_client = AsyncMock()
  22. mock_client.post = AsyncMock(return_value=mock_response)
  23. return mock_client
  24. def _provider(provider_type: str, config: dict, attach_photo: bool = True):
  25. provider = MagicMock()
  26. provider.provider_type = provider_type
  27. provider.config = json.dumps(config)
  28. provider.quiet_hours_enabled = False
  29. provider.attach_photo = attach_photo
  30. return provider
  31. @pytest.fixture
  32. def service():
  33. return NotificationService()
  34. class TestNotificationPhotoStore:
  35. """The flat directory HA/Bark/Slack fetch ad-hoc snapshots from."""
  36. @pytest.fixture(autouse=True)
  37. def _base_dir(self, tmp_path, monkeypatch):
  38. monkeypatch.setattr(notification_photos.settings, "base_dir", tmp_path)
  39. self.directory = tmp_path / "notification_photos"
  40. def test_save_then_find_round_trips(self):
  41. filename = notification_photos.save_notification_photo(b"jpeg-bytes", "plate_not_empty")
  42. assert filename.startswith("plate_not_empty_")
  43. assert filename.endswith(".jpg")
  44. found = notification_photos.find_notification_photo(filename)
  45. assert found is not None
  46. assert found.read_bytes() == b"jpeg-bytes"
  47. def test_event_type_is_sanitised_into_the_filename(self):
  48. filename = notification_photos.save_notification_photo(b"x", "../../etc/passwd")
  49. assert "/" not in filename
  50. assert ".." not in filename
  51. assert (self.directory / filename).exists()
  52. def test_filename_embeds_a_urlsafe_token(self):
  53. """The filename is the URL's only credential, so it has to carry the
  54. full 24-byte token, not a guessable suffix."""
  55. with patch.object(notification_photos.secrets, "token_urlsafe", return_value="T" * 32) as mock_token:
  56. filename = notification_photos.save_notification_photo(b"x", "test")
  57. mock_token.assert_called_once_with(24)
  58. assert filename.endswith("_" + "T" * 32 + ".jpg")
  59. def test_filenames_are_unique_per_save(self):
  60. first = notification_photos.save_notification_photo(b"x", "test")
  61. second = notification_photos.save_notification_photo(b"x", "test")
  62. assert first != second
  63. def test_find_rejects_traversal_and_missing_files(self):
  64. notification_photos.save_notification_photo(b"x", "test")
  65. assert notification_photos.find_notification_photo("../secret.jpg") is None
  66. assert notification_photos.find_notification_photo("does_not_exist.jpg") is None
  67. @pytest.mark.parametrize(
  68. "name",
  69. [
  70. # the pre-#3089-review shape: an 8-hex uuid suffix is guessable
  71. "test_20260930_120000_abcdef12.jpg",
  72. # token one character short / long
  73. "test_20260930_120000_" + "A" * 31 + ".jpg",
  74. "test_20260930_120000_" + "A" * 33 + ".jpg",
  75. # other extensions, even if such a file exists on disk
  76. "test_20260930_120000_" + "A" * 32 + ".png",
  77. "test_20260930_120000_" + "A" * 32 + ".jpg.bak",
  78. ],
  79. )
  80. def test_find_serves_only_the_exact_saved_shape(self, name):
  81. self.directory.mkdir(parents=True, exist_ok=True)
  82. (self.directory / name).write_bytes(b"x")
  83. assert notification_photos.find_notification_photo(name) is None
  84. def test_find_refuses_a_photo_past_its_expiry_even_before_prune(self):
  85. """The 3-day age limit is the URL's expiry, so it can't depend on
  86. another notification coming along to trigger a prune."""
  87. filename = notification_photos.save_notification_photo(b"x", "test")
  88. old = time.time() - notification_photos._MAX_AGE_SECONDS - 60
  89. os.utime(self.directory / filename, (old, old))
  90. assert notification_photos.find_notification_photo(filename) is None
  91. def test_save_prunes_photos_older_than_max_age(self):
  92. self.directory.mkdir(parents=True)
  93. stale = self.directory / "stale.jpg"
  94. stale.write_bytes(b"old")
  95. old = time.time() - notification_photos._MAX_AGE_SECONDS - 60
  96. os.utime(stale, (old, old))
  97. fresh = self.directory / "fresh.jpg"
  98. fresh.write_bytes(b"new")
  99. notification_photos.save_notification_photo(b"x", "test")
  100. assert not stale.exists()
  101. assert fresh.exists()
  102. class TestPhotoCapableEvents:
  103. def test_every_photo_capable_event_is_a_known_event(self):
  104. """supports_photo is looked up per EVENT_VARIABLES key, so a typo here
  105. would silently never show the preview."""
  106. assert set(EVENT_VARIABLES) >= PHOTO_CAPABLE_EVENTS
  107. class TestAttachPhotoOptOut:
  108. """provider.attach_photo=False must keep the photo off every provider type."""
  109. @pytest.mark.asyncio
  110. async def test_byte_upload_provider_gets_no_image_when_opted_out(self, service):
  111. provider = _provider("ntfy", {"topic": "t"}, attach_photo=False)
  112. with patch.object(service, "_send_ntfy", new_callable=AsyncMock) as mock_send:
  113. mock_send.return_value = (True, "OK")
  114. await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
  115. assert mock_send.call_args.kwargs.get("image_data") is None
  116. @pytest.mark.asyncio
  117. async def test_byte_upload_provider_gets_image_by_default(self, service):
  118. provider = _provider("ntfy", {"topic": "t"})
  119. with patch.object(service, "_send_ntfy", new_callable=AsyncMock) as mock_send:
  120. mock_send.return_value = (True, "OK")
  121. await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
  122. assert mock_send.call_args.kwargs.get("image_data") == b"jpeg"
  123. @pytest.mark.asyncio
  124. @pytest.mark.parametrize(
  125. ("provider_type", "config", "sender"),
  126. [
  127. ("bark", {"device_key": "abc"}, "_send_bark"),
  128. ("homeassistant", {"service": "notify.mobile_app_x"}, "_send_homeassistant"),
  129. ("webhook", {"webhook_url": "http://hook.local", "payload_format": "slack"}, "_send_webhook"),
  130. ],
  131. )
  132. async def test_fetched_url_providers_skip_the_url_when_opted_out(self, service, provider_type, config, sender):
  133. """HA/Bark/Slack could otherwise get the URL an earlier provider in the
  134. same send left in the shared photo cache, so they need their own check."""
  135. provider = _provider(provider_type, config, attach_photo=False)
  136. photo_cache = {"url": "https://bambuddy.example/api/v1/notifications/photos/a.jpg"}
  137. with (
  138. patch.object(service, sender, new_callable=AsyncMock) as mock_send,
  139. patch.object(service, "_get_or_build_photo_url", wraps=service._get_or_build_photo_url) as mock_build,
  140. ):
  141. mock_send.return_value = (True, "OK")
  142. await service._send_to_provider(
  143. provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg", photo_cache=photo_cache
  144. )
  145. mock_build.assert_not_called()
  146. assert mock_send.call_args.kwargs.get("image_url") is None
  147. @pytest.mark.asyncio
  148. async def test_one_send_persists_the_photo_once_and_keeps_it_out_of_variables(self, service):
  149. """Every HA/Bark/Slack provider in a send shares one saved photo, and
  150. the URL (the photo's only credential) never reaches the variables a
  151. generic webhook copies into its payload."""
  152. providers = [
  153. _provider("bark", {"device_key": "abc"}),
  154. _provider("homeassistant", {"service": "notify.mobile_app_x"}),
  155. ]
  156. for i, provider in enumerate(providers):
  157. provider.id = i
  158. provider.name = f"p{i}"
  159. provider.daily_digest_enabled = False
  160. variables = {"printer": "X1C"}
  161. with (
  162. patch.object(service, "_send_bark", new_callable=AsyncMock, return_value=(True, "OK")) as bark,
  163. patch.object(service, "_send_homeassistant", new_callable=AsyncMock, return_value=(True, "OK")) as ha,
  164. patch.object(service, "_update_provider_status", new_callable=AsyncMock),
  165. patch.object(service, "_log_notification", new_callable=AsyncMock),
  166. patch(
  167. "backend.app.api.routes.settings.get_setting",
  168. new_callable=AsyncMock,
  169. return_value="https://bambuddy.example",
  170. ),
  171. patch(
  172. "backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"
  173. ) as mock_save,
  174. ):
  175. await service._send_to_providers(
  176. providers, "T", "B", AsyncMock(), event_type="print_complete", image_data=b"jpeg", variables=variables
  177. )
  178. mock_save.assert_called_once()
  179. url = "https://bambuddy.example/api/v1/notifications/photos/p.jpg"
  180. assert bark.call_args.kwargs["image_url"] == url
  181. assert ha.call_args.kwargs["image_url"] == url
  182. assert variables == {"printer": "X1C"}
  183. @pytest.mark.asyncio
  184. async def test_bark_gets_photo_url_when_enabled(self, service):
  185. provider = _provider("bark", {"device_key": "abc"})
  186. with (
  187. patch.object(service, "_send_bark", new_callable=AsyncMock) as mock_send,
  188. patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as mock_build,
  189. ):
  190. mock_send.return_value = (True, "OK")
  191. mock_build.return_value = "https://bambuddy.example/photo.jpg"
  192. await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
  193. assert mock_send.call_args.kwargs.get("image_url") == "https://bambuddy.example/photo.jpg"
  194. @pytest.mark.asyncio
  195. async def test_generic_webhook_never_builds_a_photo_url(self, service):
  196. """The generic format carries base64 bytes, so it must not pay for a disk write."""
  197. provider = _provider("webhook", {"webhook_url": "http://hook.local"})
  198. with (
  199. patch.object(service, "_send_webhook", new_callable=AsyncMock) as mock_send,
  200. patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as mock_build,
  201. ):
  202. mock_send.return_value = (True, "OK")
  203. await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
  204. mock_build.assert_not_called()
  205. assert mock_send.call_args.kwargs.get("image_data") == b"jpeg"
  206. class TestFetchedUrlPayloads:
  207. @pytest.mark.asyncio
  208. async def test_bark_sends_photo_as_icon(self, service):
  209. mock_client = _client_returning(200, {"code": 200})
  210. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  211. await service._send_bark({"device_key": "abc"}, "T", "B", image_url="https://x.example/p.jpg")
  212. assert mock_client.post.call_args.kwargs["json"]["icon"] == "https://x.example/p.jpg"
  213. @pytest.mark.asyncio
  214. async def test_bark_keeps_tap_url_alongside_photo(self, service):
  215. mock_client = _client_returning(200, {"code": 200})
  216. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  217. await service._send_bark(
  218. {"device_key": "abc"}, "T", "B", url="https://x.example/confirm", image_url="https://x.example/p.jpg"
  219. )
  220. payload = mock_client.post.call_args.kwargs["json"]
  221. assert payload["url"] == "https://x.example/confirm"
  222. assert payload["icon"] == "https://x.example/p.jpg"
  223. @pytest.mark.asyncio
  224. async def test_slack_webhook_attaches_photo_url(self, service):
  225. mock_client = _client_returning()
  226. config = {"webhook_url": "http://mattermost.local/hooks/abc", "payload_format": "slack"}
  227. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  228. await service._send_webhook(config, "T", "B", image_data=b"jpeg", image_url="https://x.example/p.jpg")
  229. payload = mock_client.post.call_args.kwargs["json"]
  230. assert payload["attachments"] == [{"fallback": "T", "image_url": "https://x.example/p.jpg"}]
  231. assert "image" not in payload
  232. @pytest.fixture
  233. def ha_settings(self):
  234. with patch(
  235. "backend.app.api.routes.settings.get_homeassistant_settings",
  236. new_callable=AsyncMock,
  237. return_value={"ha_url": "http://ha.local:8123", "ha_token": "tok", "ha_enabled": True},
  238. ):
  239. yield
  240. @pytest.mark.asyncio
  241. async def test_homeassistant_notify_service_gets_data_image(self, service, ha_settings):
  242. mock_client = _client_returning()
  243. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  244. await service._send_homeassistant(
  245. {"service": "notify.mobile_app_x"}, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg"
  246. )
  247. assert mock_client.post.call_args.kwargs["json"]["data"] == {"image": "https://x.example/p.jpg"}
  248. @pytest.mark.asyncio
  249. async def test_homeassistant_persistent_notification_never_gets_image(self, service, ha_settings):
  250. """persistent_notification.create 400s on unknown keys, so the default
  251. service must not get a data block just because a photo exists."""
  252. mock_client = _client_returning()
  253. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  254. await service._send_homeassistant({}, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg")
  255. assert "data" not in mock_client.post.call_args.kwargs["json"]
  256. @pytest.mark.asyncio
  257. async def test_homeassistant_user_data_image_wins(self, service, ha_settings):
  258. mock_client = _client_returning()
  259. config = {"service": "notify.mobile_app_x", "data": json.dumps({"image": "mine", "ttl": 0})}
  260. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  261. await service._send_homeassistant(config, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg")
  262. assert mock_client.post.call_args.kwargs["json"]["data"] == {"image": "mine", "ttl": 0}
  263. class TestGetOrBuildPhotoUrl:
  264. @pytest.fixture
  265. def external_url(self):
  266. with patch(
  267. "backend.app.api.routes.settings.get_setting",
  268. new_callable=AsyncMock,
  269. return_value="https://bambuddy.example/",
  270. ):
  271. yield
  272. @pytest.mark.asyncio
  273. async def test_no_image_returns_none(self, service):
  274. assert await service._get_or_build_photo_url(AsyncMock(), None, "x") is None
  275. @pytest.mark.asyncio
  276. async def test_no_external_url_returns_none(self, service):
  277. """A relative path is useless to a service that fetches it itself."""
  278. with (
  279. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=""),
  280. patch("backend.app.services.notification_service.save_notification_photo") as mock_save,
  281. ):
  282. result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x")
  283. assert result is None
  284. mock_save.assert_not_called()
  285. @pytest.mark.asyncio
  286. async def test_saves_and_builds_a_url_with_no_token(self, service, external_url):
  287. """The filename is the credential. A camera stream token here would
  288. open every printer's live stream to whoever sees the notification."""
  289. with (
  290. patch(
  291. "backend.app.services.notification_service.save_notification_photo", return_value="first_layer_a.jpg"
  292. ) as mock_save,
  293. patch("backend.app.core.auth.create_camera_stream_token", new_callable=AsyncMock) as mock_token,
  294. ):
  295. result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "first_layer_complete")
  296. assert result == "https://bambuddy.example/api/v1/notifications/photos/first_layer_a.jpg"
  297. mock_save.assert_called_once_with(b"jpeg", "first_layer_complete")
  298. mock_token.assert_not_called()
  299. @pytest.mark.asyncio
  300. async def test_print_complete_gets_its_own_photo_not_the_archive_url(self, service, external_url):
  301. """The archive's finish_photo_url needs a media token, so with auth on
  302. HA/Bark/Slack would get a 401 on it. Print Complete must build its own."""
  303. with patch("backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"):
  304. result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "print_complete")
  305. assert result == "https://bambuddy.example/api/v1/notifications/photos/p.jpg"
  306. @pytest.mark.asyncio
  307. async def test_cache_is_filled_and_then_reused(self, service, external_url):
  308. photo_cache: dict = {}
  309. with patch(
  310. "backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"
  311. ) as mock_save:
  312. first = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x", photo_cache)
  313. second = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x", photo_cache)
  314. assert first == second == photo_cache["url"]
  315. mock_save.assert_called_once()
  316. class TestNotificationPhotoRoute:
  317. """GET /notifications/photos/{filename} is public: the filename is the auth."""
  318. @pytest.mark.asyncio
  319. async def test_serves_a_saved_photo_without_credentials(self, tmp_path, monkeypatch):
  320. from backend.app.api.routes.notifications import get_notification_photo
  321. monkeypatch.setattr(notification_photos.settings, "base_dir", tmp_path)
  322. filename = notification_photos.save_notification_photo(b"\xff\xd8jpeg", "test")
  323. response = await get_notification_photo(filename)
  324. assert response.media_type == "image/jpeg"
  325. assert response.headers["cache-control"] == "private, no-store"
  326. @pytest.mark.asyncio
  327. async def test_unknown_name_is_404(self, tmp_path, monkeypatch):
  328. from fastapi import HTTPException
  329. from backend.app.api.routes.notifications import get_notification_photo
  330. monkeypatch.setattr(notification_photos.settings, "base_dir", tmp_path)
  331. with pytest.raises(HTTPException) as exc:
  332. await get_notification_photo("test_20260930_120000_" + "A" * 32 + ".jpg")
  333. assert exc.value.status_code == 404
  334. class TestSendEmailInlineImage:
  335. @pytest.fixture
  336. def smtp_settings(self):
  337. return SimpleNamespace(
  338. smtp_from_name="Bambuddy",
  339. smtp_from_email="bambuddy@example.com",
  340. smtp_security="none",
  341. smtp_auth_enabled=False,
  342. smtp_username=None,
  343. smtp_password=None,
  344. smtp_host="smtp.example.com",
  345. smtp_port=25,
  346. )
  347. def _sent_message(self, smtp_settings, **kwargs):
  348. with patch("backend.app.services.email_service.smtplib.SMTP") as mock_smtp:
  349. send_email(smtp_settings, "to@example.com", "Subject", "text", **kwargs)
  350. return mock_smtp.return_value.__enter__.return_value.send_message.call_args[0][0]
  351. def test_image_with_html_becomes_multipart_related(self, smtp_settings):
  352. msg = self._sent_message(
  353. smtp_settings, body_html='<img src="cid:photo">', image_data=b"\xff\xd8jpeg", image_cid="photo"
  354. )
  355. assert msg.get_content_type() == "multipart/related"
  356. alternative, image = msg.get_payload()
  357. assert alternative.get_content_type() == "multipart/alternative"
  358. assert image.get_content_type() == "image/jpeg"
  359. assert image["Content-ID"] == "<photo>"
  360. assert image.get_payload(decode=True) == b"\xff\xd8jpeg"
  361. def test_image_without_html_is_dropped(self, smtp_settings):
  362. """No HTML part means nowhere to reference the cid, so no attachment."""
  363. msg = self._sent_message(smtp_settings, image_data=b"jpeg")
  364. assert msg.get_content_type() == "multipart/alternative"
  365. assert [part.get_content_type() for part in msg.get_payload()] == ["text/plain"]