feat: replace admin auth with cookie-based profile picker

Remove all authentication (login, sessions, bcrypt, itsdangerous) since
the app runs on a private homelab LAN. Replace with a profile picker
landing page and cookie-based profile selection (1-year expiry).

- Add Alembic migration to drop password_hash/is_admin columns
- Delete auth service, auth routes, login template, and auth tests
- Rewrite app/utils/auth.py with NoProfileSelectedError and
  require_active_profile dependency
- Add profile creation flow (GET/POST /profiles/create)
- Rewrite home page as profile picker with card layout
- Update all route files to use profile dependency instead of admin auth
- Remove bcrypt and itsdangerous from requirements
- Remove admin_username/admin_password from config
- Update all tests for new profile-based access model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 12:40:54 -05:00
parent 3dc0171639
commit 576d3bbb68
44 changed files with 523 additions and 1024 deletions

View File

@@ -11,8 +11,6 @@ from fastapi.testclient import TestClient
def _set_test_env() -> None:
"""Ensure required env vars are set for all tests."""
with patch.dict(os.environ, {
"ADMIN_USERNAME": "testadmin",
"ADMIN_PASSWORD": "testpass123",
"APP_ENV": "development",
"DATABASE_URL": "sqlite:///data/test_sneakyswole.db",
}, clear=False):
@@ -26,3 +24,8 @@ def client() -> TestClient:
app = create_app()
return TestClient(app)
def set_profile_cookie(client: TestClient, profile_id: int) -> None:
"""Helper to set the active_profile_id cookie on a test client."""
client.cookies.set("active_profile_id", str(profile_id))

View File

@@ -21,7 +21,7 @@ class TestAnalyticsService:
SQLModel.metadata.create_all(engine)
session = Session(engine)
user = User(username="phil", password_hash="h", display_name="Phillip")
user = User(username="phil", display_name="Phillip")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
exercise = Exercise(
name="DB Chest Press", muscle_group="Chest",

View File

@@ -1,56 +0,0 @@
"""Tests for the auth dependency (require_admin)."""
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from app.utils.auth import get_current_admin_user, get_active_profile_id
class TestAuthDependency:
"""Tests for the require_admin dependency."""
def test_redirects_when_no_session_cookie(self) -> None:
"""Should redirect to /login (303) when no session cookie is present."""
request = MagicMock()
request.cookies = {}
with pytest.raises(HTTPException) as exc_info:
get_current_admin_user(request=request, session=MagicMock())
assert exc_info.value.status_code == 303
def test_redirects_when_invalid_token(self) -> None:
"""Should redirect to /login (303) when session cookie has invalid token."""
request = MagicMock()
request.cookies = {"session": "invalid-token"}
request.app.state.secret_key = "test-secret"
mock_session = MagicMock()
mock_session.get.return_value = None
with pytest.raises(HTTPException) as exc_info:
get_current_admin_user(request=request, session=mock_session)
assert exc_info.value.status_code == 303
class TestGetActiveProfileId:
"""Tests for the get_active_profile_id dependency."""
def test_returns_profile_id_from_cookie(self) -> None:
"""Should return the integer profile ID from cookie."""
request = MagicMock()
request.cookies = {"active_profile_id": "5"}
assert get_active_profile_id(request) == 5
def test_returns_none_when_no_cookie(self) -> None:
"""Should return None when no active_profile_id cookie is set."""
request = MagicMock()
request.cookies = {}
assert get_active_profile_id(request) is None
def test_returns_none_for_non_numeric(self) -> None:
"""Should return None for non-numeric cookie values."""
request = MagicMock()
request.cookies = {"active_profile_id": "abc"}
assert get_active_profile_id(request) is None

View File

@@ -1,43 +0,0 @@
"""Tests for login/logout routes."""
from fastapi.testclient import TestClient
class TestLoginPage:
"""Tests for GET /login."""
def test_login_page_returns_200(self, client: TestClient) -> None:
"""GET /login should render the login form."""
response = client.get("/login")
assert response.status_code == 200
assert "Login" in response.text
def test_login_page_has_form(self, client: TestClient) -> None:
"""GET /login should contain a login form with username and password."""
response = client.get("/login")
assert 'name="username"' in response.text
assert 'name="password"' in response.text
class TestLoginPost:
"""Tests for POST /login."""
def test_login_invalid_credentials_shows_error(self, client: TestClient) -> None:
"""POST /login with wrong credentials should show error message."""
response = client.post(
"/login",
data={"username": "wrong", "password": "wrong"},
follow_redirects=False,
)
# Should re-render login page with error or redirect back
assert response.status_code in (200, 303)
class TestLogout:
"""Tests for GET /logout."""
def test_logout_redirects_to_login(self, client: TestClient) -> None:
"""GET /logout should clear session and redirect to login."""
response = client.get("/logout", follow_redirects=False)
assert response.status_code in (303, 307)
assert "/login" in response.headers.get("location", "")

View File

@@ -1,93 +0,0 @@
"""Tests for the AuthService class."""
import bcrypt
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.services.auth_service import AuthService
class TestAuthService:
"""Tests for admin authentication logic."""
def _setup(self):
"""Create an in-memory DB with an admin user."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
# Create admin user with known password
pw_hash = bcrypt.hashpw(b"adminpass", bcrypt.gensalt()).decode("utf-8")
admin = User(
username="admin",
password_hash=pw_hash,
display_name="Admin",
is_admin=True,
)
session.add(admin)
session.commit()
service = AuthService(session, secret_key="test-secret-key")
return session, service
def test_authenticate_valid_credentials(self) -> None:
"""authenticate should return the User for valid credentials."""
session, service = self._setup()
user = service.authenticate("admin", "adminpass")
assert user is not None
assert user.username == "admin"
session.close()
def test_authenticate_wrong_password(self) -> None:
"""authenticate should return None for wrong password."""
session, service = self._setup()
user = service.authenticate("admin", "wrongpass")
assert user is None
session.close()
def test_authenticate_unknown_user(self) -> None:
"""authenticate should return None for unknown username."""
session, service = self._setup()
user = service.authenticate("nobody", "anything")
assert user is None
session.close()
def test_authenticate_non_admin(self) -> None:
"""authenticate should return None for non-admin users."""
session, service = self._setup()
pw_hash = bcrypt.hashpw(b"userpass", bcrypt.gensalt()).decode("utf-8")
non_admin = User(
username="regular",
password_hash=pw_hash,
display_name="Regular",
is_admin=False,
)
session.add(non_admin)
session.commit()
user = service.authenticate("regular", "userpass")
assert user is None
session.close()
def test_create_session_token(self) -> None:
"""create_session_token should return a non-empty signed string."""
session, service = self._setup()
token = service.create_session_token(user_id=1)
assert isinstance(token, str)
assert len(token) > 0
session.close()
def test_validate_session_token(self) -> None:
"""validate_session_token should return the user_id from a valid token."""
session, service = self._setup()
token = service.create_session_token(user_id=42)
user_id = service.validate_session_token(token)
assert user_id == 42
session.close()
def test_validate_session_token_invalid(self) -> None:
"""validate_session_token should return None for tampered tokens."""
session, service = self._setup()
user_id = service.validate_session_token("fake-token-value")
assert user_id is None
session.close()

View File

@@ -11,37 +11,17 @@ class TestSettings:
def test_settings_loads_defaults(self) -> None:
"""Settings should have sensible defaults for all fields."""
env = {
"ADMIN_USERNAME": "testadmin",
"ADMIN_PASSWORD": "testpass123",
}
# Remove DATABASE_URL so we test the actual default
env_clean = {k: v for k, v in os.environ.items() if k != "DATABASE_URL"}
env_clean.update(env)
with patch.dict(os.environ, env_clean, clear=True):
settings = Settings()
assert settings.admin_username == "testadmin"
assert settings.admin_password == "testpass123"
assert settings.app_env == "development"
assert settings.app_host == "0.0.0.0"
assert settings.app_port == 8000
assert settings.database_url == "sqlite:///data/sneakyswole.db"
def test_settings_requires_admin_username(self) -> None:
"""Settings should require ADMIN_USERNAME to be set."""
with patch.dict(os.environ, {"ADMIN_PASSWORD": "testpass"}, clear=True):
try:
Settings()
assert False, "Should have raised an error"
except Exception:
pass
def test_get_settings_returns_singleton(self) -> None:
"""get_settings should return the same instance on repeated calls."""
with patch.dict(os.environ, {
"ADMIN_USERNAME": "admin",
"ADMIN_PASSWORD": "pass",
}, clear=False):
s1 = get_settings()
s2 = get_settings()
assert s1 is s2
s1 = get_settings()
s2 = get_settings()
assert s1 is s2

View File

@@ -6,16 +6,18 @@ from fastapi.testclient import TestClient
class TestDashboard:
"""Tests for GET /dashboard."""
def test_dashboard_requires_auth(self, client: TestClient) -> None:
"""GET /dashboard should require admin login."""
def test_dashboard_requires_profile(self, client: TestClient) -> None:
"""GET /dashboard should redirect to / without profile cookie."""
response = client.get("/dashboard", follow_redirects=False)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"
class TestExerciseProgress:
"""Tests for GET /dashboard/exercise/<id>."""
def test_exercise_progress_requires_auth(self, client: TestClient) -> None:
"""GET /dashboard/exercise/1 should require admin login."""
def test_exercise_progress_requires_profile(self, client: TestClient) -> None:
"""GET /dashboard/exercise/1 should redirect to / without profile cookie."""
response = client.get("/dashboard/exercise/1", follow_redirects=False)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"

View File

@@ -6,19 +6,21 @@ from fastapi.testclient import TestClient
class TestExerciseBrowser:
"""Tests for GET /exercises."""
def test_exercise_browser_requires_auth(self, client: TestClient) -> None:
"""GET /exercises should require admin login."""
def test_exercise_browser_requires_profile(self, client: TestClient) -> None:
"""GET /exercises should redirect to / without profile cookie."""
response = client.get("/exercises", follow_redirects=False)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"
class TestExerciseSearch:
"""Tests for HTMX exercise search."""
def test_exercise_search_requires_auth(self, client: TestClient) -> None:
"""GET /exercises/search should require admin login."""
def test_exercise_search_requires_profile(self, client: TestClient) -> None:
"""GET /exercises/search should redirect to / without profile cookie."""
response = client.get(
"/exercises/search?workout_day=Push",
follow_redirects=False,
)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"

View File

@@ -6,16 +6,18 @@ from fastapi.testclient import TestClient
class TestLogHistory:
"""Tests for GET /history."""
def test_history_requires_auth(self, client: TestClient) -> None:
"""GET /history should require admin login."""
def test_history_requires_profile(self, client: TestClient) -> None:
"""GET /history should redirect to / without profile cookie."""
response = client.get("/history", follow_redirects=False)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"
class TestSessionDetail:
"""Tests for GET /history/<session_id>."""
def test_session_detail_requires_auth(self, client: TestClient) -> None:
"""GET /history/1 should require admin login."""
def test_session_detail_requires_profile(self, client: TestClient) -> None:
"""GET /history/1 should redirect to / without profile cookie."""
response = client.get("/history/1", follow_redirects=False)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"

View File

@@ -21,7 +21,7 @@ class TestLogService:
SQLModel.metadata.create_all(engine)
session = Session(engine)
user = User(username="phil", password_hash="h", display_name="Phillip")
user = User(username="phil", display_name="Phillip")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
exercise = Exercise(
name="DB Chest Press", muscle_group="Chest",

View File

@@ -6,8 +6,8 @@ from fastapi.testclient import TestClient
class TestLogSet:
"""Tests for POST /log."""
def test_log_set_requires_auth(self, client: TestClient) -> None:
"""POST /log should require admin login."""
def test_log_set_requires_profile(self, client: TestClient) -> None:
"""POST /log should redirect to / without profile cookie."""
response = client.post(
"/log",
data={
@@ -19,26 +19,29 @@ class TestLogSet:
},
follow_redirects=False,
)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"
class TestLogEdit:
"""Tests for POST /log/<id>/edit."""
def test_edit_log_requires_auth(self, client: TestClient) -> None:
"""POST /log/1/edit should require admin login."""
def test_edit_log_requires_profile(self, client: TestClient) -> None:
"""POST /log/1/edit should redirect to / without profile cookie."""
response = client.post(
"/log/1/edit",
data={"reps": "10", "weight": "35 lbs"},
follow_redirects=False,
)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"
class TestLogDelete:
"""Tests for POST /log/<id>/delete."""
def test_delete_log_requires_auth(self, client: TestClient) -> None:
"""POST /log/1/delete should require admin login."""
def test_delete_log_requires_profile(self, client: TestClient) -> None:
"""POST /log/1/delete should redirect to / without profile cookie."""
response = client.post("/log/1/delete", follow_redirects=False)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"

View File

@@ -28,12 +28,10 @@ class TestModels:
engine = self._create_engine()
user = User(
username="testuser",
password_hash="fakehash",
display_name="Test User",
display_name="Test User",
height="6'0\"",
weight="200 lbs",
goals="Get strong",
is_admin=False,
)
with Session(engine) as session:
session.add(user)
@@ -41,7 +39,6 @@ class TestModels:
session.refresh(user)
assert user.id is not None
assert user.username == "testuser"
assert user.is_admin is False
assert isinstance(user.created_at, datetime)
def test_exercise_model_roundtrip(self) -> None:
@@ -97,7 +94,7 @@ class TestModels:
"""UserExerciseProgram model should persist with FK references."""
engine = self._create_engine()
with Session(engine) as session:
user = User(username="u", password_hash="h", display_name="U")
user = User(username="u", display_name="U")
exercise = Exercise(
name="Test Ex", muscle_group="Test",
workout_day="Push", sets=3, tempo="3-1-2", form_cues="..."
@@ -126,7 +123,7 @@ class TestModels:
"""WorkoutSession model should persist correctly."""
engine = self._create_engine()
with Session(engine) as session:
user = User(username="u", password_hash="h", display_name="U")
user = User(username="u", display_name="U")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
session.add(user)
session.add(day)
@@ -148,7 +145,7 @@ class TestModels:
"""WorkoutLog model should persist correctly."""
engine = self._create_engine()
with Session(engine) as session:
user = User(username="u", password_hash="h", display_name="U")
user = User(username="u", display_name="U")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
exercise = Exercise(
name="Ex", muscle_group="Test",
@@ -187,7 +184,7 @@ class TestModels:
"""ProgressLog model should persist correctly."""
engine = self._create_engine()
with Session(engine) as session:
user = User(username="u", password_hash="h", display_name="U")
user = User(username="u", display_name="U")
exercise = Exercise(
name="Ex", muscle_group="Test",
workout_day="Push", sets=3, tempo="3-1-2", form_cues="..."

105
tests/test_profile_auth.py Normal file
View File

@@ -0,0 +1,105 @@
"""Tests for profile-based access control and landing page."""
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.services.user_service import UserService
from tests.conftest import set_profile_cookie
class TestProfileAuth:
"""Tests for the profile cookie-based access flow."""
def test_no_profile_redirects_to_home(self, client) -> None:
"""Routes requiring a profile should redirect to / when no cookie set."""
response = client.get("/workouts", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/"
def test_invalid_profile_redirects_to_home(self, client) -> None:
"""An invalid profile_id cookie should redirect to /."""
client.cookies.set("active_profile_id", "99999")
response = client.get("/workouts", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/"
def test_valid_profile_allows_access(self, client) -> None:
"""A valid profile cookie should allow access to protected routes."""
# The seeded profiles exist; find one
response = client.get("/")
assert response.status_code == 200
# Get the first profile ID from the seeded data
set_profile_cookie(client, 1)
response = client.get("/workouts")
assert response.status_code == 200
class TestLandingPage:
"""Tests for the home page profile picker."""
def test_home_page_shows_profiles(self, client) -> None:
"""GET / should show seeded profile names."""
response = client.get("/")
assert response.status_code == 200
assert "Select Profile" in response.text
def test_home_page_accessible_without_cookie(self, client) -> None:
"""GET / should work without any profile cookie."""
response = client.get("/")
assert response.status_code == 200
assert "SneakySwole" in response.text
class TestProfileCreation:
"""Tests for the profile creation flow."""
def test_create_profile_form_renders(self, client) -> None:
"""GET /profiles/create should render the form."""
response = client.get("/profiles/create")
assert response.status_code == 200
assert "Display Name" in response.text
def test_create_profile_sets_cookie(self, client) -> None:
"""POST /profiles/create should create profile and set cookie."""
response = client.post(
"/profiles/create",
data={"display_name": "NewUser", "height": "5'10\"", "weight": "180 lbs"},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/workouts"
assert "active_profile_id" in response.cookies
def test_create_profile_requires_name(self, client) -> None:
"""POST /profiles/create with empty name should show error."""
response = client.post(
"/profiles/create",
data={"display_name": "", "height": "", "weight": ""},
)
assert response.status_code == 200
assert "required" in response.text.lower()
class TestProfileSwitch:
"""Tests for the profile switch flow."""
def test_switch_profile_sets_cookie(self, client) -> None:
"""POST /profiles/switch should set cookie and redirect."""
response = client.post(
"/profiles/switch",
data={"profile_id": "1"},
follow_redirects=False,
)
assert response.status_code == 303
assert "active_profile_id" in response.cookies
def test_switch_invalid_profile_redirects_home(self, client) -> None:
"""POST /profiles/switch with invalid ID should redirect to /."""
response = client.post(
"/profiles/switch",
data={"profile_id": "99999"},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/"

View File

@@ -2,25 +2,34 @@
from fastapi.testclient import TestClient
from tests.conftest import set_profile_cookie
class TestProfileSwitcher:
"""Tests for POST /profiles/switch."""
def test_switch_profile_requires_auth(self, client: TestClient) -> None:
"""POST /profiles/switch should require admin login."""
def test_switch_profile_redirects_to_workouts(self, client: TestClient) -> None:
"""POST /profiles/switch should set cookie and redirect to /workouts."""
response = client.post(
"/profiles/switch",
data={"profile_id": "1"},
follow_redirects=False,
)
# Should redirect to login or return 401
assert response.status_code in (401, 303)
assert response.status_code == 303
assert response.headers["location"] == "/workouts"
class TestProfileList:
"""Tests for GET /profiles."""
def test_profiles_page_requires_auth(self, client: TestClient) -> None:
"""GET /profiles should require admin login."""
def test_profiles_page_requires_profile(self, client: TestClient) -> None:
"""GET /profiles should redirect to / without profile cookie."""
response = client.get("/profiles", follow_redirects=False)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"
def test_profiles_page_with_profile(self, client: TestClient) -> None:
"""GET /profiles should succeed with a valid profile cookie."""
set_profile_cookie(client, 1)
response = client.get("/profiles")
assert response.status_code == 200

View File

@@ -23,7 +23,7 @@ class TestProgressionService:
SQLModel.metadata.create_all(engine)
session = Session(engine)
user = User(username="phil", password_hash="h", display_name="Phillip")
user = User(username="phil", display_name="Phillip")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
exercise = Exercise(
name="DB Chest Press", muscle_group="Chest",

View File

@@ -6,7 +6,8 @@ from fastapi.testclient import TestClient
class TestSchedule:
"""Tests for GET /schedule."""
def test_schedule_requires_auth(self, client: TestClient) -> None:
"""GET /schedule should require admin login."""
def test_schedule_requires_profile(self, client: TestClient) -> None:
"""GET /schedule should redirect to / without profile cookie."""
response = client.get("/schedule", follow_redirects=False)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"

View File

@@ -1,9 +1,7 @@
"""Tests for the SeedService class."""
from pathlib import Path
from unittest.mock import patch
import bcrypt
from sqlmodel import SQLModel, Session, create_engine, select
from app.models.user import User
@@ -53,20 +51,6 @@ class TestSeedService:
assert len(warmups) == 6
session.close()
def test_seed_admin_user(self) -> None:
"""seed_admin should create admin user with hashed password."""
session, service = self._setup()
with patch.dict("os.environ", {
"ADMIN_USERNAME": "admin",
"ADMIN_PASSWORD": "testpass",
}):
service.seed_admin()
admin = session.exec(select(User).where(User.is_admin == True)).first() # noqa: E712
assert admin is not None
assert admin.username == "admin"
assert bcrypt.checkpw(b"testpass", admin.password_hash.encode())
session.close()
def test_seed_user_programs(self) -> None:
"""seed_user_programs should create user profiles and link exercises."""
session, service = self._setup()
@@ -74,19 +58,15 @@ class TestSeedService:
service.seed_user_programs()
programs = session.exec(select(UserExerciseProgram)).all()
assert len(programs) > 0
users = session.exec(select(User).where(User.is_admin == False)).all() # noqa: E712
users = session.exec(select(User)).all()
assert len(users) == 2 # Phillip and Daughter
session.close()
def test_seed_is_idempotent(self) -> None:
"""Running seed_all twice should not create duplicate records."""
session, service = self._setup()
with patch.dict("os.environ", {
"ADMIN_USERNAME": "admin",
"ADMIN_PASSWORD": "testpass",
}):
service.seed_all()
service.seed_all()
service.seed_all()
service.seed_all()
users = session.exec(select(User)).all()
exercises = session.exec(select(Exercise)).all()
# Should not be doubled

View File

@@ -22,12 +22,10 @@ class TestUserService:
session, service = self._setup()
user = service.create_user(
username="phil",
password_hash="hashed",
display_name="Phillip",
height="6'0\"",
weight="260 lbs",
goals="Muscle build",
is_admin=False,
)
assert user.id is not None
assert user.username == "phil"
@@ -37,7 +35,7 @@ class TestUserService:
"""get_user_by_id should return the correct user."""
session, service = self._setup()
created = service.create_user(
username="test", password_hash="h", display_name="Test"
username="test", display_name="Test"
)
found = service.get_user_by_id(created.id)
assert found is not None
@@ -47,10 +45,10 @@ class TestUserService:
def test_get_user_by_username(self) -> None:
"""get_user_by_username should return the correct user."""
session, service = self._setup()
service.create_user(username="admin", password_hash="h", display_name="Admin")
found = service.get_user_by_username("admin")
service.create_user(username="phil", display_name="Phillip")
found = service.get_user_by_username("phil")
assert found is not None
assert found.display_name == "Admin"
assert found.display_name == "Phillip"
session.close()
def test_get_user_by_username_not_found(self) -> None:
@@ -63,26 +61,16 @@ class TestUserService:
def test_list_users(self) -> None:
"""list_users should return all users."""
session, service = self._setup()
service.create_user(username="a", password_hash="h", display_name="A")
service.create_user(username="b", password_hash="h", display_name="B")
service.create_user(username="a", display_name="A")
service.create_user(username="b", display_name="B")
users = service.list_users()
assert len(users) == 2
session.close()
def test_list_non_admin_users(self) -> None:
"""list_users with exclude_admin=True should skip admin users."""
session, service = self._setup()
service.create_user(username="admin", password_hash="h", display_name="Admin", is_admin=True)
service.create_user(username="user", password_hash="h", display_name="User", is_admin=False)
users = service.list_users(exclude_admin=True)
assert len(users) == 1
assert users[0].username == "user"
session.close()
def test_update_user(self) -> None:
"""update_user should modify the specified fields."""
session, service = self._setup()
user = service.create_user(username="u", password_hash="h", display_name="Old")
user = service.create_user(username="u", display_name="Old")
updated = service.update_user(user.id, display_name="New", weight="250 lbs")
assert updated.display_name == "New"
assert updated.weight == "250 lbs"

View File

@@ -2,16 +2,26 @@
from fastapi.testclient import TestClient
from tests.conftest import set_profile_cookie
class TestWorkoutDayViewer:
"""Tests for GET /workouts/<day_name>."""
def test_workout_day_requires_auth(self, client: TestClient) -> None:
"""GET /workouts/push should require admin login."""
def test_workout_day_requires_profile(self, client: TestClient) -> None:
"""GET /workouts/push should redirect to / without profile cookie."""
response = client.get("/workouts/push", follow_redirects=False)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"
def test_workout_days_list_requires_auth(self, client: TestClient) -> None:
"""GET /workouts should require admin login."""
def test_workout_days_list_requires_profile(self, client: TestClient) -> None:
"""GET /workouts should redirect to / without profile cookie."""
response = client.get("/workouts", follow_redirects=False)
assert response.status_code in (401, 303)
assert response.status_code == 302
assert response.headers["location"] == "/"
def test_workout_days_list_with_profile(self, client: TestClient) -> None:
"""GET /workouts should succeed with a valid profile cookie."""
set_profile_cookie(client, 1)
response = client.get("/workouts")
assert response.status_code == 200

View File

@@ -19,7 +19,7 @@ class TestWorkoutSessionService:
SQLModel.metadata.create_all(engine)
session = Session(engine)
user = User(username="phil", password_hash="h", display_name="Phillip")
user = User(username="phil", display_name="Phillip")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
session.add_all([user, day])
session.commit()