feat(files): the Files library API — folders, moving and keeping files, upload into a folder, the file's hash
/api/files is the library's home: list a user's done files (by folder, searched, sorted, or the ones shared with them), rename / move / keep / delete a file, make, rename and delete own folders (a deleted folder's files go back to their source folders), and set how long any folder keeps a file — done files shift by the difference, never closer than the GC's warning window. An upload can name its folder (an own folder or Uploads), checked before the body is read and again before the file lands; it is hashed while it streams, and any file can be hashed on first ask. The download says the hash (Repr-Digest) once it is known; a new file at an asset's path forgets it. Uploads, deletes, keeps, moves and folder changes are audited (ids only). The fileless "expired" rows of the old retention are deleted by migration 0103: an expired file now leaves the library. The shared route helpers moved to routes/files.py; the old /api/downloads library routes delegate to the new ones until the client moves over.
This commit is contained in:
@@ -254,20 +254,24 @@ def as_user(db, override_dep, _configured):
|
||||
|
||||
@pytest.fixture
|
||||
def upload_headers():
|
||||
"""Build the headers `POST /api/downloads/upload` takes (the file is the raw body).
|
||||
"""Build the headers the upload routes take (the file is the raw body).
|
||||
|
||||
Shared, because two test modules drive that route and a second copy of this is exactly the drift
|
||||
that lets the wire shape change in one place only. The body IS the file now; the names ride as
|
||||
percent-encoded headers (`quote` mirrors the client's `encodeURIComponent`, since headers are
|
||||
latin-1 and a Hungarian filename would not survive raw)."""
|
||||
|
||||
def _headers(filename: str, display_name: str | None = None) -> dict[str, str]:
|
||||
def _headers(
|
||||
filename: str, display_name: str | None = None, folder: int | str | None = None
|
||||
) -> dict[str, str]:
|
||||
headers = {
|
||||
"content-type": "application/octet-stream",
|
||||
"x-upload-filename": quote(filename),
|
||||
}
|
||||
if display_name is not None:
|
||||
headers["x-upload-display-name"] = quote(display_name)
|
||||
if folder is not None:
|
||||
headers["x-upload-folder"] = str(folder)
|
||||
return headers
|
||||
|
||||
return _headers
|
||||
|
||||
@@ -221,7 +221,7 @@ def test_the_default_ceiling_clears_the_largest_body_a_default_route_takes():
|
||||
CAPPED_BODY_SITES = {
|
||||
("diagnostics.py", "_parsed_events", "capped_body", "BODY_MAX_BYTES"),
|
||||
("debug_sessions.py", "_image_body", "capped_body", "debugsession.SCREENSHOT_MAX_BYTES"),
|
||||
("downloads.py", "spool_upload", "capped_stream_to", "cap"),
|
||||
("files.py", "spool_upload", "capped_stream_to", "cap"),
|
||||
}
|
||||
|
||||
|
||||
@@ -467,7 +467,7 @@ def test_every_body_reading_site_hands_its_pooled_connection_back_first():
|
||||
assert not missing, (
|
||||
"a route reads its own request body while still inside its read transaction, pinning a "
|
||||
f"pooled connection for the whole transfer: {missing}. Call "
|
||||
"db.release_read_connection(db) before the await — see routes/downloads.py:spool_upload."
|
||||
"db.release_read_connection(db) before the await — see routes/files.py:spool_upload."
|
||||
)
|
||||
|
||||
|
||||
@@ -540,7 +540,7 @@ def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, templa
|
||||
"its dependencies, so FastAPI reads it before the first dependency runs and the raised "
|
||||
"ceiling is all that stands between an anonymous caller and the whole allowance. Read the "
|
||||
"body yourself, from a dependency declared AFTER the auth one — see "
|
||||
"routes/downloads.py:spool_upload."
|
||||
"routes/files.py:spool_upload."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ unlocked read: two overlapping requests for one job (a double-click, a second ta
|
||||
gateway timeout) both saw it holding its asset and both released the hold. The count then reaches 0
|
||||
while another job still holds the asset, and GC or the ready-file delete removes it under that job.
|
||||
A pause racing a cancel did the same one step later: its unlocked write turned the released job back
|
||||
into a holder, which a later delete releases again. `_own_job_for_update` makes the second request
|
||||
into a holder, which a later delete releases again. `own_job_for_update` makes the second request
|
||||
wait and decide on the first one's committed result.
|
||||
"""
|
||||
|
||||
@@ -18,12 +18,8 @@ from sqlalchemy import select
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import service
|
||||
from app.models import DownloadJob, MediaAsset, User
|
||||
from app.routes.downloads import (
|
||||
_own_job_for_update,
|
||||
cancel_download,
|
||||
delete_download,
|
||||
pause_download,
|
||||
)
|
||||
from app.routes.downloads import cancel_download, delete_download, pause_download
|
||||
from app.routes.files import delete_file, own_job_for_update
|
||||
|
||||
_REF = "https://example.com/video"
|
||||
|
||||
@@ -59,6 +55,7 @@ def _asset_held_by_two_jobs(db, status="error"):
|
||||
[
|
||||
("error", service.cancel, cancel_download, "canceled"),
|
||||
("error", service.delete, delete_download, None),
|
||||
("error", service.delete, delete_file, None),
|
||||
("queued", service.cancel, pause_download, "canceled"),
|
||||
],
|
||||
)
|
||||
@@ -80,7 +77,7 @@ def test_overlapping_requests_for_one_job_release_its_hold_once(
|
||||
finished.set()
|
||||
|
||||
try:
|
||||
locked = _own_job_for_update(first, user, job_id)
|
||||
locked = own_job_for_update(first, user, job_id)
|
||||
other = threading.Thread(target=the_other_request)
|
||||
other.start()
|
||||
# The second request waits for the first one's locks instead of deciding on the same row.
|
||||
|
||||
@@ -188,7 +188,7 @@ def test_list_downloads_is_capped(db, monkeypatch):
|
||||
_job(db, u.id, queue_pos=i, status="done")
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(dl, "_MAX_LIST", 2)
|
||||
monkeypatch.setattr(dl, "MAX_LIST", 2)
|
||||
rows = dl.list_downloads(user=u, db=db)
|
||||
assert len(rows) == 2 # bounded by the cap, not the 3 rows on disk
|
||||
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
"""The Files library API (`routes.files`), DB-lane: folders (list, own create/rename/delete, a
|
||||
folder's retention), the file list (done only, by folder, searched, sorted, shared with me), a
|
||||
file's rename / move / keep / delete, the upload into a folder, and the file's hash.
|
||||
|
||||
Every route is also held to the caller's own rows: another user's folder or file answers like a
|
||||
missing one.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.downloads import folders, service
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import AuditLog, DownloadJob, DownloadShare, FileFolder, MediaAsset
|
||||
|
||||
NOW = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _root(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _file(db, user_id, *, kind="youtube", ref=None, title="A title", size=10, rel=None, **kw):
|
||||
"""A done library file: an asset with bytes on disk-path terms, and the user's job over it."""
|
||||
n = db.execute(select(MediaAsset.id)).all()
|
||||
ref = ref or f"vid{len(n):08d}"
|
||||
asset = MediaAsset(
|
||||
source_kind=kind,
|
||||
source_ref=ref,
|
||||
format_sig=f"sig-{ref}",
|
||||
status="ready",
|
||||
rel_path=rel or f"{ref}.mp4",
|
||||
size_bytes=size,
|
||||
ref_count=0,
|
||||
title=title,
|
||||
media_kind="video",
|
||||
container="mp4",
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
asset_id=asset.id,
|
||||
source_kind=kind,
|
||||
source_ref=ref,
|
||||
profile_snapshot={},
|
||||
status=kw.pop("status", JobStatus.DONE),
|
||||
**kw,
|
||||
)
|
||||
db.add(job)
|
||||
db.flush()
|
||||
folders.place(db, job)
|
||||
folders.stamp_expiry(db, job)
|
||||
service.incr_ref_count(db, asset.id)
|
||||
db.commit()
|
||||
return job
|
||||
|
||||
|
||||
def _own(db, user_id, name="Saját"):
|
||||
folder = FileFolder(user_id=user_id, kind=folders.FolderKind.OWN, name=name)
|
||||
db.add(folder)
|
||||
db.commit()
|
||||
return folder
|
||||
|
||||
|
||||
def _audits(db, action):
|
||||
return db.execute(select(AuditLog).where(AuditLog.action == action)).scalars().all()
|
||||
|
||||
|
||||
# --- folders -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_folder_list_counts_each_folders_done_files_and_is_the_callers_own(
|
||||
db, make_user, as_user
|
||||
):
|
||||
alice, bob = make_user(), make_user()
|
||||
_file(db, alice.id, size=10)
|
||||
_file(db, alice.id, size=5)
|
||||
_file(db, alice.id, kind="url", ref="https://vimeo.com/1", size=7)
|
||||
_file(db, alice.id, status=JobStatus.RUNNING) # not in the library yet
|
||||
_file(db, bob.id)
|
||||
got = {
|
||||
(f["kind"], f["source_key"]): (f["count"], f["size_bytes"])
|
||||
for f in as_user(alice).get("/api/files/folders").json()
|
||||
}
|
||||
assert got == {("source", "youtube"): (2, 15), ("source", "site:vimeo.com"): (1, 7)}
|
||||
|
||||
|
||||
def test_an_own_folder_is_made_once_per_name_whatever_its_case(db, make_user, as_user):
|
||||
alice = make_user()
|
||||
c = as_user(alice)
|
||||
made = c.post("/api/files/folders", json={"name": "Nyaralás"})
|
||||
assert made.status_code == 200, made.text
|
||||
assert (made.json()["kind"], made.json()["name"]) == ("own", "Nyaralás")
|
||||
again = c.post("/api/files/folders", json={"name": "NYARALÁS"})
|
||||
assert again.status_code == 409
|
||||
assert again.json()["detail"]["reason"] == "file_folder_name_taken"
|
||||
blank = c.post("/api/files/folders", json={"name": " "})
|
||||
assert blank.json()["detail"]["reason"] == "file_folder_name_required"
|
||||
# Another user may have a folder of the same name.
|
||||
assert as_user(make_user()).post("/api/files/folders", json={"name": "Nyaralás"}).is_success
|
||||
assert len(_audits(db, "file_folder_create")) == 2
|
||||
|
||||
|
||||
def test_only_an_own_folder_is_renamed_and_only_by_its_user(db, make_user, as_user):
|
||||
alice = make_user()
|
||||
c = as_user(alice)
|
||||
src = db.get(FileFolder, _file(db, alice.id).folder_id)
|
||||
own = _own(db, alice.id, "Régi")
|
||||
taken = _own(db, alice.id, "Foglalt")
|
||||
ok = c.patch(f"/api/files/folders/{own.id}", json={"name": "Új"})
|
||||
assert ok.json()["name"] == "Új"
|
||||
clash = c.patch(f"/api/files/folders/{own.id}", json={"name": taken.name.lower()})
|
||||
assert clash.json()["detail"]["reason"] == "file_folder_name_taken"
|
||||
no = c.patch(f"/api/files/folders/{src.id}", json={"name": "YouTube 2"})
|
||||
assert no.status_code == 409 and no.json()["detail"]["reason"] == "file_folder_not_own"
|
||||
stranger = as_user(make_user()).patch(f"/api/files/folders/{own.id}", json={"name": "X"})
|
||||
assert stranger.status_code == 404
|
||||
db.refresh(own)
|
||||
assert own.name == "Új"
|
||||
|
||||
|
||||
def test_a_shorter_retention_moves_each_files_expiry_but_never_inside_the_warning_window(
|
||||
db, make_user, as_user
|
||||
):
|
||||
sysconfig.set_value(db, "download_gc_grace_days", 3)
|
||||
db.commit()
|
||||
alice = make_user()
|
||||
fresh = _file(db, alice.id) # expires in 30 days
|
||||
old = _file(db, alice.id)
|
||||
old.expires_at = NOW + timedelta(days=10) # 20 days old already
|
||||
kept = _file(db, alice.id, kept=True)
|
||||
db.commit()
|
||||
folder_id = fresh.folder_id
|
||||
r = as_user(alice).patch(f"/api/files/folders/{folder_id}", json={"retention_days": 20})
|
||||
assert r.json()["retention_days"] == 20
|
||||
for j in (fresh, old, kept):
|
||||
db.refresh(j)
|
||||
# 30 → 20 days: each loses 10 — the one with 10 left would be due now, so it gets the window.
|
||||
assert abs(fresh.expires_at - (NOW + timedelta(days=20))) < timedelta(minutes=5)
|
||||
assert abs(old.expires_at - (NOW + timedelta(days=3))) < timedelta(minutes=5)
|
||||
assert kept.expires_at is None
|
||||
forever = as_user(alice).patch(f"/api/files/folders/{folder_id}", json={"retention_days": None})
|
||||
assert forever.json()["retention_days"] is None
|
||||
db.refresh(fresh)
|
||||
assert fresh.expires_at is None
|
||||
rows = _audits(db, "file_folder_retention")
|
||||
assert [(a.before, a.after) for a in rows] == [
|
||||
({"retention_days": 30}, {"retention_days": 20}),
|
||||
({"retention_days": 20}, {"retention_days": None}),
|
||||
]
|
||||
|
||||
|
||||
def test_a_retention_set_on_a_folder_that_kept_forever_starts_its_files_clock_now(
|
||||
db, make_user, as_user
|
||||
):
|
||||
alice = make_user()
|
||||
up = _file(db, alice.id, kind="upload", ref="u1")
|
||||
assert up.expires_at is None
|
||||
r = as_user(alice).patch(f"/api/files/folders/{up.folder_id}", json={"retention_days": 7})
|
||||
assert r.status_code == 200, r.text
|
||||
db.refresh(up)
|
||||
assert abs(up.expires_at - (NOW + timedelta(days=7))) < timedelta(minutes=5)
|
||||
|
||||
|
||||
def test_deleting_an_own_folder_sends_its_files_home_and_a_source_folder_cannot_be_deleted(
|
||||
db, make_user, as_user
|
||||
):
|
||||
alice = make_user()
|
||||
c = as_user(alice)
|
||||
yt = _file(db, alice.id)
|
||||
site = _file(db, alice.id, kind="url", ref="https://vimeo.com/2")
|
||||
home = {yt.id: yt.folder_id, site.id: site.folder_id}
|
||||
own = _own(db, alice.id)
|
||||
for j in (yt, site):
|
||||
assert c.patch(f"/api/files/{j.id}", json={"folder_id": own.id}).is_success
|
||||
r = c.delete(f"/api/files/folders/{own.id}")
|
||||
assert r.json() == {"ok": True, "deleted": own.id, "files_moved": 2}
|
||||
assert db.get(FileFolder, own.id) is None
|
||||
for j in (yt, site):
|
||||
db.refresh(j)
|
||||
assert j.folder_id == home[j.id]
|
||||
# Back in a source folder, its clock runs again from now.
|
||||
assert abs(j.expires_at - (NOW + timedelta(days=30))) < timedelta(minutes=5)
|
||||
src = c.delete(f"/api/files/folders/{yt.folder_id}")
|
||||
assert src.status_code == 409 and src.json()["detail"]["reason"] == "file_folder_not_own"
|
||||
other = _own(db, alice.id, "Más")
|
||||
assert as_user(make_user()).delete(f"/api/files/folders/{other.id}").status_code == 404
|
||||
assert len(_audits(db, "file_folder_delete")) == 1
|
||||
|
||||
|
||||
# --- the file list -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_list_holds_the_callers_done_files_by_folder(db, make_user, as_user):
|
||||
alice, bob = make_user(), make_user()
|
||||
yt = _file(db, alice.id)
|
||||
site = _file(db, alice.id, kind="url", ref="https://vimeo.com/3")
|
||||
_file(db, alice.id, status=JobStatus.QUEUED)
|
||||
_file(db, bob.id)
|
||||
c = as_user(alice)
|
||||
assert {f["id"] for f in c.get("/api/files").json()} == {yt.id, site.id}
|
||||
only = c.get("/api/files", params={"folder_id": site.folder_id}).json()
|
||||
assert [f["id"] for f in only] == [site.id]
|
||||
assert only[0]["folder_id"] == site.folder_id and only[0]["shared"] is False
|
||||
# Bob's folder id names nothing of Alice's.
|
||||
bobs = db.execute(select(FileFolder.id).where(FileFolder.user_id == bob.id)).scalar()
|
||||
assert c.get("/api/files", params={"folder_id": bobs}).json() == []
|
||||
|
||||
|
||||
def test_the_list_is_searched_by_name_and_channel_with_wildcards_taken_literally(
|
||||
db, make_user, as_user
|
||||
):
|
||||
alice = make_user()
|
||||
hit = _file(db, alice.id, title="Balaton 100% nyár")
|
||||
by_channel = _file(db, alice.id, title="Más", display_uploader="Balatoni csatorna")
|
||||
_file(db, alice.id, title="Balaton 1000 nyár")
|
||||
c = as_user(alice)
|
||||
assert [f["id"] for f in c.get("/api/files", params={"q": "100%"}).json()] == [hit.id]
|
||||
found = {f["id"] for f in c.get("/api/files", params={"q": "balatoni"}).json()}
|
||||
assert found == {by_channel.id}
|
||||
|
||||
|
||||
def test_the_list_sorts_by_name_size_or_when_added(db, make_user, as_user):
|
||||
alice = make_user()
|
||||
b = _file(db, alice.id, title="Béka", size=30)
|
||||
a = _file(db, alice.id, title="alma", size=10)
|
||||
z = _file(db, alice.id, title="Zebra", size=20)
|
||||
c = as_user(alice)
|
||||
|
||||
def ids(**params):
|
||||
return [f["id"] for f in c.get("/api/files", params=params).json()]
|
||||
|
||||
assert ids() == [z.id, a.id, b.id] # newest first
|
||||
assert ids(sort="name", order="asc") == [a.id, b.id, z.id] # the reader's alphabet, any case
|
||||
assert ids(sort="size", order="desc") == [b.id, z.id, a.id]
|
||||
|
||||
|
||||
def test_shared_with_me_lists_what_others_shared_and_nothing_else(db, make_user, as_user):
|
||||
alice, bob, carol = make_user(), make_user(), make_user()
|
||||
theirs = _file(db, bob.id)
|
||||
_file(db, bob.id) # not shared
|
||||
for_carol = _file(db, bob.id)
|
||||
db.add(DownloadShare(job_id=theirs.id, from_user_id=bob.id, to_user_id=alice.id))
|
||||
db.add(DownloadShare(job_id=for_carol.id, from_user_id=bob.id, to_user_id=carol.id))
|
||||
db.commit()
|
||||
got = as_user(alice).get("/api/files", params={"scope": "shared"}).json()
|
||||
assert [(f["id"], f["shared"]) for f in got] == [(theirs.id, True)]
|
||||
|
||||
|
||||
# --- one file ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_file_is_renamed_kept_and_given_back_to_its_folders_retention(db, make_user, as_user):
|
||||
alice = make_user()
|
||||
job = _file(db, alice.id)
|
||||
c = as_user(alice)
|
||||
assert c.patch(f"/api/files/{job.id}", json={"name": "Új név"}).json()["display_name"] == (
|
||||
"Új név"
|
||||
)
|
||||
kept = c.patch(f"/api/files/{job.id}", json={"kept": True}).json()
|
||||
assert (kept["kept"], kept["expires_at"]) == (True, None)
|
||||
back = c.patch(f"/api/files/{job.id}", json={"kept": False}).json()
|
||||
assert back["kept"] is False and back["expires_at"] is not None
|
||||
# Setting what it already is records nothing.
|
||||
assert c.patch(f"/api/files/{job.id}", json={"kept": False}).status_code == 200
|
||||
assert len(_audits(db, "file_keep")) == 1 and len(_audits(db, "file_unkeep")) == 1
|
||||
assert as_user(make_user()).patch(f"/api/files/{job.id}", json={"kept": True}).status_code == (
|
||||
404
|
||||
)
|
||||
|
||||
|
||||
def test_a_file_moves_to_an_own_folder_or_home_and_nowhere_else(db, make_user, as_user):
|
||||
alice, bob = make_user(), make_user()
|
||||
job = _file(db, alice.id)
|
||||
home = job.folder_id
|
||||
vimeo = db.get(FileFolder, _file(db, alice.id, kind="url", ref="https://vimeo.com/4").folder_id)
|
||||
own = _own(db, alice.id)
|
||||
bobs = _own(db, bob.id, "Bobé")
|
||||
c = as_user(alice)
|
||||
moved = c.patch(f"/api/files/{job.id}", json={"folder_id": own.id}).json()
|
||||
assert (moved["folder_id"], moved["expires_at"]) == (own.id, None) # an own folder keeps it
|
||||
wrong = c.patch(f"/api/files/{job.id}", json={"folder_id": vimeo.id})
|
||||
assert wrong.json()["detail"]["reason"] == "file_folder_not_its_source"
|
||||
foreign = c.patch(f"/api/files/{job.id}", json={"folder_id": bobs.id})
|
||||
assert foreign.json()["detail"]["reason"] == "file_folder_gone"
|
||||
home_again = c.patch(f"/api/files/{job.id}", json={"folder_id": home}).json()
|
||||
assert home_again["folder_id"] == home and home_again["expires_at"] is not None
|
||||
assert [(a.before, a.after) for a in _audits(db, "file_move")] == [
|
||||
({"folder_id": home}, {"folder_id": own.id}),
|
||||
({"folder_id": own.id}, {"folder_id": home}),
|
||||
]
|
||||
|
||||
|
||||
def test_a_deleted_file_leaves_the_library_and_the_log_says_so(db, make_user, as_user, _root):
|
||||
alice = make_user()
|
||||
job = _file(db, alice.id)
|
||||
assert as_user(make_user()).delete(f"/api/files/{job.id}").status_code == 404
|
||||
r = as_user(alice).delete(f"/api/files/{job.id}")
|
||||
assert r.json() == {"ok": True, "deleted": job.id}
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id) is None
|
||||
[row] = _audits(db, "file_delete")
|
||||
assert (row.actor_id, row.target_id) == (alice.id, str(job.id))
|
||||
|
||||
|
||||
# --- upload, the file, its hash ------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _no_probe(monkeypatch):
|
||||
# A file ffprobe cannot read: a generic file, done at once (no conversion).
|
||||
monkeypatch.setattr(uploadmod.probe, "probe_json", lambda _p: None)
|
||||
|
||||
|
||||
def test_an_upload_lands_in_the_folder_it_asked_for_with_its_hash(
|
||||
db, make_user, as_user, _root, _no_probe, upload_headers
|
||||
):
|
||||
alice = make_user()
|
||||
own = _own(db, alice.id)
|
||||
body = b"hello files"
|
||||
c = as_user(alice)
|
||||
r = c.post("/api/files/upload", content=body, headers=upload_headers("a.txt", folder=own.id))
|
||||
assert r.status_code == 200, r.text
|
||||
job = db.get(DownloadJob, r.json()["id"])
|
||||
assert job.folder_id == own.id and job.expires_at is None
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert asset.sha256 == hashlib.sha256(body).hexdigest()
|
||||
plain = c.post("/api/files/upload", content=b"x", headers=upload_headers("b.txt")).json()
|
||||
assert db.get(FileFolder, plain["folder_id"]).source_key == "upload"
|
||||
assert len(_audits(db, "file_upload")) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("which", "reason"),
|
||||
[
|
||||
("site", "file_folder_not_its_source"), # YouTube's folder: no upload came from there
|
||||
("foreign", "file_folder_gone"),
|
||||
("garbage", "file_folder_gone"),
|
||||
],
|
||||
)
|
||||
def test_an_upload_to_a_folder_it_may_not_use_is_refused_before_its_body_is_read(
|
||||
db, make_user, as_user, _root, _no_probe, upload_headers, monkeypatch, which, reason
|
||||
):
|
||||
from app.routes import files as files_routes
|
||||
|
||||
alice, bob = make_user(), make_user()
|
||||
folder = {
|
||||
"site": lambda: _file(db, alice.id).folder_id,
|
||||
"foreign": lambda: _own(db, bob.id).id,
|
||||
"garbage": lambda: "12abc",
|
||||
}[which]()
|
||||
streamed = []
|
||||
|
||||
async def spy(*args, **kwargs):
|
||||
streamed.append(True)
|
||||
raise AssertionError("the body was read")
|
||||
|
||||
monkeypatch.setattr(files_routes, "capped_stream_to", spy)
|
||||
before = db.execute(select(DownloadJob.id)).all()
|
||||
r = as_user(alice).post(
|
||||
"/api/files/upload", content=b"data", headers=upload_headers("a.txt", folder=folder)
|
||||
)
|
||||
assert (r.status_code, r.json()["detail"]["reason"]) == (409, reason)
|
||||
assert streamed == []
|
||||
assert db.execute(select(DownloadJob.id)).all() == before
|
||||
|
||||
|
||||
def test_a_folder_deleted_while_its_upload_streams_refuses_the_upload(
|
||||
db, make_user, as_user, _root, _no_probe, upload_headers, monkeypatch
|
||||
):
|
||||
from app.routes import files as files_routes
|
||||
|
||||
alice = make_user()
|
||||
own = _own(db, alice.id)
|
||||
real = files_routes.capped_stream_to
|
||||
|
||||
async def deleting_meanwhile(*args, **kwargs):
|
||||
size = await real(*args, **kwargs)
|
||||
db.delete(db.get(FileFolder, own.id))
|
||||
db.commit()
|
||||
return size
|
||||
|
||||
monkeypatch.setattr(files_routes, "capped_stream_to", deleting_meanwhile)
|
||||
r = as_user(alice).post(
|
||||
"/api/files/upload", content=b"data", headers=upload_headers("a.txt", folder=own.id)
|
||||
)
|
||||
assert (r.status_code, r.json()["detail"]["reason"]) == (409, "file_folder_gone")
|
||||
assert db.execute(select(DownloadJob.id).where(DownloadJob.user_id == alice.id)).all() == []
|
||||
assert not (_root / ".uploads").exists() # checked before the file was placed
|
||||
|
||||
|
||||
def test_the_file_carries_its_hash_once_known_and_the_hash_is_taken_on_first_ask(
|
||||
db, make_user, as_user, _root
|
||||
):
|
||||
alice, friend = make_user(), make_user()
|
||||
body = b"\x00\x01video bytes"
|
||||
job = _file(db, alice.id, rel="v.mp4")
|
||||
(_root / "v.mp4").write_bytes(body)
|
||||
c = as_user(alice)
|
||||
first = c.get(f"/api/files/{job.id}/file")
|
||||
assert first.status_code == 200 and first.content == body
|
||||
assert "repr-digest" not in first.headers # not hashed yet: nothing claimed
|
||||
want = hashlib.sha256(body).hexdigest()
|
||||
assert c.get(f"/api/files/{job.id}/sha256").json() == {"sha256": want}
|
||||
assert db.get(MediaAsset, job.asset_id).sha256 == want
|
||||
ranged = c.get(f"/api/files/{job.id}/file", headers={"Range": "bytes=0-3"})
|
||||
assert ranged.status_code == 206
|
||||
digest = base64.b64encode(bytes.fromhex(want)).decode()
|
||||
assert ranged.headers["repr-digest"] == f"sha-256=:{digest}:"
|
||||
# A friend it was shared with may read it too; a stranger is told it does not exist.
|
||||
db.add(DownloadShare(job_id=job.id, from_user_id=alice.id, to_user_id=friend.id))
|
||||
db.commit()
|
||||
assert as_user(friend).get(f"/api/files/{job.id}/sha256").json() == {"sha256": want}
|
||||
assert as_user(make_user()).get(f"/api/files/{job.id}/sha256").status_code == 404
|
||||
|
||||
|
||||
def test_a_new_file_at_an_assets_path_forgets_the_old_hash(db, make_user):
|
||||
job = _file(db, make_user().id)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
asset.sha256 = "ab" * 32
|
||||
asset.rel_path = "converted.mp4"
|
||||
assert asset.sha256 is None
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Migration 0103's data step, DB-lane: the fileless "expired" rows of the old retention go, every
|
||||
other job stays.
|
||||
|
||||
An expired file now leaves the library (`downloads.gc`); before, it stayed as a done job with no
|
||||
asset. Left in place, those rows would sit unseen in no listing forever.
|
||||
"""
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
|
||||
|
||||
def test_the_fileless_expired_rows_go_and_every_other_job_stays(db, make_user):
|
||||
uid = make_user().id
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0102_file_folders")
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
folder = conn.execute(
|
||||
text(
|
||||
"INSERT INTO file_folders (user_id, kind, source_key, retention_days)"
|
||||
" VALUES (:u, 'source', 'youtube', 30) RETURNING id"
|
||||
),
|
||||
{"u": uid},
|
||||
).scalar_one()
|
||||
asset = conn.execute(
|
||||
text(
|
||||
"INSERT INTO media_assets (source_kind, source_ref, format_sig, status, ref_count)"
|
||||
" VALUES ('youtube', 'abcdefghijk', 'sig', 'ready', 1) RETURNING id"
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
def job(status, asset_id):
|
||||
return conn.execute(
|
||||
text(
|
||||
"INSERT INTO download_jobs (user_id, asset_id, source_kind, source_ref,"
|
||||
" profile_snapshot, status, folder_id) VALUES (:u, :a, 'youtube',"
|
||||
" 'abcdefghijk', '{}', :s, :f) RETURNING id"
|
||||
),
|
||||
{"u": uid, "a": asset_id, "s": status, "f": folder},
|
||||
).scalar_one()
|
||||
|
||||
expired = job("done", None)
|
||||
alive = job("done", asset)
|
||||
failed = job("error", None) # a failed job has no file either, and is not expired
|
||||
queued = job("queued", None)
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
with engine.connect() as conn:
|
||||
left = set(conn.execute(text("SELECT id FROM download_jobs")).scalars())
|
||||
has_hash = conn.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM information_schema.columns"
|
||||
" WHERE table_name = 'media_assets' AND column_name = 'sha256'"
|
||||
)
|
||||
).scalar()
|
||||
assert left == {alive, failed, queued}
|
||||
assert expired not in left
|
||||
assert has_hash == 1
|
||||
@@ -46,7 +46,9 @@ SERIALIZERS = {
|
||||
"_collection_card",
|
||||
"_playlist_card",
|
||||
"_serialize",
|
||||
"_serialize_job",
|
||||
"_serialize_folder",
|
||||
"serialize",
|
||||
"serialize_job",
|
||||
"_serialize_profile",
|
||||
"_serialize_user",
|
||||
"_summary",
|
||||
|
||||
@@ -584,7 +584,7 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
||||
# one value that needs no bound, and a clear is still a write.
|
||||
# service.enqueue / enqueue_edit / `display_name` is already bounded by its caller; the fallback
|
||||
# enqueue_clip slices the source's title to the same width.
|
||||
# downloads.upload_download passes through `spool_upload`'s value, bounded there.
|
||||
# files.ingest_spooled passes through `spool_upload`'s value, bounded there.
|
||||
# routes enqueue_clip the `display_name=` keyword passes on the name bound above it.
|
||||
# pipeline._download / _process_clip / worker._finish_from_cache
|
||||
# slice a fetched or stored title to the column's width.
|
||||
@@ -614,8 +614,9 @@ DISPLAY_WRITE_SITES = {
|
||||
"display_text(body.display_name)",
|
||||
"display_text(body.display_uploader)",
|
||||
},
|
||||
("routes/downloads.py", "upload_download"): {"upload.display_name"},
|
||||
("routes/downloads.py", "spool_upload"): {
|
||||
("routes/files.py", "ingest_spooled"): {"upload.display_name"},
|
||||
("routes/files.py", "update_file"): {"display_text(body.name)"},
|
||||
("routes/files.py", "spool_upload"): {
|
||||
"display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER))"
|
||||
},
|
||||
}
|
||||
@@ -694,12 +695,12 @@ def test_the_upload_holds_no_db_transaction_while_the_body_streams(
|
||||
the checkout is its consequence (measured), and the pool counter is shared with whatever else
|
||||
the test process is holding.
|
||||
"""
|
||||
from app.routes import downloads as downloads_routes
|
||||
from app.routes import files as files_routes
|
||||
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
seen: dict[str, bool] = {}
|
||||
real_stream = downloads_routes.capped_stream_to
|
||||
real_release = downloads_routes.release_read_connection
|
||||
real_stream = files_routes.capped_stream_to
|
||||
real_release = files_routes.release_read_connection
|
||||
|
||||
def spy_release(session):
|
||||
# The PRECONDITION, so this test cannot pass vacuously. Asserting only "no transaction
|
||||
@@ -714,8 +715,8 @@ def test_the_upload_holds_no_db_transaction_while_the_body_streams(
|
||||
seen["while_streaming"] = bool(db.in_transaction())
|
||||
return await real_stream(request, out, limit, refusal)
|
||||
|
||||
monkeypatch.setattr(downloads_routes, "release_read_connection", spy_release)
|
||||
monkeypatch.setattr(downloads_routes, "capped_stream_to", spy_stream)
|
||||
monkeypatch.setattr(files_routes, "release_read_connection", spy_release)
|
||||
monkeypatch.setattr(files_routes, "capped_stream_to", spy_stream)
|
||||
c = as_user(make_user())
|
||||
r = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("held.bin"))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
Reference in New Issue
Block a user