diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json index 5ea56f47..a669dffb 100644 --- a/backend/.basedpyright/baseline.json +++ b/backend/.basedpyright/baseline.json @@ -548,64 +548,6 @@ } } ], - "./app/plex/stream.py": [ - { - "code": "reportOptionalMemberAccess", - "range": { - "startColumn": 51, - "endColumn": 55, - "lineCount": 1 - } - }, - { - "code": "reportOptionalMemberAccess", - "range": { - "startColumn": 23, - "endColumn": 25, - "lineCount": 1 - } - }, - { - "code": "reportOptionalMemberAccess", - "range": { - "startColumn": 18, - "endColumn": 31, - "lineCount": 1 - } - }, - { - "code": "reportOptionalMemberAccess", - "range": { - "startColumn": 75, - "endColumn": 79, - "lineCount": 1 - } - }, - { - "code": "reportOptionalMemberAccess", - "range": { - "startColumn": 10, - "endColumn": 23, - "lineCount": 1 - } - }, - { - "code": "reportOptionalMemberAccess", - "range": { - "startColumn": 10, - "endColumn": 12, - "lineCount": 1 - } - }, - { - "code": "reportOptionalMemberAccess", - "range": { - "startColumn": 10, - "endColumn": 17, - "lineCount": 1 - } - } - ], "./app/plex/sync.py": [ { "code": "reportArgumentType", diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index b25d1e24..f9ac6caa 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -745,8 +745,7 @@ def start_session( # check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent # seek-restart) installed between the fallback's decision and here is never clobbered. When the # registered session is no longer `replace_only`, we abort and return None (the caller yields to - # whoever is registered now). The INITIAL spawn passes replace_only=None and always supersedes — - # that is the seek-restart's intended last-writer-wins. + # whoever is registered now). The INITIAL spawn never gets None — see its call site. # Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and # remux ignore preset/crf/max_height, and remux must not touch the DB for them). preset, crf, max_height = "veryfast", 20, 0 @@ -823,6 +822,7 @@ def start_session( return s s = _spawn(*_select_encode(db, item, transcode, src)) + assert s is not None # replace_only=None always supersedes: a seek-restart's last-writer-wins # Wait for the first video segment outside the lock (this blocks on ffmpeg, and we must not hold up # other sessions). Pass the proc so a HW session that dies at VAAPI init fails fast (not after 25s). ready = wait_for(_seg(0), timeout=25.0, proc=s.proc) diff --git a/backend/tests/test_abuse_limits.py b/backend/tests/test_abuse_limits.py index 6ff1ca84..0593cacb 100644 --- a/backend/tests/test_abuse_limits.py +++ b/backend/tests/test_abuse_limits.py @@ -36,9 +36,14 @@ def test_a_public_url_gets_past_the_guard(db, as_user, make_user, monkeypatch): "getaddrinfo", lambda *a, **kw: [(None, None, None, "", ("93.184.216.34", 0))], ) - r = as_user(user).post("/api/downloads", json={"source": "https://example.com/v"}) - detail = r.json().get("detail") if r.status_code >= 400 else None - assert not (isinstance(detail, dict) and detail.get("code") == "url_blocked") + # An inline spec, so nothing ELSE refuses it: without one the request stopped at the + # profile lookup (400 no_profile) and "not url_blocked" held for a request the guard never + # had to wave through (E32 S2). + r = as_user(user).post( + "/api/downloads", json={"source": "https://example.com/v", "spec": {"format": "best"}} + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "queued" def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeypatch): @@ -50,7 +55,13 @@ def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeyp user = make_user() db.commit() monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom) - as_user(user).post("/api/downloads", json={"source": "dQw4w9WgXcQ"}) + # The job must actually be created: a request refused for any other reason would never reach + # the guard either, and the test would pass without proving anything (E32 S2). + r = as_user(user).post( + "/api/downloads", json={"source": "dQw4w9WgXcQ", "spec": {"format": "best"}} + ) + assert r.status_code == 200, r.text + assert r.json()["source_kind"] == "youtube" def test_manual_subscription_sync_goes_on_cooldown(db, as_user, make_user, monkeypatch): @@ -81,8 +92,12 @@ def test_the_cooldown_is_per_user_not_global(db, as_user, make_user, monkeypatch monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0}) ca = as_user(a) - for _ in range(4): - ca.post("/api/sync/subscriptions") + assert [ca.post("/api/sync/subscriptions").status_code for _ in range(4)] == [ + 200, + 200, + 200, + 429, + ] assert ca.post("/api/sync/subscriptions").status_code == 429 assert as_user(b).post("/api/sync/subscriptions").status_code == 200 diff --git a/backend/tests/test_debug_sessions.py b/backend/tests/test_debug_sessions.py index 6a791922..30f20bc8 100644 --- a/backend/tests/test_debug_sessions.py +++ b/backend/tests/test_debug_sessions.py @@ -151,7 +151,7 @@ def test_nobody_reaches_another_users_session(db, make_user, as_user): def test_the_list_is_the_users_own_newest_first(db, make_user, as_user): client = as_user(make_user()) first = _start(client)["id"] - client.post(f"/api/debug-sessions/{first}/stop") + assert client.post(f"/api/debug-sessions/{first}/stop").status_code == 200 second = _start(client)["id"] assert [s["id"] for s in client.get("/api/debug-sessions").json()["items"]] == [second, first] @@ -199,10 +199,13 @@ def test_an_erased_account_takes_its_sessions_with_it(db, make_user, as_user): user = make_user() client = as_user(user) sid = _start(client)["id"] - client.post( - f"/api/debug-sessions/{sid}/screenshots", - content=_PNG, - headers={"content-type": "image/png"}, + assert ( + client.post( + f"/api/debug-sessions/{sid}/screenshots", + content=_PNG, + headers={"content-type": "image/png"}, + ).status_code + == 200 ) purge_user(db, user, BackgroundTasks()) assert db.get(DebugSession, sid) is None @@ -250,7 +253,7 @@ def test_a_request_naming_the_own_open_session_is_tagged_and_raised(gated, db, c _signed_in(client, make_user) sid = _start(client, "DEBUG")["id"] gated.records.clear() - client.get("/api/debug-sessions", headers={"x-debug-session": sid}) + assert client.get("/api/debug-sessions", headers={"x-debug-session": sid}).status_code == 200 (line,) = _request_lines(gated) assert getattr(line, "debug_session_id", None) == sid # Raised to the session's level: the line is kept for storage though the user's own is Alap. @@ -263,7 +266,7 @@ def test_a_header_naming_anything_else_is_ignored_not_refused(gated, db, client, db.commit() _signed_in(client, make_user) own = _start(client)["id"] - client.post(f"/api/debug-sessions/{own}/stop") + assert client.post(f"/api/debug-sessions/{own}/stop").status_code == 200 for value in (foreign, own, "not-a-session", "f" * 16, "A" * 16): gated.records.clear() # Not `/api/me`: a polled route logs at DEBUG, and an un-raised request would drop the very @@ -280,7 +283,7 @@ def test_a_header_naming_the_own_session_past_its_cap_is_ignored(gated, db, clie sid = _start(client)["id"] _expire(db, sid) # past its cap, not yet swept: still open in the table gated.records.clear() - client.get("/api/debug-sessions", headers={"x-debug-session": sid}) + assert client.get("/api/debug-sessions", headers={"x-debug-session": sid}).status_code == 200 (line,) = _request_lines(gated) assert getattr(line, "debug_session_id", None) is None assert getattr(line, "elevated", False) is False @@ -616,7 +619,7 @@ def test_a_users_screenshots_are_capped_across_their_sessions(recording, monkeyp client, sid = recording monkeypatch.setattr(debugsession, "SCREENSHOT_USER_MAX_BYTES", len(_PNG) * 2) assert _upload(client, sid, _PNG).status_code == 200 - client.post(f"/api/debug-sessions/{sid}/stop") + assert client.post(f"/api/debug-sessions/{sid}/stop").status_code == 200 other = _start(client)["id"] assert _upload(client, other, _PNG).status_code == 200 r = _upload(client, other, _PNG) @@ -631,7 +634,7 @@ def test_new_sessions_are_paced_but_joining_one_is_not(db, make_user, as_user, m client = as_user(make_user()) sid = _start(client)["id"] assert _start(client)["id"] == sid # another tab joining - client.post(f"/api/debug-sessions/{sid}/stop") + assert client.post(f"/api/debug-sessions/{sid}/stop").status_code == 200 r = client.post("/api/debug-sessions", json={"level": "DEBUG"}) assert r.status_code == 429 and r.json()["detail"]["reason"] == "debug_session_rate_limited" @@ -745,9 +748,16 @@ def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make db.commit() _upload(client, sid, _PNG) _upload(client, sid, _JPEG) - client.put( - f"/api/debug-sessions/{sid}/draft", - json={"title": "Spinner stays", "body": "It spins.", "excluded": [dropped_web.id, 999999]}, + assert ( + client.put( + f"/api/debug-sessions/{sid}/draft", + json={ + "title": "Spinner stays", + "body": "It spins.", + "excluded": [dropped_web.id, 999999], + }, + ).status_code + == 200 ) r = client.get(f"/api/debug-sessions/{sid}/export") @@ -1144,7 +1154,7 @@ def test_new_issues_are_paced_per_user(gitea_fake, db, make_user, as_user, monke first = _start(client)["id"] _ready(client, first) assert client.post(f"/api/debug-sessions/{first}/issue").status_code == 200 - client.post(f"/api/debug-sessions/{first}/stop") + assert client.post(f"/api/debug-sessions/{first}/stop").status_code == 200 second = _start(client)["id"] _ready(client, second) r = client.post(f"/api/debug-sessions/{second}/issue") diff --git a/backend/tests/test_diagnostics.py b/backend/tests/test_diagnostics.py index a64b4712..93ea40ce 100644 --- a/backend/tests/test_diagnostics.py +++ b/backend/tests/test_diagnostics.py @@ -212,7 +212,12 @@ def test_a_timestamp_outside_the_consent_window_is_clamped(elevated): now = datetime.now(timezone.utc) future = (now + timedelta(days=365)).isoformat() past = (now - timedelta(days=30)).isoformat() - client.post("/api/diagnostics/events", json={"events": [_event(ts=future), _event(ts=past)]}) + assert ( + client.post( + "/api/diagnostics/events", json={"events": [_event(ts=future), _event(ts=past)]} + ).status_code + == 200 + ) late, early = rows() assert late.ts <= datetime.now(timezone.utc) assert early.ts >= now - timedelta(hours=diagnostics.ELEVATION_HOURS, minutes=1) @@ -342,15 +347,15 @@ def _app() -> TestClient: def test_the_account_reaches_the_handlers_own_lines(gated): - _app().get("/api/thing/41") + assert _app().get("/api/thing/41").status_code == 200 line = next(r for r in gated.records if r.getMessage() == "an ordinary line") assert getattr(line, "user_id", None) == 41 def test_one_users_elevation_logs_their_requests_in_detail_and_nobody_elses(gated): client = _app() - client.get("/api/thing/7", params={"level": "DEBUG"}) - client.get("/api/thing/8") + assert client.get("/api/thing/7", params={"level": "DEBUG"}).status_code == 200 + assert client.get("/api/thing/8").status_code == 200 detail = [r for r in gated.records if r.getMessage().startswith("detail only")] assert [r.user_id for r in detail] == [7] assert getattr(detail[0], "elevated", False) is True @@ -363,9 +368,9 @@ def test_one_users_elevation_logs_their_requests_in_detail_and_nobody_elses(gate def test_the_root_level_returns_to_the_base_when_the_elevation_ends(gated): client = _app() - client.get("/api/thing/7", params={"level": "TRACE"}) + assert client.get("/api/thing/7", params={"level": "TRACE"}).status_code == 200 assert logging.getLogger().level == logs.TRACE - client.get("/api/thing/7") # the same user, back at the base + assert client.get("/api/thing/7").status_code == 200 # the same user, back at the base assert logging.getLogger().level == logging.INFO logs._track_elevation(9, logging.DEBUG, datetime.now(timezone.utc) - timedelta(seconds=1)) assert logging.getLogger().level == logging.INFO # an already-expired one is pruned at once @@ -373,15 +378,25 @@ def test_the_root_level_returns_to_the_base_when_the_elevation_ends(gated): def test_the_query_string_is_logged_only_for_a_traced_request_and_redacted(gated): client = _app() - client.get("/api/thing/7", params={"level": "DEBUG", "q": "lofi"}) - client.get("/api/thing/7", params={"level": "TRACE", "q": "lofi", "token": "SECRET"}) + assert client.get("/api/thing/7", params={"level": "DEBUG", "q": "lofi"}).status_code == 200 + assert ( + client.get( + "/api/thing/7", params={"level": "TRACE", "q": "lofi", "token": "SECRET"} + ).status_code + == 200 + ) lines = [r for r in gated.records if getattr(r, "event", None) == "http.request"] assert "query" not in lines[0].attrs assert "q=lofi" in lines[1].attrs["query"] and "SECRET" not in lines[1].attrs["query"] def test_an_address_typed_into_a_search_is_redacted_though_the_browser_encodes_it(gated): - _app().get("/api/thing/7?level=TRACE&q=someone%40example.com&new_password=hunter2") + assert ( + _app() + .get("/api/thing/7?level=TRACE&q=someone%40example.com&new_password=hunter2") + .status_code + == 200 + ) (line,) = [r for r in gated.records if getattr(r, "event", None) == "http.request"] assert line.attrs["query"] == "level=TRACE&q=[email]&new_password=[redacted]" @@ -400,7 +415,7 @@ def test_a_credential_param_goes_whole_however_its_value_decodes(raw): def test_an_admin_level_of_trace_does_not_log_other_users_query_strings(gated): logs.set_level("TRACE") - _app().get("/api/thing/7", params={"q": "something private"}) + assert _app().get("/api/thing/7", params={"q": "something private"}).status_code == 200 (line,) = [r for r in gated.records if getattr(r, "event", None) == "http.request"] assert "query" not in line.attrs @@ -488,7 +503,7 @@ def test_a_real_sign_in_carries_the_consented_level_into_the_process(gated, clie user = _signed_in(client, make_user) assert client.put("/api/me/diagnostics", json={"level": "DEBUG"}).status_code == 200 logs._elevations.clear() # only a request resolving the account through auth may put it back - client.get("/api/me") + assert client.get("/api/me").status_code == 200 assert logs._elevations[user.id][0] == logging.DEBUG assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200 assert user.id not in logs._elevations diff --git a/backend/tests/test_email_canonical.py b/backend/tests/test_email_canonical.py index 1fd45233..71d49f9c 100644 --- a/backend/tests/test_email_canonical.py +++ b/backend/tests/test_email_canonical.py @@ -334,7 +334,7 @@ def test_me_reports_the_google_address_only_when_it_differs(client, db, make_use ) assert client.get("/api/me").json()["google_email"] is None - client.post("/auth/logout") + assert client.post("/auth/logout").status_code == 200 tagged = make_user(email="me+siftlode@gmail.com", password=_UAT_PW) tagged.google_email = "me@gmail.com" db.commit() diff --git a/backend/tests/test_last_login.py b/backend/tests/test_last_login.py index 02256d04..9b27aaff 100644 --- a/backend/tests/test_last_login.py +++ b/backend/tests/test_last_login.py @@ -100,7 +100,12 @@ def test_the_admin_user_list_carries_last_login(as_user, db, make_user): def test_the_account_sees_its_own_last_sign_in(client, db, make_user): # Settings → Account shows it (prod UAT 2026-09-19: only the admin's user list did). make_user(email="me@example.com", password=_PW) - client.post("/auth/password-login", json={"email": "me@example.com", "password": _PW}) + assert ( + client.post( + "/auth/password-login", json={"email": "me@example.com", "password": _PW} + ).status_code + == 200 + ) stamp = client.get("/api/me").json()["last_login_at"] diff --git a/backend/tests/test_logs.py b/backend/tests/test_logs.py index 86363f67..0d77f296 100644 --- a/backend/tests/test_logs.py +++ b/backend/tests/test_logs.py @@ -277,7 +277,7 @@ def test_a_signed_in_request_is_never_capped_and_names_its_account(sink): client = TestClient(app) for _ in range(logs.ANONYMOUS_LINES_PER_MINUTE + 3): - client.get("/api/me") + assert client.get("/api/me").status_code == 200 records = sink.records[-(logs.ANONYMOUS_LINES_PER_MINUTE + 3) :] assert all(getattr(r, logs.PERSIST, True) for r in records) assert sink.objects()[-1]["user_id"] == 42 and "user_id" not in sink.objects()[-1]["attrs"] @@ -325,9 +325,9 @@ def test_the_log_consoles_own_reads_are_debug_but_its_failures_are_not(sink): http = logging.getLogger("siftlode.http") http.setLevel(logs.TRACE) client = _client() - client.get("/api/admin/logs/facets") + assert client.get("/api/admin/logs/facets").status_code == 200 assert sink.objects()[-1]["level"] == "DEBUG" - client.get("/api/admin/logs/facets?fail=1") + assert client.get("/api/admin/logs/facets?fail=1").status_code == 400 assert sink.objects()[-1]["level"] == "WARNING" @@ -344,7 +344,7 @@ def test_a_polled_read_is_debug_a_write_to_the_same_route_is_not(sink): r = client.post("/api/messages/unread_count") assert sink.objects()[-1]["level"] == "INFO" assert "X-Log-Quiet" not in r.headers - client.get("/api/videos/abc") + assert client.get("/api/videos/abc").status_code == 200 assert sink.objects()[-1]["level"] == "INFO" @@ -360,7 +360,7 @@ def test_every_polled_route_is_a_route_the_app_has(): def test_a_failing_request_is_logged_by_status_and_an_unhandled_error_is_logged_at_all(sink): client = _client() - client.get("/api/videos/missing/deeper") # 404 from the router + assert client.get("/api/videos/missing/deeper").status_code == 404 # 404 from the router assert sink.objects()[-1]["level"] == "WARNING" client.get("/boom") payload = sink.objects()[-1] diff --git a/backend/tests/test_mail_language.py b/backend/tests/test_mail_language.py index ce1c38df..f5ef1b7d 100644 --- a/backend/tests/test_mail_language.py +++ b/backend/tests/test_mail_language.py @@ -154,8 +154,18 @@ def test_an_access_request_records_the_language_it_was_made_in(db, client): def test_asking_again_from_another_language_updates_the_row(db, client): - client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"}) - client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}) + assert ( + client.post( + "/auth/request-access", json={"email": "asking@example.com", "lang": "en"} + ).status_code + == 200 + ) + assert ( + client.post( + "/auth/request-access", json={"email": "asking@example.com", "lang": "hu"} + ).status_code + == 200 + ) db.expire_all() inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one() @@ -165,7 +175,12 @@ def test_asking_again_from_another_language_updates_the_row(db, client): def test_an_unsupported_language_is_ignored_rather_than_stored(db, client): - client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"}) + assert ( + client.post( + "/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"} + ).status_code + == 200 + ) inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one() assert inv.lang is None @@ -194,7 +209,12 @@ def test_the_confirmation_mail_is_written_in_the_accounts_language(db, client, m monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True) sent = _bodies(monkeypatch) - client.post("/auth/register", json={"email": "hu@example.com", "password": _PW, "lang": "hu"}) + assert ( + client.post( + "/auth/register", json={"email": "hu@example.com", "password": _PW, "lang": "hu"} + ).status_code + == 200 + ) (_, subject, body) = sent[0] assert subject == mailtext.MAIL_TEXT["verify_email.subject"]["hu"] @@ -209,7 +229,12 @@ def test_the_approval_mail_answers_in_the_language_the_request_was_made_in( monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True) sent = _bodies(monkeypatch) admin = make_user(email="admin@example.com", role="admin") - client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}) + assert ( + client.post( + "/auth/request-access", json={"email": "asking@example.com", "lang": "hu"} + ).status_code + == 200 + ) inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one() sent.clear() # drop the admin notification; this test is about the reply to the requester @@ -227,11 +252,16 @@ def test_a_denial_answers_in_that_language_too(db, client, make_user, as_user, m monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True) sent = _bodies(monkeypatch) admin = make_user(email="admin@example.com", role="admin") - client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}) + assert ( + client.post( + "/auth/request-access", json={"email": "asking@example.com", "lang": "hu"} + ).status_code + == 200 + ) inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one() sent.clear() - as_user(admin).post(f"/api/admin/invites/{inv.id}/deny") + assert as_user(admin).post(f"/api/admin/invites/{inv.id}/deny").status_code == 200 assert sent[0][1] == mailtext.MAIL_TEXT["access_denied.subject"]["hu"] diff --git a/backend/tests/test_playlist_add_position.py b/backend/tests/test_playlist_add_position.py index 994774cb..d351b0f2 100644 --- a/backend/tests/test_playlist_add_position.py +++ b/backend/tests/test_playlist_add_position.py @@ -97,7 +97,7 @@ def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user, pl.synced_fingerprint = current_fingerprint(db, pl) pl.dirty = False db.commit() - client.delete(f"/api/playlists/{pl.id}/items/b") + assert client.delete(f"/api/playlists/{pl.id}/items/b").status_code == 200 db.expire_all() assert db.get(Playlist, pl.id).dirty is True _add(client, pl.id, {"video_id": "b", "position": 1}) diff --git a/backend/tests/test_plex_image_sizes.py b/backend/tests/test_plex_image_sizes.py index 6e3cdaad..3c250296 100644 --- a/backend/tests/test_plex_image_sizes.py +++ b/backend/tests/test_plex_image_sizes.py @@ -67,7 +67,7 @@ def test_each_variant_is_cut_at_its_own_size(db, make_user, monkeypatch, tmp_pat ] # Each cut is cached under its own name: asking again reaches Plex no more. for variant in ("thumb", "thumb_sm", "art", "art_sm"): - client.get(f"/api/plex/image/pm1?variant={variant}") + assert client.get(f"/api/plex/image/pm1?variant={variant}").status_code == 200 assert len(_FakePlex.calls) == 4 diff --git a/backend/tests/test_plex_playlist_add_positions.py b/backend/tests/test_plex_playlist_add_positions.py index 4da18967..3d8f970a 100644 --- a/backend/tests/test_plex_playlist_add_positions.py +++ b/backend/tests/test_plex_playlist_add_positions.py @@ -105,8 +105,11 @@ def test_remove_then_put_back_is_no_change(db, make_user, as_user): user = make_user() pl = _seed(db, user, ["a", "b", "c", "d"], extra=()) client = as_user(user) - client.post( - f"/api/plex/playlists/{pl.id}/items/remove-bulk", json={"item_rating_keys": ["b", "d"]} + assert ( + client.post( + f"/api/plex/playlists/{pl.id}/items/remove-bulk", json={"item_rating_keys": ["b", "d"]} + ).status_code + == 200 ) assert _order(db, pl.id)[0] == ["a", "c"] _add(client, pl.id, ["b", "d"], [1, 3]) diff --git a/backend/tests/test_plex_subtitle_prepare.py b/backend/tests/test_plex_subtitle_prepare.py index f0a7c50a..87677ab8 100644 --- a/backend/tests/test_plex_subtitle_prepare.py +++ b/backend/tests/test_plex_subtitle_prepare.py @@ -208,7 +208,7 @@ def test_an_unshowable_track_says_why_and_maps_to_a_status(client, monkeypatch): raise subtitles.SubtitleUnavailable("bitmap") monkeypatch.setattr(subtitles, "_extract_and_publish", _bitmap) - client.get("/api/plex/subtitle/98614/0/status") + assert client.get("/api/plex/subtitle/98614/0/status").status_code == 200 pool.run_all() st = client.get("/api/plex/subtitle/98614/0/status").json() assert st["state"] == "failed" and st["code"] == "bitmap" diff --git a/backend/tests/test_reset_link_states.py b/backend/tests/test_reset_link_states.py index a11c03d4..30f0e7c7 100644 --- a/backend/tests/test_reset_link_states.py +++ b/backend/tests/test_reset_link_states.py @@ -51,7 +51,12 @@ def test_a_link_whose_account_was_deleted_is_reported_dead(client, db, make_user def test_a_spent_link_is_reported_dead(client, db, make_user): raw = _issue(db, make_user(email="spent@example.com", password=_PW)) - client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW}) + assert ( + client.post( + "/auth/password-reset/confirm", json={"token": raw, "password": _NEW} + ).status_code + == 200 + ) assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False} diff --git a/backend/tests/test_response_checked.py b/backend/tests/test_response_checked.py new file mode 100644 index 00000000..655c8a23 --- /dev/null +++ b/backend/tests/test_response_checked.py @@ -0,0 +1,58 @@ +"""No test sends a request and throws the response away (E32 S2). + +A discarded `client.post(...)` passes whatever it got back. As a SETUP step, a failure leaves the +test asserting on a state it never reached; as the step UNDER test, the test proves nothing. E32 +found `test_youtube_sources_never_consult_the_guard` passing on a 400 that stopped the request +before the code it meant to exercise, and 53 more discarded responses across the suite. Each one +now states the status it expects; this keeps it that way. +""" + +import ast +from pathlib import Path + +TESTS = Path(__file__).resolve().parent +VERBS = {"get", "post", "put", "patch", "delete", "head", "options", "request"} + + +def discarded_requests(source: str) -> list[int]: + """Line numbers of the statements that are a bare request call on an app path.""" + hits = [] + for node in ast.walk(ast.parse(source)): + if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)): + continue + func = node.value.func + if not (isinstance(func, ast.Attribute) and func.attr in VERBS): + continue + path = node.value.args[0] if node.value.args else None + literal = path.value if isinstance(path, ast.Constant) else None + if isinstance(path, ast.JoinedStr): + first = path.values[0] if path.values else None + literal = first.value if isinstance(first, ast.Constant) else None + if isinstance(literal, str) and literal.startswith(("/api", "/auth")): + hits.append(node.lineno) + return hits + + +def test_the_detector_sees_a_discarded_request_and_passes_a_checked_one(): + src = ( + "client.post('/api/x')\n" + "client.get(f'/auth/{u}')\n" + "assert client.post('/api/x').status_code == 200\n" + "r = client.get('/api/y')\n" + "items.get('/api/not-a-request-but-a-key')\n" + ) + # The last line is a false positive by design: a dict keyed by an app path is not a thing the + # suite does, and a narrower match would miss a client held under any other name. + assert discarded_requests(src) == [1, 2, 5] + + +def test_no_test_discards_a_response(): + offenders = [ + f"{p.name}:{line}" + for p in sorted(TESTS.rglob("test_*.py")) + for line in discarded_requests(p.read_text(encoding="utf-8")) + ] + assert offenders == [], ( + "assert the status these requests expect (`assert client.post(...).status_code == 200`), " + f"or keep the response and check it: {offenders}" + ) diff --git a/backend/tests/test_review_round.py b/backend/tests/test_review_round.py index 5f1e6f39..6574e296 100644 --- a/backend/tests/test_review_round.py +++ b/backend/tests/test_review_round.py @@ -100,15 +100,22 @@ def test_a_later_registration_never_touches_an_existing_password(db, client, smt have installed a password where the whole point is that there is none. Reverted; the owner's recourse is password-reset, which proves the mailbox. Residual logged as SEC-16. """ - client.post( - "/auth/register", json={"email": "victim@example.com", "password": "the first password"} + assert ( + client.post( + "/auth/register", json={"email": "victim@example.com", "password": "the first password"} + ).status_code + == 200 ) db.expire_all() user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one() before = user.password_hash - client.post( - "/auth/register", json={"email": "victim@example.com", "password": "the second password"} + assert ( + client.post( + "/auth/register", + json={"email": "victim@example.com", "password": "the second password"}, + ).status_code + == 200 ) db.expire_all() @@ -116,8 +123,11 @@ def test_a_later_registration_never_touches_an_existing_password(db, client, smt def test_a_later_registration_leaves_a_link_already_in_flight_alive(db, client, smtp_on, wire): - client.post( - "/auth/register", json={"email": "victim@example.com", "password": "the first password"} + assert ( + client.post( + "/auth/register", json={"email": "victim@example.com", "password": "the first password"} + ).status_code + == 200 ) db.expire_all() user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one() @@ -126,8 +136,12 @@ def test_a_later_registration_leaves_a_link_already_in_flight_alive(db, client, # would go green while asserting nothing the day registration stops issuing a token. assert len(first) == 1 - client.post( - "/auth/register", json={"email": "victim@example.com", "password": "the second password"} + assert ( + client.post( + "/auth/register", + json={"email": "victim@example.com", "password": "the second password"}, + ).status_code + == 200 ) db.expire_all() @@ -148,8 +162,12 @@ def test_a_stripped_account_keeps_its_recovery_stamp(db, client, smtp_on, make_u password_revoked_at=datetime.now(timezone.utc), ) - client.post( - "/auth/register", json={"email": "stripped@example.com", "password": "an attacker password"} + assert ( + client.post( + "/auth/register", + json={"email": "stripped@example.com", "password": "an attacker password"}, + ).status_code + == 200 ) db.expire_all() @@ -169,7 +187,9 @@ def test_a_denied_sibling_is_not_reopened_while_a_pending_row_exists(db, client) ) db.commit() - client.post("/auth/request-access", json={"email": "pairone@gmail.com"}) + assert ( + client.post("/auth/request-access", json={"email": "pairone@gmail.com"}).status_code == 200 + ) db.expire_all() rows = {i.email: i.status for i in db.execute(select(Invite)).scalars().all()} @@ -195,7 +215,7 @@ def test_an_env_granted_address_cannot_be_shadowed_by_a_stranger(db, client, mon # env grant. With the env list matched canonically the request never opens a row. monkeypatch.setattr(settings, "allowed_emails", "bob@gmail.com") - client.post("/auth/request-access", json={"email": "b.ob@gmail.com"}) + assert client.post("/auth/request-access", json={"email": "b.ob@gmail.com"}).status_code == 200 assert db.execute(select(Invite)).scalars().all() == [] assert auth_core.is_allowed(db, "bob@gmail.com") is True @@ -331,7 +351,12 @@ def test_two_admins_two_languages_two_mails(db, client, make_user, smtp_on, wire make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"}) make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"}) - client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"}) + assert ( + client.post( + "/auth/request-access", json={"email": "asking@example.com", "lang": "en"} + ).status_code + == 200 + ) notices = [w for w in wire if "asking@example.com" in w[1]] assert len(notices) == 2 diff --git a/backend/tests/test_saved_views_source.py b/backend/tests/test_saved_views_source.py index 215c2642..f7e078e3 100644 --- a/backend/tests/test_saved_views_source.py +++ b/backend/tests/test_saved_views_source.py @@ -53,8 +53,8 @@ def test_default_is_per_source(db, monkeypatch, override_dep): pid = c.post("/api/saved-views", json={"name": "P", "filters": {}, "source": "plex"}).json()[ "id" ] - c.patch(f"/api/saved-views/{fid}", json={"is_default": True}) - c.patch(f"/api/saved-views/{pid}", json={"is_default": True}) + assert c.patch(f"/api/saved-views/{fid}", json={"is_default": True}).status_code == 200 + assert c.patch(f"/api/saved-views/{pid}", json={"is_default": True}).status_code == 200 # Making the Plex view default must NOT clear the feed view's default (different source). assert c.get("/api/saved-views?source=feed").json()[0]["is_default"] is True assert c.get("/api/saved-views?source=plex").json()[0]["is_default"] is True diff --git a/backend/tests/test_set_password_link.py b/backend/tests/test_set_password_link.py index 2e7f196f..2c037e24 100644 --- a/backend/tests/test_set_password_link.py +++ b/backend/tests/test_set_password_link.py @@ -68,8 +68,12 @@ def test_using_the_link_evicts_whoever_held_the_old_password(db, as_user, make_u admin = make_user(role="admin") db.commit() token = _link(as_user(admin), target.id) - client.post( - "/auth/password-reset/confirm", json={"token": token, "password": "the owner's own choice"} + assert ( + client.post( + "/auth/password-reset/confirm", + json={"token": token, "password": "the owner's own choice"}, + ).status_code + == 200 ) db.refresh(target) diff --git a/backend/tests/test_welcome_mail.py b/backend/tests/test_welcome_mail.py index 4d92acfe..4ce4f99d 100644 --- a/backend/tests/test_welcome_mail.py +++ b/backend/tests/test_welcome_mail.py @@ -229,7 +229,12 @@ def test_registering_again_does_not_create_a_second_account(db, client, monkeypa _smtp_on(monkeypatch) _capture(monkeypatch, "send_verify_email") for _ in range(2): - client.post("/auth/register", json={"email": "twice@example.com", "password": _PW}) + assert ( + client.post( + "/auth/register", json={"email": "twice@example.com", "password": _PW} + ).status_code + == 200 + ) db.expire_all() rows = db.execute(select(User).where(User.email == "twice@example.com")).scalars().all()