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:
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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", "")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"] == "/"
|
||||
|
||||
@@ -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"] == "/"
|
||||
|
||||
@@ -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"] == "/"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] == "/"
|
||||
|
||||
@@ -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
105
tests/test_profile_auth.py
Normal 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"] == "/"
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] == "/"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user