The nine Capture tables (files, jobs, folders, shares, links, quota, cookies, profiles, library resume points) leave app/models.py for the unit's own module; app.tables registers every unit's tables for the migrations, the test database and the ownership check. No schema change.
399 lines
16 KiB
Python
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.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"<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() == []
|