feat(credentials+downloads): real credential Verify button + live download-activity polling
Operator-flagged 2026-05-28, two asks.
**1. Credential Verify (was missing vs GS — and now actually verifies).**
GS's Verify was a stub (`TODO: implement actual verification` — just
stamped last_verified). FC does a real check, which matters given the
recent auth pain (subscribestar age cookie, HF host-only PHPSESSID):
- GalleryDLService.verify(url, platform, cookies_path, auth_token) runs
gallery-dl in `--simulate --range 1-1` mode (no download) against the
URL with the materialized credentials, then reuses _categorize_error:
returncode 0 / NO_NEW_CONTENT → valid; AUTH_ERROR → invalid; other →
inconclusive (reason surfaced). 45s timeout.
- POST /api/credentials/<platform>/verify picks an enabled Source for
the platform to probe, runs verify, and on success stamps
credential.last_verified (new CredentialService.mark_verified).
Returns {valid: bool|null, reason, last_verified?}. valid=null means
untestable (no credential, or no enabled source to point at).
- CredentialCard gains a Verify button (on credentialed cards) + a
result chip (Verified ✓ / Failed / Untestable) and a toast with the
reason. SettingsTab reloads on @verified so last_verified refreshes.
**2. Live download-activity feedback.** The Downloads tab was static —
no way to tell if downloads were succeeding without manually hitting
Refresh. It now auto-polls: stats every 4s, and the event list too
while anything is queued/running. Polling pauses when the tab is
backgrounded (document.hidden) and the list reload is skipped on idle
ticks to stay light. A pulsing "● live" indicator next to the stat
chips shows when auto-refresh is active (queued+running > 0); honors
prefers-reduced-motion.
Tests: verify endpoint — untestable with no credential, untestable with
no enabled source, valid+stamped on success (gallery-dl mocked), and
auth-failure reported without stamping.
This commit is contained in:
@@ -124,3 +124,56 @@ async def delete_credential(platform: str):
|
|||||||
except LookupError:
|
except LookupError:
|
||||||
return _bad("not_found", status=404)
|
return _bad("not_found", status=404)
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@credentials_bp.route("/<platform>/verify", methods=["POST"])
|
||||||
|
async def verify_credential(platform: str):
|
||||||
|
"""Test the stored credential by running gallery-dl --simulate
|
||||||
|
against one of the platform's enabled sources. On success stamps
|
||||||
|
last_verified. Returns {valid: bool|null, reason, last_verified?}.
|
||||||
|
valid=null means "couldn't test" (no credential, or no enabled
|
||||||
|
source to point at)."""
|
||||||
|
from ..models import Artist, Source
|
||||||
|
from ..services.gallery_dl import GalleryDLService, SourceConfig
|
||||||
|
|
||||||
|
async with get_session() as session:
|
||||||
|
if not await _ext_key_ok(session):
|
||||||
|
return _bad("unauthorized", status=401)
|
||||||
|
svc = CredentialService(session, _get_crypto())
|
||||||
|
record = await svc.get(platform)
|
||||||
|
if record is None:
|
||||||
|
return jsonify({"valid": None, "reason": "No credential stored for this platform."})
|
||||||
|
|
||||||
|
# Pick an enabled source for this platform to point the probe at.
|
||||||
|
row = (await session.execute(
|
||||||
|
select(Source, Artist)
|
||||||
|
.join(Artist, Artist.id == Source.artist_id)
|
||||||
|
.where(Source.platform == platform, Source.enabled.is_(True))
|
||||||
|
.order_by(Source.id.asc())
|
||||||
|
)).first()
|
||||||
|
if row is None:
|
||||||
|
return jsonify({
|
||||||
|
"valid": None,
|
||||||
|
"reason": "No enabled source for this platform to verify against — add a subscription first.",
|
||||||
|
})
|
||||||
|
source, artist = row
|
||||||
|
|
||||||
|
cookies_path = await svc.get_cookies_path(platform)
|
||||||
|
auth_token = await svc.get_token(platform)
|
||||||
|
|
||||||
|
gdl = GalleryDLService(images_root=Path("/images"))
|
||||||
|
ok, message = await gdl.verify(
|
||||||
|
url=source.url,
|
||||||
|
artist_slug=artist.slug,
|
||||||
|
platform=platform,
|
||||||
|
source_config=SourceConfig.from_dict(source.config_overrides or {}),
|
||||||
|
cookies_path=str(cookies_path) if cookies_path else None,
|
||||||
|
auth_token=auth_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_verified = None
|
||||||
|
if ok:
|
||||||
|
async with get_session() as session:
|
||||||
|
ts = await CredentialService(session, _get_crypto()).mark_verified(platform)
|
||||||
|
last_verified = ts.isoformat() if ts else None
|
||||||
|
return jsonify({"valid": ok, "reason": message, "last_verified": last_verified})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -163,6 +163,19 @@ class CredentialService:
|
|||||||
return None
|
return None
|
||||||
return self.crypto.decrypt(row.encrypted_blob)
|
return self.crypto.decrypt(row.encrypted_blob)
|
||||||
|
|
||||||
|
async def mark_verified(self, platform: str) -> datetime | None:
|
||||||
|
"""Stamp last_verified=now after a successful verify. Returns the
|
||||||
|
timestamp, or None if the credential is gone."""
|
||||||
|
row = (await self.session.execute(
|
||||||
|
select(Credential).where(Credential.platform == platform)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
ts = datetime.now(UTC)
|
||||||
|
row.last_verified = ts
|
||||||
|
await self.session.commit()
|
||||||
|
return ts
|
||||||
|
|
||||||
|
|
||||||
def _augment_cookies(platform: str, netscape: str) -> str:
|
def _augment_cookies(platform: str, netscape: str) -> str:
|
||||||
"""Delegate to the platform's `augment_cookies` hook if one is
|
"""Delegate to the platform's `augment_cookies` hook if one is
|
||||||
|
|||||||
@@ -658,3 +658,64 @@ class GalleryDLService:
|
|||||||
Path(temp_config_path).unlink() # noqa: ASYNC240
|
Path(temp_config_path).unlink() # noqa: ASYNC240
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def verify(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
artist_slug: str,
|
||||||
|
platform: str,
|
||||||
|
source_config: SourceConfig | None = None,
|
||||||
|
cookies_path: str | None = None,
|
||||||
|
auth_token: str | None = None,
|
||||||
|
timeout: float = 45.0,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Test that credentials authenticate against `url` WITHOUT
|
||||||
|
downloading anything. Runs gallery-dl in --simulate mode limited
|
||||||
|
to the first item; if auth is bad the extractor errors before it
|
||||||
|
can list, which _categorize_error flags as AUTH_ERROR. Returns
|
||||||
|
(ok, message). Used by the credential Verify button."""
|
||||||
|
if source_config is None:
|
||||||
|
source_config = SourceConfig()
|
||||||
|
config = self._build_config_for_source(platform, source_config, artist_slug)
|
||||||
|
if cookies_path:
|
||||||
|
config["extractor"]["cookies"] = cookies_path
|
||||||
|
if auth_token and platform == "discord":
|
||||||
|
config["extractor"].setdefault("discord", {})["token"] = auth_token
|
||||||
|
if auth_token and platform == "pixiv":
|
||||||
|
config["extractor"].setdefault("pixiv", {})["refresh-token"] = auth_token
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".json", delete=False, dir=str(self._config_dir),
|
||||||
|
) as fh:
|
||||||
|
json.dump(config, fh, indent=2)
|
||||||
|
temp_config_path = fh.name
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
sys.executable, "-m", "gallery_dl",
|
||||||
|
"--config", temp_config_path,
|
||||||
|
"--simulate", "--range", "1-1", "--verbose", url,
|
||||||
|
]
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
proc = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=timeout,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
etype, msg = self._categorize_error(proc.returncode, proc.stdout, proc.stderr)
|
||||||
|
if proc.returncode == 0 or etype == ErrorType.NO_NEW_CONTENT:
|
||||||
|
return True, "Credentials valid — the feed authenticated."
|
||||||
|
if etype == ErrorType.AUTH_ERROR:
|
||||||
|
return False, msg
|
||||||
|
# Network / not-found / rate-limit / unknown: inconclusive,
|
||||||
|
# not a definitive credential failure. Surface the reason.
|
||||||
|
return False, f"Could not confirm ({etype.value}): {msg}"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, f"Verification timed out after {timeout:.0f}s"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, f"Verification error: {exc}"
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
Path(temp_config_path).unlink() # noqa: ASYNC240
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -55,7 +55,21 @@
|
|||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-card-actions class="px-3 pb-3 pt-0">
|
<v-card-actions class="px-3 pb-3 pt-0">
|
||||||
<v-spacer />
|
<v-chip
|
||||||
|
v-if="verifyResult"
|
||||||
|
:color="verifyResult.valid === true ? 'success' : (verifyResult.valid === false ? 'error' : 'grey')"
|
||||||
|
size="x-small" variant="tonal" class="me-auto"
|
||||||
|
:title="verifyResult.reason"
|
||||||
|
>{{ verifyChipLabel }}</v-chip>
|
||||||
|
<v-spacer v-else />
|
||||||
|
<v-btn
|
||||||
|
v-if="hasCredential"
|
||||||
|
size="small" variant="text"
|
||||||
|
:loading="verifying"
|
||||||
|
@click="onVerify"
|
||||||
|
>
|
||||||
|
Verify
|
||||||
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="hasCredential"
|
v-if="hasCredential"
|
||||||
size="small" variant="text" color="error"
|
size="small" variant="text" color="error"
|
||||||
@@ -76,14 +90,43 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import PlatformChip from './PlatformChip.vue'
|
import PlatformChip from './PlatformChip.vue'
|
||||||
|
import { useCredentialsStore } from '../../stores/credentials.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
platform: { type: Object, required: true },
|
platform: { type: Object, required: true },
|
||||||
credential: { type: Object, default: null },
|
credential: { type: Object, default: null },
|
||||||
})
|
})
|
||||||
defineEmits(['replace', 'remove'])
|
const emit = defineEmits(['replace', 'remove', 'verified'])
|
||||||
|
|
||||||
|
const credsStore = useCredentialsStore()
|
||||||
|
const verifying = ref(false)
|
||||||
|
const verifyResult = ref(null)
|
||||||
|
|
||||||
|
const verifyChipLabel = computed(() => {
|
||||||
|
if (!verifyResult.value) return ''
|
||||||
|
if (verifyResult.value.valid === true) return 'Verified ✓'
|
||||||
|
if (verifyResult.value.valid === false) return 'Failed'
|
||||||
|
return 'Untestable'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onVerify() {
|
||||||
|
verifying.value = true
|
||||||
|
verifyResult.value = null
|
||||||
|
try {
|
||||||
|
const res = await credsStore.verify(props.platform.key)
|
||||||
|
verifyResult.value = res
|
||||||
|
const type = res.valid === true ? 'success' : (res.valid === false ? 'error' : 'info')
|
||||||
|
window.__fcToast?.({ text: `${props.platform.name}: ${res.reason}`, type })
|
||||||
|
if (res.valid === true) emit('verified')
|
||||||
|
} catch (e) {
|
||||||
|
verifyResult.value = { valid: false, reason: e.message }
|
||||||
|
window.__fcToast?.({ text: `Verify failed: ${e.message}`, type: 'error' })
|
||||||
|
} finally {
|
||||||
|
verifying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hasCredential = computed(() => !!props.credential)
|
const hasCredential = computed(() => !!props.credential)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="fc-dl__top">
|
<div class="fc-dl__top">
|
||||||
<DownloadStatChips :stats="store.stats" />
|
<DownloadStatChips :stats="store.stats" />
|
||||||
|
<span v-if="liveActive" class="fc-dl__live" title="Auto-refreshing while downloads are active">
|
||||||
|
<span class="fc-dl__live-dot" />
|
||||||
|
live
|
||||||
|
</span>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="text" icon @click="refresh">
|
<v-btn variant="text" icon @click="refresh">
|
||||||
<v-icon>mdi-refresh</v-icon>
|
<v-icon>mdi-refresh</v-icon>
|
||||||
@@ -74,7 +78,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { useDownloadsStore } from '../../stores/downloads.js'
|
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||||
@@ -105,12 +109,39 @@ async function refresh() {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live auto-refresh: while any download is queued or running, poll the
|
||||||
|
// stats + first page every 4s so the operator can watch events succeed/
|
||||||
|
// fail in real time without hitting Refresh. Polling stops automatically
|
||||||
|
// when the queue drains (nothing pending/running), and is paused while
|
||||||
|
// the tab is backgrounded. Operator-flagged 2026-05-28: couldn't tell if
|
||||||
|
// downloads were succeeding because the view was static.
|
||||||
|
const POLL_MS = 4000
|
||||||
|
const liveActive = computed(
|
||||||
|
() => (store.stats.pending || 0) + (store.stats.running || 0) > 0,
|
||||||
|
)
|
||||||
|
let pollId = null
|
||||||
|
function startPolling() {
|
||||||
|
if (pollId) return
|
||||||
|
pollId = setInterval(async () => {
|
||||||
|
if (document.hidden) return
|
||||||
|
// Refresh stats cheaply every tick; only reload the event list when
|
||||||
|
// there's active work to reflect (keeps idle ticks light).
|
||||||
|
await store.loadStats(24)
|
||||||
|
if (liveActive.value) await store.loadFirst()
|
||||||
|
}, POLL_MS)
|
||||||
|
}
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollId) { clearInterval(pollId); pollId = null }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (route.query.source_id) {
|
if (route.query.source_id) {
|
||||||
filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) }
|
filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) }
|
||||||
}
|
}
|
||||||
refresh()
|
refresh()
|
||||||
|
startPolling()
|
||||||
})
|
})
|
||||||
|
onUnmounted(stopPolling)
|
||||||
|
|
||||||
// Client-side date filter on the loaded page (avoids a backend round-trip
|
// Client-side date filter on the loaded page (avoids a backend round-trip
|
||||||
// for the date pickers; the existing /api/downloads endpoint can grow
|
// for the date pickers; the existing /api/downloads endpoint can grow
|
||||||
@@ -194,6 +225,23 @@ async function openDetail(id) {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.fc-dl__filter { margin-bottom: 12px; }
|
.fc-dl__filter { margin-bottom: 12px; }
|
||||||
|
.fc-dl__live {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
color: rgb(var(--v-theme-info));
|
||||||
|
}
|
||||||
|
.fc-dl__live-dot {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: rgb(var(--v-theme-info));
|
||||||
|
animation: fc-dl-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes fc-dl-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.35; transform: scale(0.7); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fc-dl__live-dot { animation: none; }
|
||||||
|
}
|
||||||
.fc-dl__loading, .fc-dl__empty {
|
.fc-dl__loading, .fc-dl__empty {
|
||||||
display: flex; justify-content: center; padding: 3rem 0;
|
display: flex; justify-content: center; padding: 3rem 0;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
:credential="credentialsStore.byPlatform.get(p.key) || null"
|
:credential="credentialsStore.byPlatform.get(p.key) || null"
|
||||||
@replace="openUpload"
|
@replace="openUpload"
|
||||||
@remove="confirmRemove"
|
@remove="confirmRemove"
|
||||||
|
@verified="onSaved"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export const useCredentialsStore = defineStore('credentials', () => {
|
|||||||
byPlatform.value.delete(platform)
|
byPlatform.value.delete(platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runs gallery-dl --simulate against an enabled source for the
|
||||||
|
// platform. Returns {valid: bool|null, reason, last_verified?}.
|
||||||
|
async function verify(platform) {
|
||||||
|
return await api.post(`/api/credentials/${platform}/verify`)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadKey() {
|
async function loadKey() {
|
||||||
const body = await api.get('/api/settings/extension_api_key')
|
const body = await api.get('/api/settings/extension_api_key')
|
||||||
extensionKey.value = body.key
|
extensionKey.value = body.key
|
||||||
@@ -50,6 +56,6 @@ export const useCredentialsStore = defineStore('credentials', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
byPlatform, extensionKey, loading, error,
|
byPlatform, extensionKey, loading, error,
|
||||||
loadAll, upload, remove, loadKey, rotateKey,
|
loadAll, upload, remove, verify, loadKey, rotateKey,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -143,3 +143,84 @@ async def test_extension_key_wrong_rejects(client, ext_key):
|
|||||||
headers={"X-Extension-Key": "WRONG"},
|
headers={"X-Extension-Key": "WRONG"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
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):
|
||||||
|
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": "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
|
||||||
|
|
||||||
|
# The stamp is persisted on the credential record.
|
||||||
|
rec = await (await client.get("/api/credentials/patreon")).get_json()
|
||||||
|
assert rec["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
|
||||||
|
|||||||
Reference in New Issue
Block a user