"""The password unlock of a shared /watch link (routes/public.py), DB-lane. Guesses are slowed per viewer (the link AND the client), under a per-link ceiling on wrong passwords, so someone who holds a password link without its password can't lock out the viewers who have it. """ import pytest from fastapi.testclient import TestClient from app.config import settings from app.downloads import probe as probemod from app.main import app from app.ratelimit import RateLimiter from app.routes import public _PW = "open sesame" @pytest.fixture def unlock_url(as_user, make_user, tmp_path, monkeypatch): monkeypatch.setattr(settings, "download_root", str(tmp_path)) monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 320, 240, 5)) owner = as_user(make_user()) job = owner.post( "/api/downloads/upload", files={"file": ("clip.mp4", b"fake-video", "video/mp4")} ) assert job.status_code == 200, job.text link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW}) assert link.status_code == 200, link.text return f"/api/public/watch/{link.json()['token']}/unlock" def _from(ip: str) -> TestClient: # Its own socket peer: with no trusted proxy configured, that is the client's IP. return TestClient(app, client=(ip, 50000)) def test_a_link_holder_without_the_password_cannot_lock_out_its_viewers(unlock_url): # The limit used to be keyed on the link alone: ten wrong guesses every five minutes kept every # viewer out, the ones with the password included. guesser = _from("203.0.113.5") for _ in range(10): assert guesser.post(unlock_url, json={"password": "guess"}).status_code == 403 assert guesser.post(unlock_url, json={"password": "guess"}).status_code == 429 assert _from("198.51.100.7").post(unlock_url, json={"password": _PW}).status_code == 200 def test_wrong_passwords_from_many_clients_still_reach_the_links_ceiling(unlock_url, monkeypatch): monkeypatch.setattr(public, "_unlock_failures", RateLimiter(max_events=3, window_seconds=300)) viewer = _from("198.51.100.7") for _ in range(5): # unlocking with the password never adds to the ceiling assert viewer.post(unlock_url, json={"password": _PW}).status_code == 200 for n in range(3): wrong = _from(f"203.0.113.{n}").post(unlock_url, json={"password": "guess"}) assert wrong.status_code == 403 late = _from("203.0.113.9").post(unlock_url, json={"password": "guess"}) assert late.status_code == 429