207 lines
7.7 KiB
Python
207 lines
7.7 KiB
Python
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
|