Add optional photo providers, layout studio and scoped integrations
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from pydantic import ValidationError
|
||||
|
||||
from pigway_frame_flow.extensions.layout import LayoutInput, LayoutPlan, local_plan
|
||||
from pigway_frame_flow.extensions.secrets import save_secret
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", range(1, 13))
|
||||
@pytest.mark.parametrize("orientation", ["portrait", "landscape"])
|
||||
@pytest.mark.parametrize("style", ["grid", "hero", "poster"])
|
||||
def test_local_layout_preserves_all_images_inside_canvas(count, orientation, style):
|
||||
ids = [f"{i:032x}" for i in range(count)]
|
||||
plan = local_plan(LayoutInput(asset_ids=ids, orientation=orientation, style=style))
|
||||
assert [r.asset_id for r in plan.regions] == ids
|
||||
assert LayoutPlan.model_validate(plan.model_dump()) == plan
|
||||
|
||||
|
||||
def test_layout_rejects_overlap():
|
||||
plan = local_plan(LayoutInput(asset_ids=["a" * 32, "b" * 32])).model_dump()
|
||||
plan["regions"][1].update(x=plan["regions"][0]["x"], y=plan["regions"][0]["y"])
|
||||
with pytest.raises(ValidationError):
|
||||
LayoutPlan.model_validate(plan)
|
||||
|
||||
|
||||
def test_secret_atomic_private_replacement(tmp_path):
|
||||
path = tmp_path / "key.secret"
|
||||
save_secret(path, "first")
|
||||
save_secret(path, "second")
|
||||
assert path.read_text() == "second"
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
assert list(tmp_path.iterdir()) == [path]
|
||||
|
||||
|
||||
def test_multi_image_import_keeps_aspect_and_never_uploads(client, service):
|
||||
image = BytesIO()
|
||||
Image.new("RGB", (320, 200), "red").save(image, "PNG")
|
||||
response = client.post(
|
||||
"/api/v1/layout/images",
|
||||
files=[("files", ("photo.png", image.getvalue(), "image/png"))],
|
||||
)
|
||||
assert response.status_code == 201
|
||||
asset_id = response.json()[0]["asset_id"]
|
||||
with Image.open(service.assets.path(asset_id)) as imported:
|
||||
assert imported.size == (320, 200)
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
|
||||
|
||||
def test_ai_requires_explicit_configuration(client):
|
||||
image = BytesIO()
|
||||
Image.new("RGB", (320, 200), "red").save(image, "PNG")
|
||||
result = client.post(
|
||||
"/api/v1/layout/images",
|
||||
files=[("files", ("photo.png", image.getvalue(), "image/png"))],
|
||||
).json()
|
||||
response = client.post(
|
||||
"/api/v1/layout/plan",
|
||||
json={"asset_ids": [result[0]["asset_id"]], "use_ai": True},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["error"]["code"] == "CONFIG_REQUIRED"
|
||||
|
||||
|
||||
def test_default_retention_keeps_everything(service):
|
||||
result = service.local_retention.sweep(force=True)
|
||||
assert result["deleted"] == 0
|
||||
assert result["disabled"] is True
|
||||
|
||||
|
||||
def test_ai_secret_never_returned_or_stored_in_database(client, service):
|
||||
key = "unit-test-private-key"
|
||||
response = client.put("/api/v1/settings/ai-layout/key", json={"key": key})
|
||||
assert response.status_code == 204
|
||||
assert key not in client.get("/api/v1/settings/ai-layout").text
|
||||
assert key not in str([dict(r) for r in service.db.all("SELECT * FROM app_config")])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,reply,location",
|
||||
[
|
||||
("google-drive", {"files": [{"id": "photo1", "name": "photo.jpg"}]}, "root"),
|
||||
(
|
||||
"alipan",
|
||||
{"items": [{"file_id": "photo1", "name": "photo.jpg", "type": "file"}]},
|
||||
"root",
|
||||
),
|
||||
(
|
||||
"baidu",
|
||||
{"list": [{"fs_id": 123, "server_filename": "photo.jpg", "isdir": 0}]},
|
||||
"/Photos",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_native_drives_metadata_does_not_expose_token(
|
||||
service, monkeypatch, kind, reply, location
|
||||
):
|
||||
from pigway_frame_flow.providers.cloud_drives import CloudDrives
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||
|
||||
drive = CloudDrives(service.photo_sources)
|
||||
monkeypatch.setattr(
|
||||
drive,
|
||||
"credentials",
|
||||
lambda *args: {"access_token": "private-token", "drive_id": "drive1"},
|
||||
)
|
||||
monkeypatch.setattr(drive, "api", lambda *args, **kwargs: reply)
|
||||
result = drive.catalog(
|
||||
"source", PhotoSourceInput(name="Cloud", kind=kind, location=location)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "photo.jpg"
|
||||
assert "private-token" not in str(result)
|
||||
|
||||
|
||||
def test_native_drive_refuses_stalled_pagination(service, monkeypatch):
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.providers.cloud_drives import CloudDrives
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||
|
||||
drive = CloudDrives(service.photo_sources)
|
||||
monkeypatch.setattr(
|
||||
drive, "credentials", lambda *args: {"access_token": "private-token"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
drive, "api", lambda *args, **kwargs: {"files": [], "nextPageToken": "same"}
|
||||
)
|
||||
with pytest.raises(FlowError):
|
||||
drive.catalog(
|
||||
"source",
|
||||
PhotoSourceInput(name="Cloud", kind="google-drive", location="root"),
|
||||
)
|
||||
|
||||
|
||||
def test_native_credentials_require_authorization(service):
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.providers.cloud_drives import CloudDrives
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||
|
||||
with pytest.raises(FlowError) as exc:
|
||||
CloudDrives(service.photo_sources).credentials(
|
||||
"source", PhotoSourceInput(name="Cloud", kind="baidu", location="/")
|
||||
)
|
||||
assert "CONFIG_REQUIRED" in str(exc.value.code)
|
||||
|
||||
|
||||
def test_client_token_hash_and_revocation(client, service):
|
||||
response = client.post(
|
||||
"/api/v1/clients",
|
||||
json={
|
||||
"client_id": "calendar",
|
||||
"name": "Calendar",
|
||||
"permissions": ["devices:read"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
token = response.json()["token"]
|
||||
assert token not in client.get("/api/v1/clients").text
|
||||
assert token not in str([dict(r) for r in service.db.all("SELECT * FROM clients")])
|
||||
assert (
|
||||
client.get(
|
||||
"/api/v1/devices", headers={"Authorization": "Bearer " + token}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/display", headers={"Authorization": "Bearer " + token}, json={}
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert client.delete("/api/v1/clients/calendar").status_code == 204
|
||||
assert (
|
||||
client.get(
|
||||
"/api/v1/devices", headers={"Authorization": "Bearer " + token}
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_member_prevents_partial_multi_import(client, service):
|
||||
image = BytesIO()
|
||||
Image.new("RGB", (100, 100)).save(image, "PNG")
|
||||
before = len(service.db.all("SELECT * FROM assets"))
|
||||
result = client.post(
|
||||
"/api/v1/layout/images",
|
||||
files=[
|
||||
("files", ("good.png", image.getvalue(), "image/png")),
|
||||
("files", ("bad.jpg", b"bad", "image/jpeg")),
|
||||
],
|
||||
)
|
||||
assert result.status_code == 400
|
||||
assert len(service.db.all("SELECT * FROM assets")) == before
|
||||
|
||||
|
||||
def test_widget_mutation_is_queued_and_idempotent(client, service):
|
||||
catalog = service.registry.official["living-room"].widgets().catalog
|
||||
cover_id = catalog[0].id
|
||||
headers = {"Idempotency-Key": "widget-test"}
|
||||
first = client.post(
|
||||
"/api/v1/devices/living-room/widgets/selection",
|
||||
json={"cover_id": cover_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert first.status_code == 202
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/devices/living-room/widgets/selection",
|
||||
json={"cover_id": cover_id},
|
||||
headers=headers,
|
||||
).json()
|
||||
== first.json()
|
||||
)
|
||||
service.process_one()
|
||||
assert service.operation(first.json()["operation_id"])["status"] == "COMPLETED"
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
|
||||
|
||||
def test_widget_post_write_read_failure_blocks_further_writes(
|
||||
client, service, monkeypatch
|
||||
):
|
||||
connection = service.registry.official["living-room"]
|
||||
cover_id = connection.widgets().catalog[0].id
|
||||
response = client.post(
|
||||
"/api/v1/devices/living-room/widgets/selection", json={"cover_id": cover_id}
|
||||
)
|
||||
original = connection.widgets
|
||||
calls = 0
|
||||
|
||||
def fail_confirmation():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls > 1:
|
||||
raise RuntimeError("network unavailable")
|
||||
return original()
|
||||
|
||||
monkeypatch.setattr(connection, "widgets", fail_confirmation)
|
||||
service.process_one()
|
||||
assert service.operation(response.json()["operation_id"])["status"] == "UNKNOWN"
|
||||
assert service.blocked("living-room")
|
||||
|
||||
|
||||
def test_webhook_signs_exact_body_and_records_delivery(client, service, monkeypatch):
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
service.settings.image_url_hosts.append("example.com")
|
||||
response = client.post(
|
||||
"/api/v1/webhooks",
|
||||
json={
|
||||
"callback_url": "https://example.com/events",
|
||||
"event_types": ["display.changed"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
key = response.json()["signing_secret"]
|
||||
service.db.event("display.changed", {"device_id": "living-room"})
|
||||
sent = []
|
||||
monkeypatch.setattr(
|
||||
service.assets, "fetch_url", lambda url, **kwargs: sent.append(kwargs) or b""
|
||||
)
|
||||
service.webhooks.tick()
|
||||
assert len(sent) == 1
|
||||
message = sent[0]
|
||||
assert (
|
||||
message["headers"]["X-Frame-Flow-Signature"]
|
||||
== "sha256="
|
||||
+ hmac.new(key.encode(), message["body"], hashlib.sha256).hexdigest()
|
||||
)
|
||||
assert json.loads(message["body"])["type"] == "display.changed"
|
||||
assert key not in client.get("/api/v1/webhooks").text
|
||||
deliveries = client.get(
|
||||
"/api/v1/webhooks/" + response.json()["id"] + "/deliveries"
|
||||
).json()
|
||||
assert deliveries[0]["status"] == "DELIVERED"
|
||||
|
||||
|
||||
def test_webhook_requires_explicit_host(client):
|
||||
response = client.post(
|
||||
"/api/v1/webhooks",
|
||||
json={
|
||||
"callback_url": "http://127.0.0.1/events",
|
||||
"event_types": ["display.changed"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_external_provider_requires_explicit_lan_approval(client):
|
||||
payload = {
|
||||
"kind": "http-json",
|
||||
"name": "NAS",
|
||||
"endpoint": "http://192.0.2.10/state",
|
||||
}
|
||||
assert client.put("/api/v1/providers/external/nas", json=payload).status_code == 403
|
||||
payload["allow_insecure_lan"] = True
|
||||
assert client.put("/api/v1/providers/external/nas", json=payload).status_code == 200
|
||||
|
||||
|
||||
def test_google_calendar_requires_token(client):
|
||||
assert (
|
||||
client.put(
|
||||
"/api/v1/providers/external/calendar",
|
||||
json={"kind": "google-calendar", "name": "Calendar", "endpoint": "primary"},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
response = client.get("/api/v1/providers/external/calendar/data")
|
||||
assert response.status_code == 409
|
||||
assert response.json()["error"]["code"] == "CONFIG_REQUIRED"
|
||||
|
||||
|
||||
def test_photo_quality_dark_and_duplicate(service):
|
||||
from pigway_frame_flow.extensions.quality import passes, photo_quality
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||
|
||||
image = BytesIO()
|
||||
Image.new("RGB", (100, 100), "black").save(image, "PNG")
|
||||
result = photo_quality(image.getvalue())
|
||||
assert result["brightness"] == 0
|
||||
assert result == photo_quality(image.getvalue())
|
||||
source = PhotoSourceInput(
|
||||
name="NAS", kind="local", location=".", minimum_brightness=10
|
||||
)
|
||||
assert not passes(result, source)
|
||||
|
||||
|
||||
def test_layout_missing_plan_is_rejected_before_queue(client):
|
||||
response = client.post(
|
||||
"/api/v1/render", json={"type": "template", "template": "collage", "data": {}}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
|
||||
def test_ai_layout_sends_metadata_only_and_validates_response(
|
||||
client, service, monkeypatch
|
||||
):
|
||||
import json
|
||||
|
||||
image = BytesIO()
|
||||
Image.new("RGB", (100, 200), "red").save(image, "PNG")
|
||||
asset = client.post(
|
||||
"/api/v1/layout/images",
|
||||
files=[("files", ("photo.png", image.getvalue(), "image/png"))],
|
||||
).json()[0]
|
||||
client.put(
|
||||
"/api/v1/settings/ai-layout",
|
||||
json={
|
||||
"endpoint": "https://example.com/v1/chat/completions",
|
||||
"model": "test-model",
|
||||
},
|
||||
)
|
||||
client.put("/api/v1/settings/ai-layout/key", json={"key": "test-ai-secret"})
|
||||
plan = local_plan(LayoutInput(asset_ids=[asset["asset_id"]]))
|
||||
calls = []
|
||||
|
||||
def respond(url, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return json.dumps(
|
||||
{"choices": [{"message": {"content": plan.model_dump_json()}}]}
|
||||
).encode()
|
||||
|
||||
monkeypatch.setattr(service.assets, "fetch_url", respond)
|
||||
response = client.post(
|
||||
"/api/v1/layout/plan", json={"asset_ids": [asset["asset_id"]], "use_ai": True}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["engine"] == "ai"
|
||||
assert b"base64" not in calls[0]["body"]
|
||||
assert b"test-ai-secret" not in calls[0]["body"]
|
||||
assert image.getvalue() not in calls[0]["body"]
|
||||
assert calls[0]["headers"]["Authorization"] == "Bearer test-ai-secret"
|
||||
Reference in New Issue
Block a user