Release v0.1.9: enforce Photos API boundary and finalize Server migration
This commit is contained in:
@@ -30,3 +30,17 @@ def client(app):
|
||||
@pytest.fixture
|
||||
def service(app):
|
||||
return app.state.service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_photo_engine(monkeypatch):
|
||||
"""Exercise retained legacy internals without weakening production policy.
|
||||
|
||||
These fixtures cover rendering, retention and migration algorithms that used
|
||||
filesystem inputs historically. API boundary tests never use this override.
|
||||
"""
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSources
|
||||
|
||||
monkeypatch.setattr(
|
||||
PhotoSources, "require_api_source", staticmethod(lambda source: None)
|
||||
)
|
||||
|
||||
@@ -222,3 +222,7 @@ def test_unreadable_source_is_forbidden_not_empty(
|
||||
response = client.get("/api/v1/photo-sources/family/images")
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"]["code"] == "FORBIDDEN"
|
||||
|
||||
|
||||
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import Schedule
|
||||
from pigway_frame_flow.scheduler.engine import next_run
|
||||
|
||||
|
||||
@pytest.mark.parametrize("minutes", [1, 7, 60, 120, 420, 10080])
|
||||
def test_interval_crosses_calendar_boundaries(minutes):
|
||||
start = datetime(2026, 10, 16, 23, 58, tzinfo=UTC)
|
||||
schedule = Schedule(type="interval", interval_minutes=minutes)
|
||||
first = datetime.fromisoformat(next_run(schedule, start))
|
||||
assert first == start + timedelta(minutes=minutes)
|
||||
assert datetime.fromisoformat(next_run(schedule, first)) == first + timedelta(
|
||||
minutes=minutes
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("minutes", [0, -1, 10081])
|
||||
def test_invalid_interval(minutes):
|
||||
with pytest.raises(ValidationError):
|
||||
Schedule(type="interval", interval_minutes=minutes)
|
||||
|
||||
|
||||
def test_missing_interval_fails():
|
||||
with pytest.raises(FlowError):
|
||||
next_run(Schedule(type="interval"))
|
||||
@@ -1,5 +1,6 @@
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||
@@ -168,3 +169,7 @@ def test_manual_display_does_not_replace_task_head(client, service, settings):
|
||||
"SELECT * FROM managed_cloud_uploads WHERE image_id=?", (second,)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||
|
||||
@@ -138,3 +138,7 @@ def test_auto_preview_uses_requested_device_not_default(
|
||||
assert op["status"] == "COMPLETED"
|
||||
with Image.open(BytesIO(client.get(op["result"]["url"]).content)) as image:
|
||||
assert image.size == (1200, 1600)
|
||||
|
||||
|
||||
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||
|
||||
@@ -159,3 +159,7 @@ def test_oversized_synology_photo_uses_read_only_cached_preview(service, setting
|
||||
"synology", source.model_copy(update={"synology_previews": False})
|
||||
)
|
||||
assert service.photo_sources.catalog("synology") == []
|
||||
|
||||
|
||||
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||
@@ -101,3 +102,7 @@ def test_random_bad_images_bounded_to_five(client, service, settings, monkeypatc
|
||||
"SELECT write_started FROM operations WHERE id=?", (op["operation_id"],)
|
||||
)
|
||||
assert row["write_started"] == 0
|
||||
|
||||
|
||||
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||
|
||||
@@ -62,12 +62,16 @@ def test_cached_dimensions_invalidation_and_no_fallback(
|
||||
),
|
||||
)
|
||||
assert len(oriented.catalog("family")) == 1
|
||||
from pigway_frame_flow.providers.photo_sources import dimensions
|
||||
|
||||
monkeypatch.setattr(
|
||||
"pigway_frame_flow.providers.photo_sources.dimensions",
|
||||
lambda *a: pytest.fail("unchanged dimensions should be cached"),
|
||||
)
|
||||
assert len(oriented.catalog("family", refresh=True)) == 1
|
||||
monkeypatch.undo()
|
||||
monkeypatch.setattr(
|
||||
"pigway_frame_flow.providers.photo_sources.dimensions", dimensions
|
||||
)
|
||||
Image.new("RGB", (50, 100)).save(settings.photo_root / "20240101 Day" / "wide.jpg")
|
||||
assert oriented.catalog("family", refresh=True) == []
|
||||
with pytest.raises(FlowError):
|
||||
@@ -110,3 +114,7 @@ def test_orientation_validation_and_api(client, oriented):
|
||||
).json()["error"]["code"]
|
||||
== "UNSUPPORTED_CAPABILITY"
|
||||
)
|
||||
|
||||
|
||||
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import pytest
|
||||
|
||||
|
||||
def test_uploaded_photos_exclude_generated_templates_before_limit(client, service):
|
||||
photo = service.assets.put(b"fixture", "family.jpg", "images")
|
||||
for _ in range(5):
|
||||
@@ -66,3 +69,7 @@ def test_multiple_sources_keep_separate_people_and_folder_settings(
|
||||
assert service.photo_sources.get("travel").model_dump() == before
|
||||
assert service.photo_sources.get("family").person_ids == [3]
|
||||
assert service.photo_sources.get("travel").person_ids == [2]
|
||||
|
||||
|
||||
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import json
|
||||
|
||||
|
||||
def test_filesystem_source_cannot_be_created(client):
|
||||
response = client.put(
|
||||
"/api/v1/photo-sources/legacy",
|
||||
json={"name": "NAS directory", "kind": "local", "location": "."},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "Filesystem photo sources are disabled" in response.text
|
||||
|
||||
|
||||
def test_persisted_filesystem_source_cannot_be_read(client, service):
|
||||
service.db.execute(
|
||||
"INSERT INTO app_config(key,payload) VALUES (?,?)",
|
||||
(
|
||||
"photo_source:legacy",
|
||||
json.dumps({"name": "Legacy", "kind": "local", "location": "."}),
|
||||
),
|
||||
)
|
||||
response = client.get("/api/v1/photo-sources/legacy/images")
|
||||
assert response.status_code == 403
|
||||
assert "Filesystem photo sources are disabled" in response.text
|
||||
@@ -248,18 +248,14 @@ def test_actual_verify_rejects_ready_smoke_and_commit_failure(
|
||||
tool.verify(config)
|
||||
|
||||
|
||||
def test_workflow_bootstrap_manual_only_and_ci_namespace_separate(tool):
|
||||
def test_retired_workflow_has_no_bootstrap_and_ci_namespace_separate(tool):
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
root = Path(__file__).parents[1]
|
||||
workflow = yaml.safe_load((root / ".github/workflows/verify.yml").read_text())
|
||||
inputs = workflow[True]["workflow_dispatch"]["inputs"]["deploy_action"]
|
||||
assert inputs["options"] == ["plan", "bootstrap", "deploy", "rollback"]
|
||||
assert inputs["default"] == "plan"
|
||||
assert workflow["jobs"]["deploy"]["needs"] == "verify"
|
||||
assert "success()" in workflow["jobs"]["deploy"]["if"]
|
||||
assert set(workflow["jobs"]) == {"verify"}
|
||||
tool.env["PROD_PROJECT"] = "ffci-invalid"
|
||||
with pytest.raises(ValueError):
|
||||
tool.settings()
|
||||
|
||||
@@ -164,21 +164,14 @@ def test_activate_escapes_dollar_secrets_and_never_down(tool, monkeypatch):
|
||||
assert len(calls) == 2 and "up" in calls[1] and "--no-build" in calls[1]
|
||||
|
||||
|
||||
def test_workflow_deploy_is_verify_gated_and_manual_plan_default(tool):
|
||||
def test_workflow_verifies_without_legacy_production_deploy(tool):
|
||||
import yaml
|
||||
|
||||
workflow = yaml.safe_load(
|
||||
(Path(__file__).parents[1] / ".github/workflows/verify.yml").read_text()
|
||||
)
|
||||
job = workflow["jobs"]["deploy"]
|
||||
assert job["needs"] == "verify" and "success()" in job["if"]
|
||||
assert "refs/heads/main" in job["if"] and "pull_request" not in job["if"]
|
||||
assert job["env"]["PROD_DEPLOY_ENABLED"] == "${{ vars.PROD_DEPLOY_ENABLED }}"
|
||||
assert (
|
||||
workflow[True]["workflow_dispatch"]["inputs"]["deploy_action"]["default"]
|
||||
== "plan"
|
||||
)
|
||||
assert job["steps"][-1]["if"] == "always()"
|
||||
assert set(workflow["jobs"]) == {"verify"}
|
||||
assert workflow[True]["workflow_dispatch"] is None
|
||||
|
||||
|
||||
def test_recovery_handles_missing_or_crashed_production_container(tool, monkeypatch):
|
||||
@@ -311,3 +304,24 @@ def test_image_retention_failure_does_not_fail_verified_deploy(
|
||||
output = capsys.readouterr().out
|
||||
assert "cleanup deferred" in output
|
||||
assert "private diagnostic" not in output
|
||||
|
||||
|
||||
def test_retired_nas_target_is_rejected(tool):
|
||||
tool.env["PROD_DATA_DIR"] = "/volume2/docker/pigway-frame-flow/data"
|
||||
with pytest.raises(ValueError, match="retired"):
|
||||
tool.settings()
|
||||
|
||||
|
||||
def test_preflight_rejects_legacy_photo_mount(tool, monkeypatch):
|
||||
old = existing(tool)
|
||||
old["Mounts"].append(
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/volume1/photo",
|
||||
"Destination": "/photos",
|
||||
"RW": False,
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(tool, "inspect", lambda _: old)
|
||||
with pytest.raises(ValueError, match="retired"):
|
||||
tool.preflight()
|
||||
|
||||
@@ -6,10 +6,10 @@ def test_external_forks_do_not_execute_on_nas_runner():
|
||||
assert (
|
||||
"github.event.pull_request.head.repo.full_name == github.repository" in workflow
|
||||
)
|
||||
assert "needs: verify" in workflow
|
||||
assert "python scripts/production-deploy.py" not in workflow
|
||||
|
||||
|
||||
def test_private_production_configuration_uses_secrets():
|
||||
def test_verification_runner_receives_no_production_configuration():
|
||||
workflow = Path(".github/workflows/verify.yml").read_text()
|
||||
for key in (
|
||||
"PROD_DATA_DIR",
|
||||
@@ -19,7 +19,7 @@ def test_private_production_configuration_uses_secrets():
|
||||
"SYNOLOGY_PHOTOS_URL",
|
||||
"SYNOLOGY_PHOTOS_CERT_SHA256",
|
||||
):
|
||||
assert "${{ secrets." + key + " }}" in workflow
|
||||
assert "${{ secrets." + key + " }}" not in workflow
|
||||
assert "${{ vars." + key + " }}" not in workflow
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_actions_downloads_do_not_depend_on_personal_server():
|
||||
|
||||
workflow = Path(".github/workflows/verify.yml").read_text()
|
||||
actions = re.findall(r"uses:\s*(\S+)", workflow)
|
||||
assert len(actions) == 4
|
||||
assert len(actions) == 2
|
||||
for action in actions:
|
||||
assert re.fullmatch(
|
||||
r"https://github\.com/actions/(checkout|setup-python)@[0-9a-f]{40}",
|
||||
|
||||
@@ -172,3 +172,7 @@ def test_first_cleanup_runs_after_recent_host_boot(
|
||||
]
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Production release preflight must reject unsafe inherited container settings."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def release_tool():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"server_release", Path(__file__).parents[1] / "scripts/server-release.py"
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe", ["mock", "writes", "photos", "system_disk", "root", "caps"]
|
||||
)
|
||||
def test_release_rejects_unsafe_runtime(release_tool, monkeypatch, unsafe):
|
||||
container = {
|
||||
"Config": {
|
||||
"User": "10333:10333",
|
||||
"Env": [
|
||||
"KOKONNA_MOCK=false",
|
||||
"KOKONNA_WRITE_ENABLED=true",
|
||||
"BUILD_COMMIT=test",
|
||||
],
|
||||
},
|
||||
"State": {"Running": True},
|
||||
"HostConfig": {"ReadonlyRootfs": True, "CapDrop": ["ALL"]},
|
||||
"Mounts": [
|
||||
{"Destination": "/data", "Source": str(release_tool.APP / "data")},
|
||||
{"Destination": "/config", "Source": str(release_tool.APP / "config")},
|
||||
],
|
||||
}
|
||||
if unsafe == "mock":
|
||||
container["Config"]["Env"][0] = "KOKONNA_MOCK=true"
|
||||
elif unsafe == "writes":
|
||||
container["Config"]["Env"][1] = "KOKONNA_WRITE_ENABLED=false"
|
||||
elif unsafe == "photos":
|
||||
container["Mounts"].append(
|
||||
{"Destination": "/photos", "Source": "/volume1/photo"}
|
||||
)
|
||||
elif unsafe == "system_disk":
|
||||
container["Mounts"][0]["Source"] = "/var/lib/frame-flow"
|
||||
elif unsafe == "root":
|
||||
container["Config"]["User"] = "root"
|
||||
else:
|
||||
container["HostConfig"]["CapDrop"] = []
|
||||
monkeypatch.setattr(release_tool, "inspect", lambda: container)
|
||||
with pytest.raises(AssertionError):
|
||||
release_tool.verify_runtime("test")
|
||||
@@ -152,3 +152,7 @@ def test_people_api_auth_and_secret_response(client, service, monkeypatch):
|
||||
assert r.status_code == 422 and "input" not in r.text
|
||||
client.headers.pop("Authorization")
|
||||
assert client.get("/api/v1/integrations/synology-photos/people").status_code == 401
|
||||
|
||||
|
||||
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
||||
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|
||||
|
||||
Reference in New Issue
Block a user