"""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, 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.routes.files 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"") 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.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() == []