Fix scan-to-download auth and share every photo widget's current photo
The share QR's URL carried no auth params at all, so it silently fell back through require_device's legacy-token resolution to whichever frame happened to still be flagged legacy -- working only by accident for a single frame, sharing the wrong frame's photos for any other, and going fully dead once that frame's legacy flag was cleared. Move the endpoint to manage.py, keyed on the frame's own manage_token (same pattern /m/<manage_token> already uses) instead of device auth. Since the server now resolves assets itself instead of trusting a caller-supplied asset_id, it naturally generalizes to gather every photo widget's current photo into one Immich share link, not just one "primary" widget's.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""End-to-end HTTP tests for the widget-system cutover in
|
||||
routers/device.py -- /frame/image, /frame/advance, /frame/back, and
|
||||
/frame/share against real widget rows (via the migration-backfilled
|
||||
frame #1, or a purpose-built second frame), a real TestClient, real
|
||||
routers/device.py -- /frame/image, /frame/advance, and /frame/back
|
||||
against real widget rows (via the migration-backfilled frame #1, or a
|
||||
purpose-built second frame), a real TestClient, real
|
||||
render_panel/compose_into. Only Immich itself is mocked (monkeypatched
|
||||
at the app.widgets.photos module boundary, same pattern as
|
||||
test_widgets_photos.py) -- everything else in the pipeline is real.
|
||||
@@ -23,7 +23,6 @@ from app.models import (
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
PhotoWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
)
|
||||
|
||||
@@ -145,33 +144,3 @@ def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_s
|
||||
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
|
||||
assert cal_cfg.browse_offset == 1
|
||||
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it
|
||||
|
||||
|
||||
def test_frame_share_checks_widget_scoped_state(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
frame.owner_user_id = db_session.query(User).filter_by(username="alice").one().id
|
||||
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
cfg.album_id = "album-1"
|
||||
cfg.current_asset_id = "asset-1"
|
||||
cfg.queue = ["asset-2"]
|
||||
frame.immich_url = "http://immich.example.com"
|
||||
frame.immich_api_key = "key"
|
||||
db_session.commit()
|
||||
|
||||
# Not showing/queued -- rejected before ever touching Immich
|
||||
resp = client.get("/frame/share/asset-not-on-this-frame")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# Currently showing -- allowed through to the (mocked) Immich call
|
||||
monkeypatch.setattr(
|
||||
"app.routers.device.immich_client_for",
|
||||
lambda frame: type("C", (), {"create_share_link": lambda self, asset_id, expires_in_s: "https://immich.example.com/share/abc"})(),
|
||||
)
|
||||
resp = client.get("/frame/share/asset-1", follow_redirects=False)
|
||||
assert resp.status_code in (302, 303, 307)
|
||||
|
||||
# Queued (not current) -- also allowed
|
||||
resp = client.get("/frame/share/asset-2", follow_redirects=False)
|
||||
assert resp.status_code in (302, 303, 307)
|
||||
|
||||
@@ -119,3 +119,106 @@ def test_manage_thumbnail_scoped_to_showing_or_queued(client, db_session, monkey
|
||||
def test_unknown_manage_token_404s(client, db_session):
|
||||
resp = client.get("/api/m/not-a-real-token/queue")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_manage_share_requires_current_photo(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame, _ = _configure_photo_widget(db_session)
|
||||
resp = client.get(f"/frame/share/{frame.manage_token}", follow_redirects=False)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_manage_share_creates_link_and_redirects(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame, widget = _configure_photo_widget(db_session)
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
cfg.current_asset_id = "asset-1"
|
||||
db_session.commit()
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"app.routers.manage.immich_client_for",
|
||||
lambda frame: type("C", (), {
|
||||
"create_share_link": lambda self, asset_ids, expires_in_s: (
|
||||
calls.append(asset_ids) or "https://immich.example.com/share/abc"
|
||||
),
|
||||
})(),
|
||||
)
|
||||
resp = client.get(f"/frame/share/{frame.manage_token}", follow_redirects=False)
|
||||
assert resp.status_code in (302, 303, 307)
|
||||
assert calls == [["asset-1"]]
|
||||
|
||||
|
||||
def test_manage_share_covers_every_photo_widget(client, db_session, monkeypatch):
|
||||
from app.models import Widget
|
||||
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame, widget = _configure_photo_widget(db_session)
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
cfg.current_asset_id = "asset-1"
|
||||
|
||||
second_widget = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
|
||||
sort_order=1, created_at=0)
|
||||
db_session.add(second_widget)
|
||||
db_session.flush()
|
||||
db_session.add(PhotoWidgetConfig(widget_id=second_widget.id, album_id="album-2", current_asset_id="asset-9"))
|
||||
db_session.commit()
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"app.routers.manage.immich_client_for",
|
||||
lambda frame: type("C", (), {
|
||||
"create_share_link": lambda self, asset_ids, expires_in_s: (
|
||||
calls.append(asset_ids) or "https://immich.example.com/share/abc"
|
||||
),
|
||||
})(),
|
||||
)
|
||||
resp = client.get(f"/frame/share/{frame.manage_token}", follow_redirects=False)
|
||||
assert resp.status_code in (302, 303, 307)
|
||||
assert calls == [["asset-1", "asset-9"]]
|
||||
|
||||
|
||||
def test_manage_share_scoped_to_its_own_frame(client, db_session, monkeypatch):
|
||||
"""The regression test for the original bug: each frame's own share
|
||||
QR must only ever surface that frame's own current photo, never
|
||||
another frame's -- previously it silently resolved via whichever
|
||||
frame happened to still carry the legacy migration token, regardless
|
||||
of which frame's QR was actually scanned."""
|
||||
from app.models import Widget
|
||||
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame_a, widget_a = _configure_photo_widget(db_session)
|
||||
cfg_a = db_session.get(PhotoWidgetConfig, widget_a.id)
|
||||
cfg_a.current_asset_id = "asset-a"
|
||||
|
||||
frame_b = Frame(
|
||||
name="Frame B", device_id="112233445566", device_token="devtok-b",
|
||||
manage_token="mtok-b", orientation="landscape", created_at=0,
|
||||
immich_url="http://immich.example.com", immich_api_key="key",
|
||||
)
|
||||
db_session.add(frame_b)
|
||||
db_session.flush()
|
||||
widget_b = Widget(frame_id=frame_b.id, widget_type="photos", x=0, y=0, w=4, h=5,
|
||||
sort_order=0, created_at=0)
|
||||
db_session.add(widget_b)
|
||||
db_session.flush()
|
||||
db_session.add(PhotoWidgetConfig(widget_id=widget_b.id, album_id="album-b", current_asset_id="asset-b"))
|
||||
db_session.commit()
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"app.routers.manage.immich_client_for",
|
||||
lambda frame: type("C", (), {
|
||||
"create_share_link": lambda self, asset_ids, expires_in_s: (
|
||||
calls.append(asset_ids) or "https://immich.example.com/share/abc"
|
||||
),
|
||||
})(),
|
||||
)
|
||||
client.get(f"/frame/share/{frame_a.manage_token}", follow_redirects=False)
|
||||
client.get(f"/frame/share/{frame_b.manage_token}", follow_redirects=False)
|
||||
assert calls == [["asset-a"], ["asset-b"]]
|
||||
|
||||
|
||||
def test_unknown_manage_share_token_404s(client, db_session):
|
||||
resp = client.get("/frame/share/not-a-real-token")
|
||||
assert resp.status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user