Release v0.1.9: enforce Photos API boundary and finalize Server migration

This commit is contained in:
渡口浪人
2026-10-17 01:39:23 +08:00
parent 46a0429d5e
commit b87bf6f77e
35 changed files with 724 additions and 155 deletions
+14
View File
@@ -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)
)
+4
View File
@@ -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")
+30
View File
@@ -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"))
+5
View File
@@ -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")
+4
View File
@@ -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")
+4
View File
@@ -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")
+5
View File
@@ -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")
+9 -1
View File
@@ -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")
+7
View File
@@ -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")
+23
View File
@@ -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
+2 -6
View File
@@ -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()
+24 -10
View File
@@ -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()
+4 -4
View File
@@ -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}",
+4
View File
@@ -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")
+55
View File
@@ -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")
+4
View File
@@ -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")