"""Unit tests for camera snapshot attachments on notifications.
Covers the per-provider attach_photo opt-out, the fetched-URL providers (Home
Assistant, Bark, Slack-format webhooks) and the on-disk store they fetch from,
and the inline image on emails.
"""
import json
import os
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.schemas.notification_template import EVENT_VARIABLES, PHOTO_CAPABLE_EVENTS
from backend.app.services.email_service import send_email
from backend.app.services.notification_service import NotificationService
from backend.app.utils import notification_photos
def _client_returning(status_code=200, json_body=None):
mock_response = MagicMock()
mock_response.status_code = status_code
mock_response.text = ""
mock_response.json = MagicMock(return_value=json_body or {})
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
return mock_client
def _provider(provider_type: str, config: dict, attach_photo: bool = True):
provider = MagicMock()
provider.provider_type = provider_type
provider.config = json.dumps(config)
provider.quiet_hours_enabled = False
provider.attach_photo = attach_photo
return provider
@pytest.fixture
def service():
return NotificationService()
class TestNotificationPhotoStore:
"""The flat directory HA/Bark/Slack fetch ad-hoc snapshots from."""
@pytest.fixture(autouse=True)
def _base_dir(self, tmp_path, monkeypatch):
monkeypatch.setattr(notification_photos.settings, "base_dir", tmp_path)
self.directory = tmp_path / "notification_photos"
def test_save_then_find_round_trips(self):
filename = notification_photos.save_notification_photo(b"jpeg-bytes", "plate_not_empty")
assert filename.startswith("plate_not_empty_")
assert filename.endswith(".jpg")
found = notification_photos.find_notification_photo(filename)
assert found is not None
assert found.read_bytes() == b"jpeg-bytes"
def test_event_type_is_sanitised_into_the_filename(self):
filename = notification_photos.save_notification_photo(b"x", "../../etc/passwd")
assert "/" not in filename
assert ".." not in filename
assert (self.directory / filename).exists()
def test_filename_embeds_a_urlsafe_token(self):
"""The filename is the URL's only credential, so it has to carry the
full 24-byte token, not a guessable suffix."""
with patch.object(notification_photos.secrets, "token_urlsafe", return_value="T" * 32) as mock_token:
filename = notification_photos.save_notification_photo(b"x", "test")
mock_token.assert_called_once_with(24)
assert filename.endswith("_" + "T" * 32 + ".jpg")
def test_filenames_are_unique_per_save(self):
first = notification_photos.save_notification_photo(b"x", "test")
second = notification_photos.save_notification_photo(b"x", "test")
assert first != second
def test_find_rejects_traversal_and_missing_files(self):
notification_photos.save_notification_photo(b"x", "test")
assert notification_photos.find_notification_photo("../secret.jpg") is None
assert notification_photos.find_notification_photo("does_not_exist.jpg") is None
@pytest.mark.parametrize(
"name",
[
# the pre-#3089-review shape: an 8-hex uuid suffix is guessable
"test_20260930_120000_abcdef12.jpg",
# token one character short / long
"test_20260930_120000_" + "A" * 31 + ".jpg",
"test_20260930_120000_" + "A" * 33 + ".jpg",
# other extensions, even if such a file exists on disk
"test_20260930_120000_" + "A" * 32 + ".png",
"test_20260930_120000_" + "A" * 32 + ".jpg.bak",
],
)
def test_find_serves_only_the_exact_saved_shape(self, name):
self.directory.mkdir(parents=True, exist_ok=True)
(self.directory / name).write_bytes(b"x")
assert notification_photos.find_notification_photo(name) is None
def test_find_refuses_a_photo_past_its_expiry_even_before_prune(self):
"""The 3-day age limit is the URL's expiry, so it can't depend on
another notification coming along to trigger a prune."""
filename = notification_photos.save_notification_photo(b"x", "test")
old = time.time() - notification_photos._MAX_AGE_SECONDS - 60
os.utime(self.directory / filename, (old, old))
assert notification_photos.find_notification_photo(filename) is None
def test_save_prunes_photos_older_than_max_age(self):
self.directory.mkdir(parents=True)
stale = self.directory / "stale.jpg"
stale.write_bytes(b"old")
old = time.time() - notification_photos._MAX_AGE_SECONDS - 60
os.utime(stale, (old, old))
fresh = self.directory / "fresh.jpg"
fresh.write_bytes(b"new")
notification_photos.save_notification_photo(b"x", "test")
assert not stale.exists()
assert fresh.exists()
class TestPhotoCapableEvents:
def test_every_photo_capable_event_is_a_known_event(self):
"""supports_photo is looked up per EVENT_VARIABLES key, so a typo here
would silently never show the preview."""
assert set(EVENT_VARIABLES) >= PHOTO_CAPABLE_EVENTS
class TestAttachPhotoOptOut:
"""provider.attach_photo=False must keep the photo off every provider type."""
@pytest.mark.asyncio
async def test_byte_upload_provider_gets_no_image_when_opted_out(self, service):
provider = _provider("ntfy", {"topic": "t"}, attach_photo=False)
with patch.object(service, "_send_ntfy", new_callable=AsyncMock) as mock_send:
mock_send.return_value = (True, "OK")
await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
assert mock_send.call_args.kwargs.get("image_data") is None
@pytest.mark.asyncio
async def test_byte_upload_provider_gets_image_by_default(self, service):
provider = _provider("ntfy", {"topic": "t"})
with patch.object(service, "_send_ntfy", new_callable=AsyncMock) as mock_send:
mock_send.return_value = (True, "OK")
await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
assert mock_send.call_args.kwargs.get("image_data") == b"jpeg"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("provider_type", "config", "sender"),
[
("bark", {"device_key": "abc"}, "_send_bark"),
("homeassistant", {"service": "notify.mobile_app_x"}, "_send_homeassistant"),
("webhook", {"webhook_url": "http://hook.local", "payload_format": "slack"}, "_send_webhook"),
],
)
async def test_fetched_url_providers_skip_the_url_when_opted_out(self, service, provider_type, config, sender):
"""HA/Bark/Slack could otherwise get the URL an earlier provider in the
same send left in the shared photo cache, so they need their own check."""
provider = _provider(provider_type, config, attach_photo=False)
photo_cache = {"url": "https://bambuddy.example/api/v1/notifications/photos/a.jpg"}
with (
patch.object(service, sender, new_callable=AsyncMock) as mock_send,
patch.object(service, "_get_or_build_photo_url", wraps=service._get_or_build_photo_url) as mock_build,
):
mock_send.return_value = (True, "OK")
await service._send_to_provider(
provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg", photo_cache=photo_cache
)
mock_build.assert_not_called()
assert mock_send.call_args.kwargs.get("image_url") is None
@pytest.mark.asyncio
async def test_one_send_persists_the_photo_once_and_keeps_it_out_of_variables(self, service):
"""Every HA/Bark/Slack provider in a send shares one saved photo, and
the URL (the photo's only credential) never reaches the variables a
generic webhook copies into its payload."""
providers = [
_provider("bark", {"device_key": "abc"}),
_provider("homeassistant", {"service": "notify.mobile_app_x"}),
]
for i, provider in enumerate(providers):
provider.id = i
provider.name = f"p{i}"
provider.daily_digest_enabled = False
variables = {"printer": "X1C"}
with (
patch.object(service, "_send_bark", new_callable=AsyncMock, return_value=(True, "OK")) as bark,
patch.object(service, "_send_homeassistant", new_callable=AsyncMock, return_value=(True, "OK")) as ha,
patch.object(service, "_update_provider_status", new_callable=AsyncMock),
patch.object(service, "_log_notification", new_callable=AsyncMock),
patch(
"backend.app.api.routes.settings.get_setting",
new_callable=AsyncMock,
return_value="https://bambuddy.example",
),
patch(
"backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"
) as mock_save,
):
await service._send_to_providers(
providers, "T", "B", AsyncMock(), event_type="print_complete", image_data=b"jpeg", variables=variables
)
mock_save.assert_called_once()
url = "https://bambuddy.example/api/v1/notifications/photos/p.jpg"
assert bark.call_args.kwargs["image_url"] == url
assert ha.call_args.kwargs["image_url"] == url
assert variables == {"printer": "X1C"}
@pytest.mark.asyncio
async def test_bark_gets_photo_url_when_enabled(self, service):
provider = _provider("bark", {"device_key": "abc"})
with (
patch.object(service, "_send_bark", new_callable=AsyncMock) as mock_send,
patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as mock_build,
):
mock_send.return_value = (True, "OK")
mock_build.return_value = "https://bambuddy.example/photo.jpg"
await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
assert mock_send.call_args.kwargs.get("image_url") == "https://bambuddy.example/photo.jpg"
@pytest.mark.asyncio
async def test_generic_webhook_never_builds_a_photo_url(self, service):
"""The generic format carries base64 bytes, so it must not pay for a disk write."""
provider = _provider("webhook", {"webhook_url": "http://hook.local"})
with (
patch.object(service, "_send_webhook", new_callable=AsyncMock) as mock_send,
patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as mock_build,
):
mock_send.return_value = (True, "OK")
await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
mock_build.assert_not_called()
assert mock_send.call_args.kwargs.get("image_data") == b"jpeg"
class TestFetchedUrlPayloads:
@pytest.mark.asyncio
async def test_bark_sends_photo_as_icon(self, service):
mock_client = _client_returning(200, {"code": 200})
with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
await service._send_bark({"device_key": "abc"}, "T", "B", image_url="https://x.example/p.jpg")
assert mock_client.post.call_args.kwargs["json"]["icon"] == "https://x.example/p.jpg"
@pytest.mark.asyncio
async def test_bark_keeps_tap_url_alongside_photo(self, service):
mock_client = _client_returning(200, {"code": 200})
with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
await service._send_bark(
{"device_key": "abc"}, "T", "B", url="https://x.example/confirm", image_url="https://x.example/p.jpg"
)
payload = mock_client.post.call_args.kwargs["json"]
assert payload["url"] == "https://x.example/confirm"
assert payload["icon"] == "https://x.example/p.jpg"
@pytest.mark.asyncio
async def test_slack_webhook_attaches_photo_url(self, service):
mock_client = _client_returning()
config = {"webhook_url": "http://mattermost.local/hooks/abc", "payload_format": "slack"}
with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
await service._send_webhook(config, "T", "B", image_data=b"jpeg", image_url="https://x.example/p.jpg")
payload = mock_client.post.call_args.kwargs["json"]
assert payload["attachments"] == [{"fallback": "T", "image_url": "https://x.example/p.jpg"}]
assert "image" not in payload
@pytest.fixture
def ha_settings(self):
with patch(
"backend.app.api.routes.settings.get_homeassistant_settings",
new_callable=AsyncMock,
return_value={"ha_url": "http://ha.local:8123", "ha_token": "tok", "ha_enabled": True},
):
yield
@pytest.mark.asyncio
async def test_homeassistant_notify_service_gets_data_image(self, service, ha_settings):
mock_client = _client_returning()
with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
await service._send_homeassistant(
{"service": "notify.mobile_app_x"}, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg"
)
assert mock_client.post.call_args.kwargs["json"]["data"] == {"image": "https://x.example/p.jpg"}
@pytest.mark.asyncio
async def test_homeassistant_persistent_notification_never_gets_image(self, service, ha_settings):
"""persistent_notification.create 400s on unknown keys, so the default
service must not get a data block just because a photo exists."""
mock_client = _client_returning()
with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
await service._send_homeassistant({}, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg")
assert "data" not in mock_client.post.call_args.kwargs["json"]
@pytest.mark.asyncio
async def test_homeassistant_user_data_image_wins(self, service, ha_settings):
mock_client = _client_returning()
config = {"service": "notify.mobile_app_x", "data": json.dumps({"image": "mine", "ttl": 0})}
with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
await service._send_homeassistant(config, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg")
assert mock_client.post.call_args.kwargs["json"]["data"] == {"image": "mine", "ttl": 0}
class TestGetOrBuildPhotoUrl:
@pytest.fixture
def external_url(self):
with patch(
"backend.app.api.routes.settings.get_setting",
new_callable=AsyncMock,
return_value="https://bambuddy.example/",
):
yield
@pytest.mark.asyncio
async def test_no_image_returns_none(self, service):
assert await service._get_or_build_photo_url(AsyncMock(), None, "x") is None
@pytest.mark.asyncio
async def test_no_external_url_returns_none(self, service):
"""A relative path is useless to a service that fetches it itself."""
with (
patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=""),
patch("backend.app.services.notification_service.save_notification_photo") as mock_save,
):
result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x")
assert result is None
mock_save.assert_not_called()
@pytest.mark.asyncio
async def test_saves_and_builds_a_url_with_no_token(self, service, external_url):
"""The filename is the credential. A camera stream token here would
open every printer's live stream to whoever sees the notification."""
with (
patch(
"backend.app.services.notification_service.save_notification_photo", return_value="first_layer_a.jpg"
) as mock_save,
patch("backend.app.core.auth.create_camera_stream_token", new_callable=AsyncMock) as mock_token,
):
result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "first_layer_complete")
assert result == "https://bambuddy.example/api/v1/notifications/photos/first_layer_a.jpg"
mock_save.assert_called_once_with(b"jpeg", "first_layer_complete")
mock_token.assert_not_called()
@pytest.mark.asyncio
async def test_print_complete_gets_its_own_photo_not_the_archive_url(self, service, external_url):
"""The archive's finish_photo_url needs a media token, so with auth on
HA/Bark/Slack would get a 401 on it. Print Complete must build its own."""
with patch("backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"):
result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "print_complete")
assert result == "https://bambuddy.example/api/v1/notifications/photos/p.jpg"
@pytest.mark.asyncio
async def test_cache_is_filled_and_then_reused(self, service, external_url):
photo_cache: dict = {}
with patch(
"backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"
) as mock_save:
first = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x", photo_cache)
second = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x", photo_cache)
assert first == second == photo_cache["url"]
mock_save.assert_called_once()
class TestNotificationPhotoRoute:
"""GET /notifications/photos/{filename} is public: the filename is the auth."""
@pytest.mark.asyncio
async def test_serves_a_saved_photo_without_credentials(self, tmp_path, monkeypatch):
from backend.app.api.routes.notifications import get_notification_photo
monkeypatch.setattr(notification_photos.settings, "base_dir", tmp_path)
filename = notification_photos.save_notification_photo(b"\xff\xd8jpeg", "test")
response = await get_notification_photo(filename)
assert response.media_type == "image/jpeg"
assert response.headers["cache-control"] == "private, no-store"
@pytest.mark.asyncio
async def test_unknown_name_is_404(self, tmp_path, monkeypatch):
from fastapi import HTTPException
from backend.app.api.routes.notifications import get_notification_photo
monkeypatch.setattr(notification_photos.settings, "base_dir", tmp_path)
with pytest.raises(HTTPException) as exc:
await get_notification_photo("test_20260930_120000_" + "A" * 32 + ".jpg")
assert exc.value.status_code == 404
class TestSendEmailInlineImage:
@pytest.fixture
def smtp_settings(self):
return SimpleNamespace(
smtp_from_name="Bambuddy",
smtp_from_email="bambuddy@example.com",
smtp_security="none",
smtp_auth_enabled=False,
smtp_username=None,
smtp_password=None,
smtp_host="smtp.example.com",
smtp_port=25,
)
def _sent_message(self, smtp_settings, **kwargs):
with patch("backend.app.services.email_service.smtplib.SMTP") as mock_smtp:
send_email(smtp_settings, "to@example.com", "Subject", "text", **kwargs)
return mock_smtp.return_value.__enter__.return_value.send_message.call_args[0][0]
def test_image_with_html_becomes_multipart_related(self, smtp_settings):
msg = self._sent_message(
smtp_settings, body_html='
', image_data=b"\xff\xd8jpeg", image_cid="photo"
)
assert msg.get_content_type() == "multipart/related"
alternative, image = msg.get_payload()
assert alternative.get_content_type() == "multipart/alternative"
assert image.get_content_type() == "image/jpeg"
assert image["Content-ID"] == ""
assert image.get_payload(decode=True) == b"\xff\xd8jpeg"
def test_image_without_html_is_dropped(self, smtp_settings):
"""No HTML part means nowhere to reference the cid, so no attachment."""
msg = self._sent_message(smtp_settings, image_data=b"jpeg")
assert msg.get_content_type() == "multipart/alternative"
assert [part.get_content_type() for part in msg.get_payload()] == ["text/plain"]