feat: implement user profile management and oauth/traefik integration

User Profile (US-004):
- Add authenticated profile endpoints (GET/PUT /users/me)
- Add avatar upload with file validation (PNG/JPEG, max 2MB)
- Create frontend profile page with edit form and avatar upload
- Update app shell to link to profile page

OAuth/Traefik Integration:
- Externalize all Authentik URLs to environment variables
- Add domain configuration (API_DOMAIN, WEB_DOMAIN, AUTHENTIK_DOMAIN)
- Create docker-compose.traefik.yml for reverse proxy deployment
- Update OAuth redirect/callback URLs to use configured domains
- Add VITE_APP_URL for frontend public URL configuration

Quality gates: pytest (50 passed), ruff, mypy, npm test (12 passed), typecheck, lint, build
This commit is contained in:
Fusion
2026-05-17 23:17:10 +02:00
parent 56f440db1b
commit 577b052c05
32 changed files with 1153 additions and 16 deletions
+1 -1
View File
@@ -102,7 +102,7 @@ async def test_exchange_code_for_tokens_posts_expected_payload() -> None:
settings = Settings()
def handler(request: httpx.Request) -> httpx.Response:
assert request.url == httpx.URL(settings.authentik_token_url)
assert request.url == httpx.URL(settings.resolved_authentik_token_url)
payload = dict(httpx.QueryParams(request.content.decode("utf-8")))
assert payload["grant_type"] == "authorization_code"
assert payload["code"] == "auth-code"
+3 -3
View File
@@ -33,9 +33,9 @@ def test_auth_settings_have_secure_defaults() -> None:
assert settings.authentik_client_id == "headquarter-web"
assert settings.authentik_client_secret == "change-me"
assert settings.authentik_authorize_url.endswith("/application/o/authorize/")
assert settings.authentik_token_url.endswith("/application/o/token/")
assert settings.authentik_jwks_url.endswith("/application/o/headquarter-web/jwks/")
assert settings.resolved_authentik_authorize_url.endswith("/application/o/authorize/")
assert settings.resolved_authentik_token_url.endswith("/application/o/token/")
assert settings.resolved_authentik_jwks_url.endswith("/application/o/headquarter-web/jwks/")
assert settings.jwt_algorithm == "HS256"
assert settings.access_token_ttl_minutes == 15
assert settings.refresh_token_ttl_days == 7
+225
View File
@@ -0,0 +1,225 @@
import uuid
from datetime import UTC, datetime, timedelta
import asyncio
import io
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_users_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 importlib
import src.database as database_module
import src.api.users as users_module
import src.main as main_module
importlib.reload(database_module)
importlib.reload(users_module)
importlib.reload(main_module)
return main_module.app
def _insert_test_user(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="test@headquarter.local",
name="Test User",
authentik_id="test-user",
avatar_url=None,
)
await session.merge(user)
await session.commit()
await engine.dispose()
asyncio.run(_run())
def _create_auth_cookie(user_id: str) -> str:
settings = Settings()
return mint_access_token(
settings=settings,
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
)
def test_get_profile_returns_401_without_cookie() -> None:
_prepare_users_test_db()
app = _load_app()
client = TestClient(app)
response = client.get("/users/me")
assert response.status_code == 401
def test_get_profile_returns_user_data() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
response = client.get("/users/me")
assert response.status_code == 200
data = response.json()
assert data["email"] == "test@headquarter.local"
assert data["name"] == "Test User"
assert data["avatar_url"] is None
def test_update_profile_changes_name_and_email() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
response = client.put("/users/me", json={"name": "Updated Name", "email": "updated@headquarter.local"})
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Name"
assert data["email"] == "updated@headquarter.local"
def test_update_profile_rejects_empty_name() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
response = client.put("/users/me", json={"name": " "})
assert response.status_code == 400
def test_update_profile_rejects_invalid_email() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
response = client.put("/users/me", json={"email": "not-an-email"})
assert response.status_code == 400
def test_upload_avatar_updates_avatar_url() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
# Create a simple 1x1 PNG
png_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
response = client.post(
"/users/me/avatar",
files={"file": ("test.png", io.BytesIO(png_data), "image/png")},
)
assert response.status_code == 200
data = response.json()
assert data["avatar_url"] is not None
assert data["avatar_url"].startswith("/uploads/avatars/")
def test_upload_avatar_rejects_invalid_file_type() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
response = client.post(
"/users/me/avatar",
files={"file": ("test.txt", io.BytesIO(b"not an image"), "text/plain")},
)
assert response.status_code == 400
def test_upload_avatar_rejects_oversized_file() -> None:
_prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
_insert_test_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _create_auth_cookie(user_id))
large_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * (3 * 1024 * 1024) # 3MB
response = client.post(
"/users/me/avatar",
files={"file": ("large.png", io.BytesIO(large_png), "image/png")},
)
assert response.status_code == 400