"""Per-user, per-host download cookies: host-scope, cache privacy, encryption, write-only API. The two load-bearing security properties are covered as behaviour, not prose: * **cache privacy** — a cookie-authed download is a PRIVATE, salted asset; a second user without a cookie enqueuing the same URL gets the shared (unsalted) asset and can never be cache-served the authed one, and the worker attaches a cookie ONLY to that private identity. * **host scope (SEC-12)** — a cookie is resolved ONLY for a URL whose netloc the credential owns; a look-alike or unrelated host resolves to no host_key and gets no cookie. Pure-logic parts run with no DB; the CRUD / enqueue-salting / API parts use the `db`/`as_user` lane. """ import os from pathlib import Path import pytest from cryptography.fernet import Fernet from app import security from app.downloads import credentials, formats, service from app.models import User from app.downloads.models import DownloadCredential, DownloadJob, MediaAsset @pytest.fixture(autouse=True) def _fernet(monkeypatch): # A throwaway key so encrypt/decrypt work without TOKEN_ENCRYPTION_KEY in the env (mirrors # test_security). Autouse: every credential write in this module encrypts. monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key())) COOKIES = "# Netscape HTTP Cookie File\n.facebook.com\tTRUE\t/\tTRUE\t0\tc_user\t123\n" # --- host scope (pure logic) ------------------------------------------------------------------ class TestHostKeyForUrl: def test_matches_a_supported_host(self): assert credentials.host_key_for_url("https://www.facebook.com/watch?v=1") == "facebook" assert credentials.host_key_for_url("https://fb.watch/abc/") == "facebook" assert credentials.host_key_for_url("https://x.com/i/status/1") == "twitter" # YouTube gates individual videos behind its bot check; the cookie is the only way past it. for url in ( "https://www.youtube.com/watch?v=abc", "https://youtu.be/abc", "https://music.youtube.com/watch?v=abc", "https://www.youtube-nocookie.com/embed/abc", ): assert credentials.host_key_for_url(url) == "youtube", url def test_look_alike_host_never_matches(self): assert credentials.host_key_for_url("https://myfacebook.com/x") is None assert credentials.host_key_for_url("https://facebook.com.evil.test/x") is None assert credentials.host_key_for_url("https://notyoutube.com/watch?v=abc") is None assert credentials.host_key_for_url("https://youtube.com.evil.test/x") is None def test_unrelated_or_empty_url_has_no_host_key(self): assert credentials.host_key_for_url("https://vimeo.com/123456") is None assert credentials.host_key_for_url("") is None assert credentials.host_key_for_url(None) is None class TestValidateCookies: def test_accepts_a_netscape_file(self): assert credentials.validate_cookies(COOKIES).endswith("\n") def test_rejects_empty(self): with pytest.raises(credentials.InvalidCookies): credentials.validate_cookies(" ") def test_rejects_non_cookie_paste(self): # Comments only, no tab-separated data line -> not a usable cookies.txt. with pytest.raises(credentials.InvalidCookies): credentials.validate_cookies("just some pasted text\n# a comment") def test_authed_sig_differs_per_user_and_from_the_shared_sig(): spec = formats.normalize({}) shared = formats.format_sig(spec) a = credentials.authed_format_sig(spec, 1, "facebook") b = credentials.authed_format_sig(spec, 2, "facebook") assert a != shared and b != shared and a != b def test_an_authed_asset_queued_before_a_signature_version_bump_still_gets_its_cookie(monkeypatch): # A job keeps the asset it was queued with. A deploy that bumps the signature version between the # queueing and the download used to make the user's private asset stop counting as theirs, so the # download went without its cookie and failed on the login wall. spec = formats.normalize({"mode": "a"}) queued = MediaAsset(format_sig=credentials.authed_format_sig(spec, 1, "facebook")) rebuild = credentials.rebuild_salt(7, credentials.cookie_salt(1, "facebook")) rebuilt = MediaAsset(id=7, format_sig=formats.format_sig(spec, salt=rebuild)) monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", formats._AUDIO_SIG_VERSION + 1) # the deploy for asset in (queued, rebuilt): assert credentials.is_authed_asset(asset, spec, 1, FB_URL) assert not credentials.is_authed_asset(asset, spec, 2, FB_URL) # still private to its user def test_write_cookiefile_is_private_and_survives_the_retry_wipe(tmp_path): staging = tmp_path / "staging" staging.mkdir() path = credentials._write_cookiefile(COOKIES, staging) assert path.read_text() == COOKIES # 0600 (owner-only) — POSIX perms only (the worker is Linux-only; Windows has no mode bits). if os.name == "posix": assert (path.stat().st_mode & 0o777) == 0o600 # It lives in a SUBDIR so the download retry loop (which unlinks files in the staging ROOT) and # _produced_file (which scans the root) never see it. assert path.parent == staging / ".auth" assert not any(p.is_file() for p in staging.iterdir()) # --- CRUD + encryption at rest (DB) ----------------------------------------------------------- def _user(db, email="cred@example.com") -> User: u = User(email=email) db.add(u) db.flush() return u class TestCrud: def test_upsert_stores_ciphertext_not_plaintext(self, db): u = _user(db) credentials.upsert_credential(db, u.id, "facebook", COOKIES) db.commit() row = db.get(DownloadCredential, 1) assert row.cookies_enc != COOKIES # encrypted at rest assert COOKIES not in row.cookies_enc assert security.decrypt(row.cookies_enc) == COOKIES def test_upsert_replaces_in_place(self, db): u = _user(db) credentials.upsert_credential(db, u.id, "facebook", COOKIES) db.commit() credentials.upsert_credential(db, u.id, "facebook", COOKIES.replace("123", "999")) db.commit() rows = db.query(DownloadCredential).filter_by(user_id=u.id, host_key="facebook").all() assert len(rows) == 1 assert "999" in security.decrypt(rows[0].cookies_enc) def test_status_list_never_leaks_the_blob(self, db): u = _user(db) credentials.upsert_credential(db, u.id, "facebook", COOKIES) db.commit() status = credentials.status_list(db, u.id) fb = next(h for h in status if h["host_key"] == "facebook") assert fb["configured"] is True and fb["updated_at"] is not None ig = next(h for h in status if h["host_key"] == "instagram") assert ig["configured"] is False and ig["updated_at"] is None # No field anywhere carries the cookie value. assert all(COOKIES not in str(v) for h in status for v in h.values()) def test_delete(self, db): u = _user(db) credentials.upsert_credential(db, u.id, "facebook", COOKIES) db.commit() assert credentials.delete_credential(db, u.id, "facebook") is True db.commit() assert credentials.get_credential(db, u.id, "facebook") is None assert credentials.delete_credential(db, u.id, "facebook") is False # --- the crux: enqueue salts privately, no cross-user cache serve (DB) ------------------------- FB_URL = "https://www.facebook.com/watch?v=100" class TestCachePrivacy: def test_enqueue_with_a_cookie_creates_a_private_salted_asset(self, db): u = _user(db) credentials.upsert_credential(db, u.id, "facebook", COOKIES) db.commit() job = service.enqueue(db, u.id, "url", FB_URL, {}) asset = db.get(MediaAsset, job.asset_id) spec = formats.normalize({}) assert asset.format_sig == credentials.authed_format_sig(spec, u.id, "facebook") assert asset.format_sig != formats.format_sig(spec) def test_enqueue_without_a_cookie_uses_the_shared_sig(self, db): u = _user(db) job = service.enqueue(db, u.id, "url", FB_URL, {}) asset = db.get(MediaAsset, job.asset_id) assert asset.format_sig == formats.format_sig(formats.normalize({})) def test_a_youtube_job_reaches_the_gate_through_its_rebuilt_watch_url(self, db): # The one join nothing else makes. A YouTube job stores a bare 11-char ref, and the URL the # cookie gate keys on is REBUILT by the adapter (service.source_url -> YouTubeAdapter). Both # ends are pinned apart — the watch-URL shape in test_adapters, the host_key in # TestHostKeyForUrl — so this welds them: an enqueue that ever handed the gate the raw ref # would silently stop salting, every YouTube cookie would quietly stop being attached, and # signed-out downloads would keep working, so nothing else here would go red. u = _user(db) credentials.upsert_credential(db, u.id, "youtube", COOKIES) db.commit() job = service.enqueue(db, u.id, "youtube", "VIDID12345_", {}) asset = db.get(MediaAsset, job.asset_id) spec = formats.normalize({}) assert asset.format_sig == credentials.authed_format_sig(spec, u.id, "youtube") assert asset.format_sig != formats.format_sig(spec) def test_two_users_never_share_the_authed_asset(self, db): a = _user(db, "a@example.com") b = _user(db, "b@example.com") credentials.upsert_credential(db, a.id, "facebook", COOKIES) db.commit() ja = service.enqueue(db, a.id, "url", FB_URL, {}) # A: authed, private jb = service.enqueue(db, b.id, "url", FB_URL, {}) # B: no cookie, shared assert ja.asset_id != jb.asset_id # B's asset is the shared identity — A's private group video can never be served to B. assert db.get(MediaAsset, jb.asset_id).format_sig == formats.format_sig( formats.normalize({}) ) # --- the worker gate: attach only to the private identity, host-scoped (DB) -------------------- class TestResolveCookiefile: def _asset_sig(self, sig: str) -> MediaAsset: return MediaAsset( source_kind="url", source_ref=FB_URL, format_sig=sig, status="downloading" ) def test_attaches_to_the_private_salted_asset(self, db, tmp_path): u = _user(db) credentials.upsert_credential(db, u.id, "facebook", COOKIES) db.commit() spec = formats.normalize({}) salted = credentials.authed_format_sig(spec, u.id, "facebook") path = credentials.resolve_cookiefile( db, u.id, FB_URL, self._asset_sig(salted), spec, tmp_path ) assert path is not None and Path(path).read_text() == COOKIES def test_never_attaches_to_a_shared_asset(self, db, tmp_path): # Cookie exists, but the asset is the UNSALTED shared identity -> attaching would poison the # global cache with a private-group video. Must return None. u = _user(db) credentials.upsert_credential(db, u.id, "facebook", COOKIES) db.commit() spec = formats.normalize({}) shared = formats.format_sig(spec) assert ( credentials.resolve_cookiefile( db, u.id, FB_URL, self._asset_sig(shared), spec, tmp_path ) is None ) def test_non_matching_host_gets_no_cookie(self, db, tmp_path): u = _user(db) credentials.upsert_credential(db, u.id, "facebook", COOKIES) db.commit() spec = formats.normalize({}) # A URL of ANOTHER host (supported or not) never gets this credential's cookie. YouTube is # a supported host in its own right now, so the case needs a host that is not. other = "https://vimeo.com/123456" salted = credentials.authed_format_sig(spec, u.id, "facebook") assert ( credentials.resolve_cookiefile(db, u.id, other, self._asset_sig(salted), spec, tmp_path) is None ) # ...and a YouTube link gets nothing either: the stored credential is Facebook's. yt = "https://www.youtube.com/watch?v=abc" assert ( credentials.resolve_cookiefile(db, u.id, yt, self._asset_sig(salted), spec, tmp_path) is None ) def test_no_credential_gets_no_cookie(self, db, tmp_path): u = _user(db) spec = formats.normalize({}) salted = credentials.authed_format_sig(spec, u.id, "facebook") assert ( credentials.resolve_cookiefile( db, u.id, FB_URL, self._asset_sig(salted), spec, tmp_path ) is None ) # --- Retry / Re-download take a cookie added later (DB) ----------------------------------------- # Measured on dev: a Facebook reel failed on the login wall, the user added their cookie as # the failure copy tells them to, pressed Retry — and it failed again, because the job still held the # shared asset the worker never attaches a cookie to. Re-download forked a salt the gate could not # recognise, so even a job downloaded WITH a cookie rebuilt without it. def _held_job(db, u, *, sig, asset_status="error", job_status="error"): a = MediaAsset( source_kind="url", source_ref=FB_URL, format_sig=sig, status=asset_status, ref_count=1 ) db.add(a) db.flush() j = DownloadJob( user_id=u.id, asset_id=a.id, source_kind="url", source_ref=FB_URL, status=job_status, queue_pos=0, profile_snapshot={}, ) db.add(j) db.commit() return a, j class TestRequeueTakesTheCookie: spec = formats.normalize({}) @pytest.mark.parametrize( ("asset_status", "job_status"), # A failed download; and a job paused before it ever started (its asset never downloaded). [("error", "error"), ("pending", "paused")], ) def test_retry_after_adding_a_cookie_moves_the_job_to_the_private_asset( self, db, tmp_path, asset_status, job_status ): u = _user(db) shared, j = _held_job( db, u, sig=formats.format_sig(self.spec), asset_status=asset_status, job_status=job_status, ) credentials.upsert_credential(db, u.id, "facebook", COOKIES) db.commit() service.resume(db, j) db.expire_all() job = db.get(DownloadJob, j.id) target = db.get(MediaAsset, job.asset_id) assert job.status == "queued" and target.id != shared.id assert target.format_sig == credentials.authed_format_sig(self.spec, u.id, "facebook") assert (target.status, target.ref_count) == ("pending", 1) assert db.get(MediaAsset, shared.id).ref_count == 0 # the old hold went with the move assert credentials.resolve_cookiefile(db, u.id, FB_URL, target, self.spec, tmp_path) def test_retry_without_a_cookie_keeps_the_shared_asset(self, db): u = _user(db) shared, j = _held_job(db, u, sig=formats.format_sig(self.spec)) service.resume(db, j) db.expire_all() assert db.get(DownloadJob, j.id).asset_id == shared.id assert db.get(MediaAsset, shared.id).ref_count == 1 @pytest.mark.parametrize( ("asset_status", "job_status"), [("ready", "error"), ("downloading", "paused")] ) def test_retry_never_moves_off_a_file_or_a_download_in_flight( self, db, asset_status, job_status ): u = _user(db) shared, j = _held_job( db, u, sig=formats.format_sig(self.spec), asset_status=asset_status, job_status=job_status, ) credentials.upsert_credential(db, u.id, "facebook", COOKIES) db.commit() service.resume(db, j) db.expire_all() assert db.get(DownloadJob, j.id).asset_id == shared.id def test_redownload_with_a_cookie_rebuilds_into_an_asset_the_cookie_goes_with( self, db, tmp_path ): u = _user(db) other = _user(db, "other@example.com") credentials.upsert_credential(db, u.id, "facebook", COOKIES) credentials.upsert_credential(db, other.id, "facebook", COOKIES) authed = credentials.authed_format_sig(self.spec, u.id, "facebook") first, j = _held_job(db, u, sig=authed, asset_status="ready", job_status="done") seen = {first.id} for _ in range(2): # a rebuild of a rebuild is still recognised service.redownload(db, j) db.expire_all() job = db.get(DownloadJob, j.id) target = db.get(MediaAsset, job.asset_id) assert target.id not in seen and target.format_sig != authed seen.add(target.id) assert credentials.resolve_cookiefile(db, u.id, FB_URL, target, self.spec, tmp_path) # Private: another user's cookie is never attached to it. assert not credentials.is_authed_asset(target, self.spec, other.id, FB_URL) # A Retry of a failed rebuild keeps the rebuild — it is already the cookie's identity. target.status, job.status = "error", "error" db.commit() service.resume(db, job) db.expire_all() assert db.get(DownloadJob, j.id).asset_id == target.id def test_a_rebuild_made_without_a_cookie_is_no_cookie_identity(self, db, tmp_path): u = _user(db) _, j = _held_job(db, u, sig=formats.format_sig(self.spec)) service.redownload(db, j) credentials.upsert_credential(db, u.id, "facebook", COOKIES) db.commit() db.expire_all() target = db.get(MediaAsset, db.get(DownloadJob, j.id).asset_id) assert not credentials.is_authed_asset(target, self.spec, u.id, FB_URL) # --- API: write-only, never echoes the blob (DB) ---------------------------------------------- class TestApi: def test_put_get_delete_roundtrip_never_returns_the_blob(self, db, as_user): u = _user(db) db.commit() client = as_user(u) r = client.put("/api/downloads/credentials/facebook", json={"cookies": COOKIES}) assert r.status_code == 200 body = r.json() assert body["ok"] is True assert COOKIES not in r.text # the response must never carry the value fb = next(h for h in body["hosts"] if h["host_key"] == "facebook") assert fb["configured"] is True r = client.get("/api/downloads/credentials") assert r.status_code == 200 and COOKIES not in r.text r = client.delete("/api/downloads/credentials/facebook") assert r.status_code == 200 assert credentials.get_credential(db, u.id, "facebook") is None def test_unknown_host_is_404(self, db, as_user): u = _user(db) db.commit() r = as_user(u).put("/api/downloads/credentials/nope", json={"cookies": COOKIES}) assert r.status_code == 404 def test_bad_cookies_are_rejected(self, db, as_user): u = _user(db) db.commit() r = as_user(u).put("/api/downloads/credentials/facebook", json={"cookies": " "}) assert r.status_code == 400 assert r.json()["detail"]["code"] == "cookies"