Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/auth/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def _token_response(user: models.DbUser, refresh_token: str) -> dict:
'access_token': access_token,
'refresh_token': refresh_token,
'token_type': 'bearer',
'expires_in': oauth2.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
'expires_in': get_settings().access_token_expire_minutes * 60,
'refresh_expires_in': get_settings().refresh_token_expire_days * 86400,
'user_id': user.id,
'user_group': user.user_group,
Expand Down
4 changes: 3 additions & 1 deletion app/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
expire = datetime.now(timezone.utc) + timedelta(
minutes=get_settings().access_token_expire_minutes
)
to_encode.update({'exp': expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
Expand Down
31 changes: 30 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class Settings(BaseSettings):

# --- Auth ---
jwt_secret_key: Optional[str] = None
access_token_expire_minutes: int = 30
access_token_expire_minutes: int = 1440
# Refresh tokens (#246) keep the access token short-lived without making
# people re-authenticate with Google. Expiry slides on every rotation, so
# a user who opens the app at least once a month never signs in again;
Expand All @@ -58,6 +58,11 @@ class Settings(BaseSettings):
# would look like theft and sign the user out. Beyond the window, reuse
# still burns the whole session down.
refresh_token_reuse_leeway_seconds: int = 30

# Argon2 password hashing cost parameters (#285).
argon2_time_cost: Optional[int] = None
argon2_memory_cost: Optional[int] = None
argon2_parallelism: Optional[int] = None
google_client_id: Optional[str] = None
# Additional OAuth client ids accepted at sign-in, comma-separated. Native
# clients need their own client id (an iOS client is keyed to the bundle
Expand Down Expand Up @@ -220,6 +225,30 @@ def google_client_ids(self) -> List[str]:
ids.append(client_id)
return ids

@property
def argon2_params(self) -> dict:
"""
Argon2 password hashing parameters (#285).

In test environment (env == 'test'), cheap settings (time_cost=1, memory_cost=8,
parallelism=1) are used to accelerate tests. In non-test environments, standard
Argon2 defaults apply.
"""
if self.env == 'test':
return {
'time_cost': self.argon2_time_cost or 1,
'memory_cost': self.argon2_memory_cost or 8,
'parallelism': self.argon2_parallelism or 1,
}
res = {}
if self.argon2_time_cost is not None:
res['time_cost'] = self.argon2_time_cost
if self.argon2_memory_cost is not None:
res['memory_cost'] = self.argon2_memory_cost
if self.argon2_parallelism is not None:
res['parallelism'] = self.argon2_parallelism
return res


@lru_cache
def get_settings() -> Settings:
Expand Down
10 changes: 9 additions & 1 deletion app/db/hash.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher

pwd_cxt = PasswordHash((Argon2Hasher(),))
from app.config import get_settings


def _get_pwd_cxt() -> PasswordHash:
params = get_settings().argon2_params
return PasswordHash((Argon2Hasher(**params),))


pwd_cxt = _get_pwd_cxt()


class Hash:
Expand Down
8 changes: 4 additions & 4 deletions app/router/v1/router_visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,10 +435,10 @@ def public_profile( # pylint: disable=too-many-arguments, too-many-positional-a
if admits(ceiling, getattr(user, s.visibility_tier))
]

# A named shelf that doesn't exist or isn't admitted lands here exactly
# like the multi-shelf case coming up empty — same 404, see the
# docstring on why that has to be indistinguishable.
if not shelves:
# A named shelf query (`?shelf=...`) that doesn't exist or isn't admitted
# returns 404. When no specific shelf is requested, an admitted profile
# with no visible shelves returns 200 with an empty shelves list (#296).
if shelf is not None and not shelves:
raise not_found

# Following (#276) grants no additional visibility — it never touches
Expand Down
1 change: 1 addition & 0 deletions app/schemas/schemas_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ class MovieSearchResult(BaseModel):
imdb: Optional[str] = None
title: str
year: Optional[str] = None
release_date: Optional[str] = None
poster_url: Optional[str] = None
type: Optional[str] = None
popularity: Optional[float] = None
Expand Down
1 change: 1 addition & 0 deletions app/services/movie_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def _normalize_hit(item: dict) -> dict:
'imdb': item.get('imdb_id'),
'title': item.get('title') or item.get('original_title'),
'year': _year(item.get('release_date')),
'release_date': item.get('release_date'),
'poster_url': tmdb.image_url(item.get('poster_path')),
'type': 'movie',
# TMDB supplies a real popularity score; search_ranking uses it as the
Expand Down
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ filterwarnings =
; Setting defaults for asyncio
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
addopts = -n auto
1 change: 1 addition & 0 deletions requirements/test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pytest-cov==7.1.0
pytest-html==4.2.0
pytest-metadata==3.1.1
pytest-testmon==2.2.0 # pre-push: run only tests affected by changes
pytest-xdist==3.6.1
Faker==40.36.0
httpx==0.28.1
coverage==7.15.2
Expand Down
131 changes: 131 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# pylint: disable=wrong-import-position, import-outside-toplevel
"""
Creates a fixture to provide a database session for testing.
"""

import os

# Ensure test environment mode is set before app config is loaded (#285)
os.environ['ENV'] = 'test'

from datetime import datetime, timedelta, timezone
from unittest.mock import patch

Expand All @@ -23,6 +28,132 @@
fake = Faker()


@pytest.fixture(autouse=True)
def _block_outbound_http(monkeypatch):
"""
Guard against unmocked outbound network requests in tests (#284).

Fails any test that attempts a socket connection to a non-loopback address,
naming the offending host in the failure message.
"""
import socket

orig_connect = socket.socket.connect

def guarded_connect(self, address):
host = (
address[0]
if isinstance(address, tuple) and len(address) > 0
else str(address)
)
if host not in ('127.0.0.1', 'localhost', '::1', 'testserver'):
pytest.fail(
f"Outbound network request blocked in test: attempted connection to {host}. "
'Mock the upstream provider instead of calling live services (#284).'
)
return orig_connect(self, address)

monkeypatch.setattr(socket.socket, 'connect', guarded_connect)


@pytest.fixture(autouse=True)
def _mock_upstream_providers(request, monkeypatch):
"""
Autouse fixture that stubs out upstream provider calls (TMDB, TVMaze, Open Library, IGDB)
during integration tests to keep test execution hermetic and fast (#284).
"""
if 'integration' in str(request.path):
monkeypatch.setattr(
'app.services.tmdb.try_request', lambda *args, **kwargs: None, raising=False
)
monkeypatch.setattr(
'app.services.movie_search.get_movie_details',
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
'app.services.tv_search.get_tv_show_details',
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
'app.services.tv_search.enrich_tv_show',
lambda db, show, *args, **kwargs: show,
raising=False,
)
monkeypatch.setattr(
'app.services.tv_search._tvmaze_detail',
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
'app.services.tv_search.sync_episodes',
lambda *args, **kwargs: 0,
raising=False,
)
monkeypatch.setattr(
'app.services.tv_search.get_show_episodes',
lambda *args, **kwargs: [],
raising=False,
)
monkeypatch.setattr(
'app.services.book_search.get_book_detail',
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
'app.services.book_search.enrich_book',
lambda db, book, *args, **kwargs: book,
raising=False,
)
monkeypatch.setattr(
'app.services.book_search._openlibrary_detail',
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
'app.services.game_search.get_game_details',
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
'app.services.game_search.enrich_game',
lambda db, game, *args, **kwargs: game,
raising=False,
)
monkeypatch.setattr(
'app.services.game_search._igdb_detail',
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
'app.services.watch_providers.get_watch_providers',
lambda *args, **kwargs: None,
raising=False,
)

monkeypatch.setattr(
'app.router.v1.router_tv.get_tv_show_detail',
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
'app.router.v1.router_books.get_book_detail',
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
'app.router.v1.router_movies.get_movie_details',
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
'app.router.v1.router_games.get_game_details',
lambda *args, **kwargs: None,
raising=False,
)


# Create a new database session for testing
@pytest.fixture(scope='session', name='test_db_engine')
def db_engine():
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/router_movies_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,9 @@ def test_search_movies_returns_results(
assert data[0]['poster_url'] == 'https://image.tmdb.org/t/p/w500/matrix.jpg'
# TMDB title search carries no IMDb id.
assert data[0]['imdb'] is None
# Full release_date, not just year, so the frontend can show unreleased
# titles a date instead of a rank affordance (web#180).
assert data[0]['release_date'] == '1999-03-30'
# A missing poster_path becomes null rather than a URL that would 404.
assert data[1]['poster_url'] is None

Expand Down
9 changes: 7 additions & 2 deletions tests/integration/router_visibility_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,10 @@ def test_toggling_a_category_off_removes_it(test_client: TestClient):
headers=_auth(token),
json={'visibility_movies': 'private'},
)
assert test_client.get('/v1/public/avery').status_code == 404
resp = test_client.get('/v1/public/avery')
assert resp.status_code == 200
assert resp.json()['shelves'] == []
assert test_client.get('/v1/public/avery?shelf=movies').status_code == 404


def test_watchlist_tier_alone_exposes_nothing(test_client: TestClient):
Expand All @@ -372,7 +375,9 @@ def test_watchlist_tier_alone_exposes_nothing(test_client: TestClient):
},
)

assert test_client.get('/v1/public/avery').status_code == 404
resp = test_client.get('/v1/public/avery')
assert resp.status_code == 200
assert resp.json()['shelves'] == []


def test_watchlist_shown_only_when_both_tiers_public(test_client: TestClient):
Expand Down
18 changes: 15 additions & 3 deletions tests/integration/router_visibility_viewer_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,11 +292,11 @@ def test_a_friends_only_profile_is_a_404_for_everybody_else(
) == _fingerprint(unknown)


def test_nothing_visible_404s_even_when_the_profile_tier_admits_you(
def test_nothing_visible_returns_200_when_profile_tier_admits_you(
test_client: TestClient,
):
# Profile reachable by a friend, but every shelf below it is private: the
# friend must not get an empty 200 that confirms the account exists.
# friend gets a 200 with empty shelves list and profile header (#296).
owner_token = test_client.first_user.token
_stock_every_shelf(test_client, owner_token)
_set_visibility(
Expand All @@ -316,7 +316,19 @@ def test_nothing_visible_404s_even_when_the_profile_tier_admits_you(
friend = test_client.get(
f'/v1/public/{HANDLE}', headers=_auth(test_client.second_user.token)
)
assert _fingerprint(friend) == _fingerprint(test_client.get('/v1/public/nobody'))
assert friend.status_code == 200
data = friend.json()
assert data['handle'] == HANDLE
assert data['shelves'] == []
assert data['total_ranked'] == 0
assert data['viewer'] == {'relationship': 'friend', 'following': False}

# Querying a specific unadmitted shelf still returns 404.
named_shelf = test_client.get(
f'/v1/public/{HANDLE}?shelf=movies',
headers=_auth(test_client.second_user.token),
)
assert named_shelf.status_code == 404


def test_a_pending_request_is_not_a_friendship(test_client: TestClient):
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/argon2_config_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# pylint: disable=missing-module-docstring, missing-function-docstring
"""
Unit test verifying Argon2 hashing cost configuration (#285).

Ensures that reduced cost parameters apply strictly in the test environment,
and non-test/production environments preserve standard strong Argon2 defaults.
"""

from app.config import Settings


def test_argon2_params_in_test_env():
"""In test environment (env='test'), argon2_params returns low cost settings."""
settings = Settings(env='test')
assert settings.argon2_params == {
'time_cost': 1,
'memory_cost': 8,
'parallelism': 1,
}


def test_argon2_params_in_production_env():
"""In production/local environments, argon2_params defaults to empty dict."""
settings = Settings(env='prod')
assert not settings.argon2_params

settings_local = Settings(env='local')
assert not settings_local.argon2_params
4 changes: 4 additions & 0 deletions tests/unit/movie_search_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ def test_search_movies_by_imdb_id_returns_search_hit_shape(mock_get, mock_settin
'imdb': 'tt0120338',
'title': 'Titanic',
'year': '1997',
'release_date': '1997-11-18',
'poster_url': 'https://image.tmdb.org/t/p/w500/t.jpg',
'type': 'movie',
'popularity': 91.2,
Expand Down Expand Up @@ -210,6 +211,9 @@ def test_search_movies_title_query_uses_search_endpoint(mock_get, mock_settings)
# Title search carries no IMDb id — that's why tmdb is the join key.
assert results[0]['imdb'] is None
assert results[0]['popularity'] == 91.2
# Full release_date (not just year) so the frontend can tell unreleased
# titles apart and show a date instead of a rank affordance (web#180).
assert results[0]['release_date'] == '1997-11-18'
args, kwargs = mock_get.call_args
assert args[0].endswith('/search/movie')
assert kwargs['params']['query'] == 'Titanic'
Expand Down