Files
FabledCurator/tests/test_api_credentials.py
T
bvandeusen d8d8ecd78f
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Failing after 3m18s
feat(subscribestar): flip dispatch to the native ingester (#893, Step 5)
SubscribeStar now downloads + verifies through the native core ingester instead
of gallery-dl — the go-live switch for milestone #71.

- download_backends: subscribestar added to NATIVE_INGESTER_PLATFORMS; a
  _NATIVE_INGESTERS registry + _resolve_native_campaign_id make _run_native_ingester
  / preview_source / verify_source_credential platform-aware. SubscribeStar's
  campaign_id IS the creator URL (no resolver); Patreon still resolves the vanity.
  preview now catches the shared NativeIngestError (covers both platforms).
- platform_lock: subscribestar serialized (one paced walk at a time).
- gallery_dl: subscribestar entry removed from PLATFORM_DEFAULTS (rule 22 — no
  fallback once native works).
- frontend SourceActions: isPatreon → isNative (patreon|subscribestar) so the
  recover/recapture actions show for subscribestar; download_service's
  cursor/mode/post_first + the preview endpoint already key on
  uses_native_ingester, so backfill/recovery/recapture/preview light up for free.
- tests: download_backends (subscribestar native), platform_lock (serialized),
  and three gallery-dl-sample tests repointed to hentaifoundry (api_credentials
  verify, gallery_dl_service skip-value, api_sources arm-no-preflight).

post_is_gated stays best-effort (can't cause junk downloads); not gating this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:03:48 -04:00

253 lines
8.7 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)
# hentaifoundry is a gallery-dl platform (subscribestar migrated to native).
await client.post("/api/credentials", json={
"platform": "hentaifoundry", "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="hentaifoundry",
url="https://www.hentai-foundry.com/user/Maewix", enabled=True, config_overrides={},
))
await db.commit()
resp = await client.post("/api/credentials/hentaifoundry/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/hentaifoundry")).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