| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- """Integration tests for the admin-set session-lifetime ceiling (#1706).
- Covers the four token-issuance sites that read ``session_max_hours``:
- plain login, 2FA backup-code login, 2FA TOTP/email login, OIDC login.
- Only the first is exercised end-to-end via ``async_client``; the helper
- ``resolve_session_max_minutes`` itself is unit-tested below so the MFA
- and OIDC paths inherit the same clamping behaviour by construction.
- """
- import time
- import jwt
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.auth import (
- ACCESS_TOKEN_EXPIRE_MINUTES,
- ALGORITHM,
- SECRET_KEY,
- SESSION_MAX_HOURS_HARD_CEILING,
- resolve_session_max_minutes,
- )
- from backend.app.models.settings import Settings
- async def _set_session_max_hours(db: AsyncSession, value: str | None) -> None:
- """Upsert the session_max_hours setting row (value=None deletes it)."""
- result = await db.execute(select(Settings).where(Settings.key == "session_max_hours"))
- existing = result.scalar_one_or_none()
- if value is None:
- if existing is not None:
- await db.delete(existing)
- await db.commit()
- return
- if existing is None:
- db.add(Settings(key="session_max_hours", value=value))
- else:
- existing.value = value
- await db.commit()
- class TestResolveSessionMaxMinutes:
- """Unit-style tests for the clamping resolver."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_missing_row_returns_24h_default(self, db_session: AsyncSession):
- await _set_session_max_hours(db_session, None)
- assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
- assert ACCESS_TOKEN_EXPIRE_MINUTES == 60 * 24
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_empty_string_returns_24h_default(self, db_session: AsyncSession):
- await _set_session_max_hours(db_session, "")
- assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_unparseable_value_returns_24h_default(self, db_session: AsyncSession):
- await _set_session_max_hours(db_session, "not-a-number")
- assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_zero_or_negative_returns_24h_default(self, db_session: AsyncSession):
- await _set_session_max_hours(db_session, "0")
- assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
- await _set_session_max_hours(db_session, "-5")
- assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_one_hour_minimum(self, db_session: AsyncSession):
- await _set_session_max_hours(db_session, "1")
- assert await resolve_session_max_minutes(db_session) == 60
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_seven_days_passes_through(self, db_session: AsyncSession):
- await _set_session_max_hours(db_session, "168")
- assert await resolve_session_max_minutes(db_session) == 168 * 60
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_thirty_days_passes_through(self, db_session: AsyncSession):
- await _set_session_max_hours(db_session, str(SESSION_MAX_HOURS_HARD_CEILING))
- assert await resolve_session_max_minutes(db_session) == SESSION_MAX_HOURS_HARD_CEILING * 60
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_above_ceiling_is_clamped_to_30_days(self, db_session: AsyncSession):
- """Defense-in-depth: a tampered settings row above 720h must be clamped."""
- await _set_session_max_hours(db_session, "99999")
- assert await resolve_session_max_minutes(db_session) == SESSION_MAX_HOURS_HARD_CEILING * 60
- class TestLoginRespectsSessionPolicy:
- """The /auth/login route must honour the resolved ceiling."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_login_uses_default_24h_when_unset(self, async_client: AsyncClient, db_session: AsyncSession):
- await async_client.post(
- "/api/v1/auth/setup",
- json={
- "auth_enabled": True,
- "admin_username": "sessiontest1",
- "admin_password": "SessionPass1!",
- },
- )
- await _set_session_max_hours(db_session, None)
- before = int(time.time())
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": "sessiontest1", "password": "SessionPass1!"},
- )
- after = int(time.time())
- assert response.status_code == 200
- token = response.json()["access_token"]
- decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
- # exp should be ~24h ahead. Allow generous bounds for clock drift.
- expected_min = before + 24 * 3600 - 60
- expected_max = after + 24 * 3600 + 60
- assert expected_min <= decoded["exp"] <= expected_max
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_login_uses_configured_7d_ceiling(self, async_client: AsyncClient, db_session: AsyncSession):
- await async_client.post(
- "/api/v1/auth/setup",
- json={
- "auth_enabled": True,
- "admin_username": "sessiontest2",
- "admin_password": "SessionPass2!",
- },
- )
- await _set_session_max_hours(db_session, "168") # 7 days
- before = int(time.time())
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": "sessiontest2", "password": "SessionPass2!"},
- )
- after = int(time.time())
- assert response.status_code == 200
- token = response.json()["access_token"]
- decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
- expected_min = before + 168 * 3600 - 60
- expected_max = after + 168 * 3600 + 60
- assert expected_min <= decoded["exp"] <= expected_max
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_login_clamps_above_ceiling(self, async_client: AsyncClient, db_session: AsyncSession):
- """A settings row above the 720h ceiling must be clamped at login time."""
- await async_client.post(
- "/api/v1/auth/setup",
- json={
- "auth_enabled": True,
- "admin_username": "sessiontest3",
- "admin_password": "SessionPass3!",
- },
- )
- await _set_session_max_hours(db_session, "5000") # would be ~208 days
- before = int(time.time())
- response = await async_client.post(
- "/api/v1/auth/login",
- json={"username": "sessiontest3", "password": "SessionPass3!"},
- )
- after = int(time.time())
- assert response.status_code == 200
- token = response.json()["access_token"]
- decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
- # Clamped to 30 days, not 5000 hours.
- expected_min = before + SESSION_MAX_HOURS_HARD_CEILING * 3600 - 60
- expected_max = after + SESSION_MAX_HOURS_HARD_CEILING * 3600 + 60
- assert expected_min <= decoded["exp"] <= expected_max
- class TestSettingsAPIExposesSessionMaxHours:
- """The /settings API must round-trip session_max_hours as an int."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_default_is_24(self, async_client: AsyncClient, db_session: AsyncSession):
- await _set_session_max_hours(db_session, None)
- response = await async_client.get("/api/v1/settings/")
- assert response.status_code == 200
- assert response.json()["session_max_hours"] == 24
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_accepts_valid_value(self, async_client: AsyncClient, db_session: AsyncSession):
- response = await async_client.patch(
- "/api/v1/settings/",
- json={"session_max_hours": 168},
- )
- assert response.status_code == 200
- assert response.json()["session_max_hours"] == 168
- # Persisted as the int's string form so the resolver round-trips.
- result = await db_session.execute(select(Settings).where(Settings.key == "session_max_hours"))
- row = result.scalar_one()
- assert row.value == "168"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_rejects_zero(self, async_client: AsyncClient):
- response = await async_client.patch(
- "/api/v1/settings/",
- json={"session_max_hours": 0},
- )
- assert response.status_code == 422
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_rejects_above_ceiling(self, async_client: AsyncClient):
- response = await async_client.patch(
- "/api/v1/settings/",
- json={"session_max_hours": SESSION_MAX_HOURS_HARD_CEILING + 1},
- )
- assert response.status_code == 422
|