Files
siftlode/backend/tests/test_abuse_limits.py
T
peter 358d722ce9 fix(e12): close what round one broke, and revert two fixes that overreached
Round two of the review loop, on round one's own diff. Two of its findings were
regressions the fixes themselves introduced, which is exactly what the loop is for:

**A per-user refusal was writing onto a shared row.** MediaAssets are deduped across
users, and the generic failure handler puts the message on the asset — right for an
upstream failure, wrong for "your storage is full". Two users queuing the same video share
one asset, so the first user hitting their cap failed the second user's job with the first
one's private reason, telling someone with an empty library to free up space. The refusal
now raises `_OwnerRefused`, caught separately: the job fails, the asset goes back to
`pending` for whoever else holds it. Pinned by a test that drives `_download` directly —
the refusal happens before extraction, so nothing needs stubbing.

**The restore scripts restarted the app, undoing the restore.** `docker compose start`
re-runs the container command, and that runs `alembic upgrade head` — so on the rollback
path this documents as recommended (restore the dump, THEN pin the older tag) the newer
image immediately re-applied the migrations the restore had just undone, and on a FAILED
restore it put the app back on a half-restored schema. Both are the opposite of why the
stop was added. Nothing is restarted automatically now; the scripts say what to run and
when, and the docs' snippets say the same.

**Reverted rather than patched:** the OnboardingWizard toast (the wizard already renders
that failure inline, next to a Retry button — three recoveries for one failure), and
`useVerifyEntry`'s "each new link clears the other". The latter blanked the signed-out
card's only banner, where nothing renders the reset flag to replace it. The two states are
independent again; what changes is that `dismiss` peels ONE layer, which is what the
original finding was actually about.

Also: `Stats` uses the YouTube helper rather than the bare one, so the route's English 403
detail no longer beats the translated fallback under a Hungarian UI; `urlparse` guarded
alongside `.port` (an unclosed bracket raised just as late), the reason renamed `malformed`
since "bad port" misdescribes it; three docstrings narrowed to what the code actually does
(the worker's byte check is per-job, not an aggregate ledger, and not on the edit path;
the redirect residual leaks a reachability oracle, which the note had denied; the
invalidation helper does not cover suspend); the pasted Windows recipe wrapped so `throw`
can stop it; the bash one-liner's failure branch no longer exits 0; and the tests that were
missing for the round-one fixes — the account panel's error paths, the invalidation set,
and the shared-asset case above.

Gate green: vitest 685, pytest 950.
2026-09-05 04:02:57 +02:00

113 lines
5.0 KiB
Python

"""E12 · S3 — the route-level halves of SEC-12 and SEC-14.
`test_urlguard.py` pins the address judgement itself; this pins that the ENQUEUE route actually
consults it and answers in the structured `{code, reason}` shape the frontend localizes, and that a
manual sync trigger stops spending the instance-wide YouTube budget when held down.
DB-backed (the `as_user` fixture needs real rows) — skipped when no database is reachable.
"""
from app.downloads import urlguard
def test_enqueueing_a_loopback_url_is_refused_with_a_structured_detail(
db, as_user, make_user, monkeypatch
):
user = make_user()
db.commit()
monkeypatch.setattr(
urlguard.socket, "getaddrinfo", lambda *a, **kw: [(None, None, None, "", ("127.0.0.1", 0))]
)
r = as_user(user).post("/api/downloads", json={"source": "http://redis.internal:6379/x"})
assert r.status_code == 422
# The shape matters as much as the refusal: a bare English string under a bilingual UI is the
# thing the canonical-patterns table forbids.
assert r.json()["detail"] == {"code": "url_blocked", "reason": urlguard.REASON_PRIVATE}
def test_a_public_url_gets_past_the_guard(db, as_user, make_user, monkeypatch):
# Mutation check for the test above: with a public answer the request must fail LATER (or
# succeed), never with url_blocked — otherwise the guard would be refusing everything and the
# first test would still pass.
user = make_user()
db.commit()
monkeypatch.setattr(
urlguard.socket,
"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")
def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeypatch):
# A YouTube job resolves to a fixed public host from an 11-char id, so there is no
# user-controlled destination — resolving one would be pure latency on every enqueue.
def boom(*a, **kw):
raise AssertionError("the URL guard resolved DNS for a YouTube source")
user = make_user()
db.commit()
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
as_user(user).post("/api/downloads", json={"source": "dQw4w9WgXcQ"})
def test_manual_subscription_sync_goes_on_cooldown(db, as_user, make_user, monkeypatch):
from app.routes import sync as syncroutes
user = make_user()
db.commit()
# Neither the YouTube grant nor the import itself is under test — the limiter is.
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
c = as_user(user)
codes = [c.post("/api/sync/subscriptions").status_code for _ in range(4)]
# 3 per 10 minutes: the first three pass, the fourth is refused.
assert codes[:3] == [200, 200, 200]
assert codes[3] == 429
assert c.post("/api/sync/subscriptions").json()["detail"] == {"code": "sync_cooldown"}
def test_the_cooldown_is_per_user_not_global(db, as_user, make_user, monkeypatch):
# Keyed by user id on purpose: the shared YouTube budget is spent per account, and one user
# exhausting their allowance must not lock everyone else out of their own sync button.
from app.routes import sync as syncroutes
a, b = make_user(), make_user()
db.commit()
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
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 == 429
assert as_user(b).post("/api/sync/subscriptions").status_code == 200
def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_user, monkeypatch):
# SEC-14, the sweep half: `revert-youtube` spends under the SAME QuotaAction.PLAYLISTS_PULL key
# as the bulk `sync-youtube`, so limiting only one of them left the identical drain open one
# route over. Both must draw on the one allowance.
from app.models import Playlist
from app.routes import playlists as plroutes
user = make_user()
pl = Playlist(user_id=user.id, name="Linked", source="youtube", yt_playlist_id="PL123")
db.add(pl)
db.commit()
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True})
c = as_user(user)
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
assert codes[:3] == [200, 200, 200]
assert codes[3] == 429
# One allowance, not one each: the bulk route is now on cooldown too.
r = c.post("/api/playlists/sync-youtube")
assert r.status_code == 429
assert r.json()["detail"] == {"code": "sync_cooldown"}