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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue