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:
vrubelroman 2026-09-17 17:22:22 +00:00
parent 6c704cac97
commit fde9a439df
25 changed files with 420 additions and 174 deletions

View file

@ -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"]

View file

@ -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")