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"