feat: add Phase 3 Workout UI — auth, profiles, workout viewer, exercise browser

Build the core user-facing experience with admin login (bcrypt + signed
session cookies), profile switcher, workout day viewer with warmups and
exercise cards, and HTMX-powered exercise browser with search/filter.

- AuthService with bcrypt password verification and itsdangerous session tokens
- Auth dependency redirects to /login (303) for unauthenticated requests
- NavContextMiddleware injects admin/profiles/active_profile into all templates
- Profile management (list, switch, edit) with cookie-based active profile
- Workout day viewer shows warmups + exercises + per-user programming targets
- Exercise browser with HTMX filter dropdowns (no page reloads)
- Flash message partial for success/error feedback
- 12 new tests (66 total passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 11:14:52 -06:00
parent 1f47103480
commit 23754ea239
29 changed files with 1267 additions and 11 deletions

View File

@@ -0,0 +1,93 @@
"""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()