fix(uploads): finish the sweeps round 1 left half-done
Deep-review round 2, on round 1's own fix commit. It found MORE than round 1 did (28 raw, against 13), and most of it was mine -- a new piece of UI attracts findings a backend-only diff never does. The pattern in almost all of them is the same: a principle applied to the site that prompted it and not to its siblings. * `display_text` reached three of the FIVE writers of a user-supplied name. `POST /api/downloads` and `POST /api/downloads/edit` still handed Postgres an unbounded string, so a 300-character name pasted into the Add-download dialog or the editor was a bare 500 (main.py re-raises every DataError but the NUL case). Four of the seven reviewers found it independently. The bound now lives in `models.py`, the three columns are DECLARED with it, and the guard for the next writer is parsed rather than remembered: every `display_name` / `display_uploader` assignment under `app/routes/` must go through the helper. That guard immediately found two more -- the Google sign-in callback writes the profile name into the same 255-wide column -- so it is six, not five. * The connection release reached one of the THREE routes that read their own body. The screenshot route (10 MiB, from a phone) and the client log sink both still held a pooled connection idle-in-transaction for the length of the upload, which is the invariant db.py sizes the pool on. All three now call `db.release_read_connection`, whose precondition -- nothing pending on the shared session -- is CHECKED rather than described, because a whole-session rollback discarding a caller's staged write is a mistake this repo has already made once. `test_body_limit.py` holds the rule for the next such route. * The AST drift registry keyed its sites by (module, limit expression), so a second route copied from the first collapsed onto the same entry and went uncovered -- the exact hole the registry exists to close. Keyed per call site now, and it carries the release requirement too. * The dialog's own refusal was announced once and never again: two over-cap picks leave an identical node with identical text, and an untouched live region says nothing. It is keyed per pick now, the drop zone reads as invalid and points at the sentence, and the reason is reachable from the control rather than only from a one-shot announcement. * Two tests rebuilt the blind spot they were written to remove: the dialog's cap test sat nowhere near the boundary (a `>` loosened to `>=` survived it, which is the mutation round 1 fixed on the server side), and the header-precedence test only covered an exact-case duplicate while a differently-cased one would have been COMBINED onto the wire rather than replaced. Both fixed, and the merge now dedupes case-insensitively because header names are. Also: the upload dialog's unstubbed usage query was firing a real request from jsdom in a neighbouring suite and leaking a delayed "connection lost" toast across test boundaries; `GET /api/downloads/usage` had no test at all though the whole client-side check hangs off it; and the comment claiming the edge 413 is readable now says what was measured -- readable to 200 MiB over loopback, a bare `onerror` through a proxy -- so the pre-send check is named as the mechanism and that branch as the backstop it is. Every test added or changed here was mutation-checked. One mutation SURVIVED and is recorded rather than papered over: the `tooBig` term inside `submit()` cannot be reached, because `ui/Button` swallows the activation itself, so it is marked as belt and braces instead of being described as the guard.
This commit is contained in:
+134
-10
@@ -12,8 +12,10 @@ parsing (image → file, cover-art skip, dims, duration) is covered directly by
|
||||
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import io
|
||||
import pathlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -23,10 +25,10 @@ from app.config import settings
|
||||
from app.routes._common import capped_stream_to
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.downloads import mediakind, quota, service, storage
|
||||
from app.downloads import mediakind, quota, storage
|
||||
from app.downloads import probe as probemod
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, DownloadQuota, MediaAsset
|
||||
|
||||
|
||||
# --- pure: the kind invariant ---------------------------------------------------------------
|
||||
@@ -422,12 +424,99 @@ def test_a_non_ascii_filename_survives_the_header_round_trip(
|
||||
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.
|
||||
|
||||
`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 in deep-review round 2 — 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/downloads/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 metadata PATCH's two fields.
|
||||
patched = c.patch(
|
||||
f"/api/downloads/{up.json()['id']}",
|
||||
json={"display_name": long_name, "display_uploader": long_name},
|
||||
)
|
||||
assert patched.status_code == 200, patched.text
|
||||
assert patched.json()["display_name"] == bound
|
||||
assert patched.json()["display_uploader"] == bound
|
||||
|
||||
|
||||
def test_no_route_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 `display_name = ...` / `display_uploader = ...` assignment under
|
||||
`app/routes/` takes a string a user typed and puts it on its way to a varchar(255) column, so
|
||||
every one of them must go through `display_text`. A sixth route added tomorrow fails here
|
||||
instead of 500ing in production.
|
||||
"""
|
||||
routes_dir = pathlib.Path(__file__).resolve().parents[1] / "app" / "routes"
|
||||
offenders: list[str] = []
|
||||
for py in sorted(routes_dir.rglob("*.py")):
|
||||
tree = ast.parse(py.read_text("utf-8"), filename=str(py))
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
names = {
|
||||
t.id if isinstance(t, ast.Name) else t.attr
|
||||
for t in node.targets
|
||||
if isinstance(t, (ast.Name, ast.Attribute))
|
||||
}
|
||||
if not names & {"display_name", "display_uploader"}:
|
||||
continue
|
||||
value = ast.unparse(node.value)
|
||||
if "display_text" not in value:
|
||||
offenders.append(f"{py.name}:{node.lineno}: {value}")
|
||||
assert not offenders, (
|
||||
"a display field is assigned without models.display_text, so a name longer "
|
||||
f"than the column is a 500 rather than a truncation: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
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 == service.DISPLAY_TEXT_MAX
|
||||
assert DownloadJob.__table__.c[name].type.length == DISPLAY_TEXT_MAX
|
||||
|
||||
|
||||
def test_the_display_name_header_reaches_the_job_and_is_bounded(
|
||||
@@ -456,7 +545,7 @@ def test_the_display_name_header_reaches_the_job_and_is_bounded(
|
||||
"/api/downloads/upload", content=b"data2", headers=upload_headers("clip2.bin", long_name)
|
||||
)
|
||||
assert bounded.status_code == 200, bounded.text
|
||||
assert bounded.json()["display_name"] == "n" * service.DISPLAY_TEXT_MAX
|
||||
assert bounded.json()["display_name"] == "n" * DISPLAY_TEXT_MAX
|
||||
|
||||
|
||||
def test_the_upload_holds_no_db_transaction_while_the_body_streams(
|
||||
@@ -477,22 +566,57 @@ def test_the_upload_holds_no_db_transaction_while_the_body_streams(
|
||||
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
seen: dict[str, bool] = {}
|
||||
real = downloads_routes.capped_stream_to
|
||||
real_stream = downloads_routes.capped_stream_to
|
||||
real_release = downloads_routes.release_read_connection
|
||||
|
||||
async def spy(request, out, limit, refusal):
|
||||
seen["in_transaction"] = bool(db.in_transaction())
|
||||
return await real(request, out, limit, refusal)
|
||||
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())
|
||||
|
||||
monkeypatch.setattr(downloads_routes, "capped_stream_to", spy)
|
||||
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(downloads_routes, "release_read_connection", spy_release)
|
||||
monkeypatch.setattr(downloads_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
|
||||
assert seen["in_transaction"] is False, (
|
||||
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 (round 2).
|
||||
"""
|
||||
from app import sysconfig
|
||||
|
||||
c = as_user(make_user())
|
||||
r = c.get("/api/downloads/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/downloads/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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user