Files
siftlode/backend/tests/test_folder_links.py
peter e9c60f72cd refactor(capture): the HTTP layer reads through queries and writes through the service
Capture's routes (downloads, files, public watch links) and the token list no longer build SQL, call
the session or set a model's fields: reads are downloads/queries, writes the unit's service (service,
folders, links, quota), and each write flushes for the handler to commit. Whose a row is goes
through userscope (owned_by, owned_row), which owned_or_404 now uses too.

The fence holds Capture below its routes as well: it reads only Catalog's and Plex's tables, writes
no other unit's, filters owners through userscope and commits only in its jobs and the upload
ingest. A test per service use case, among them that the tray lists only the caller's files.
2026-10-19 00:19:03 +02:00

399 lines
16 KiB
Python

"""A `/watch` link to one of its owner's own folders (routes/downloads.py, routes/public.py), DB-lane.
The link shows what is in the folder when it is opened: a file moved in appears, one moved out stops
opening at its next request. Only an own folder can be shared — a source folder lets its files go on
its own clock, the Hub is the channel to a machine. A password, an expiry and stream-only work as on
a file link; a document is never served to open as a page.
"""
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import select, text
from app.config import settings
from app.db import engine
from app.downloads import folders, og
from app.downloads import probe as probemod
from app.models import AuditLog
from app.downloads.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
_VIDEO = {
"streams": [
{"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"},
{"codec_type": "audio", "codec_name": "aac"},
],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
@pytest.fixture
def probe(monkeypatch):
"""What ffprobe says the next upload is: a phone-safe video, or (None) a file it cannot read."""
said = {"data": _VIDEO}
monkeypatch.setattr(probemod, "probe_json", lambda _p: said["data"])
return said
@pytest.fixture
def shelf(as_user, make_user, tmp_path, monkeypatch, upload_headers, probe):
"""An owner with an own folder "Friends" holding two videos, a third video in their Uploads, and
a function to upload more."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
user = make_user()
owner = as_user(user)
folder = owner.post("/api/files/folders", json={"name": "Friends"}).json()
def upload(name: str, folder_id: int | None = None, body: bytes = b"fake-video") -> int:
r = owner.post(
"/api/files/upload", content=body, headers=upload_headers(name, folder=folder_id)
)
assert r.status_code == 200, r.text
return r.json()["id"]
inside = [upload("a.mp4", folder["id"]), upload("b.mp4", folder["id"])]
outside = upload("c.mp4")
return {
"user": user,
"owner": owner,
"folder": folder["id"],
"inside": inside,
"outside": outside,
"upload": upload,
}
def _link(shelf, **body) -> dict:
r = shelf["owner"].post(f"/api/downloads/folders/{shelf['folder']}/links", json=body)
assert r.status_code == 200, r.text
return r.json()
def _ids(client, token: str) -> list[int]:
meta = client.get(f"/api/public/watch/{token}").json()
return [f["id"] for f in meta["files"]]
def test_a_folder_link_lists_what_is_in_the_folder(shelf, client):
token = _link(shelf)["token"]
meta = client.get(f"/api/public/watch/{token}").json()
assert meta["kind"] == "folder"
assert meta["name"] == "Friends"
assert sorted(f["id"] for f in meta["files"]) == sorted(shelf["inside"])
# Each one plays and downloads through the link, under its own id.
for f in meta["files"]:
assert f["file_url"] == f"/api/public/watch/{token}/files/{f['id']}/file"
served = client.get(f["file_url"])
assert served.status_code == 200
assert served.content == b"fake-video"
def test_a_file_outside_the_folder_does_not_open_through_it(shelf, client):
token = _link(shelf)["token"]
r = client.get(f"/api/public/watch/{token}/files/{shelf['outside']}/file")
assert r.status_code == 404
# Nor does the folder link open as a file link.
assert client.get(f"/api/public/watch/{token}/file").status_code == 404
def test_the_link_follows_the_folder_as_files_move(shelf, client):
token = _link(shelf)["token"]
owner, moved_out = shelf["owner"], shelf["inside"][0]
other = owner.post("/api/files/folders", json={"name": "Other"}).json()["id"]
def move(job_id: int, folder_id: int) -> None:
r = owner.patch(f"/api/files/{job_id}", json={"folder_id": folder_id})
assert r.status_code == 200, r.text
move(shelf["outside"], shelf["folder"])
move(moved_out, other)
assert sorted(_ids(client, token)) == sorted([shelf["inside"][1], shelf["outside"]])
assert client.get(f"/api/public/watch/{token}/files/{moved_out}/file").status_code == 404
url = f"/api/public/watch/{token}/files/{shelf['outside']}/file"
assert client.get(url).status_code == 200
def test_only_a_folder_of_ones_own_can_be_shared(shelf, db, as_user, make_user):
owner = shelf["owner"]
source = db.execute(
select(FileFolder.id).where(
FileFolder.user_id == shelf["user"].id, FileFolder.kind == "source"
)
).scalar_one()
r = owner.post(f"/api/downloads/folders/{source}/links", json={})
assert r.status_code == 409
assert r.json()["detail"]["reason"] == "share_folder_kind"
# Nor the Hub: whatever a machine drops there would be public the moment it lands.
hub = folders.hub_folder(db, shelf["user"].id).id
db.commit()
r = owner.post(f"/api/downloads/folders/{hub}/links", json={})
assert r.status_code == 409
assert r.json()["detail"]["reason"] == "share_folder_kind"
stranger = as_user(make_user())
assert (
stranger.post(f"/api/downloads/folders/{shelf['folder']}/links", json={}).status_code == 404
)
assert stranger.get(f"/api/downloads/folders/{shelf['folder']}/links").status_code == 404
link_id = _link(shelf)["id"]
patch = stranger.patch(f"/api/downloads/links/{link_id}", json={"allow_download": False})
assert patch.status_code == 404
assert stranger.delete(f"/api/downloads/links/{link_id}").status_code == 404
def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client):
token = _link(shelf, password="open sesame")["token"]
assert client.get(f"/api/public/watch/{token}").json() == {
"needs_password": True,
"kind": "folder",
}
bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file"
assert client.get(bare).status_code == 403
unlocked = client.post(f"/api/public/watch/{token}/unlock", json={"password": "open sesame"})
assert unlocked.status_code == 200
files = unlocked.json()["files"]
assert len(files) == 2
assert all("?g=" in f["file_url"] for f in files)
assert client.get(files[0]["file_url"]).status_code == 200
def test_a_files_poster_opens_by_the_links_own_rules(shelf, db, client):
"""The poster route on its own: a password link's poster needs the unlock grant, and a file
outside the folder has none through it, grant or not."""
for job_id in (shelf["inside"][0], shelf["outside"]):
asset = db.get(MediaAsset, db.get(DownloadJob, job_id).asset_id)
asset.poster_path = f"poster-{job_id}.jpg"
(Path(settings.files_root) / asset.poster_path).write_bytes(b"a poster frame")
db.commit()
token = _link(shelf, password="open sesame")["token"]
inside = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/poster.jpg"
assert client.get(inside).status_code == 403
unlocked = client.post(f"/api/public/watch/{token}/unlock", json={"password": "open sesame"})
[entry] = [f for f in unlocked.json()["files"] if f["id"] == shelf["inside"][0]]
assert entry["poster_url"].startswith(inside + "?g=")
got = client.get(entry["poster_url"])
assert got.status_code == 200 and got.content == b"a poster frame"
grant = entry["poster_url"].split("?g=", 1)[1]
outside = f"/api/public/watch/{token}/files/{shelf['outside']}/poster.jpg?g={grant}"
assert client.get(outside).status_code == 404
def test_every_open_counts_as_a_view(shelf, client):
made = _link(shelf)
for _ in range(3):
assert client.get(f"/api/public/watch/{made['token']}").status_code == 200
[listed] = shelf["owner"].get(f"/api/downloads/folders/{shelf['folder']}/links").json()
assert listed["view_count"] == 3
def test_the_folder_list_is_capped_like_the_owners_files_list():
from app.downloads import links as linksmod
from app.downloads.queries import MAX_LIST
assert linksmod.FOLDER_MAX == MAX_LIST
def test_a_document_in_a_shared_folder_only_downloads(shelf, client, probe):
probe["data"] = None
doc = shelf["upload"]("notes.html", shelf["folder"], b"<script>1</script>")
token = _link(shelf, allow_download=False)["token"]
assert doc in _ids(client, token)
served = client.get(f"/api/public/watch/{token}/files/{doc}/file")
assert served.status_code == 200
assert served.headers["content-type"].startswith("application/octet-stream")
assert "attachment" in served.headers["content-disposition"].lower()
def test_a_stream_only_folder_link_plays_inline(shelf, client):
token = _link(shelf, allow_download=False)["token"]
meta = client.get(f"/api/public/watch/{token}").json()
assert meta["allow_download"] is False
served = client.get(meta["files"][0]["file_url"])
assert "inline" in served.headers["content-disposition"].lower()
def test_an_expired_or_deleted_folder_link_is_gone(shelf, client, db):
made = _link(shelf)
db.get(DownloadLink, made["id"]).expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db.commit()
assert client.get(f"/api/public/watch/{made['token']}").status_code == 404
token = _link(shelf)["token"]
assert shelf["owner"].delete(f"/api/files/folders/{shelf['folder']}").status_code == 200
assert client.get(f"/api/public/watch/{token}").status_code == 404
db.expire_all()
assert db.execute(select(DownloadLink)).scalars().all() == []
def test_the_folder_list_marks_a_folder_a_live_link_opens(shelf, db):
owner = shelf["owner"]
def marked() -> dict[int, bool]:
return {f["id"]: f["has_link"] for f in owner.get("/api/files/folders").json()}
assert marked()[shelf["folder"]] is False
made = _link(shelf)
assert marked()[shelf["folder"]] is True
# A rename answers with the mark too, so the rail does not lose it.
renamed = owner.patch(f"/api/files/folders/{shelf['folder']}", json={"name": "Pals"}).json()
assert renamed["has_link"] is True
db.get(DownloadLink, made["id"]).expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db.commit()
assert marked()[shelf["folder"]] is False
live = _link(shelf)
assert owner.delete(f"/api/downloads/links/{live['id']}").status_code == 200
assert marked()[shelf["folder"]] is False
assert not any(marked()[f] for f in marked() if f != shelf["folder"])
def test_making_and_revoking_a_folder_link_is_audited(shelf, db):
made = _link(shelf)
assert shelf["owner"].delete(f"/api/downloads/links/{made['id']}").status_code == 200
db.expire_all()
rows = {
a.action: a.after
for a in db.execute(
select(AuditLog).where(AuditLog.action.like("download_link_%"))
).scalars()
}
assert rows["download_link_create"]["folder_id"] == shelf["folder"]
assert "job_id" not in rows["download_link_create"]
assert rows["download_link_revoke"] == {"folder_id": shelf["folder"]}
def test_the_unfurl_names_the_folder_and_counts_its_files_only(shelf, db):
token = _link(shelf)["token"]
tags = og._watch_tags(token, db)
assert "Friends" in tags
assert "2 files" in tags
names = db.execute(
select(DownloadJob.display_name).where(DownloadJob.id.in_(shelf["inside"]))
).scalars()
for name in names:
assert not name or name not in tags
# A file whose bytes are gone is off the page, and so off the card's count.
gone = db.get(MediaAsset, db.get(DownloadJob, shelf["inside"][0]).asset_id)
(Path(settings.files_root) / gone.rel_path).unlink()
assert "1 file " in og._watch_tags(token, db)
locked = _link(shelf, password="pw")["token"]
hidden = og._watch_tags(locked, db)
assert "Friends" not in hidden and "Password-protected folder" in hidden
assert 'content="website"' in hidden and "video.other" not in hidden
def test_the_downgrade_drops_folder_links_and_keeps_file_links(shelf, db, client):
_link(shelf)
file_link = shelf["owner"].post(f"/api/downloads/{shelf['outside']}/links", json={}).json()
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
cfg = Config("alembic.ini")
command.downgrade(cfg, "0107_link_token_at_rest")
try:
with engine.connect() as conn:
left = conn.execute(text("SELECT job_id FROM download_links")).scalars().all()
finally:
command.upgrade(cfg, "head")
assert left == [shelf["outside"]]
assert client.get(f"/api/public/watch/{file_link['token']}").status_code == 200
def test_a_password_file_link_says_it_opens_a_file(shelf, client):
made = (
shelf["owner"]
.post(f"/api/downloads/{shelf['outside']}/links", json={"password": "pw"})
.json()
)
meta = client.get(f"/api/public/watch/{made['token']}").json()
assert meta == {"needs_password": True, "kind": "file"}
def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db):
from pathlib import Path
from app.downloads.models import MediaAsset
token = _link(shelf)["token"]
gone = shelf["inside"][0]
asset = db.get(MediaAsset, db.get(DownloadJob, gone).asset_id)
(Path(settings.files_root) / asset.rel_path).unlink()
assert _ids(client, token) == [shelf["inside"][1]]
assert client.get(f"/api/public/watch/{token}/files/{gone}/file").status_code == 404
def _meanwhile(work):
"""Run `work(other_session)` in another thread and session, committed; (thread, outcome)."""
import threading
from app.db import SessionLocal
outcome: list[object] = []
def run():
other = SessionLocal()
try:
outcome.append(work(other))
other.commit()
except Exception as e: # noqa: BLE001 — the assertion below names what went wrong
outcome.append(e)
finally:
other.close()
t = threading.Thread(target=run)
t.start()
return t, outcome
def test_two_opens_at_once_count_as_two_views(shelf, db):
from app.routes import public
made = _link(shelf)
link = db.get(DownloadLink, made["id"])
public._opened(db, link, made["token"]) # this open's count is not committed yet
t, outcome = _meanwhile(
lambda other: public._opened(other, other.get(DownloadLink, made["id"]), made["token"])
)
t.join(timeout=1)
db.commit()
t.join(timeout=10)
assert not isinstance(outcome[0], Exception), outcome
db.expire_all()
assert db.get(DownloadLink, made["id"]).view_count == 2
def test_a_file_shared_as_it_is_deleted_takes_its_new_link_with_it(shelf, db):
from app.routes import downloads as routes
job_id = shelf["outside"]
routes._shareable_job(db, shelf["user"], job_id) # held from the check until this commits
t, outcome = _meanwhile(lambda other: other.delete(other.get(DownloadJob, job_id)))
t.join(timeout=1)
assert t.is_alive() # the delete waits for the link on its way in
db.add(DownloadLink(job_id=job_id, token_hash="h" * 64, created_by=shelf["user"].id))
db.commit()
t.join(timeout=10)
assert outcome == [None]
assert db.execute(select(DownloadLink.id)).scalars().all() == []
def test_a_folder_shared_as_it_is_deleted_takes_its_new_link_with_it(shelf, db):
from app.routes import downloads as routes
fid = shelf["folder"]
routes._shareable_folder(db, shelf["user"], fid) # held from the check until this commits
t, outcome = _meanwhile(lambda other: folders.delete_own(other, other.get(FileFolder, fid)))
t.join(timeout=1)
assert t.is_alive() # the delete waits for the link on its way in
db.add(DownloadLink(folder_id=fid, token_hash="h" * 64, created_by=shelf["user"].id))
db.commit()
t.join(timeout=10)
assert not isinstance(outcome[0], Exception), outcome
assert db.execute(select(DownloadLink.id)).scalars().all() == []