"""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_a_refused_push_spends_no_allowance(db, as_user, make_user, monkeypatch): # The allowance counts pushes that run: Watch later (never pushable) and a list another sync # holds are refused before any YouTube call, so they must not leave the next real press at 429. from contextlib import contextmanager from app.reader.models import Playlist from app.routes import playlists as plroutes user, pl = _pushable_list(db, make_user, monkeypatch, plroutes) wl = Playlist(user_id=user.id, name="Watch later", kind="watch_later") db.add(wl) db.commit() c = as_user(user) over = plroutes.PUSH_ALLOWANCE + 1 assert {c.get(f"/api/playlists/{wl.id}/push-plan").status_code for _ in range(over)} == {400} assert {c.post(f"/api/playlists/{wl.id}/push").status_code for _ in range(over)} == {400} @contextmanager def busy(_db, _id): yield False with monkeypatch.context() as m: m.setattr(plroutes, "sync_claim", busy) assert {c.post(f"/api/playlists/{pl.id}/push").status_code for _ in range(over)} == {409} assert c.post(f"/api/playlists/{pl.id}/push").status_code == 200 def test_a_mirrored_youtube_list_is_pushable(db, as_user, make_user, monkeypatch): # A mirror of the user's own YouTube list takes local edits (`dirty`) and syncs them back; only # Watch later is refused. from app.reader.models import Playlist from app.routes import playlists as plroutes user, _ = _pushable_list(db, make_user, monkeypatch, plroutes) mirror = Playlist( user_id=user.id, name="Mine", source="youtube", yt_playlist_id="PLx", dirty=True ) db.add(mirror) db.commit() assert as_user(user).get(f"/api/playlists/{mirror.id}/push-plan").status_code == 200 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