"""Local file upload ingest (the non-yt-dlp path into the media library). Covers the ingest spine: media vs generic-file classification, the download-only serving of a generic file (force-download so uploaded HTML/SVG can't execute inline), quota accounting with the size known up front, the per-file cap enforced while streaming, filename sanitization, and the two guards that keep an upload out of paths that assume a re-fetchable source (edit / redownload). DB-backed (real Postgres) — skipped when no database is reachable. `settings.files_root` is pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests monkeypatch the shared `probe.probe_json` so they don't shell out to ffprobe; the classifier's OWN parsing (image → file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier` below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`. The phone-safe conversion of an upload runs the real ffmpeg (`TestPhoneSafeUpload`). """ import ast import asyncio import io import pathlib import shutil import subprocess from pathlib import Path import pytest from fastapi import HTTPException from app.config import settings from app.routes._common import capped_stream_to from sqlalchemy import select from app.downloads import gc as gcmod from app.downloads import mediakind, pipeline, playable, quota, service, storage from app.downloads import probe as probemod from app.downloads import upload as uploadmod from app.downloads.stated import StatedFailure from app.models import DISPLAY_TEXT_MAX, DownloadJob, DownloadQuota, MediaAsset # --- pure: the kind invariant --------------------------------------------------------------- def test_derive_media_kind(): assert uploadmod.mediakind.derive("h264", "aac") == mediakind.VIDEO assert uploadmod.mediakind.derive("vp9", None) == mediakind.VIDEO assert uploadmod.mediakind.derive(None, "aac") == mediakind.AUDIO assert uploadmod.mediakind.derive(None, None) == mediakind.FILE # --- ingest (direct) ------------------------------------------------------------------------ @pytest.fixture def _root(tmp_path, monkeypatch): monkeypatch.setattr(settings, "files_root", str(tmp_path)) return tmp_path def _probe(monkeypatch, result): """Stub the shared ffprobe call (`probe.probe_json`, the same ground truth the download worker reads) with the JSON a file of these (vcodec, acodec, width, height, duration) would give — its video 8-bit 4:2:0, so an H.264 + AAC .mp4 is already phone-safe and ingests straight to `done`. All-None is a file ffprobe could not read.""" v, a, w, h, d = result streams = [] if v: streams.append( {"codec_type": "video", "codec_name": v, "pix_fmt": "yuv420p", "width": w, "height": h} ) if a: streams.append({"codec_type": "audio", "codec_name": a}) data = {"streams": streams, "format": {"format_name": "mov,mp4", "duration": d}} monkeypatch.setattr(probemod, "probe_json", lambda _p: data if streams else None) def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch): _probe(monkeypatch, (None, None, None, None, None)) u = make_user() src = _root / "src.bin" src.write_bytes(b"%PDF-1.4 hello") job = uploadmod.ingest_upload( db, u.id, filename="My Notes.pdf", src_path=src, size=src.stat().st_size ) asset = db.get(MediaAsset, job.asset_id) assert asset.media_kind == mediakind.FILE assert asset.status == "ready" and job.status == "done" and job.progress == 100 assert asset.container == "pdf" assert asset.poster_path is None # a generic file has no player and no poster assert asset.title == "My Notes" # display title from the original name assert job.job_kind == "upload" and job.source_kind == "upload" # The file was MOVED into the per-user uploads tree, inside the root, and the temp is gone. assert not src.exists() assert asset.rel_path.startswith(".uploads/") assert storage.safe_abs_path(str(_root), asset.rel_path) is not None def test_ingest_audio_is_media_no_poster(db, make_user, _root, monkeypatch): _probe(monkeypatch, (None, "aac", None, None, 200)) u = make_user() src = _root / "song.m4a" src.write_bytes(b"fake-audio") job = uploadmod.ingest_upload(db, u.id, filename="song.m4a", src_path=src, size=10) asset = db.get(MediaAsset, job.asset_id) assert asset.media_kind == mediakind.AUDIO assert asset.duration_s == 200 assert asset.poster_path is None # audio has no frame to poster def test_ingest_video_records_dims_and_poster(db, make_user, _root, monkeypatch): _probe(monkeypatch, ("h264", "aac", 640, 480, 60)) # Stub the frame-cut so the test doesn't shell out to ffmpeg. monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda _a, _root: "p/x.jpg") u = make_user() src = _root / "clip.mp4" src.write_bytes(b"fake-video") job = uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=10) asset = db.get(MediaAsset, job.asset_id) assert asset.media_kind == mediakind.VIDEO assert (asset.width, asset.height) == (640, 480) assert asset.poster_path == "p/x.jpg" def test_ingest_filename_is_sanitized_no_traversal(db, make_user, _root, monkeypatch): _probe(monkeypatch, (None, None, None, None, None)) u = make_user() src = _root / "src" src.write_bytes(b"x") job = uploadmod.ingest_upload(db, u.id, filename="../../etc/passwd", src_path=src, size=1) asset = db.get(MediaAsset, job.asset_id) assert ".." not in asset.rel_path assert asset.rel_path.startswith(f".uploads/{u.id}/") assert storage.safe_abs_path(str(_root), asset.rel_path) is not None def test_ingest_rejects_over_footprint_cap_before_writing(db, make_user, _root, monkeypatch): _probe(monkeypatch, (None, None, None, None, None)) u = make_user() db.add( DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=50, unlimited=False) ) db.commit() src = _root / "big.bin" src.write_bytes(b"x" * 10) with pytest.raises(quota.QuotaExceeded) as e: uploadmod.ingest_upload(db, u.id, filename="big.bin", src_path=src, size=2000) assert e.value.reason == "max_bytes" assert src.exists() # rejected before any disk move — nothing landed assert db.query(MediaAsset).count() == 0 # and no orphan asset row # --- route: the auth-first body ------------------------------------------------------------ def _counting_body(chunks: int = 4, size: int = 1024): """A request body that records how much of itself the server actually pulled. httpx's ASGI transport feeds the app lazily, one `http.request` message per yielded chunk, so a chunk that was never yielded is a chunk the app never asked for. That is the whole assertion below: it is not "the upload was refused", it is "the refusal happened before the bytes moved". """ pulled: list[int] = [] def body(): for _ in range(chunks): pulled.append(size) yield b"x" * size return body(), pulled _BOUNDARY = "p1boundary" def _counting_multipart(chunks: int = 4, size: int = 1024): """The same counter, wrapped in a real multipart envelope. The content-type is the load-bearing part. Starlette's `request.form()` returns an EMPTY FormData without touching the stream when the content-type is not multipart/urlencoded — so a raw-body probe cannot tell "the route stopped reading strangers" apart from "there was nothing here to parse". Only a well-formed multipart body makes the old shape actually spool. """ pulled: list[int] = [] head = ( f"--{_BOUNDARY}\r\n" 'Content-Disposition: form-data; name="file"; filename="clip.mp4"\r\n' "Content-Type: application/octet-stream\r\n\r\n" ).encode() tail = f"\r\n--{_BOUNDARY}--\r\n".encode() def body(): pulled.append(len(head)) yield head for _ in range(chunks): pulled.append(size) yield b"x" * size pulled.append(len(tail)) yield tail return body(), pulled def test_an_anonymous_upload_is_refused_before_a_byte_of_it_is_read(client): """The defect an earlier fix bounded but did not close. While the route declared `file: UploadFile = File(...)`, FastAPI parsed and spooled the body BEFORE the first dependency ran, so the auth dependency could not gate it: an unauthenticated caller could make the server write the whole per-file allowance (500 MiB by default) to temp, per request. SEC-20's ceiling capped the number; it could not stop the writing. The route now reads its own body from a dependency declared AFTER the auth one, the shape `POST /api/debug-sessions/{sid}/screenshots` already uses. The claim under test is the strong one — a stranger's bytes are never read WHATEVER they claim to send, so the probe sends the multipart the old route would have spooled, not the raw body the new one expects.""" body, pulled = _counting_multipart() r = client.post( "/api/files/upload", content=body, headers={"content-type": f"multipart/form-data; boundary={_BOUNDARY}"}, ) assert r.status_code == 401 assert pulled == [], f"the server read {sum(pulled)} bytes from an anonymous caller" def test_an_authenticated_upload_still_has_its_body_read(as_user, make_user, _root, monkeypatch): """The mutation check for the test above: a route that read NOBODY's body would pass it just as well as the fixed one. The same counting body, signed in, must be pulled in full.""" _probe(monkeypatch, (None, None, None, None, None)) body, pulled = _counting_body() c = as_user(make_user()) r = c.post( "/api/files/upload", content=body, headers={"content-type": "application/octet-stream", "x-upload-filename": "clip.bin"}, ) assert r.status_code == 200, r.text assert sum(pulled) == 4096 def test_capped_stream_to_drains_the_rest_before_refusing(): """Measured in a real browser, then pinned HERE rather than at the route. The cap now fires while the client is still sending, and answering + closing mid-upload is what a browser reports as a NETWORK failure: the dialog said "the server couldn't be reached" instead of naming the limit, because XHR never got to read the 422. `capped_stream_to` reads the remainder to nowhere before raising, so the client finishes sending and reads the real answer. It is asserted against the helper and not through TestClient on purpose — httpx's ASGI transport pumps the request body whether or not the app reads it, so a route-level version of this test passes just as happily with the drain deleted. It was written that way first and caught by its own mutation check. """ pulled = [] async def stream(): for i in range(8): pulled.append(i) yield b"x" * 1024 class _Req: def stream(self): return stream() out = io.BytesIO() refusal = HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": 2048}) # `asyncio.run` rather than an async test: the suite has no async plugin, and adding one to # pin four lines of helper would mean a new dev dependency and a lock regeneration. with pytest.raises(HTTPException) as exc: asyncio.run(capped_stream_to(_Req(), out, 2048, refusal)) assert exc.value is refusal assert len(pulled) == 8, "the body was abandoned mid-stream, not drained" # Drained means DISCARDED: nothing past the limit is written. assert len(out.getvalue()) <= 2048 # --- route ---------------------------------------------------------------------------------- def test_upload_route_generic_forces_download( as_user, make_user, _root, monkeypatch, upload_headers ): _probe(monkeypatch, (None, None, None, None, None)) c = as_user(make_user()) r = c.post( "/api/files/upload", content=b"", headers=upload_headers("report.html"), ) assert r.status_code == 200, r.text job = r.json() assert job["asset"]["media_kind"] == "file" assert job["can_download"] is True # The served file must be a neutral force-download attachment — never inline text/html. fr = c.get(f"/api/files/{job['id']}/file") assert fr.status_code == 200 assert fr.headers["content-type"].startswith("application/octet-stream") assert "attachment" in fr.headers["content-disposition"].lower() def test_upload_route_media_html_extension_not_served_as_html( db, as_user, make_user, _root, monkeypatch, upload_headers ): """The XSS guard: a MEDIA-classified upload (real codecs) named with an executable extension must NOT be served with a guessed text/html type. The served Content-Type comes from the verified media_kind + a container allow-list ('html' isn't one), so it force-downloads as octet-stream — on both the authenticated file endpoint AND the login-free public watch endpoint. Without the fix the public inline branch let a media/HTML polyglot named evil.html run as stored XSS.""" _probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # ffprobe says it's video… c = as_user(make_user()) r = c.post( "/api/files/upload", content=b"", headers=upload_headers("evil.html"), ) assert r.status_code == 200, r.text job = r.json() assert job["asset"]["media_kind"] == "video" and job["asset"]["container"] == "html" # Media in no phone-safe container is queued for conversion to .mp4, so today it is never served # as .html at all. The serving guard below is the second line: a row from before that rule (or # one whose conversion is skipped some day) must still not run as HTML. assert job["status"] == "queued" row = db.get(DownloadJob, job["id"]) db.get(MediaAsset, row.asset_id).status = "ready" row.status, row.ready_at = "done", row.created_at db.commit() # Authenticated endpoint: never text/html. fr = c.get(f"/api/files/{job['id']}/file") assert fr.status_code == 200 assert fr.headers["content-type"].startswith("application/octet-stream") assert "attachment" in fr.headers["content-disposition"].lower() # Public login-free endpoint via a STREAM-ONLY link (the inline branch) — still not text/html. link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json() pr = c.get(f"/api/public/watch/{link['token']}/file") assert pr.status_code == 200 assert not pr.headers["content-type"].startswith("text/html") assert pr.headers["content-type"].startswith("application/octet-stream") assert "attachment" in pr.headers["content-disposition"].lower() def test_public_watch_file_forces_download_for_generic_file( as_user, make_user, _root, monkeypatch, upload_headers ): """The login-free public serve of a generic upload (media_kind='file') must be a neutral force-download attachment — the branch that stops uploaded HTML/SVG executing on the public origin. This is a SEPARATE code path from the authenticated /file route, so it needs its own route-level assertion (previously only the authenticated branch was tested).""" _probe(monkeypatch, (None, None, None, None, None)) c = as_user(make_user()) job = c.post( "/api/files/upload", content=b"", headers=upload_headers("notes.html"), ).json() assert job["asset"]["media_kind"] == "file" link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": True}).json() pr = c.get(f"/api/public/watch/{link['token']}/file") assert pr.status_code == 200 assert pr.headers["content-type"].startswith("application/octet-stream") assert "attachment" in pr.headers["content-disposition"].lower() def test_public_watch_file_serves_media_inline_with_real_type( as_user, make_user, _root, monkeypatch, upload_headers ): """A recognised media container IS still served inline with its real audio/video type (so it plays in the watch page) — the fix must not break legitimate playback. A stream-only link → inline; the type is video/mp4 (from the container allow-list), never text/* and never a guess.""" _probe(monkeypatch, ("h264", "aac", 640, 360, 12)) c = as_user(make_user()) job = c.post( "/api/files/upload", content=b"fake-mp4-bytes", headers=upload_headers("clip.mp4"), ).json() link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json() pr = c.get(f"/api/public/watch/{link['token']}/file") assert pr.status_code == 200 assert pr.headers["content-type"].startswith("video/mp4") assert "inline" in pr.headers["content-disposition"].lower() def test_upload_route_enforces_per_file_cap( as_user, make_user, db, _root, monkeypatch, upload_headers ): from app import sysconfig sysconfig.set_value(db, "download_upload_max_bytes", 5) db.commit() c = as_user(make_user()) r = c.post( "/api/files/upload", content=b"way over the cap", headers=upload_headers("big.bin"), ) assert r.status_code == 422 assert r.json()["detail"]["code"] == "upload_too_large" def test_the_cap_admits_a_body_of_EXACTLY_the_cap_and_refuses_one_byte_more( as_user, make_user, db, _root, monkeypatch, upload_headers ): """The boundary, which the test above cannot see: it sends 16 bytes against a cap of 5, so a `>` silently loosened to `>=` refuses it just the same and the mutation survives the whole suite. The cap is `size > limit`, so a file of exactly the cap must be ACCEPTED. """ from app import sysconfig _probe(monkeypatch, (None, None, None, None, None)) payload = b"x" * 64 sysconfig.set_value(db, "download_upload_max_bytes", len(payload)) db.commit() c = as_user(make_user()) at_the_cap = c.post("/api/files/upload", content=payload, headers=upload_headers("exact.bin")) assert at_the_cap.status_code == 200, at_the_cap.text over = c.post("/api/files/upload", content=payload + b"x", headers=upload_headers("over.bin")) assert over.status_code == 422 assert over.json()["detail"] == {"code": "upload_too_large", "limit": len(payload)} def test_a_non_ascii_filename_survives_the_header_round_trip( as_user, make_user, db, _root, monkeypatch, upload_headers ): """The percent-decoding, which every other test in this file leaves vacuous. Headers are latin-1 on the wire, so the client `encodeURIComponent`s the name and the route `unquote`s it. Every filename the suite sends is pure ASCII — and `quote()` is the IDENTITY function on ASCII — so deleting the `unquote` call changes nothing anywhere else. A Hungarian name is the only input that can tell the two apart: without the decode the sanitizer would be handed the literal `nyaral%C3%A1s.bin` and would keep the percent-escapes in the stored name. """ _probe(monkeypatch, (None, None, None, None, None)) c = as_user(make_user()) r = c.post("/api/files/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin")) assert r.status_code == 200, r.text asset = db.execute(select(MediaAsset).order_by(MediaAsset.id.desc())).scalars().first() assert asset is not None stored = Path(asset.rel_path).name assert "%" not in stored and "c3" not in stored.lower(), stored assert stored.startswith("nyaralas_osszel"), stored def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one( db, as_user, make_user, _root, monkeypatch, upload_headers ): """The sweep, held end to end rather than at one site (the clip route joined it later). `display_text` was introduced for the upload path and applied to three of the five writers of this varchar(255) column; `POST /api/downloads` and `POST /api/downloads/edit` kept the old `(x or "").strip() or None` and would still have answered a bare 500 (a psycopg DataError, which main.py re-raises for everything but the NUL case) to a name a user simply pasted. Found by four independent readers of one review — a sweep is exactly the kind of thing that gets done four-fifths of the way, so this holds all of it at once. """ _probe(monkeypatch, ("h264", "aac", 640, 480, 60)) monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda _a, _root: None) long_name = "n" * 300 bound = "n" * DISPLAY_TEXT_MAX c = as_user(make_user()) # 1. the upload route — the name arrives in a request header. up = c.post("/api/files/upload", content=b"video", headers=upload_headers("a.mp4", long_name)) assert up.status_code == 200, up.text assert up.json()["display_name"] == bound # 2. the enqueue route — a plain JSON field, from the Add-download dialog. An inline `spec` # rather than a profile id, so the test does not have to seed the builtin profile table. enq = c.post( "/api/downloads", json={"source": "dQw4w9WgXcQ", "spec": {"kind": "video"}, "display_name": long_name}, ) assert enq.status_code == 200, enq.text assert enq.json()["display_name"] == bound # 3. the edit route — the same field, from the editor. edit = c.post( "/api/downloads/edit", json={ "source_job_id": up.json()["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}, "display_name": long_name, }, ) assert edit.status_code == 200, edit.text assert edit.json()["display_name"] == bound # 4 + 5. the library PATCH's two fields. patched = c.patch( f"/api/files/{up.json()['id']}", json={"name": long_name, "uploader": long_name}, ) assert patched.status_code == 200, patched.text assert patched.json()["display_name"] == bound assert patched.json()["display_uploader"] == bound # 6. the clip route — the clip panel's name field. clip = c.post( "/api/downloads/clip", json={ "source": "youtube", "ref": "dQw4w9WgXcQ", "start_s": 0, "end_s": 5, "display_name": long_name, }, ) assert clip.status_code == 200, clip.text assert clip.json()["display_name"] == bound def _display_write_sites() -> dict[tuple[str, str], set[str]]: """Every place under `backend/app/` that puts a value into a display field, as (module relative to `app/`, enclosing function) -> the set of value expressions written there. THREE SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore blind to the shape the upload route itself uses — `SpooledUpload(..., display_name=...)` is an `ast.keyword`, not an assignment, so the one site with an unbounded REQUEST HEADER behind it was the site the guard could not see. The third, `fields["display_name"] = …`, is a dict a finalize call is spread from (the worker's), which the first two missed as well. A value passed POSITIONALLY is still unseen: a writer binds it to a name or a keyword first, as every route here does. ANNOTATED assignments are excluded on purpose: `display_name: Mapped[str | None] = mapped_column(...)` in `models.py` and `display_name: str | None = None` in a Pydantic model are declarations of the field, not writes into it. The whole of `app/`, not just `app/routes/`: a job row is constructed in `downloads/service.py` and `downloads/upload.py`, so a walk stopping at the HTTP boundary misses the writers that actually reach the column. """ app_dir = pathlib.Path(__file__).resolve().parents[1] / "app" found: dict[tuple[str, str], set[str]] = {} for py in sorted(app_dir.rglob("*.py")): module = py.relative_to(app_dir).as_posix() tree = ast.parse(py.read_text("utf-8"), filename=str(py)) enclosing: dict[int, str] = {} for fn in ast.walk(tree): if isinstance(fn, (ast.AsyncFunctionDef, ast.FunctionDef)): for node in ast.walk(fn): enclosing.setdefault(id(node), fn.name) for node in ast.walk(tree): if isinstance(node, ast.Assign): names = { t.id if isinstance(t, ast.Name) else t.attr for t in node.targets if isinstance(t, (ast.Name, ast.Attribute)) } | { # `fields["display_name"] = …` — a dict the write is then spread from. t.slice.value for t in node.targets if isinstance(t, ast.Subscript) and isinstance(t.slice, ast.Constant) and isinstance(t.slice.value, str) } value = node.value elif isinstance(node, ast.keyword): names = {node.arg} if node.arg else set() value = node.value else: continue if not names & {"display_name", "display_uploader"}: continue key = (module, enclosing.get(id(node), "")) found.setdefault(key, set()).add(ast.unparse(value)) return found # Every write into a display field, with the expression each one writes. A varchar(255) column with # a request header behind it turns a too-long name into a psycopg DataError — a 500 AFTER the whole # file has transferred — so each of these has to be bounded, and each one here has been read. # # Bounded by `display_text` unless noted: # service.redownload CLEARS the field so the fresh metadata wins — `None` is the # 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. # 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. # # Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail # upward: refactoring the writers into a shape the walker did not recognise left it asserting # `not []` and enforcing nothing, which is how a drift guard dies quietly. This one fails # when a site appears, when one disappears, and when one changes what it writes. DISPLAY_WRITE_SITES = { ("downloads/service.py", "redownload"): {"None"}, ("downloads/service.py", "enqueue"): { "display_name or (asset.title[:DISPLAY_TEXT_MAX] " "if asset.status == 'ready' and asset.title else None)" }, ("downloads/service.py", "enqueue_edit"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"}, ("downloads/service.py", "enqueue_clip"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"}, ("downloads/pipeline.py", "_download"): {"meta.title[:DISPLAY_TEXT_MAX]"}, ("downloads/pipeline.py", "_process_clip"): {"named[:DISPLAY_TEXT_MAX]"}, ("worker.py", "_finish_from_cache"): {"asset.title[:DISPLAY_TEXT_MAX]"}, ("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"}, ("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"}, ("routes/auth.py", "_complete_link"): {"display_text(userinfo.get('name'))"}, ("routes/downloads.py", "enqueue_download"): {"display_text(body.display_name)"}, ("routes/downloads.py", "enqueue_edit"): {"display_text(body.display_name)"}, ("routes/downloads.py", "enqueue_clip"): {"display_text(body.display_name)", "display_name"}, ("routes/files.py", "ingest_spooled"): {"upload.display_name"}, ("routes/files.py", "update_file"): { "display_text(body.name)", "display_text(body.uploader)", }, ("routes/files.py", "spool_upload"): { "display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER))" }, } def test_no_code_path_assigns_a_display_field_without_the_shared_helper(): """The drift guard for the test above: it holds five routes, this holds the rule. Parsed, not grepped. Every value that reaches a display field takes a string a user typed (or YouTube supplied) and puts it on its way to a varchar(255) column, so every one of them must be bounded. A writer added tomorrow fails here instead of 500ing in production. """ found = _display_write_sites() moved = set(found) ^ set(DISPLAY_WRITE_SITES) changed = { key: (found[key], DISPLAY_WRITE_SITES[key]) for key in set(found) & set(DISPLAY_WRITE_SITES) if found[key] != DISPLAY_WRITE_SITES[key] } assert found == DISPLAY_WRITE_SITES, ( "a display-field write appeared, moved or changed shape. Every value reaching " "display_name/display_uploader must be bounded to models.DISPLAY_TEXT_MAX — normally by " "models.display_text — or a name longer than the column is a 500 rather than a truncation. " f"Sites that appeared or vanished: {moved}. Sites that changed: {changed}." ) def test_the_display_text_bound_matches_the_columns_it_guards(): """A number living in two places is how the `cancelled`/`canceled` bug happened. `display_text` truncates to `DISPLAY_TEXT_MAX`; the columns it feeds are `String(255)`. Held against the model itself, so widening one without the other fails here rather than as a DataError in production.""" for name in ("display_name", "display_uploader"): assert DownloadJob.__table__.c[name].type.length == DISPLAY_TEXT_MAX def test_the_display_name_header_reaches_the_job_and_is_bounded( as_user, make_user, _root, monkeypatch, upload_headers ): """The second header, which had no backend test at all — and the length nothing bounded. The display name arrives in a REQUEST HEADER rather than as a form field, so the only limit on it was the header size. The column is varchar(255), so a longer one reached Postgres as a DataError: a 500 AFTER the whole file had been transferred. It is truncated now, by the same helper the metadata PATCH uses. """ _probe(monkeypatch, (None, None, None, None, None)) c = as_user(make_user()) ok = c.post( "/api/files/upload", content=b"data", headers=upload_headers("clip.bin", "Nyári vakáció"), ) assert ok.status_code == 200, ok.text assert ok.json()["display_name"] == "Nyári vakáció" long_name = "n" * 300 bounded = c.post( "/api/files/upload", content=b"data2", headers=upload_headers("clip2.bin", long_name) ) assert bounded.status_code == 200, bounded.text assert bounded.json()["display_name"] == "n" * DISPLAY_TEXT_MAX def test_the_upload_holds_no_db_transaction_while_the_body_streams( as_user, make_user, db, _root, monkeypatch, upload_headers ): """Moving the body read behind the auth dependency put the whole transfer INSIDE the request's database session: resolving the caller and reading the cap are SELECTs, SQLAlchemy 2.0 autobegins on the first one, and the session then holds one of the twenty pooled connections — `idle in transaction` to Postgres, with no `idle_in_transaction_session_timeout` to end it — for as long as the client keeps sending. `db.py` sizes the pool on that not happening and `plex/playback.py` records the production incident behind the rule. Asserted on the session rather than on `engine.pool.checkedout()`: the transaction is the cause, the checkout is its consequence (measured), and the pool counter is shared with whatever else the test process is holding. """ from app.routes import files as files_routes _probe(monkeypatch, (None, None, None, None, None)) seen: dict[str, bool] = {} 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 # while streaming" would stay green if the auth/cap reads stopped opening one at all — # and under `as_user` the auth SELECT is already overridden away, so that is a live risk # rather than a theoretical one. seen["before"] = bool(session.in_transaction()) real_release(session) seen["after_release"] = bool(session.in_transaction()) async def spy_stream(request, out, limit, refusal): seen["while_streaming"] = bool(db.in_transaction()) return await real_stream(request, out, limit, refusal) 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/files/upload", content=b"data", headers=upload_headers("held.bin")) assert r.status_code == 200, r.text assert seen["before"] is True, ( "nothing had opened a transaction before the release, so this test proves nothing — the " "reads it is meant to follow must have moved or been stubbed away" ) assert seen["after_release"] is False assert seen["while_streaming"] is False, ( "the request's session was inside a transaction when the transfer began, so a pooled " "connection is pinned for the whole upload" ) def test_the_usage_route_reports_the_per_file_upload_cap(as_user, make_user, db): """The dialog refuses an over-cap file off this number, and it is the only layer that produces a refusal carrying the limit on a default install — so the field is load-bearing, not decorative. Nothing held it: the route had no test at all, and the frontend's `DownloadUsage` is an unvalidated cast, so dropping the key would have been green everywhere. """ from app import sysconfig c = as_user(make_user()) r = c.get("/api/files/usage") assert r.status_code == 200, r.text assert r.json()["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes") # And it is the KNOB, not a constant: a lowered override must show through. sysconfig.set_value(db, "download_upload_max_bytes", 4096) db.commit() assert c.get("/api/files/usage").json()["upload_max_bytes"] == 4096 def test_a_refused_upload_leaves_nothing_in_the_staging_directory( as_user, make_user, db, _root, monkeypatch, upload_headers ): """`spool_upload` writes the body to a staging temp file as it arrives, so a refusal happens with a partial file already on disk — its `finally` is what removes it. Nothing asserted that: deleting the unlink left the whole suite green while every refused upload leaked its partial bytes onto the volume, one file per attempt, with no job to ever clean them up. """ from app import sysconfig sysconfig.set_value(db, "download_upload_max_bytes", 8) db.commit() staging = Path(settings.files_root) / ".staging" / "uploads" c = as_user(make_user()) r = c.post("/api/files/upload", content=b"x" * 4096, headers=upload_headers("leaky.bin")) assert r.status_code == 422 assert staging.exists(), "the dependency did not get as far as creating the staging dir" assert list(staging.iterdir()) == [], "a refused upload left its partial file behind" def test_upload_cap_cannot_be_disabled_with_zero(db): """download_upload_max_bytes floors at 1, and set_value must enforce it. The falsy-0 footgun this originally guarded is gone — the route's old `if cap and size > cap` read a stored 0 as "no cap at all", while `capped_stream_to` compares unconditionally and a 0 would refuse everything instead. Refusing everything is the safe direction, but it is still wrong, and the knob is also the number the admin page and the 422 `limit` advertise. Pins the min=1 invariant so a revert to min=0 can't pass the gate.""" from app import sysconfig with pytest.raises(ValueError): sysconfig.set_value(db, "download_upload_max_bytes", 0) def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch, upload_headers): c = as_user(make_user()) r = c.post("/api/files/upload", content=b"", headers=upload_headers("empty.bin")) assert r.status_code == 400 assert r.json()["detail"]["code"] == "upload_empty" def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch, upload_headers): _probe(monkeypatch, (None, None, None, None, None)) c = as_user(make_user()) job = c.post("/api/files/upload", content=b"data", headers=upload_headers("a.bin")).json() r = c.post(f"/api/downloads/{job['id']}/redownload") assert r.status_code == 409 def test_edit_rejects_a_generic_upload_source( as_user, make_user, _root, monkeypatch, upload_headers ): _probe(monkeypatch, (None, None, None, None, None)) c = as_user(make_user()) job = c.post("/api/files/upload", content=b"data", headers=upload_headers("a.bin")).json() r = c.post( "/api/downloads/edit", json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}}, ) assert r.status_code == 400 assert r.json()["detail"]["reason"] == "not_media" def test_ingest_rejects_over_max_jobs(db, make_user, _root, monkeypatch): """The other check_upload rejection branch (holdings count, not bytes): once the user is at max_jobs, a further upload is refused before any disk write.""" _probe(monkeypatch, (None, None, None, None, None)) u = make_user() db.add( DownloadQuota(user_id=u.id, max_bytes=10**12, max_concurrent=2, max_jobs=1, unlimited=False) ) db.commit() src1 = _root / "a.bin" src1.write_bytes(b"a") uploadmod.ingest_upload(db, u.id, filename="a.bin", src_path=src1, size=1) # now 1 holding job src2 = _root / "b.bin" src2.write_bytes(b"b") with pytest.raises(quota.QuotaExceeded) as e: uploadmod.ingest_upload(db, u.id, filename="b.bin", src_path=src2, size=1) assert e.value.reason == "max_jobs" assert src2.exists() # rejected before the move — nothing landed def test_ingest_cleans_up_placed_files_on_commit_failure(db, make_user, _root, monkeypatch): """A failure AFTER storage.place_file has moved the file onto disk must roll back AND unlink the placed file + any (even partial) poster — otherwise nothing names it (the per-user delete keys off the rolled-back asset.rel_path) and it sits on disk until the GC's `.uploads` sweep. Exercises the rollback/cleanup branch, incl. the poster case where ensure_poster left a partial file but returned None.""" _probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # video → the poster path runs u = make_user() # Simulate ffmpeg leaving a partial poster on disk, then failing (ensure_poster returns None). def _partial_poster(asset, root): storage.abs_path(root, str(Path(asset.rel_path).with_suffix(".jpg"))).write_bytes( b"partial" ) return None monkeypatch.setattr(uploadmod.editmod, "ensure_poster", _partial_poster) # Force the commit to fail after the file (and partial poster) are on disk. def _boom(): raise RuntimeError("db down") monkeypatch.setattr(db, "commit", _boom) src = _root / "clip.mp4" src.write_bytes(b"fake-video-bytes") with pytest.raises(RuntimeError): uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=16) db.rollback() # drop the monkeypatched-commit session state before querying uploads = _root / ".uploads" leftover = [p for p in uploads.rglob("*") if p.is_file()] if uploads.exists() else [] assert leftover == [], f"orphaned files left on disk: {leftover}" assert db.query(MediaAsset).count() == 0 # the flushed asset rolled back — no dangling row def test_ingest_keeps_the_file_when_its_commit_landed_but_raised(db, make_user, _root, monkeypatch): # The commit reached the server and lost its answer: the row names the file now, so removing it # as "rolled back" would leave a ready upload pointing at nothing. _probe(monkeypatch, ("h264", "aac", 320, 240, 5)) monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda *_a: None) u = make_user() real_commit = db.commit def _landed_then_lost(): real_commit() raise RuntimeError("connection lost after COMMIT") monkeypatch.setattr(db, "commit", _landed_then_lost) src = _root / "clip.mp4" src.write_bytes(b"fake-video-bytes") with pytest.raises(RuntimeError): uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=16) (asset,) = db.query(MediaAsset).all() assert storage.abs_path(str(_root), asset.rel_path).exists() # --- classifier: the REAL probe_media parsing (stubs ffprobe one level down) ------------------ # The ingest/route tests above stub probe_media wholesale; these exercise its OWN logic — image # detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a # regression in the classifier the force-download guarantee leans on is actually caught. class TestProbeClassifier: def _probe_with(self, monkeypatch, data): monkeypatch.setattr(probemod.editmod, "run_ffprobe_json", lambda *a, **k: data) return probemod.probe_media(Path("x")) def test_probe_failure_is_a_generic_file(self, monkeypatch): # run_ffprobe_json returns None (ffprobe failed / not media) → all None → derive() == FILE. v, a, w, h, d = self._probe_with(monkeypatch, None) assert (v, a, w, h, d) == (None, None, None, None, None) assert mediakind.derive(v, a) == mediakind.FILE @pytest.mark.parametrize("fmt", ["image2", "png_pipe", "jpeg_pipe", "webp_pipe", "gif"]) def test_still_image_classifies_as_file_not_video(self, monkeypatch, fmt): # A standalone image exposes a real video stream, but its container format is an image demuxer # — it must NOT become media_kind='video' (broken player + inline serving). Stays a file. data = { "format": {"format_name": fmt}, "streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}], } v, a, w, h, d = self._probe_with(monkeypatch, data) assert v is None and mediakind.derive(v, a) == mediakind.FILE def test_cover_art_video_stream_is_skipped_audio_stays_audio(self, monkeypatch): # Album art embedded in an audio file is a video stream flagged attached_pic — skip it so the # asset classifies as audio, not video. data = { "format": {"format_name": "mp3", "duration": "212.5"}, "streams": [ { "codec_type": "video", "codec_name": "mjpeg", "disposition": {"attached_pic": 1}, "width": 600, "height": 600, }, {"codec_type": "audio", "codec_name": "mp3"}, ], } v, a, w, h, d = self._probe_with(monkeypatch, data) assert (v, a, d) == (None, "mp3", 212) assert mediakind.derive(v, a) == mediakind.AUDIO def test_real_video_records_codecs_dims_duration(self, monkeypatch): data = { "format": {"format_name": "mov,mp4,m4a", "duration": "60.0"}, "streams": [ {"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080}, {"codec_type": "audio", "codec_name": "aac"}, ], } v, a, w, h, d = self._probe_with(monkeypatch, data) assert (v, a, w, h, d) == ("h264", "aac", 1920, 1080, 60) assert mediakind.derive(v, a) == mediakind.VIDEO def test_unparseable_duration_is_none(self, monkeypatch): data = { "format": {"format_name": "wav", "duration": "N/A"}, "streams": [{"codec_type": "audio", "codec_name": "pcm_s16le"}], } v, a, w, h, d = self._probe_with(monkeypatch, data) assert d is None and mediakind.derive(v, a) == mediakind.AUDIO # --- resolve_media_facts: probe-of-the-file wins over yt-dlp's info (the FB regression fix) --------- # The download worker's finalize must classify from the ACTUAL file, not the extractor's `info` dict. # yt-dlp omits vcodec/acodec/dims for some sources (Facebook), and deriving media_kind from those # nulls turned a real H.264 video into a download-only 'file' (no in-app play). These pin that the # probe is authoritative and `info` is only a gap-filler. class TestResolveMediaFacts: # These stub run_ffprobe_json (one level below probe_media) so the REAL probe parsing + the # probe-vs-info precedence are exercised together — the precedence keys off whether ffprobe RAN. @staticmethod def _ffprobe(monkeypatch, data): monkeypatch.setattr(probemod.editmod, "run_ffprobe_json", lambda *a, **k: data) def test_probe_wins_when_info_lacks_codecs(self, monkeypatch): # THE original regression: info has no vcodec/acodec/dims (Facebook), but the file probes as # h264/aac. self._ffprobe( monkeypatch, { "format": {"format_name": "mov,mp4,m4a"}, "streams": [ {"codec_type": "video", "codec_name": "h264", "width": 720, "height": 1280}, {"codec_type": "audio", "codec_name": "aac"}, ], }, ) v, a, w, h, _d = probemod.resolve_media_facts(Path("x"), {"width": None, "height": None}) assert (v, a, w, h) == ("h264", "aac", 720, 1280) assert mediakind.derive(v, a) == mediakind.VIDEO # NOT 'file' def test_the_duration_comes_from_the_file_when_info_has_none(self, monkeypatch): # Facebook reel 257: yt-dlp gave no duration, the file is 128.4 s. A NULL duration hid # the Library row's trim action. self._ffprobe( monkeypatch, { "format": {"format_name": "mov,mp4,m4a", "duration": "128.400000"}, "streams": [{"codec_type": "video", "codec_name": "h264"}], }, ) assert probemod.resolve_media_facts(Path("x"), {"duration": None})[4] == 128 # The file wins over a differing info duration, like its codecs do. assert probemod.resolve_media_facts(Path("x"), {"duration": 300})[4] == 128 def test_info_fills_the_duration_the_probe_could_not_read(self, monkeypatch): self._ffprobe( monkeypatch, { "format": {"format_name": "mov,mp4,m4a"}, "streams": [{"codec_type": "video", "codec_name": "h264"}], }, ) assert probemod.resolve_media_facts(Path("x"), {"duration": 61.7})[4] == 61 self._ffprobe(monkeypatch, None) assert probemod.resolve_media_facts(Path("x"), {"duration": 61.7})[4] == 61 def test_probe_wins_over_a_DIFFERING_populated_info(self, monkeypatch): # Both the probe AND info are populated but DISAGREE (info carries a pre-remux/pre-merge codec). # The finished file on disk is ground truth, so the probe wins outright — pins the precedence a # `info or probe` inversion would silently break. self._ffprobe( monkeypatch, { "format": {"format_name": "mov,mp4,m4a"}, "streams": [ {"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080}, {"codec_type": "audio", "codec_name": "aac"}, ], }, ) v, a, w, h, _d = probemod.resolve_media_facts( Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 640, "height": 480} ) assert (v, a, w, h) == ("h264", "aac", 1920, 1080) # probe, not info def test_cover_art_audio_download_is_not_resurrected_to_video_by_info(self, monkeypatch): # THE bug this fix targets: an audio-mode download from a progressive source embeds cover art, # and yt-dlp's info still carries the source's video codec. The probe correctly finds only audio # (the attached_pic is skipped); info's vcodec must NOT resurrect a video track → media_kind # stays audio, not a broken in-app 'video'. self._ffprobe( monkeypatch, { "format": {"format_name": "mp3", "duration": "180.0"}, "streams": [ { "codec_type": "video", "codec_name": "mjpeg", "disposition": {"attached_pic": 1}, }, {"codec_type": "audio", "codec_name": "mp3"}, ], }, ) v, a, w, h, _d = probemod.resolve_media_facts( Path("x"), {"vcodec": "avc1", "acodec": "mp4a", "width": 1280, "height": 720} ) assert v is None and a == "mp3" # info's avc1 did NOT win assert mediakind.derive(v, a) == mediakind.AUDIO # NOT VIDEO def test_still_image_download_is_not_resurrected_to_video_by_info(self, monkeypatch): # A downloaded still image: the probe returns no vcodec (image-format guard). info's image # "vcodec" must not turn it into a broken 'video' → stays a generic file. self._ffprobe( monkeypatch, { "format": {"format_name": "png_pipe"}, "streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}], }, ) v, a, w, h, _d = probemod.resolve_media_facts( Path("x"), {"vcodec": "png", "width": 8, "height": 8} ) assert v is None and mediakind.derive(v, a) == mediakind.FILE def test_a_file_the_guarded_probe_cannot_read_is_a_download_only_file(self, monkeypatch): # ffprobe (media demuxers only) failed entirely: the player and the editor cannot open it # either, so info's codecs must not make it an editable 'video'. Dimensions still gap-fill. self._ffprobe(monkeypatch, None) v, a, w, h, _d = probemod.resolve_media_facts( Path("x"), {"vcodec": "avc1.64001f", "acodec": "mp4a.40.2", "width": 1920, "height": 1080}, ) assert (v, a, w, h) == (None, None, 1920, 1080) assert mediakind.derive(v, a) == mediakind.FILE # --- the phone-safe conversion (an iPhone's HEVC, a webm, an mkv) ------------------------------ def _queued_upload(db, make_user, root, monkeypatch, name="clip.mov"): """An upload no phone could play as it is: probed as HEVC, so ingest queues it.""" _probe(monkeypatch, ("hevc", "aac", 640, 360, 6)) src = root / "src.bin" src.write_bytes(b"hevc bytes") job = uploadmod.ingest_upload(db, make_user().id, filename=name, src_path=src, size=10) return job, db.get(MediaAsset, job.asset_id) class TestPhoneSafeUpload: def test_an_unsafe_video_is_queued_for_the_worker_not_done( self, db, make_user, _root, monkeypatch ): job, asset = _queued_upload(db, make_user, _root, monkeypatch) assert (job.status, job.progress, asset.status) == ("queued", 0, "pending") assert asset.rel_path.endswith(".mov") # the original waits at rel_path for the worker assert storage.abs_path(str(_root), asset.rel_path).exists() def test_a_safe_video_still_lands_done(self, db, make_user, _root, monkeypatch): _probe(monkeypatch, ("h264", "aac", 640, 360, 6)) src = _root / "src.bin" src.write_bytes(b"h264 bytes") job = uploadmod.ingest_upload( db, make_user().id, filename="clip.mp4", src_path=src, size=10 ) assert job.status == "done" and db.get(MediaAsset, job.asset_id).status == "ready" def test_deleting_a_queued_upload_removes_its_original(self, db, make_user, _root, monkeypatch): job, asset = _queued_upload(db, make_user, _root, monkeypatch) path, asset_id = storage.abs_path(str(_root), asset.rel_path), asset.id service.delete(db, job) assert not path.exists() assert db.get(MediaAsset, asset_id) is None def test_one_cancelled_mid_conversion_is_reaped_once_the_worker_let_go( self, db, make_user, _root, monkeypatch ): job, asset = _queued_upload(db, make_user, _root, monkeypatch) path, asset_id = storage.abs_path(str(_root), asset.rel_path), asset.id asset.status = "downloading" # the worker child is reading the original job.status = "running" db.commit() service.cancel(db, job) assert path.exists() # never pulled out from under a running ffmpeg db.get(MediaAsset, asset_id).status = "pending" # the worker stopped the child and let go db.commit() gcmod.run_download_gc(db) db.expire_all() assert not path.exists() assert db.get(MediaAsset, asset_id) is None def test_a_failed_conversion_keeps_the_original_for_retry( self, db, make_user, _root, monkeypatch ): job, asset = _queued_upload(db, make_user, _root, monkeypatch) path = storage.abs_path(str(_root), asset.rel_path) job.status, asset.status = "running", "downloading" db.commit() def _ffmpeg_fails(*_a, **_k): raise StatedFailure("ffmpeg failed (1): unsupported") monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", _ffmpeg_fails) pipeline._convert_upload(job.id, asset.id) db.expire_all() assert (db.get(DownloadJob, job.id).status, db.get(MediaAsset, asset.id).status) == ( "error", "error", ) assert path.exists() service.resume(db, db.get(DownloadJob, job.id)) assert db.get(MediaAsset, asset.id).status == "pending" # Retry converts from it again @pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available") def test_the_worker_converts_it_and_keeps_only_the_safe_file( self, db, make_user, _root, monkeypatch ): # Real bytes, real probe, real ffmpeg: a 10-bit H.264 + Opus Matroska, the kind of file # that reached the share page as-is before this rule. src = _root / "src.mkv" subprocess.run( [ "ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=1", "-f", "lavfi", "-i", "sine=duration=1", "-c:v", "libx264", "-pix_fmt", "yuv420p10le", "-c:a", "libopus", str(src), ], check=True, ) # fmt: skip job = uploadmod.ingest_upload( db, make_user().id, filename="holiday.mkv", src_path=src, size=src.stat().st_size ) asset = db.get(MediaAsset, job.asset_id) original = storage.abs_path(str(_root), asset.rel_path) assert job.status == "queued" job.status, asset.status = "running", "downloading" # what the worker's claim does db.commit() pipeline._convert_upload(job.id, asset.id) db.expire_all() asset = db.get(MediaAsset, job.asset_id) assert db.get(DownloadJob, job.id).status == "done" assert (asset.status, asset.container, asset.vcodec, asset.acodec) == ( "ready", "mp4", "h264", "aac", ) final = storage.abs_path(str(_root), asset.rel_path) assert final.suffix == ".mp4" and final.exists() assert asset.size_bytes == final.stat().st_size assert not original.exists() # one file per upload assert not playable.plan(probemod.probe_json(final), final.suffix).needs_change