feat(sources): pre-flight credential verify on backfill/recovery arm — #703 step 2
Before arming a deep walk on a native-ingester platform (Patreon — where
verify is one cheap API page), POST /sources/{id}/backfill {start|recover}
runs the shared verify_source_credential first and REFUSES (409 + reason)
only on a definitive rejection (verify→False, e.g. expired cookies). It
proceeds on valid (True) or inconclusive (None — a network blip must not
block). Gated to native platforms: gallery-dl verify is a slow --simulate
subprocess, too heavy for an arm action. The credential read happens in a
session that's CLOSED before the verify network call (no held conn).
Frontend: onBackfill/onRecover now read e.body.detail (ApiError carries the
reason in .body, not .detail) so the rejection text surfaces in the toast.
Tests: arm blocked on rejection (409, source not armed), proceeds on
inconclusive, stop never pre-flights, gallery-dl platform skips pre-flight.
An autouse fixture stubs verify to 'valid' for the existing backfill
endpoint tests so they stay network-free.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,20 @@ from backend.app.models import Artist, Source
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_preflight_verify(monkeypatch):
|
||||
"""Backfill/recover arms run a pre-flight credential verify (plan #703 #2)
|
||||
which for Patreon would hit the network. Default it to 'valid' so the
|
||||
endpoint tests stay network-free; the dedicated pre-flight tests override
|
||||
this with a rejection/inconclusive verdict."""
|
||||
from backend.app.services import download_backends as db_mod
|
||||
|
||||
async def _ok(**kwargs):
|
||||
return (True, "Credentials valid")
|
||||
|
||||
monkeypatch.setattr(db_mod, "verify_source_credential", _ok)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def artist(db):
|
||||
a = Artist(name="Alice", slug="alice")
|
||||
@@ -275,3 +289,92 @@ async def test_backfill_endpoint_recover_arms_bypass(client, artist, db):
|
||||
stopped_body = await stopped.get_json()
|
||||
assert stopped_body["backfill_state"] is None
|
||||
assert stopped_body["backfill_bypass_seen"] is False
|
||||
|
||||
|
||||
# --- Plan #703 #2: pre-flight credential verify on backfill/recover arm -----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arm_blocked_when_credential_rejected(client, artist, db, monkeypatch):
|
||||
"""A definitively-rejected credential refuses the arm (409 + reason) instead
|
||||
of starting a doomed walk; the source is NOT armed."""
|
||||
from backend.app.services import download_backends as db_mod
|
||||
|
||||
async def _reject(**kwargs):
|
||||
return (False, "Patreon rejected the credential — cookies expired")
|
||||
monkeypatch.setattr(db_mod, "verify_source_credential", _reject)
|
||||
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-preflight", enabled=True,
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
sid = src.id
|
||||
|
||||
resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "recover"})
|
||||
assert resp.status_code == 409
|
||||
assert "expired" in ((await resp.get_json()).get("detail") or "")
|
||||
|
||||
# Not armed.
|
||||
one = await (await client.get(f"/api/sources/{sid}")).get_json()
|
||||
assert one["backfill_state"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arm_proceeds_when_credential_inconclusive(client, artist, db, monkeypatch):
|
||||
"""An inconclusive verify (network blip / drift) must NOT block the arm."""
|
||||
from backend.app.services import download_backends as db_mod
|
||||
|
||||
async def _inconclusive(**kwargs):
|
||||
return (None, "couldn't verify (network)")
|
||||
monkeypatch.setattr(db_mod, "verify_source_credential", _inconclusive)
|
||||
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-incon", enabled=True,
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "start"})
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["backfill_state"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_never_pre_flights(client, artist, db, monkeypatch):
|
||||
from backend.app.services import download_backends as db_mod
|
||||
|
||||
async def _boom(**kwargs):
|
||||
raise AssertionError("stop must not pre-flight verify")
|
||||
monkeypatch.setattr(db_mod, "verify_source_credential", _boom)
|
||||
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-stop", enabled=True,
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "stop"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_dl_platform_arm_skips_pre_flight(client, artist, db, monkeypatch):
|
||||
"""Pre-flight is gated to native-ingester platforms (cheap verify); a
|
||||
gallery-dl source arms without the slow --simulate probe."""
|
||||
from backend.app.services import download_backends as db_mod
|
||||
|
||||
async def _boom(**kwargs):
|
||||
raise AssertionError("gallery-dl arm must not pre-flight verify")
|
||||
monkeypatch.setattr(db_mod, "verify_source_credential", _boom)
|
||||
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="subscribestar",
|
||||
url="https://www.subscribestar.com/x", enabled=True,
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "start"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user