Files
pigway-frame-flow/tests/test_external_photo_sources.py
T

207 lines
7.7 KiB
Python
Raw Normal View History

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 == (1200, 1600)
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"""<d:multistatus xmlns:d="DAV:"><d:response><d:href>/photos/a.jpg</d:href></d:response><d:response><d:href>/photos/a.jpg</d:href></d:response><d:response><d:href>https://evil.example/a.jpg</d:href></d:response><d:response><d:href>/photos/%2e%2e/private.jpg</d:href></d:response><d:response><d:href>/photos/sub</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>"""
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"<!DOCTYPE x><x/>", b"<broken"])
def test_webdav_bad_xml(service, settings, monkeypatch, xml):
settings.image_url_hosts = ["dav.example"]
provider = service.photo_sources
provider.save(
"dav",
PhotoSourceInput(
name="Cloud", kind="webdav", location="https://dav.example/photos/"
),
)
monkeypatch.setattr(service.assets, "fetch_url", lambda *a, **kw: xml)
with pytest.raises(FlowError):
provider.catalog("dav")
def test_photo_source_permissions_and_openapi(client, photos):
schema = client.get("/openapi.json").json()
assert "PhotoSourceContent" in schema["components"]["schemas"]
assert schema["paths"]["/api/v1/photo-sources"]["get"]["responses"]["200"][
"content"
]["application/json"]["schema"]["items"]["$ref"].endswith("PhotoSourceView")
client.headers["Authorization"] = "Bearer incorrect"
assert client.get("/api/v1/photo-sources").status_code == 401
def test_scheduled_random_photo_runs_once_with_restore(app, service, photos):
from datetime import datetime
from pigway_frame_flow.models import JobInput
scheduler = app.state.scheduler
original = service.registry.get("living-room").get_current_image()
job = scheduler.save_job(
JobInput(
name="Random",
schedule={"type": "daily", "time": "08:00"},
source={"type": "photo_source", "source_id": "family"},
display_duration=60,
restore_previous=True,
)
)
at = datetime.fromisoformat(job["next_run"])
scheduler.tick(at)
scheduler.tick(at)
rows = service.db.all("SELECT * FROM operations WHERE client_id='scheduler'")
assert len(rows) == 1
assert service.process_one()
op = service.operation(rows[0]["id"])
assert op["status"] in ("SYNCING", "COMPLETED")
assert service.state("living-room")["previous_image_id"] == original