feat: implement auth, projects, and frontend foundation
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
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())
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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", "")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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", "")
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user