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.
This commit is contained in:
2026-11-06 04:11:36 +01:00
parent 5a93a9a8e7
commit 995a493bd3
4 changed files with 83 additions and 3 deletions
+64
View File
@@ -125,3 +125,67 @@ def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_u
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