fix(downloads): Retry and Re-download take a cookie added after the failure
The login-wall copy tells the user to add their cookies and press Retry, but Retry kept the job on the shared asset the worker never attaches a cookie to, so it failed on the same wall. Resume now moves a pending/errored job onto the asset enqueue would pick with the cookie (never off a file or a download in flight). Re-download salted its fresh asset with the job's previous asset id, which the cookie gate cannot recompute, so even a cookie download rebuilt without the cookie. The rebuild salt is now the new asset's own id (plus the user's cookie salt when one applies), and is_authed_asset recognises it.
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
"""Per-user, per-host download cookies — the single source of truth for "does the acting user's
|
"""Per-user, per-host download cookies — the single source of truth for "does the acting user's
|
||||||
cookie apply to this download, and how does that keep the result private".
|
cookie apply to this download, and how does that keep the result private".
|
||||||
|
|
||||||
Two call sites depend on ONE consistent answer (house rule: an invariant lives in shared code, not
|
Two sides depend on ONE consistent answer (house rule: an invariant lives in shared code, not
|
||||||
in prose):
|
in prose):
|
||||||
* `enqueue` salts the MediaAsset cache signature with the user id WHEN a cookie applies, so the
|
* `enqueue` salts the MediaAsset cache signature with the user id WHEN a cookie applies, so the
|
||||||
authed download becomes a PRIVATE asset that is never cache-served to another user.
|
authed download becomes a PRIVATE asset that is never cache-served to another user. Retry
|
||||||
|
(`service.resume`) and Re-download (`service.redownload`) ask the same question again, so a
|
||||||
|
cookie the user added after a failure goes with the next attempt.
|
||||||
* the worker attaches the cookie file to yt-dlp ONLY when a cookie still applies AND the asset it
|
* the worker attaches the cookie file to yt-dlp ONLY when a cookie still applies AND the asset it
|
||||||
is downloading into is that private, salted identity — so a cookie can never poison a shared
|
is downloading into is that private, salted identity — so a cookie can never poison a shared
|
||||||
asset (the load-bearing cross-user privacy fix).
|
asset (the load-bearing cross-user privacy fix).
|
||||||
@@ -30,7 +32,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.downloads import formats
|
from app.downloads import formats
|
||||||
from app.downloads.adapters import _host_is
|
from app.downloads.adapters import _host_is
|
||||||
from app.models import DownloadCredential
|
from app.models import DownloadCredential, MediaAsset
|
||||||
from app.security import decrypt_optional, encrypt
|
from app.security import decrypt_optional, encrypt
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.downloads.credentials")
|
log = logging.getLogger("siftlode.downloads.credentials")
|
||||||
@@ -104,12 +106,27 @@ def authed_format_sig(spec: dict, user_id: int, host_key: str) -> str:
|
|||||||
return formats.format_sig(spec, salt=cookie_salt(user_id, host_key))
|
return formats.format_sig(spec, salt=cookie_salt(user_id, host_key))
|
||||||
|
|
||||||
|
|
||||||
def is_authed_asset(asset_format_sig: str, spec: dict, user_id: int, url: str | None) -> bool:
|
def rebuild_salt(asset_id: int, cookie: str | None) -> str:
|
||||||
|
"""The salt of a Re-download's fresh asset: its OWN row id (so every rebuild is a new identity no
|
||||||
|
enqueue can ever compute), prefixed with the user's `cookie_salt` when a cookie applies. The id is
|
||||||
|
what makes it recognisable — the worker has the asset row, so `is_authed_asset` can recompute this
|
||||||
|
one, where a salt built from the job's previous asset id could not be."""
|
||||||
|
return f"{cookie}:rebuild:{asset_id}" if cookie else f"rebuild:{asset_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_authed_asset(asset: MediaAsset, spec: dict, user_id: int, url: str | None) -> bool:
|
||||||
"""Whether an asset is `user_id`'s private, cookie-salted identity for `url`'s host — the one
|
"""Whether an asset is `user_id`'s private, cookie-salted identity for `url`'s host — the one
|
||||||
kind of asset a cookie is ever attached to. The worker's cookie gate asks it before a download,
|
kind of asset a cookie is ever attached to: the one enqueue creates (`authed_format_sig`), or a
|
||||||
|
Re-download's rebuild of it (`rebuild_salt`). The worker's cookie gate asks it before a download,
|
||||||
and the failure diagnosis asks it after one (a failure on this asset happened WITH the cookie)."""
|
and the failure diagnosis asks it after one (a failure on this asset happened WITH the cookie)."""
|
||||||
host_key = host_key_for_url(url)
|
host_key = host_key_for_url(url)
|
||||||
return host_key is not None and asset_format_sig == authed_format_sig(spec, user_id, host_key)
|
if host_key is None:
|
||||||
|
return False
|
||||||
|
if asset.format_sig == authed_format_sig(spec, user_id, host_key):
|
||||||
|
return True
|
||||||
|
return asset.id is not None and asset.format_sig == formats.format_sig(
|
||||||
|
spec, salt=rebuild_salt(asset.id, cookie_salt(user_id, host_key))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def salt_for_enqueue(db: Session, user_id: int, url: str | None) -> str | None:
|
def salt_for_enqueue(db: Session, user_id: int, url: str | None) -> str | None:
|
||||||
@@ -211,7 +228,7 @@ def resolve_cookiefile(
|
|||||||
db: Session,
|
db: Session,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
url: str | None,
|
url: str | None,
|
||||||
asset_format_sig: str,
|
asset: MediaAsset,
|
||||||
spec: dict,
|
spec: dict,
|
||||||
staging: Path,
|
staging: Path,
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
@@ -220,7 +237,7 @@ def resolve_cookiefile(
|
|||||||
The double gate that prevents cross-user leakage:
|
The double gate that prevents cross-user leakage:
|
||||||
1. a credential must still exist for `url`'s host (host-scoped via `host_key_for_url`), AND
|
1. a credential must still exist for `url`'s host (host-scoped via `host_key_for_url`), AND
|
||||||
2. the asset being downloaded into must BE this user's private, salted identity
|
2. the asset being downloaded into must BE this user's private, salted identity
|
||||||
(`asset_format_sig == authed_format_sig(...)`).
|
(`is_authed_asset`).
|
||||||
|
|
||||||
If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the
|
If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the
|
||||||
global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key →
|
global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key →
|
||||||
@@ -232,7 +249,7 @@ def resolve_cookiefile(
|
|||||||
cred = get_credential(db, user_id, host_key)
|
cred = get_credential(db, user_id, host_key)
|
||||||
if cred is None:
|
if cred is None:
|
||||||
return None
|
return None
|
||||||
if not is_authed_asset(asset_format_sig, spec, user_id, url):
|
if not is_authed_asset(asset, spec, user_id, url):
|
||||||
return None # shared asset — never attach a cookie to a non-private identity
|
return None # shared asset — never attach a cookie to a non-private identity
|
||||||
raw = decrypt_optional(cred.cookies_enc)
|
raw = decrypt_optional(cred.cookies_enc)
|
||||||
if not raw:
|
if not raw:
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from datetime import date, datetime, timedelta, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -446,12 +445,10 @@ def _download(
|
|||||||
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
||||||
remaining_bytes: int | None = None
|
remaining_bytes: int | None = None
|
||||||
with SessionLocal() as cdb:
|
with SessionLocal() as cdb:
|
||||||
asset_sig = cdb.execute(
|
target = cdb.get(MediaAsset, asset_id)
|
||||||
select(MediaAsset.format_sig).where(MediaAsset.id == asset_id)
|
if target is not None:
|
||||||
).scalar_one_or_none()
|
|
||||||
if asset_sig is not None:
|
|
||||||
cookiefile = credentials.resolve_cookiefile(
|
cookiefile = credentials.resolve_cookiefile(
|
||||||
cdb, user_id, url, asset_sig, spec, staging
|
cdb, user_id, url, target, spec, staging
|
||||||
)
|
)
|
||||||
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
||||||
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ handles everything else. State transitions used by the routes (pause/resume/canc
|
|||||||
the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping.
|
the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
@@ -341,7 +342,11 @@ def pause(db: Session, job: DownloadJob) -> None:
|
|||||||
def resume(db: Session, job: DownloadJob) -> None:
|
def resume(db: Session, job: DownloadJob) -> None:
|
||||||
"""Requeue a paused/errored job for a fresh attempt, clearing its error. If the SHARED asset itself
|
"""Requeue a paused/errored job for a fresh attempt, clearing its error. If the SHARED asset itself
|
||||||
failed, reset it to pending so the worker actually re-downloads — otherwise the requeued job would
|
failed, reset it to pending so the worker actually re-downloads — otherwise the requeued job would
|
||||||
instantly re-inherit the asset's stale error (the worker short-circuits an errored asset)."""
|
instantly re-inherit the asset's stale error (the worker short-circuits an errored asset).
|
||||||
|
|
||||||
|
A cookie the user added AFTER the job was queued moves the job onto their private asset first
|
||||||
|
(`_follow_the_cookie`): the worker only ever attaches a cookie to that identity, so without the move
|
||||||
|
the Retry a login-wall message asks for would fail on the same wall."""
|
||||||
if job.status in (JobStatus.PAUSED, JobStatus.ERROR):
|
if job.status in (JobStatus.PAUSED, JobStatus.ERROR):
|
||||||
job.progress = 0
|
job.progress = 0
|
||||||
job.error = None
|
job.error = None
|
||||||
@@ -349,6 +354,8 @@ def resume(db: Session, job: DownloadJob) -> None:
|
|||||||
job.error_detail = None
|
job.error_detail = None
|
||||||
if job.asset_id:
|
if job.asset_id:
|
||||||
asset = db.get(MediaAsset, job.asset_id)
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
if asset is not None:
|
||||||
|
asset = _follow_the_cookie(db, job, asset)
|
||||||
if asset and asset.status == "error": # MediaAsset.status (no enum), not a JobStatus
|
if asset and asset.status == "error": # MediaAsset.status (no enum), not a JobStatus
|
||||||
asset.status = "pending"
|
asset.status = "pending"
|
||||||
asset.error = None
|
asset.error = None
|
||||||
@@ -356,6 +363,32 @@ def resume(db: Session, job: DownloadJob) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _follow_the_cookie(db: Session, job: DownloadJob, asset: MediaAsset) -> MediaAsset:
|
||||||
|
"""Re-point a requeued job at the asset enqueue would pick NOW, when the user has a cookie for the
|
||||||
|
link's host but the job still holds an asset the worker won't attach it to (queued before the
|
||||||
|
cookie existed). Returns the asset the job holds afterwards.
|
||||||
|
|
||||||
|
Only a `pending` / `error` asset moves — neither has a file nor a download in flight. A `ready` one
|
||||||
|
already has the file, and a `downloading` one may be this job's own child still being stopped by
|
||||||
|
the pause; the next Retry after it settles moves the job then. Removing a cookie never moves a job
|
||||||
|
back: the worker attaches nothing without a credential, so the private asset is as good as any."""
|
||||||
|
if asset.status not in ("pending", "error"):
|
||||||
|
return asset
|
||||||
|
url = source_url(job.source_kind, job.source_ref)
|
||||||
|
salt = credentials.salt_for_enqueue(db, job.user_id, url)
|
||||||
|
spec = formats.normalize(job.profile_snapshot)
|
||||||
|
if salt is None or credentials.is_authed_asset(asset, spec, job.user_id, url):
|
||||||
|
return asset
|
||||||
|
target = get_or_create_asset(
|
||||||
|
db, job.source_kind, job.source_ref, formats.format_sig(spec, salt=salt)
|
||||||
|
)
|
||||||
|
populate_from_catalog(db, target)
|
||||||
|
release_asset(db, job) # drop the old hold BEFORE re-pointing — it keys off job.asset_id
|
||||||
|
incr_ref_count(db, target.id)
|
||||||
|
job.asset_id = target.id
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
def cancel(db: Session, job: DownloadJob) -> None:
|
def cancel(db: Session, job: DownloadJob) -> None:
|
||||||
"""Cancel a job unless it's already done/canceled, releasing its asset hold WHILE it still counts
|
"""Cancel a job unless it's already done/canceled, releasing its asset hold WHILE it still counts
|
||||||
as holding — `canceled` ∉ HOLDING, so a later delete won't double-release."""
|
as holding — `canceled` ∉ HOLDING, so a later delete won't double-release."""
|
||||||
@@ -374,13 +407,31 @@ def delete(db: Session, job: DownloadJob) -> None:
|
|||||||
|
|
||||||
def redownload(db: Session, job: DownloadJob) -> None:
|
def redownload(db: Session, job: DownloadJob) -> None:
|
||||||
"""Rebuild a finished/failed job's file into a FRESH, private asset (the cache sig is salted with
|
"""Rebuild a finished/failed job's file into a FRESH, private asset (the cache sig is salted with
|
||||||
the job id + current asset id, so it never reuses the dedup pool) and requeue the SAME job row —
|
the new asset's own id, so it never reuses the dedup pool) and requeue the SAME job row —
|
||||||
so any public share link (DownloadLink -> job_id) keeps resolving to the newly-downloaded file.
|
so any public share link (DownloadLink -> job_id) keeps resolving to the newly-downloaded file.
|
||||||
The old hold is dropped first (freeing the old file iff this job was its last holder). The caller
|
The old hold is dropped first (freeing the old file iff this job was its last holder). The caller
|
||||||
has already rejected edit clips + still-in-progress jobs (HTTP 409)."""
|
has already rejected edit clips + still-in-progress jobs (HTTP 409).
|
||||||
|
|
||||||
|
When the user has a cookie for the link's host, the salt carries it (`credentials.rebuild_salt`),
|
||||||
|
so the worker recognises the rebuild as their private identity and attaches the cookie — a cookie
|
||||||
|
download, or one the user added after a failure, rebuilds with the cookie."""
|
||||||
spec = formats.normalize(job.profile_snapshot)
|
spec = formats.normalize(job.profile_snapshot)
|
||||||
sig = formats.format_sig(spec, salt=f"{job.id}:{job.asset_id}")
|
cookie = credentials.salt_for_enqueue(
|
||||||
asset = get_or_create_asset(db, job.source_kind, job.source_ref, sig)
|
db, job.user_id, source_url(job.source_kind, job.source_ref)
|
||||||
|
)
|
||||||
|
# The salt names the row's own id, which exists only once the row does: insert it under a unique
|
||||||
|
# placeholder, then write the real signature in the same transaction (nobody sees the placeholder).
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind=job.source_kind,
|
||||||
|
source_ref=job.source_ref,
|
||||||
|
format_sig=f"rebuild:{uuid.uuid4().hex}",
|
||||||
|
status="pending",
|
||||||
|
ref_count=0,
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
|
asset.format_sig = formats.format_sig(spec, salt=credentials.rebuild_salt(asset.id, cookie))
|
||||||
|
db.flush()
|
||||||
populate_from_catalog(db, asset)
|
populate_from_catalog(db, asset)
|
||||||
# Drop the old hold BEFORE re-pointing the job — release_asset keys off the job's current asset.
|
# Drop the old hold BEFORE re-pointing the job — release_asset keys off the job's current asset.
|
||||||
release_asset(db, job)
|
release_asset(db, job)
|
||||||
|
|||||||
@@ -630,8 +630,9 @@ def redownload_download(
|
|||||||
resolves to the newly-downloaded file — a re-download never breaks a link you already sent out
|
resolves to the newly-downloaded file — a re-download never breaks a link you already sent out
|
||||||
(only a *failed* re-download leaves the job in `error`, which is the intended broken-link case).
|
(only a *failed* re-download leaves the job in `error`, which is the intended broken-link case).
|
||||||
|
|
||||||
The rebuild always re-fetches into a FRESH, private asset (the cache sig is salted with the job
|
The rebuild always re-fetches into a FRESH, private asset (the cache sig is salted with the new
|
||||||
id + current asset id) rather than the dedup pool. That means it (a) always genuinely rebuilds
|
asset's own id, and the user's cookie salt when one applies — `credentials.rebuild_salt`) rather
|
||||||
|
than the dedup pool. That means it (a) always genuinely rebuilds
|
||||||
under the current format/naming rules — never silently reuses a shared cached file — and (b)
|
under the current format/naming rules — never silently reuses a shared cached file — and (b)
|
||||||
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
||||||
was its last holder."""
|
was its last holder."""
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ def _process_job(job_id: int) -> None:
|
|||||||
asset_error,
|
asset_error,
|
||||||
url=url,
|
url=url,
|
||||||
cookie_attached=credentials.is_authed_asset(
|
cookie_attached=credentials.is_authed_asset(
|
||||||
asset.format_sig, formats.normalize(job.profile_snapshot), job.user_id, url
|
asset, formats.normalize(job.profile_snapshot), job.user_id, url
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from cryptography.fernet import Fernet
|
|||||||
|
|
||||||
from app import security
|
from app import security
|
||||||
from app.downloads import credentials, formats, service
|
from app.downloads import credentials, formats, service
|
||||||
from app.models import DownloadCredential, MediaAsset, User
|
from app.models import DownloadCredential, DownloadJob, MediaAsset, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -189,7 +189,9 @@ class TestResolveCookiefile:
|
|||||||
db.commit()
|
db.commit()
|
||||||
spec = formats.normalize({})
|
spec = formats.normalize({})
|
||||||
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
path = credentials.resolve_cookiefile(db, u.id, FB_URL, salted, spec, tmp_path)
|
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
|
assert path is not None and Path(path).read_text() == COOKIES
|
||||||
|
|
||||||
def test_never_attaches_to_a_shared_asset(self, db, tmp_path):
|
def test_never_attaches_to_a_shared_asset(self, db, tmp_path):
|
||||||
@@ -200,7 +202,12 @@ class TestResolveCookiefile:
|
|||||||
db.commit()
|
db.commit()
|
||||||
spec = formats.normalize({})
|
spec = formats.normalize({})
|
||||||
shared = formats.format_sig(spec)
|
shared = formats.format_sig(spec)
|
||||||
assert credentials.resolve_cookiefile(db, u.id, FB_URL, shared, spec, tmp_path) is None
|
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):
|
def test_non_matching_host_gets_no_cookie(self, db, tmp_path):
|
||||||
u = _user(db)
|
u = _user(db)
|
||||||
@@ -210,13 +217,141 @@ class TestResolveCookiefile:
|
|||||||
# A YouTube URL has no supported host_key -> no cookie regardless of what's stored.
|
# A YouTube URL has no supported host_key -> no cookie regardless of what's stored.
|
||||||
yt = "https://www.youtube.com/watch?v=abc"
|
yt = "https://www.youtube.com/watch?v=abc"
|
||||||
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
assert credentials.resolve_cookiefile(db, u.id, yt, salted, spec, tmp_path) is None
|
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):
|
def test_no_credential_gets_no_cookie(self, db, tmp_path):
|
||||||
u = _user(db)
|
u = _user(db)
|
||||||
spec = formats.normalize({})
|
spec = formats.normalize({})
|
||||||
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
assert credentials.resolve_cookiefile(db, u.id, FB_URL, salted, spec, tmp_path) is None
|
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 (E11.5): 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({})
|
||||||
|
|
||||||
|
def test_retry_after_adding_a_cookie_moves_the_job_to_the_private_asset(self, db, tmp_path):
|
||||||
|
u = _user(db)
|
||||||
|
shared, j = _held_job(db, u, sig=formats.format_sig(self.spec))
|
||||||
|
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) ----------------------------------------------
|
# --- API: write-only, never echoes the blob (DB) ----------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user