Files
siftlode/backend/tests/test_abuse_limits.py
T
peter 995a493bd3 fix(playlists): the push side spends its own allowance, so a scripted loop cannot drain the shared YouTube budget
push-plan reads the live list and push reads and writes it, both against the
instance-wide budget, and neither had a limit. They now share one per-user
allowance of 20 calls per 10 minutes, far above a person's plan-then-push,
refused as playlist/push_cooldown with a sentence in both languages.
2026-11-06 04:11:36 +01:00

192 lines
8.3 KiB
Python

"""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))],
)
# 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.
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):
# 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)
# 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.
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):
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)
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
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.reader.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, "pull_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"}
def _pushable_list(db, make_user, monkeypatch, plroutes):
"""A local (never exported) list and a user with the write scope; the merge itself is stubbed —
the limiter is under test, not the YouTube round trip."""
from app import auth
from app.reader.models import Playlist
user = make_user()
pl = Playlist(user_id=user.id, name="Local", source="local")
db.add(pl)
db.commit()
monkeypatch.setattr(auth, "has_write_scope", lambda u: True)
monkeypatch.setattr(plroutes, "plan_merge", lambda d, u, p, live=None: {"units_estimate": 0})
monkeypatch.setattr(plroutes, "merge_playlist", lambda d, u, p, live=None: {"added": 0})
return user, pl
def test_the_push_side_goes_on_cooldown_after_its_allowance(db, as_user, make_user, monkeypatch):
# SEC-18: push-plan reads the live list and push writes it, both against the instance-wide
# YouTube budget. A press in the UI is one plan + one push, so the allowance is generous — but a
# scripted loop stops at it, with a reason the UI can say.
from app.routes import playlists as plroutes
user, pl = _pushable_list(db, make_user, monkeypatch, plroutes)
c = as_user(user)
allowance = plroutes.PUSH_ALLOWANCE
codes = [c.get(f"/api/playlists/{pl.id}/push-plan").status_code for _ in range(allowance)]
assert codes == [200] * allowance
r = c.get(f"/api/playlists/{pl.id}/push-plan")
assert r.status_code == 429
assert r.json()["detail"] == {"code": "playlist", "reason": "push_cooldown"}
def test_plan_and_push_draw_on_one_allowance(db, as_user, make_user, monkeypatch):
# The asymmetric fix again, avoided up front: a limit on one of the two routes leaves the same
# drain open next door.
from app.routes import playlists as plroutes
user, pl = _pushable_list(db, make_user, monkeypatch, plroutes)
c = as_user(user)
for i in range(plroutes.PUSH_ALLOWANCE):
if i % 2:
r = c.get(f"/api/playlists/{pl.id}/push-plan")
else:
r = c.post(f"/api/playlists/{pl.id}/push")
assert r.status_code == 200, (i, r.json())
r = c.post(f"/api/playlists/{pl.id}/push")
assert r.status_code == 429
assert r.json()["detail"] == {"code": "playlist", "reason": "push_cooldown"}
def test_the_push_cooldown_is_per_user(db, as_user, make_user, monkeypatch):
from app.routes import playlists as plroutes
a, pa = _pushable_list(db, make_user, monkeypatch, plroutes)
b, pb = _pushable_list(db, make_user, monkeypatch, plroutes)
ca = as_user(a)
codes = [
ca.get(f"/api/playlists/{pa.id}/push-plan").status_code
for _ in range(plroutes.PUSH_ALLOWANCE + 1)
]
assert codes == [200] * plroutes.PUSH_ALLOWANCE + [429]
assert as_user(b).get(f"/api/playlists/{pb.id}/push-plan").status_code == 200