Fix review findings and remove Uncategorized from sidebar
Backend: cache Google access tokens (drop dead access_token_expires_at, migration 0007), handle MeTube cleared/canceled events by URL, return email from /auth/status only when authenticated, move Google base URLs into settings, run container as non-root. Frontend: include local feed filters in the query key, remove dead Saved page and unused assets, drop stale CategoryNav props and classes, send Content-Type only with a body, remove Uncategorized from sidebar.
This commit is contained in:
parent
6c704cac97
commit
fde9a439df
25 changed files with 420 additions and 174 deletions
|
|
@ -1,9 +1,12 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import auth as auth_module
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models.oauth_credentials import SINGLETON_ID, OAuthCredentials
|
||||
from app.services import google_oauth
|
||||
|
||||
|
||||
|
|
@ -78,3 +81,146 @@ def test_callback_rejects_invalid_state(client, monkeypatch):
|
|||
|
||||
assert response.status_code in (302, 307)
|
||||
assert "invalid_state" in response.headers["location"]
|
||||
|
||||
|
||||
def test_status_hides_email_without_session(client, db_session):
|
||||
"""/api/auth/status is public (the Connect page reads `authenticated`), so
|
||||
the connected account's email must not leak to unauthenticated requests."""
|
||||
row = OAuthCredentials(
|
||||
id=SINGLETON_ID,
|
||||
google_email="owner@example.com",
|
||||
encrypted_refresh_token="encrypted-blob",
|
||||
)
|
||||
db_session.add(row)
|
||||
db_session.commit()
|
||||
|
||||
status = client.get("/api/auth/status").json()
|
||||
|
||||
assert status["authenticated"] is False
|
||||
assert status["connected"] is True
|
||||
assert status["email"] is None
|
||||
|
||||
|
||||
def _seed_credentials_row(db_session, refresh_token="refresh-1"):
|
||||
row = OAuthCredentials(
|
||||
id=SINGLETON_ID,
|
||||
google_email="owner@example.com",
|
||||
encrypted_refresh_token=google_oauth.encrypt_token(refresh_token),
|
||||
)
|
||||
db_session.add(row)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
class FakeGoogleCredentials:
|
||||
"""Stands in for google.oauth2.credentials.Credentials in cache tests."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.refresh_token = kwargs["refresh_token"]
|
||||
self.token = None
|
||||
self.expiry = None
|
||||
|
||||
def refresh(self, request):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def test_get_credentials_caches_and_reuses_access_token(monkeypatch, db_session):
|
||||
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
|
||||
_seed_credentials_row(db_session)
|
||||
|
||||
refreshes = []
|
||||
|
||||
class Creds(FakeGoogleCredentials):
|
||||
def refresh(self, request):
|
||||
refreshes.append(request)
|
||||
self.token = "access-token"
|
||||
self.expiry = datetime(2099, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
monkeypatch.setattr(google_oauth, "Credentials", Creds)
|
||||
|
||||
first = google_oauth.get_credentials(db_session)
|
||||
second = google_oauth.get_credentials(db_session)
|
||||
|
||||
assert first is second
|
||||
assert first.token == "access-token"
|
||||
assert len(refreshes) == 1 # second call must not hit Google again
|
||||
|
||||
|
||||
def test_get_credentials_refreshes_expiring_token(monkeypatch, db_session):
|
||||
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
|
||||
_seed_credentials_row(db_session)
|
||||
|
||||
refreshes = []
|
||||
|
||||
class Creds(FakeGoogleCredentials):
|
||||
def refresh(self, request):
|
||||
refreshes.append(request)
|
||||
self.token = "access-token"
|
||||
# Expires in 30s -- inside the 60s refresh buffer.
|
||||
self.expiry = datetime.now(timezone.utc) + timedelta(seconds=30)
|
||||
|
||||
monkeypatch.setattr(google_oauth, "Credentials", Creds)
|
||||
|
||||
google_oauth.get_credentials(db_session)
|
||||
google_oauth.get_credentials(db_session)
|
||||
|
||||
assert len(refreshes) == 2
|
||||
|
||||
|
||||
def test_get_credentials_refresh_failure_clears_cache(monkeypatch, db_session):
|
||||
"""A failed token refresh must not leave a broken cached object behind:
|
||||
the error propagates and the next call rebuilds credentials from the DB
|
||||
instead of reusing (and re-failing on) the stale cache entry."""
|
||||
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
|
||||
_seed_credentials_row(db_session)
|
||||
|
||||
built = []
|
||||
|
||||
class Creds(FakeGoogleCredentials):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
built.append(self.refresh_token)
|
||||
|
||||
def refresh(self, request):
|
||||
raise RuntimeError("token endpoint down")
|
||||
|
||||
monkeypatch.setattr(google_oauth, "Credentials", Creds)
|
||||
|
||||
for _ in range(2):
|
||||
with pytest.raises(RuntimeError, match="token endpoint down"):
|
||||
google_oauth.get_credentials(db_session)
|
||||
|
||||
# Both calls failed, and the broken cache entry was dropped: each call
|
||||
# rebuilt from the stored refresh token instead of sticking.
|
||||
assert len(built) == 2
|
||||
assert google_oauth._cached_credentials is None
|
||||
|
||||
|
||||
def test_get_credentials_rebuilds_cache_after_reconnect(monkeypatch, db_session):
|
||||
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
|
||||
_seed_credentials_row(db_session)
|
||||
|
||||
built = []
|
||||
|
||||
class Creds(FakeGoogleCredentials):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
built.append(self.refresh_token)
|
||||
self.token = "already-valid"
|
||||
self.expiry = datetime(2099, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
def refresh(self, request):
|
||||
raise AssertionError("must not refresh a still-valid cached token")
|
||||
|
||||
monkeypatch.setattr(google_oauth, "Credentials", Creds)
|
||||
|
||||
google_oauth.get_credentials(db_session)
|
||||
assert built == ["refresh-1"]
|
||||
|
||||
# Reconnect: the stored refresh token changes, the old cache entry must
|
||||
# not be reused.
|
||||
row = db_session.get(OAuthCredentials, SINGLETON_ID)
|
||||
row.encrypted_refresh_token = google_oauth.encrypt_token("refresh-2")
|
||||
db_session.commit()
|
||||
|
||||
google_oauth.get_credentials(db_session)
|
||||
assert built == ["refresh-1", "refresh-2"]
|
||||
|
|
|
|||
|
|
@ -235,6 +235,101 @@ async def test_handle_metube_event_canceled_marks_failed(db_session):
|
|||
assert job.status == "failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_metube_event_canceled_matches_by_url(db_session):
|
||||
"""MeTube keys canceled/cleared events by the download's URL, not its id."""
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(db_session, "canceled", json.dumps(video.youtube_url))
|
||||
|
||||
db_session.refresh(job)
|
||||
assert job.status == "failed"
|
||||
assert job.error_message == "Отменено в MeTube"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_metube_event_cleared_matches_job_by_url(db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
|
||||
other_video = _seed_video(db_session, youtube_video_id="vid2", youtube_channel_id="chanB")
|
||||
other_job = DownloadJob(video_id=other_video.id, status="queued")
|
||||
db_session.add(job)
|
||||
db_session.add(other_job)
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(db_session, "cleared", json.dumps(video.youtube_url))
|
||||
|
||||
db_session.refresh(job)
|
||||
db_session.refresh(other_job)
|
||||
assert job.status == "failed"
|
||||
assert job.error_message == "Очищено в MeTube"
|
||||
assert other_job.status == "queued"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_metube_event_cleared_matching_terminal_job_is_noop(db_session):
|
||||
"""Cleared payload resolving to a finished job must not touch terminal
|
||||
statuses, nor spill over onto unrelated active jobs."""
|
||||
video = _seed_video(db_session)
|
||||
completed = DownloadJob(video_id=video.id, status="completed", media_url="http://x/f.mp4")
|
||||
other_video = _seed_video(db_session, youtube_video_id="vid2", youtube_channel_id="chanB")
|
||||
active = DownloadJob(video_id=other_video.id, status="downloading")
|
||||
db_session.add_all([completed, active])
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(db_session, "cleared", json.dumps(video.youtube_url))
|
||||
|
||||
db_session.refresh(completed)
|
||||
db_session.refresh(active)
|
||||
assert completed.status == "completed"
|
||||
assert active.status == "downloading"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_metube_event_cleared_unmatched_payload_is_noop(db_session):
|
||||
"""'cleared' only ever refers to the one done-entry being removed (trash
|
||||
or CLEAR_COMPLETED_AFTER). A payload that doesn't match any of our jobs is
|
||||
a foreign MeTube download we never tracked -- the jobs MeTube is still
|
||||
running must stay untouched."""
|
||||
video1 = _seed_video(db_session)
|
||||
job1 = DownloadJob(video_id=video1.id, status="downloading")
|
||||
video2 = _seed_video(db_session, youtube_video_id="vid2", youtube_channel_id="chanB")
|
||||
job2 = DownloadJob(video_id=video2.id, status="postprocessing")
|
||||
video3 = _seed_video(db_session, youtube_video_id="vid3", youtube_channel_id="chanC")
|
||||
completed = DownloadJob(video_id=video3.id, status="completed", media_url="http://x/f.mp4")
|
||||
unknown = DownloadJob(video_id=video3.id, status="unknown")
|
||||
db_session.add_all([job1, job2, completed, unknown])
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(db_session, "cleared", json.dumps("https://example.com/other"))
|
||||
|
||||
db_session.refresh(job1)
|
||||
db_session.refresh(job2)
|
||||
db_session.refresh(completed)
|
||||
db_session.refresh(unknown)
|
||||
assert job1.status == "downloading"
|
||||
assert job2.status == "postprocessing"
|
||||
assert completed.status == "completed"
|
||||
assert unknown.status == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_metube_event_cleared_without_payload_is_noop(db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="queued")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(db_session, "cleared", None)
|
||||
|
||||
db_session.refresh(job)
|
||||
assert job.status == "queued"
|
||||
assert job.error_message is None
|
||||
|
||||
|
||||
def test_reconcile_marks_unknown_when_history_unavailable(monkeypatch, db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="downloading")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue