test_notification_photo_attachments.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  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. ("gotify", {"server": "https://g.example", "app_token": "t"}, "_send_gotify"),
  129. ("homeassistant", {"service": "notify.mobile_app_x"}, "_send_homeassistant"),
  130. ("webhook", {"webhook_url": "http://hook.local", "payload_format": "slack"}, "_send_webhook"),
  131. ],
  132. )
  133. async def test_fetched_url_providers_skip_the_url_when_opted_out(self, service, provider_type, config, sender):
  134. """HA/Bark/Slack could otherwise get the URL an earlier provider in the
  135. same send left in the shared photo cache, so they need their own check."""
  136. provider = _provider(provider_type, config, attach_photo=False)
  137. photo_cache = {"url": "https://bambuddy.example/api/v1/notifications/photos/a.jpg"}
  138. with (
  139. patch.object(service, sender, new_callable=AsyncMock) as mock_send,
  140. patch.object(service, "_get_or_build_photo_url", wraps=service._get_or_build_photo_url) as mock_build,
  141. ):
  142. mock_send.return_value = (True, "OK")
  143. await service._send_to_provider(
  144. provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg", photo_cache=photo_cache
  145. )
  146. mock_build.assert_not_called()
  147. assert mock_send.call_args.kwargs.get("image_url") is None
  148. @pytest.mark.asyncio
  149. async def test_one_send_persists_the_photo_once_and_keeps_it_out_of_variables(self, service):
  150. """Every HA/Bark/Slack provider in a send shares one saved photo, and
  151. the URL (the photo's only credential) never reaches the variables a
  152. generic webhook copies into its payload."""
  153. providers = [
  154. _provider("bark", {"device_key": "abc"}),
  155. _provider("homeassistant", {"service": "notify.mobile_app_x"}),
  156. ]
  157. for i, provider in enumerate(providers):
  158. provider.id = i
  159. provider.name = f"p{i}"
  160. provider.daily_digest_enabled = False
  161. variables = {"printer": "X1C"}
  162. with (
  163. patch.object(service, "_send_bark", new_callable=AsyncMock, return_value=(True, "OK")) as bark,
  164. patch.object(service, "_send_homeassistant", new_callable=AsyncMock, return_value=(True, "OK")) as ha,
  165. patch.object(service, "_update_provider_status", new_callable=AsyncMock),
  166. patch.object(service, "_log_notification", new_callable=AsyncMock),
  167. patch(
  168. "backend.app.api.routes.settings.get_setting",
  169. new_callable=AsyncMock,
  170. return_value="https://bambuddy.example",
  171. ),
  172. patch(
  173. "backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"
  174. ) as mock_save,
  175. ):
  176. await service._send_to_providers(
  177. providers, "T", "B", AsyncMock(), event_type="print_complete", image_data=b"jpeg", variables=variables
  178. )
  179. mock_save.assert_called_once()
  180. url = "https://bambuddy.example/api/v1/notifications/photos/p.jpg"
  181. assert bark.call_args.kwargs["image_url"] == url
  182. assert ha.call_args.kwargs["image_url"] == url
  183. assert variables == {"printer": "X1C"}
  184. @pytest.mark.asyncio
  185. async def test_bark_gets_photo_url_when_enabled(self, service):
  186. provider = _provider("bark", {"device_key": "abc"})
  187. with (
  188. patch.object(service, "_send_bark", new_callable=AsyncMock) as mock_send,
  189. patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as mock_build,
  190. ):
  191. mock_send.return_value = (True, "OK")
  192. mock_build.return_value = "https://bambuddy.example/photo.jpg"
  193. await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
  194. assert mock_send.call_args.kwargs.get("image_url") == "https://bambuddy.example/photo.jpg"
  195. @pytest.mark.asyncio
  196. async def test_gotify_gets_photo_url_when_enabled(self, service):
  197. """Gotify takes no uploads, so its photo has to be a URL its app fetches (#2743)."""
  198. provider = _provider("gotify", {"server": "https://g.example", "app_token": "t"})
  199. with (
  200. patch.object(service, "_send_gotify", new_callable=AsyncMock) as mock_send,
  201. patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as mock_build,
  202. ):
  203. mock_send.return_value = (True, "OK")
  204. mock_build.return_value = "https://bambuddy.example/photo.jpg"
  205. await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
  206. assert mock_send.call_args.kwargs.get("image_url") == "https://bambuddy.example/photo.jpg"
  207. @pytest.mark.asyncio
  208. async def test_generic_webhook_never_builds_a_photo_url(self, service):
  209. """The generic format carries base64 bytes, so it must not pay for a disk write."""
  210. provider = _provider("webhook", {"webhook_url": "http://hook.local"})
  211. with (
  212. patch.object(service, "_send_webhook", new_callable=AsyncMock) as mock_send,
  213. patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as mock_build,
  214. ):
  215. mock_send.return_value = (True, "OK")
  216. await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
  217. mock_build.assert_not_called()
  218. assert mock_send.call_args.kwargs.get("image_data") == b"jpeg"
  219. class TestFetchedUrlPayloads:
  220. @pytest.mark.asyncio
  221. async def test_bark_sends_photo_as_icon(self, service):
  222. mock_client = _client_returning(200, {"code": 200})
  223. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  224. await service._send_bark({"device_key": "abc"}, "T", "B", image_url="https://x.example/p.jpg")
  225. assert mock_client.post.call_args.kwargs["json"]["icon"] == "https://x.example/p.jpg"
  226. @pytest.mark.asyncio
  227. async def test_bark_keeps_tap_url_alongside_photo(self, service):
  228. mock_client = _client_returning(200, {"code": 200})
  229. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  230. await service._send_bark(
  231. {"device_key": "abc"}, "T", "B", url="https://x.example/confirm", image_url="https://x.example/p.jpg"
  232. )
  233. payload = mock_client.post.call_args.kwargs["json"]
  234. assert payload["url"] == "https://x.example/confirm"
  235. assert payload["icon"] == "https://x.example/p.jpg"
  236. @pytest.mark.asyncio
  237. async def test_slack_webhook_attaches_photo_url(self, service):
  238. mock_client = _client_returning()
  239. config = {"webhook_url": "http://mattermost.local/hooks/abc", "payload_format": "slack"}
  240. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  241. await service._send_webhook(config, "T", "B", image_data=b"jpeg", image_url="https://x.example/p.jpg")
  242. payload = mock_client.post.call_args.kwargs["json"]
  243. assert payload["attachments"] == [{"fallback": "T", "image_url": "https://x.example/p.jpg"}]
  244. assert "image" not in payload
  245. @pytest.fixture
  246. def ha_settings(self):
  247. with patch(
  248. "backend.app.api.routes.settings.get_homeassistant_settings",
  249. new_callable=AsyncMock,
  250. return_value={"ha_url": "http://ha.local:8123", "ha_token": "tok", "ha_enabled": True},
  251. ):
  252. yield
  253. @pytest.mark.asyncio
  254. async def test_homeassistant_notify_service_gets_data_image(self, service, ha_settings):
  255. mock_client = _client_returning()
  256. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  257. await service._send_homeassistant(
  258. {"service": "notify.mobile_app_x"}, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg"
  259. )
  260. assert mock_client.post.call_args.kwargs["json"]["data"] == {"image": "https://x.example/p.jpg"}
  261. @pytest.mark.asyncio
  262. async def test_homeassistant_persistent_notification_never_gets_image(self, service, ha_settings):
  263. """persistent_notification.create 400s on unknown keys, so the default
  264. service must not get a data block just because a photo exists."""
  265. mock_client = _client_returning()
  266. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  267. await service._send_homeassistant({}, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg")
  268. assert "data" not in mock_client.post.call_args.kwargs["json"]
  269. @pytest.mark.asyncio
  270. async def test_homeassistant_user_data_image_wins(self, service, ha_settings):
  271. mock_client = _client_returning()
  272. config = {"service": "notify.mobile_app_x", "data": json.dumps({"image": "mine", "ttl": 0})}
  273. with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
  274. await service._send_homeassistant(config, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg")
  275. assert mock_client.post.call_args.kwargs["json"]["data"] == {"image": "mine", "ttl": 0}
  276. class TestGetOrBuildPhotoUrl:
  277. @pytest.fixture
  278. def external_url(self):
  279. with patch(
  280. "backend.app.api.routes.settings.get_setting",
  281. new_callable=AsyncMock,
  282. return_value="https://bambuddy.example/",
  283. ):
  284. yield
  285. @pytest.mark.asyncio
  286. async def test_no_image_returns_none(self, service):
  287. assert await service._get_or_build_photo_url(AsyncMock(), None, "x") is None
  288. @pytest.mark.asyncio
  289. async def test_no_external_url_returns_none(self, service):
  290. """A relative path is useless to a service that fetches it itself."""
  291. with (
  292. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=""),
  293. patch("backend.app.services.notification_service.save_notification_photo") as mock_save,
  294. ):
  295. result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x")
  296. assert result is None
  297. mock_save.assert_not_called()
  298. @pytest.mark.asyncio
  299. async def test_saves_and_builds_a_url_with_no_token(self, service, external_url):
  300. """The filename is the credential. A camera stream token here would
  301. open every printer's live stream to whoever sees the notification."""
  302. with (
  303. patch(
  304. "backend.app.services.notification_service.save_notification_photo", return_value="first_layer_a.jpg"
  305. ) as mock_save,
  306. patch("backend.app.core.auth.create_camera_stream_token", new_callable=AsyncMock) as mock_token,
  307. ):
  308. result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "first_layer_complete")
  309. assert result == "https://bambuddy.example/api/v1/notifications/photos/first_layer_a.jpg"
  310. mock_save.assert_called_once_with(b"jpeg", "first_layer_complete")
  311. mock_token.assert_not_called()
  312. @pytest.mark.asyncio
  313. async def test_print_complete_gets_its_own_photo_not_the_archive_url(self, service, external_url):
  314. """The archive's finish_photo_url needs a media token, so with auth on
  315. HA/Bark/Slack would get a 401 on it. Print Complete must build its own."""
  316. with patch("backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"):
  317. result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "print_complete")
  318. assert result == "https://bambuddy.example/api/v1/notifications/photos/p.jpg"
  319. @pytest.mark.asyncio
  320. async def test_cache_is_filled_and_then_reused(self, service, external_url):
  321. photo_cache: dict = {}
  322. with patch(
  323. "backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"
  324. ) as mock_save:
  325. first = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x", photo_cache)
  326. second = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x", photo_cache)
  327. assert first == second == photo_cache["url"]
  328. mock_save.assert_called_once()
  329. class TestNotificationPhotoRoute:
  330. """GET /notifications/photos/{filename} is public: the filename is the auth."""
  331. @pytest.mark.asyncio
  332. async def test_serves_a_saved_photo_without_credentials(self, tmp_path, monkeypatch):
  333. from backend.app.api.routes.notifications import get_notification_photo
  334. monkeypatch.setattr(notification_photos.settings, "base_dir", tmp_path)
  335. filename = notification_photos.save_notification_photo(b"\xff\xd8jpeg", "test")
  336. response = await get_notification_photo(filename)
  337. assert response.media_type == "image/jpeg"
  338. assert response.headers["cache-control"] == "private, no-store"
  339. @pytest.mark.asyncio
  340. async def test_unknown_name_is_404(self, tmp_path, monkeypatch):
  341. from fastapi import HTTPException
  342. from backend.app.api.routes.notifications import get_notification_photo
  343. monkeypatch.setattr(notification_photos.settings, "base_dir", tmp_path)
  344. with pytest.raises(HTTPException) as exc:
  345. await get_notification_photo("test_20260930_120000_" + "A" * 32 + ".jpg")
  346. assert exc.value.status_code == 404
  347. class TestSendEmailInlineImage:
  348. @pytest.fixture
  349. def smtp_settings(self):
  350. return SimpleNamespace(
  351. smtp_from_name="Bambuddy",
  352. smtp_from_email="bambuddy@example.com",
  353. smtp_security="none",
  354. smtp_auth_enabled=False,
  355. smtp_username=None,
  356. smtp_password=None,
  357. smtp_host="smtp.example.com",
  358. smtp_port=25,
  359. )
  360. def _sent_message(self, smtp_settings, **kwargs):
  361. with patch("backend.app.services.email_service.smtplib.SMTP") as mock_smtp:
  362. send_email(smtp_settings, "to@example.com", "Subject", "text", **kwargs)
  363. return mock_smtp.return_value.__enter__.return_value.send_message.call_args[0][0]
  364. def test_image_with_html_becomes_multipart_related(self, smtp_settings):
  365. msg = self._sent_message(
  366. smtp_settings, body_html='<img src="cid:photo">', image_data=b"\xff\xd8jpeg", image_cid="photo"
  367. )
  368. assert msg.get_content_type() == "multipart/related"
  369. alternative, image = msg.get_payload()
  370. assert alternative.get_content_type() == "multipart/alternative"
  371. assert image.get_content_type() == "image/jpeg"
  372. assert image["Content-ID"] == "<photo>"
  373. assert image.get_payload(decode=True) == b"\xff\xd8jpeg"
  374. def test_image_without_html_is_dropped(self, smtp_settings):
  375. """No HTML part means nowhere to reference the cid, so no attachment."""
  376. msg = self._sent_message(smtp_settings, image_data=b"jpeg")
  377. assert msg.get_content_type() == "multipart/alternative"
  378. assert [part.get_content_type() for part in msg.get_payload()] == ["text/plain"]