3ccd94f661
Test Organization: - Create tests/unit/, tests/integration/, tests/system/ directories - Move existing tests into appropriate categories - Add pytest markers (@pytest.mark.unit, @pytest.mark.integration) Shared Fixtures: - Create conftest.py with SQLite engine (for unit tests) - Add PostgreSQL session fixture with transaction rollback - Add TestClient fixture for API tests Configuration: - Update pyproject.toml with asyncio_mode=auto - Add test markers and default addopts - Add aiosqlite dependency for SQLite support E2E Testing: - Initialize Playwright in e2e/ directory - Add playwright.config.ts - Create login flow E2E test Build: - Add test-unit, test-integration, test-system, test-e2e to Makefile - Update test target to run all categories - Add testing documentation to README Note: Some tests have import issues due to missing python-jose package in dev environment. This needs to be addressed separately.
235 lines
7.0 KiB
Python
235 lines
7.0 KiB
Python
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
import asyncio
|
|
import importlib
|
|
|
|
from fastapi.testclient import TestClient
|
|
import pytest
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from src.auth.jwt_service import mint_access_token
|
|
from src.config import Settings, build_database_url
|
|
from src.models import Base
|
|
from src.models.user import User
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def configure_local_database(monkeypatch) -> None:
|
|
local_url = build_database_url(
|
|
user="headquarter",
|
|
password="headquarter",
|
|
host="localhost",
|
|
port=5432,
|
|
database="headquarter",
|
|
)
|
|
monkeypatch.setenv("DATABASE_URL", local_url)
|
|
|
|
|
|
def _prepare_auth_test_db() -> None:
|
|
async def _run() -> None:
|
|
engine = create_async_engine(
|
|
build_database_url(
|
|
user="headquarter",
|
|
password="headquarter",
|
|
host="localhost",
|
|
port=5432,
|
|
database="headquarter",
|
|
)
|
|
)
|
|
async with engine.begin() as connection:
|
|
await connection.run_sync(Base.metadata.create_all)
|
|
await connection.execute(text("TRUNCATE TABLE refresh_tokens, users RESTART IDENTITY CASCADE"))
|
|
await engine.dispose()
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def _load_app():
|
|
import src.database as database_module
|
|
import src.api.auth as auth_module
|
|
import src.main as main_module
|
|
|
|
importlib.reload(database_module)
|
|
importlib.reload(auth_module)
|
|
importlib.reload(main_module)
|
|
return main_module.app
|
|
|
|
|
|
def _insert_user_for_refresh(user_id: str) -> None:
|
|
async def _run() -> None:
|
|
engine = create_async_engine(
|
|
build_database_url(
|
|
user="headquarter",
|
|
password="headquarter",
|
|
host="localhost",
|
|
port=5432,
|
|
database="headquarter",
|
|
)
|
|
)
|
|
async with engine.begin() as connection:
|
|
await connection.run_sync(Base.metadata.create_all)
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
|
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
async with session_factory() as session:
|
|
user = User(
|
|
id=uuid.UUID(user_id),
|
|
email="refresh@headquarter.local",
|
|
name="Refresh User",
|
|
authentik_id="refresh-user",
|
|
avatar_url=None,
|
|
)
|
|
await session.merge(user)
|
|
await session.commit()
|
|
await engine.dispose()
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
n@pytest.mark.integration
|
|
|
|
def test_login_redirects_to_authentik_authorize_endpoint() -> None:
|
|
_prepare_auth_test_db()
|
|
app = _load_app()
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/auth/login", follow_redirects=False)
|
|
|
|
assert response.status_code == 307
|
|
assert "response_type=code" in response.headers["location"]
|
|
|
|
|
|
n@pytest.mark.integration
|
|
|
|
def test_me_returns_401_without_access_cookie() -> None:
|
|
_prepare_auth_test_db()
|
|
app = _load_app()
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/auth/me")
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
n@pytest.mark.integration
|
|
|
|
def test_me_returns_user_payload_with_valid_access_cookie() -> None:
|
|
_prepare_auth_test_db()
|
|
app = _load_app()
|
|
|
|
settings = Settings()
|
|
token = mint_access_token(
|
|
settings=settings,
|
|
subject=str(uuid.uuid4()),
|
|
email="dev@headquarter.local",
|
|
name="Dev User",
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
|
)
|
|
|
|
client = TestClient(app)
|
|
client.cookies.set("access_token", token)
|
|
response = client.get("/auth/me")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["email"] == "dev@headquarter.local"
|
|
|
|
|
|
n@pytest.mark.integration
|
|
|
|
def test_logout_clears_auth_cookies() -> None:
|
|
_prepare_auth_test_db()
|
|
app = _load_app()
|
|
|
|
client = TestClient(app)
|
|
client.cookies.set("refresh_token", "opaque-token")
|
|
response = client.post("/auth/logout")
|
|
|
|
assert response.status_code == 200
|
|
assert "access_token=" in response.headers.get("set-cookie", "")
|
|
|
|
|
|
n@pytest.mark.integration
|
|
|
|
def test_callback_rejects_mismatched_state() -> None:
|
|
_prepare_auth_test_db()
|
|
app = _load_app()
|
|
|
|
client = TestClient(app)
|
|
client.cookies.set("auth_state", "expected")
|
|
response = client.get("/auth/callback?code=test-code&state=wrong")
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
n@pytest.mark.integration
|
|
|
|
def test_callback_sets_auth_cookies_after_success(monkeypatch) -> None:
|
|
_prepare_auth_test_db()
|
|
app = _load_app()
|
|
|
|
async def fake_exchange_code_for_tokens(*, settings, code, redirect_uri, client):
|
|
return {"access_token": "provider-access", "refresh_token": "provider-refresh"}
|
|
|
|
def fake_verify_provider_access_token(*, settings, token, jwks):
|
|
return {"sub": "auth-sub-1", "email": "callback@headquarter.local", "name": "Callback User"}
|
|
|
|
async def fake_fetch_jwks(*, settings, client):
|
|
return {"keys": []}
|
|
|
|
monkeypatch.setattr("src.api.auth.exchange_code_for_tokens", fake_exchange_code_for_tokens)
|
|
monkeypatch.setattr("src.api.auth.verify_provider_access_token", fake_verify_provider_access_token)
|
|
monkeypatch.setattr("src.api.auth.fetch_jwks", fake_fetch_jwks)
|
|
|
|
client = TestClient(app)
|
|
client.cookies.set("auth_state", "good-state")
|
|
response = client.get("/auth/callback?code=valid-code&state=good-state")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["email"] == "callback@headquarter.local"
|
|
set_cookie_header = response.headers.get("set-cookie", "")
|
|
assert "access_token=" in set_cookie_header
|
|
assert "refresh_token=" in set_cookie_header
|
|
|
|
|
|
n@pytest.mark.integration
|
|
|
|
def test_refresh_rotates_cookie_and_returns_user_payload(monkeypatch) -> None:
|
|
_prepare_auth_test_db()
|
|
_insert_user_for_refresh("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb")
|
|
app = _load_app()
|
|
|
|
async def fake_rotate_refresh_token(*, session, raw_token, user_agent, ip_address):
|
|
class StoredToken:
|
|
user_id = uuid.UUID("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb")
|
|
|
|
return "new-refresh-token", StoredToken()
|
|
|
|
monkeypatch.setattr("src.api.auth.rotate_refresh_token", fake_rotate_refresh_token)
|
|
|
|
client = TestClient(app)
|
|
client.cookies.set("refresh_token", "old-refresh-token")
|
|
response = client.post("/auth/refresh")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["sub"] == "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
|
|
assert "refresh_token=" in response.headers.get("set-cookie", "")
|
|
|
|
|
|
n@pytest.mark.integration
|
|
|
|
def test_refresh_returns_401_for_invalid_refresh_token(monkeypatch) -> None:
|
|
_prepare_auth_test_db()
|
|
app = _load_app()
|
|
|
|
async def fake_rotate_refresh_token(*, session, raw_token, user_agent, ip_address):
|
|
raise ValueError("refresh token not found")
|
|
|
|
monkeypatch.setattr("src.api.auth.rotate_refresh_token", fake_rotate_refresh_token)
|
|
|
|
client = TestClient(app)
|
|
client.cookies.set("refresh_token", "invalid")
|
|
response = client.post("/auth/refresh")
|
|
|
|
assert response.status_code == 401
|