import json
from io import BytesIO
import pytest
from PIL import Image
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
def photo(path):
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (160, 200), "blue").save(path)
return path.read_bytes()
@pytest.fixture
def photos(settings, service):
settings.photo_root = settings.data_dir.parent / "photos"
photo(settings.photo_root / "family" / "a.jpg")
photo(settings.photo_root / "family" / "nested" / "b.png")
photo(settings.photo_root / "family" / "@eaDir" / "thumb.jpg")
(settings.photo_root / "family" / "note.txt").write_text("not a photo")
service.photo_sources.save(
"family", PhotoSourceInput(name="Family", kind="local", location="family")
)
return service.photo_sources
def test_local_catalog_filters_and_paginates(client, photos):
out = client.get("/api/v1/photo-sources/family/images?limit=1").json()
assert out["total"] == 2 and len(out["items"]) == 1
assert (
client.get("/api/v1/photo-sources/family/images?offset=1").json()["items"][0][
"name"
]
== "b.png"
)
assert client.get("/api/v1/photo-sources").json()[0]["id"] == "family"
@pytest.mark.parametrize("location", ["../", "/etc"])
def test_path_escape_forbidden(client, photos, location):
r = client.put(
"/api/v1/photo-sources/escape",
json={"name": "escape", "kind": "local", "location": location},
)
assert r.status_code == 403
def test_symlinks_and_post_catalog_swap_are_blocked(photos, settings):
outside = settings.data_dir.parent / "outside.jpg"
photo(outside)
(settings.photo_root / "family" / "link.jpg").symlink_to(outside)
items = photos.catalog("family", refresh=True)
assert len(items) == 2
path = settings.photo_root / "family" / "a.jpg"
path.unlink()
path.symlink_to(outside)
with pytest.raises(FlowError):
photos.read("family", "family/a.jpg")
def test_random_preview_is_local_and_display_uses_same_asset(client, service, photos):
before = service.registry.get("living-room").get_current_image()
r = client.post(
"/api/v1/devices/living-room/upload",
json={"type": "photo_source", "source_id": "family"},
)
assert r.status_code == 202
assert service.process_one()
op = client.get("/api/v1/operations/" + r.json()["operation_id"]).json()
assert op["status"] == "COMPLETED"
assert service.registry.get("living-room").get_current_image() == before
image = client.get(op["result"]["url"])
assert image.status_code == 200
assert Image.open(BytesIO(image.content)).size == (1600, 1200)
r = client.post(
"/api/v1/display",
json={"content": {"type": "image", "asset_id": op["result"]["asset_id"]}},
)
assert r.status_code == 202 and service.process_one()
def test_scheduler_source_persistence_and_delete_protection(app, client, photos):
payload = {
"name": "Random family photo",
"enabled": False,
"schedule": {"type": "daily", "time": "08:00"},
"source": {"type": "photo_source", "source_id": "family"},
}
job = client.post("/api/v1/jobs", json=payload)
assert job.status_code == 201
assert app.state.scheduler.jobs()[0]["source"]["source_id"] == "family"
assert client.delete("/api/v1/photo-sources/family").status_code == 409
assert client.delete("/api/v1/jobs/" + job.json()["id"]).status_code == 204
assert client.delete("/api/v1/photo-sources/family").status_code == 204
def test_empty_disabled_unknown_item(photos, settings):
with pytest.raises(FlowError):
photos.read("family", "../outside.jpg")
photos.save(
"family",
PhotoSourceInput(name="Family", kind="local", location="family", enabled=False),
)
with pytest.raises(FlowError):
photos.read("family")
folder = settings.photo_root / "empty"
folder.mkdir()
photos.save("empty", PhotoSourceInput(name="Empty", kind="local", location="empty"))
with pytest.raises(FlowError):
photos.read("empty")
def test_webdav_metadata_cached_credentials_not_persisted(
service, settings, monkeypatch
):
settings.image_url_hosts = ["dav.example"]
credential = "FRAME_FLOW_SOURCE_TEST"
monkeypatch.setenv(
credential, json.dumps({"username": "test-user", "password": "test-password"})
)
provider = service.photo_sources
provider.save(
"dav",
PhotoSourceInput(
name="Cloud",
kind="webdav",
location="https://dav.example/photos",
credential_env=credential,
),
)
calls = []
xml = b"""/photos/a.jpg/photos/a.jpghttps://evil.example/a.jpg/photos/%2e%2e/private.jpg/photos/sub"""
def fetch(url, **kwargs):
calls.append((url, kwargs))
return xml if kwargs.get("method") == "PROPFIND" else b"photo"
monkeypatch.setattr(service.assets, "fetch_url", fetch)
items = provider.catalog("dav")
assert len(items) == 1
assert provider.catalog("dav") == items and len(calls) == 1
assert calls[0][1]["headers"]["Depth"] == "1"
assert provider.read("dav")[0] == b"photo"
assert provider.get("dav").recursive is False
stored = service.db.one(
"SELECT payload FROM app_config WHERE key='photo_source:dav'"
)["payload"]
assert "test-password" not in stored and "test-user" not in stored
assert "test-password" not in json.dumps(
settings.sanitize({"value": "test-password"})
)
@pytest.mark.parametrize("xml", [b"", b"