218bfebb92
The credential Verify button still ran gallery-dl --simulate for Patreon after the cutover — testing the wrong path (and prone to the vanity "Failed to extract campaign ID" the native resolver fixes). Wire it to the native ingester, behind a DRY dispatch so callers never branch on platform. - services/download_backends.py (new): the ONE place that knows which platforms are native vs gallery-dl. `uses_native_ingester(platform)` is the shared predicate; `verify_source_credential(...)` is the uniform probe (same (ok|None, message) contract for both backends). As a platform migrates, it moves into NATIVE_INGESTER_PLATFORMS here and BOTH download routing and verify switch together. - PatreonClient.verify_auth(campaign_id): one authenticated /api/posts fetch → True (valid) / False (401/403/HTML-login) / None (drift or network — inconclusive, not a credential verdict). - patreon_ingester.verify_patreon_credential(): resolve campaign id, then verify_auth — the verify counterpart to the download path. - patreon_resolver.resolve_campaign_id_for_source(): extracted the override / id:-URL / vanity resolution into ONE helper now shared by the download ingester and verify (download_service no longer carries its own copy + regex; −`import re`). - download_service: routes on uses_native_ingester() instead of inline `== "patreon"` (3 sites); uses the shared resolver. - api/credentials: calls verify_source_credential — no platform branch. Tests: verify_auth mapping, resolve_campaign_id_for_source (override/id:/ vanity/none), the dispatch predicate, verify_patreon_credential glue, credentials endpoint proves Patreon uses the native path (gallery-dl verify asserted not-called); repointed the gallery-dl verify test to subscribestar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
252 lines
8.6 KiB
Python
252 lines
8.6 KiB
Python
import pytest
|
|
|
|
from backend.app.models import AppSetting
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
_NETSCAPE = (
|
|
"# Netscape HTTP Cookie File\n"
|
|
".patreon.com\tTRUE\t/\tTRUE\t1700000000\tsession_id\tabc\n"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def ext_key(db):
|
|
# Seed an extension API key for tests that want to assert the
|
|
# X-Extension-Key path explicitly.
|
|
db.add(AppSetting(key="extension_api_key", value="test-ext-key"))
|
|
await db.commit()
|
|
return "test-ext-key"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_empty(client):
|
|
resp = await client.get("/api/credentials")
|
|
assert resp.status_code == 200
|
|
assert await resp.get_json() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_and_get(client):
|
|
resp = await client.post("/api/credentials", json={
|
|
"platform": "patreon",
|
|
"credential_type": "cookies",
|
|
"data": _NETSCAPE,
|
|
})
|
|
assert resp.status_code == 201
|
|
body = await resp.get_json()
|
|
assert body["platform"] == "patreon"
|
|
assert body["credential_type"] == "cookies"
|
|
assert "data" not in body # never echoed
|
|
assert "encrypted_blob" not in body
|
|
|
|
one = await client.get("/api/credentials/patreon")
|
|
assert one.status_code == 200
|
|
assert (await one.get_json())["platform"] == "patreon"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_updates_existing(client):
|
|
a = await client.post("/api/credentials", json={
|
|
"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE,
|
|
})
|
|
assert a.status_code == 201
|
|
b = await client.post("/api/credentials", json={
|
|
"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE + "x",
|
|
})
|
|
# On update, our convention is 200 not 201
|
|
assert b.status_code == 200
|
|
listing = await (await client.get("/api/credentials")).get_json()
|
|
assert len(listing) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_platform_400(client):
|
|
resp = await client.post("/api/credentials", json={
|
|
"platform": "fanbox", "credential_type": "cookies", "data": "x",
|
|
})
|
|
assert resp.status_code == 400
|
|
assert (await resp.get_json())["error"] == "unknown_platform"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wrong_auth_type_400(client):
|
|
resp = await client.post("/api/credentials", json={
|
|
"platform": "discord", "credential_type": "cookies", "data": "x",
|
|
})
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "wrong_auth_type"
|
|
assert body["expected"] == "token"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_data_400(client):
|
|
resp = await client.post("/api/credentials", json={
|
|
"platform": "patreon", "credential_type": "cookies", "data": " ",
|
|
})
|
|
assert resp.status_code == 400
|
|
assert (await resp.get_json())["error"] == "empty_data"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_body_400(client):
|
|
resp = await client.post("/api/credentials", json=[1, 2, 3])
|
|
assert resp.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_404(client):
|
|
resp = await client.get("/api/credentials/patreon")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_204_then_404(client):
|
|
await client.post("/api/credentials", json={
|
|
"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE,
|
|
})
|
|
dele = await client.delete("/api/credentials/patreon")
|
|
assert dele.status_code == 204
|
|
nope = await client.delete("/api/credentials/patreon")
|
|
assert nope.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extension_key_correct_accepts(client, ext_key):
|
|
resp = await client.post(
|
|
"/api/credentials",
|
|
json={"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extension_key_wrong_rejects(client, ext_key):
|
|
resp = await client.post(
|
|
"/api/credentials",
|
|
json={"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE},
|
|
headers={"X-Extension-Key": "WRONG"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_no_credential_is_untestable(client):
|
|
resp = await client.post("/api/credentials/patreon/verify")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["valid"] is None
|
|
assert "No credential" in body["reason"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_no_enabled_source_is_untestable(client):
|
|
await client.post("/api/credentials", json={
|
|
"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE,
|
|
})
|
|
resp = await client.post("/api/credentials/patreon/verify")
|
|
body = await resp.get_json()
|
|
assert body["valid"] is None
|
|
assert "no enabled source" in body["reason"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_runs_gallery_dl_and_stamps_on_success(client, db, monkeypatch):
|
|
"""A gallery-dl platform routes through GalleryDLService.verify; success
|
|
stamps last_verified (platform-agnostic endpoint behavior)."""
|
|
from backend.app.models import Artist, Source
|
|
from backend.app.services import gallery_dl as gdl_mod
|
|
|
|
# Stub the gallery-dl probe so the test doesn't shell out / hit network.
|
|
async def _fake_verify(self, *args, **kwargs):
|
|
return (True, "Credentials valid — the feed authenticated.")
|
|
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _fake_verify)
|
|
|
|
await client.post("/api/credentials", json={
|
|
"platform": "subscribestar", "credential_type": "cookies", "data": _NETSCAPE,
|
|
})
|
|
artist = Artist(name="Maewix", slug="maewix")
|
|
db.add(artist)
|
|
await db.flush()
|
|
db.add(Source(
|
|
artist_id=artist.id, platform="subscribestar",
|
|
url="https://www.subscribestar.com/maewix", enabled=True, config_overrides={},
|
|
))
|
|
await db.commit()
|
|
|
|
resp = await client.post("/api/credentials/subscribestar/verify")
|
|
body = await resp.get_json()
|
|
assert body["valid"] is True
|
|
assert body["last_verified"] is not None
|
|
|
|
# The stamp is persisted on the credential record.
|
|
rec = await (await client.get("/api/credentials/subscribestar")).get_json()
|
|
assert rec["last_verified"] is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_patreon_uses_native_ingester_not_gallery_dl(client, db, monkeypatch):
|
|
"""plan #697 cutover: Patreon credential verify routes through the native
|
|
ingester (verify_patreon_credential), NOT gallery-dl --simulate."""
|
|
from backend.app.models import Artist, Source
|
|
from backend.app.services import gallery_dl as gdl_mod
|
|
from backend.app.services import patreon_ingester as pi_mod
|
|
|
|
async def _native_verify(url, cookies_path, overrides):
|
|
return (True, "Credentials valid — the Patreon feed authenticated.")
|
|
monkeypatch.setattr(pi_mod, "verify_patreon_credential", _native_verify)
|
|
|
|
# gallery-dl must NOT be consulted for Patreon.
|
|
async def _boom(self, *args, **kwargs):
|
|
raise AssertionError("gallery-dl verify must not run for patreon")
|
|
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _boom)
|
|
|
|
await client.post("/api/credentials", json={
|
|
"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE,
|
|
})
|
|
artist = Artist(name="Maewix", slug="maewix")
|
|
db.add(artist)
|
|
await db.flush()
|
|
db.add(Source(
|
|
artist_id=artist.id, platform="patreon",
|
|
url="https://www.patreon.com/maewix", enabled=True, config_overrides={},
|
|
))
|
|
await db.commit()
|
|
|
|
resp = await client.post("/api/credentials/patreon/verify")
|
|
body = await resp.get_json()
|
|
assert body["valid"] is True
|
|
assert body["last_verified"] is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_reports_auth_failure(client, db, monkeypatch):
|
|
from backend.app.models import Artist, Source
|
|
from backend.app.services import gallery_dl as gdl_mod
|
|
|
|
async def _fake_verify(self, *args, **kwargs):
|
|
return (False, "Authentication failed — cookies may be expired or invalid")
|
|
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _fake_verify)
|
|
|
|
await client.post("/api/credentials", json={
|
|
"platform": "hentaifoundry", "credential_type": "cookies", "data": _NETSCAPE,
|
|
})
|
|
artist = Artist(name="HolyMeh", slug="holymeh")
|
|
db.add(artist)
|
|
await db.flush()
|
|
db.add(Source(
|
|
artist_id=artist.id, platform="hentaifoundry",
|
|
url="https://www.hentai-foundry.com/user/HolyMeh", enabled=True,
|
|
config_overrides={},
|
|
))
|
|
await db.commit()
|
|
|
|
resp = await client.post("/api/credentials/hentaifoundry/verify")
|
|
body = await resp.get_json()
|
|
assert body["valid"] is False
|
|
assert "auth" in body["reason"].lower()
|
|
assert body["last_verified"] is None
|