Skip to content
Open
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
Empty file.
40 changes: 40 additions & 0 deletions backend/tests/integration/api/v1/api_keys/test_read.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Read routes for API keys rely on the global domain-error handlers.

These pin the generic 404 and 403 bodies (no raw exception text, no echo of the
identifier) that the route modules previously produced themselves.
"""

import pytest
from httpx import AsyncClient

from src.infrastructure.auth.dependencies import get_current_user
from src.interfaces.main import app

pytestmark = pytest.mark.asyncio


async def test_a_missing_api_key_returns_the_generic_not_found(auth_client: AsyncClient):
response = await auth_client.get("/api/v1/api-keys/999999")

assert response.status_code == 404
body = response.json()
assert body["detail"] == "The requested resource was not found."
assert body["support_id"]
assert "999999" not in body["detail"]


async def test_another_users_api_key_is_forbidden_generically(
auth_client: AsyncClient, test_user: dict, test_user_2: dict
):
created = await auth_client.post("/api/v1/api-keys/", json={"name": "Cross User Key"})
assert created.status_code == 201
key_id = created.json()["id"]

app.dependency_overrides[get_current_user] = lambda: test_user_2
response = await auth_client.get(f"/api/v1/api-keys/{key_id}")

assert response.status_code == 403
body = response.json()
assert body["detail"] == "You don't have permission for this action."
assert body["support_id"]
assert "Cross User Key" not in response.text
9 changes: 9 additions & 0 deletions backend/tests/integration/api/v1/users/test_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,12 @@ async def test_signup_accepts_a_non_latin_password(client: AsyncClient, db_sessi
response = await client.post("/api/v1/users/", json={**generate_unique_user_data(), "password": "Пароль1!"})

assert response.status_code == 201


async def test_signup_names_every_missing_class_at_once(client: AsyncClient, db_session: AsyncSession):
"""A password missing several classes reports all of them, not just the first."""
response = await client.post("/api/v1/users/", json={**generate_unique_user_data(), "password": "password"})

assert response.status_code == 422
requirements = {error["ctx"]["requirement"] for error in response.json()["detail"]}
assert {"uppercase", "digit", "special"} <= requirements
22 changes: 22 additions & 0 deletions backend/tests/integration/auth/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession

from src.infrastructure.auth import routes
from src.infrastructure.auth.dependencies import get_optional_principal
from src.infrastructure.auth.setup import auth as crud_auth
from src.interfaces.main import app
Expand Down Expand Up @@ -350,3 +351,24 @@ def recording_hashpw(password, salt):
assert response.status_code == 201
assert threads
assert threading.main_thread().name not in threads


@pytest.mark.asyncio
async def test_login_finishes_through_the_session_transport(client: AsyncClient, test_user: dict):
"""The route must create the session through SessionTransport.complete_login (cookies + hooks)."""
captured: dict = {}
original = routes.session_transport.complete_login

async def spy(request, response, user, options):
captured["options"] = options
return await original(request, response, user, options)

with patch.object(routes.session_transport, "complete_login", spy):
response = await client.post(
"/api/v1/auth/login",
data={**_credentials(test_user), "remember_me": "true"},
)

assert response.status_code == 200
assert captured["options"]["remember_me"] is True
assert captured["options"]["metadata"]["login_type"] == "password"
32 changes: 32 additions & 0 deletions backend/tests/integration/auth/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,22 @@ async def test_an_offsite_redirect_target_falls_back_to_the_app(client: AsyncCli
assert response.headers["location"] == BASE


async def test_an_absolute_same_origin_redirect_is_refused(client: AsyncClient, monkeypatch):
"""Only relative paths are safe to hand back, even when the host is the app's own."""
_stub_google(
monkeypatch,
{"sub": "google-321", "email": "absolute@example.com", "email_verified": True, "name": "Absolute Target"},
)
state = await _start(client, redirect_to=f"{BASE}/dashboard")

response = await client.get(
"/api/v1/auth/oauth/callback/google", params={"code": "the-code", "state": state}, follow_redirects=False
)

assert response.status_code == 307
assert response.headers["location"] == BASE


async def test_a_state_this_browser_never_started_is_refused(client: AsyncClient):
"""A state without its browser-bound cookie may be a login-CSRF attempt, so no session."""
response = await client.get(
Expand All @@ -170,3 +186,19 @@ async def test_a_provider_error_sends_the_browser_back_with_an_error(client: Asy
location = urlparse(response.headers["location"])
assert f"{location.scheme}://{location.netloc}" == BASE
assert parse_qs(location.query)["error"]


async def test_the_auth_paths_keep_their_existing_contract():
"""The migration to crudauth must not move the URLs clients already call."""
paths = {route.path for route in app.routes}

for path in (
"/api/v1/auth/login",
"/api/v1/auth/logout",
"/api/v1/auth/logout-all",
"/api/v1/auth/refresh-csrf",
"/api/v1/auth/check-auth",
"/api/v1/auth/oauth/{provider}",
"/api/v1/auth/oauth/callback/{provider}",
):
assert path in paths
175 changes: 174 additions & 1 deletion backend/tests/unit/infrastructure/auth/test_setup.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
"""Tests for the crudauth composition root wiring."""

from types import SimpleNamespace

import pytest
from crudauth import Principal
from crudauth import NewUserContext, Principal
from starlette.requests import Request

from src.infrastructure.auth import setup
from src.infrastructure.config.settings import settings
from src.infrastructure.database.session import async_session
from src.modules.rate_limit.crud import crud_rate_limits
from src.modules.user.constants import NAME_MAX_LENGTH


def _request(path: str, client_host: str = "203.0.113.7") -> Request:
Expand Down Expand Up @@ -84,9 +89,177 @@ def test_different_paths_get_different_budgets(self):
_request("/api/v1/rate-limits/"), principal
)

def test_ipv6_callers_are_keyed_by_their_network(self, monkeypatch):
"""Rotating addresses inside one /64 must not mint fresh budgets."""
monkeypatch.setattr(settings, "TRUSTED_PROXY_HOPS", 0)

key = setup.api_rate_limit_key(_request("/api/v1/tiers/", "2001:db8:1:2:3:4:5:6"), None)

assert key == "ip:2001:db8:1:2::/64:/api/v1/tiers/"


class TestOAuthWiring:
"""The callback URI crudauth sends to the provider matches the route that serves it."""

def test_the_callback_lives_under_the_api_prefix(self):
assert setup.OAUTH_PREFIX == "/api/v1/auth/oauth"


class TestRateLimiterBackendIndependence:
"""RATE_LIMITER_BACKEND and SESSION_BACKEND are chosen independently."""

def test_rate_limiter_does_not_follow_the_session_backend(self, monkeypatch):
monkeypatch.setattr(settings, "SESSION_BACKEND", "memory")
monkeypatch.setattr(settings, "RATE_LIMITER_BACKEND", "redis")

assert setup._rate_limiter() is not None

def test_sessions_do_not_follow_the_rate_limiter_backend(self, monkeypatch):
monkeypatch.setattr(settings, "RATE_LIMITER_BACKEND", "redis")
monkeypatch.setattr(settings, "SESSION_BACKEND", "memory")

assert setup._session_transport().redis_url is None


_SENTINEL_DB = object()


class TestResolveApiRateLimit:
"""The tier row is read through the request's own database dependency."""

async def test_the_tier_row_comes_from_the_session_override(self, monkeypatch):
monkeypatch.setattr(settings, "RATE_LIMITER_ENABLED", True)
entered: list[bool] = []

async def override_session():
entered.append(True)
yield _SENTINEL_DB

request = SimpleNamespace(
url=SimpleNamespace(path="/api/v1/tiers/"),
app=SimpleNamespace(dependency_overrides={async_session: override_session}),
)
principal = Principal(user_id=1, user=SimpleNamespace(tier_id=7), transport="session")
seen: dict[str, object] = {}

async def fake_get(db, **kwargs):
seen["db"] = db
return {"limit": 2, "period": 3600}

monkeypatch.setattr(crud_rate_limits, "get", fake_get)

result = await setup.resolve_api_rate_limit(request, principal)

assert entered == [True]
assert seen["db"] is _SENTINEL_DB
assert (result.times, result.seconds) == (2, 3600)

async def test_a_caller_without_a_tier_gets_the_default_limit(self, monkeypatch):
monkeypatch.setattr(settings, "RATE_LIMITER_ENABLED", True)
monkeypatch.setattr(settings, "DEFAULT_RATE_LIMIT_LIMIT", 11)
monkeypatch.setattr(settings, "DEFAULT_RATE_LIMIT_PERIOD", 99)

result = await setup.resolve_api_rate_limit(SimpleNamespace(url=None, app=None), None)

assert (result.times, result.seconds) == (11, 99)

async def test_a_disabled_limiter_returns_no_limit(self, monkeypatch):
monkeypatch.setattr(settings, "RATE_LIMITER_ENABLED", False)

assert await setup.resolve_api_rate_limit(SimpleNamespace(url=None, app=None), None) is None

async def test_a_tier_without_a_row_for_the_path_gets_the_default_limit(self, monkeypatch):
monkeypatch.setattr(settings, "RATE_LIMITER_ENABLED", True)
monkeypatch.setattr(settings, "DEFAULT_RATE_LIMIT_LIMIT", 11)
monkeypatch.setattr(settings, "DEFAULT_RATE_LIMIT_PERIOD", 99)

async def override_session():
yield _SENTINEL_DB

request = SimpleNamespace(
url=SimpleNamespace(path="/api/v1/tiers/"),
app=SimpleNamespace(dependency_overrides={async_session: override_session}),
)
principal = Principal(user_id=1, user=SimpleNamespace(tier_id=7), transport="session")

async def fake_get(db, **kwargs):
return None

monkeypatch.setattr(crud_rate_limits, "get", fake_get)

result = await setup.resolve_api_rate_limit(request, principal)

assert (result.times, result.seconds) == (11, 99)

async def test_a_principal_without_a_loaded_user_gets_the_default_limit(self, monkeypatch):
monkeypatch.setattr(settings, "RATE_LIMITER_ENABLED", True)
monkeypatch.setattr(settings, "DEFAULT_RATE_LIMIT_LIMIT", 11)
monkeypatch.setattr(settings, "DEFAULT_RATE_LIMIT_PERIOD", 99)

principal = Principal(user_id=1, user=None, transport="session")

result = await setup.resolve_api_rate_limit(SimpleNamespace(url=None, app=None), principal)

assert (result.times, result.seconds) == (11, 99)


class TestOAuthProviderSelection:
"""Only a fully configured Google is wired; the boilerplate has no other provider route."""

def test_google_is_wired_when_both_credentials_are_set(self, monkeypatch):
monkeypatch.setattr(settings, "OAUTH_GOOGLE_CLIENT_ID", "client-id")
monkeypatch.setattr(settings, "OAUTH_GOOGLE_CLIENT_SECRET", "client-secret")

providers = setup._oauth_providers()

assert set(providers) == {"google"}
assert providers["google"].client_id == "client-id"

def test_google_is_dropped_when_a_credential_is_missing(self, monkeypatch):
monkeypatch.setattr(settings, "OAUTH_GOOGLE_CLIENT_ID", "client-id")
monkeypatch.setattr(settings, "OAUTH_GOOGLE_CLIENT_SECRET", "")

assert setup._oauth_providers() == {}

def test_github_credentials_do_not_add_an_unrouted_provider(self, monkeypatch):
monkeypatch.setattr(settings, "OAUTH_GOOGLE_CLIENT_ID", "")
monkeypatch.setattr(settings, "OAUTH_GOOGLE_CLIENT_SECRET", "")
monkeypatch.setattr(settings, "OAUTH_GITHUB_CLIENT_ID", "gh-id")
monkeypatch.setattr(settings, "OAUTH_GITHUB_CLIENT_SECRET", "gh-secret")

assert setup._oauth_providers() == {}


class TestNewUserFields:
"""crudauth creates the account; the boilerplate supplies the required display name."""

def test_the_display_name_is_filled_and_bounded(self):
context = NewUserContext(
email="a" * 40 + "@example.com",
username="auser",
source="register",
db=None, # type: ignore[arg-type]
)

fields = setup._new_user_fields(context)

assert fields["name"] == "a" * NAME_MAX_LENGTH
assert len(fields["name"]) == NAME_MAX_LENGTH


class TestSessionTransportWiring:
"""The session settings reach the transport instead of the library defaults."""

def test_the_session_settings_reach_the_transport(self, monkeypatch):
monkeypatch.setattr(settings, "SESSION_BACKEND", "memory")
monkeypatch.setattr(settings, "CSRF_ENABLED", False)
monkeypatch.setattr(settings, "MAX_SESSIONS_PER_USER", 2)
monkeypatch.setattr(settings, "SESSION_TIMEOUT_MINUTES", 7)
monkeypatch.setattr(settings, "SESSION_CLEANUP_INTERVAL_MINUTES", 3)

transport = setup._session_transport()

assert transport.csrf_enabled is False
assert transport.max_sessions_per_user == 2
assert transport.session_timeout_minutes == 7
assert transport.cleanup_interval_minutes == 3
41 changes: 41 additions & 0 deletions backend/tests/unit/infrastructure/config/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import pytest

from src.infrastructure.config.enums import RateLimiterBackend, SessionBackend
from src.infrastructure.config.settings import Settings, get_settings


Expand Down Expand Up @@ -267,3 +268,43 @@ def test_taskiq_required_settings_exist(self):

for attr in required_attrs:
assert hasattr(settings, attr), f"Missing required Taskiq setting: {attr}"


class TestRateLimiterSettings:
"""The limiter moved to crudauth; the memcached and fail-open knobs went with it."""

def test_the_removed_memcached_and_fail_open_settings_are_gone(self):
settings = get_settings()

for name in (
"RATE_LIMITER_FAIL_OPEN",
"RATE_LIMITER_MEMCACHED_HOST",
"RATE_LIMITER_MEMCACHED_PORT",
"RATE_LIMITER_MEMCACHED_POOL_SIZE",
"RATE_LIMITER_MEMCACHED_CONNECT_TIMEOUT",
):
assert not hasattr(settings, name), name

def test_the_rate_limiter_backend_names_a_supported_backend(self):
assert RateLimiterBackend(get_settings().RATE_LIMITER_BACKEND)

def test_the_backend_enums_accept_only_redis_and_memory(self):
with pytest.raises(ValueError):
SessionBackend("memcached")
with pytest.raises(ValueError):
RateLimiterBackend("memcached")


class TestCORSSettings:
"""Credentialed requests reject a wildcard origin, so the default must not be one."""

def test_the_default_origins_are_explicit_not_a_wildcard(self):
origins = Settings().CORS_ORIGINS_LIST

assert origins
assert "*" not in origins
assert all(origin.startswith("http") for origin in origins)

@patch.dict(os.environ, {"CORS_ORIGINS": "http://a.test, http://b.test ,"})
def test_the_origin_list_strips_whitespace_and_drops_empties(self):
assert Settings().CORS_ORIGINS_LIST == ["http://a.test", "http://b.test"]
9 changes: 9 additions & 0 deletions backend/tests/unit/infrastructure/database/test_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""The shared metadata uses the standard constraint naming convention."""

from src.infrastructure.database.session import NAMING_CONVENTION, Base


def test_metadata_uses_the_standard_naming_convention():
"""Stable names keep Alembic autogenerate from renaming constraints on every run."""
assert dict(Base.metadata.naming_convention) == NAMING_CONVENTION
assert set(NAMING_CONVENTION) == {"ix", "uq", "ck", "fk", "pk"}
Loading
Loading