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