diff --git a/.env.example b/.env.example index 52673f5..74b0d57 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,12 @@ DATA_DIR=/data DATABASE_URL=sqlite:////data/app.db # Set to true only when TLS terminates in front of this service. SESSION_HTTPS_ONLY=false +# Optional: SMTP settings for "email me when action is required" user +# notifications. Leave SMTP_HOST unset to disable sending (notifications are +# silently skipped rather than failing a sync run). +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_ADDRESS= +SMTP_USE_TLS=true diff --git a/app/config.py b/app/config.py index bb1646a..7ae8349 100644 --- a/app/config.py +++ b/app/config.py @@ -15,6 +15,12 @@ class Settings(BaseSettings): database_url: str | None = Field(default=None, alias="DATABASE_URL") sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES") session_https_only: bool = Field(default=False, alias="SESSION_HTTPS_ONLY") + smtp_host: str | None = Field(default=None, alias="SMTP_HOST") + smtp_port: int = Field(default=587, alias="SMTP_PORT") + smtp_username: str | None = Field(default=None, alias="SMTP_USERNAME") + smtp_password: str | None = Field(default=None, alias="SMTP_PASSWORD") + smtp_from_address: str | None = Field(default=None, alias="SMTP_FROM_ADDRESS") + smtp_use_tls: bool = Field(default=True, alias="SMTP_USE_TLS") @model_validator(mode="after") def derive_paths(self) -> "Settings": diff --git a/app/db/models.py b/app/db/models.py index 256ef8a..4be39bd 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -53,6 +53,8 @@ class SyncUser(Base): mywhoosh_password_enc: Mapped[str] = mapped_column(Text, nullable=False) garmin_email_enc: Mapped[str] = mapped_column(Text, nullable=False) garmin_password_enc: Mapped[str] = mapped_column(Text, nullable=False) + notify_email_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + notification_email: Mapped[str | None] = mapped_column(String(255)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) diff --git a/app/db/session.py b/app/db/session.py index 22f07db..a275848 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -1,9 +1,21 @@ -from sqlalchemy import create_engine +from sqlalchemy import create_engine, inspect, text from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from app.db.models import Base +# Columns added to existing tables after their initial release. create_all() +# only creates missing tables, never adds columns to tables that already +# exist, so a column added to a model here must also be listed below or an +# already-deployed database will never receive it and the app will crash +# reading/writing that column. +_ADDITIVE_COLUMNS: dict[str, list[tuple[str, str]]] = { + "sync_users": [ + ("notify_email_enabled", "BOOLEAN NOT NULL DEFAULT 0"), + ("notification_email", "VARCHAR(255)"), + ], +} + def create_db_engine(database_url: str) -> Engine: connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {} @@ -16,3 +28,24 @@ def create_session_factory(engine: Engine) -> sessionmaker[Session]: def initialize_schema(engine: Engine) -> None: Base.metadata.create_all(engine) + _apply_additive_migrations(engine) + + +def _apply_additive_migrations(engine: Engine) -> None: + if engine.dialect.name != "sqlite": + # ALTER TABLE ... ADD COLUMN syntax/type names below are only + # verified against sqlite, the only backend this app is deployed + # against; a fresh create_all() on another backend already has every + # current column, so skipping here only matters for a pre-existing + # non-sqlite database, which does not exist in practice. + return + inspector = inspect(engine) + existing_tables = set(inspector.get_table_names()) + with engine.begin() as conn: + for table, columns in _ADDITIVE_COLUMNS.items(): + if table not in existing_tables: + continue + existing_columns = {col["name"] for col in inspector.get_columns(table)} + for name, ddl_type in columns: + if name not in existing_columns: + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {ddl_type}")) diff --git a/app/main.py b/app/main.py index f9dc5cc..2f7fbfb 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,7 @@ from app.db.session import create_db_engine, create_session_factory, initialize_ from app.fit.rewriter import convert_fit_device from app.garmin.uploader import GarminUploader from app.mywhoosh.client import MyWhooshClient +from app.notifications.emailer import EmailNotifier from app.security.credentials import CredentialCipher from app.sync.manager import SyncManager from app.sync.scheduler import SyncScheduler @@ -33,6 +34,15 @@ def create_app(settings: Settings | None = None) -> FastAPI: def garmin_factory(email, password, tokenstore): return GarminUploader(email=email, password=password, tokenstore=tokenstore) + notifier = EmailNotifier( + host=resolved.smtp_host, + port=resolved.smtp_port, + username=resolved.smtp_username, + password=resolved.smtp_password, + from_address=resolved.smtp_from_address, + use_tls=resolved.smtp_use_tls, + ) + sync_manager = SyncManager( session_factory=app.state.session_factory, credential_cipher=cipher, @@ -40,6 +50,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: mywhoosh_factory=mywhoosh_factory, garmin_factory=garmin_factory, fit_converter=convert_fit_device, + notifier=notifier, ) app.state.sync_manager = sync_manager diff --git a/app/notifications/__init__.py b/app/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/notifications/emailer.py b/app/notifications/emailer.py new file mode 100644 index 0000000..10055c2 --- /dev/null +++ b/app/notifications/emailer.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import smtplib +from email.message import EmailMessage + + +class EmailNotifier: + def __init__( + self, + *, + host: str | None, + port: int, + username: str | None, + password: str | None, + from_address: str | None, + use_tls: bool, + ) -> None: + self.host = host + self.port = port + self.username = username + self.password = password + self.from_address = from_address + self.use_tls = use_tls + + @property + def configured(self) -> bool: + return bool(self.host and self.from_address) + + def send(self, *, to_address: str, subject: str, body: str) -> None: + if not self.configured: + return + message = EmailMessage() + message["Subject"] = subject + message["From"] = self.from_address + message["To"] = to_address + message.set_content(body) + with smtplib.SMTP(self.host, self.port, timeout=10) as smtp: + if self.use_tls: + smtp.starttls() + if self.username and self.password: + smtp.login(self.username, self.password) + smtp.send_message(message) diff --git a/app/sync/manager.py b/app/sync/manager.py index 6429687..264c7ed 100644 --- a/app/sync/manager.py +++ b/app/sync/manager.py @@ -64,6 +64,7 @@ class SyncManager: mywhoosh_factory: Callable[[MyWhooshTokenStore], Any], garmin_factory: Callable[[str, str, Path], Any], fit_converter: Callable[[Path, Path], Any], + notifier: Any = None, ) -> None: self.session_factory = session_factory self.credential_cipher = credential_cipher @@ -71,9 +72,24 @@ class SyncManager: self.mywhoosh_factory = mywhoosh_factory self.garmin_factory = garmin_factory self.fit_converter = fit_converter + self.notifier = notifier self._locks: dict[int, asyncio.Lock] = {} self._locks_guard = asyncio.Lock() + def _notify_action_required(self, user: Any, message: str | None) -> None: + if self.notifier is None or not user.notify_email_enabled or not user.notification_email: + return + try: + self.notifier.send( + to_address=user.notification_email, + subject=f"MyWhoosh-Garmin Sync: action required for {user.name}", + body=message or "Your sync requires attention. Check the dashboard for details.", + ) + except Exception: + logger.warning( + "sync_user: failed to send action-required notification for user %s", user.id, exc_info=True + ) + async def _lock_for(self, user_id: int) -> asyncio.Lock: async with self._locks_guard: return self._locks.setdefault(user_id, asyncio.Lock()) @@ -102,6 +118,12 @@ class SyncManager: if user is None: raise ValueError(f"user {user_id} not found") + # Captured before this run mutates action_reason, so the + # end-of-run notification below only fires on a transition into + # (or between) action-required states -- never repeatedly for a + # cause that's already been reported and still unresolved. + previous_action_reason = user.action_reason + sync_run_repo = SyncRunRepository(session) run = sync_run_repo.start(user_id) @@ -366,6 +388,8 @@ class SyncManager: summary_error=summary_error, ) session.commit() + if user.action_reason is not None and user.action_reason != previous_action_reason: + self._notify_action_required(user, summary_error) return SyncOutcome( user_id=user.id, status=status.value, diff --git a/app/web/forms.py b/app/web/forms.py index 6010dc4..c50b2e9 100644 --- a/app/web/forms.py +++ b/app/web/forms.py @@ -9,3 +9,5 @@ class UserFormData: garmin_email: str garmin_password: str enabled: bool + notify_email_enabled: bool = False + notification_email: str = "" diff --git a/app/web/routes.py b/app/web/routes.py index e217e43..1f7bd30 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -96,6 +96,8 @@ def create_user( garmin_email: str = Form(""), garmin_password: str = Form(""), enabled: str | None = Form(None), + notify_email_enabled: str | None = Form(None), + notification_email: str = Form(""), ): require_admin(request) validate_csrf(request, csrf_token) @@ -110,7 +112,11 @@ def create_user( garmin_email=garmin_email, garmin_password=garmin_password, enabled=enabled is not None, + notify_email_enabled=notify_email_enabled is not None, + notification_email=notification_email, ) + if form.notify_email_enabled: + _require_non_empty(form.notification_email, "notification_email") cipher = _cipher(request) with request.app.state.session_factory() as session: repository = UserRepository(session) @@ -121,6 +127,8 @@ def create_user( mywhoosh_password_enc=cipher.encrypt(form.mywhoosh_password), garmin_email_enc=cipher.encrypt(form.garmin_email.strip()), garmin_password_enc=cipher.encrypt(form.garmin_password), + notify_email_enabled=form.notify_email_enabled, + notification_email=form.notification_email.strip() or None, ) user_id = user.id return RedirectResponse(f"/users/{user_id}", status_code=303) @@ -175,6 +183,8 @@ def update_user( garmin_email: str = Form(""), garmin_password: str = Form(""), enabled: str | None = Form(None), + notify_email_enabled: str | None = Form(None), + notification_email: str = Form(""), ): require_admin(request) validate_csrf(request, csrf_token) @@ -187,7 +197,11 @@ def update_user( garmin_email=garmin_email, garmin_password=garmin_password, enabled=enabled is not None, + notify_email_enabled=notify_email_enabled is not None, + notification_email=notification_email, ) + if form.notify_email_enabled: + _require_non_empty(form.notification_email, "notification_email") cipher = _cipher(request) with request.app.state.session_factory() as session: repository = UserRepository(session) @@ -197,6 +211,8 @@ def update_user( "enabled": form.enabled, "mywhoosh_email_enc": cipher.encrypt(form.mywhoosh_email.strip()), "garmin_email_enc": cipher.encrypt(form.garmin_email.strip()), + "notify_email_enabled": form.notify_email_enabled, + "notification_email": form.notification_email.strip() or None, } if form.mywhoosh_password: values["mywhoosh_password_enc"] = cipher.encrypt(form.mywhoosh_password) diff --git a/app/web/templates/users/form.html b/app/web/templates/users/form.html index f64fc66..fca91fb 100644 --- a/app/web/templates/users/form.html +++ b/app/web/templates/users/form.html @@ -36,6 +36,16 @@ Enabled + + + + + diff --git a/tests/db/test_session.py b/tests/db/test_session.py new file mode 100644 index 0000000..2d0f656 --- /dev/null +++ b/tests/db/test_session.py @@ -0,0 +1,109 @@ +from sqlalchemy import create_engine, inspect, text +from sqlalchemy.pool import StaticPool + +from app.db.models import Base +from app.db.session import initialize_schema + + +def test_initialize_schema_creates_fresh_database() -> None: + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + try: + initialize_schema(engine) + columns = {col["name"] for col in inspect(engine).get_columns("sync_users")} + assert "notify_email_enabled" in columns + assert "notification_email" in columns + finally: + engine.dispose() + + +def test_initialize_schema_adds_missing_columns_without_dropping_existing_rows() -> None: + """Regression test: a database created before notify_email_enabled/ + notification_email existed must gain those columns in place, keeping + every already-stored user row intact -- create_all() alone would not add + columns to a table that already exists.""" + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + try: + # Simulate the pre-existing production schema by creating every + # table via the current models, then dropping the two new columns + # back off sync_users the only way sqlite allows: rebuild the table. + Base.metadata.create_all(engine) + with engine.begin() as conn: + conn.execute(text("ALTER TABLE sync_users RENAME TO sync_users_old")) + conn.execute( + text( + """ + CREATE TABLE sync_users ( + id INTEGER PRIMARY KEY, + name VARCHAR(120) NOT NULL, + enabled BOOLEAN NOT NULL, + health_state VARCHAR NOT NULL, + mywhoosh_state VARCHAR(32) NOT NULL, + garmin_state VARCHAR(32) NOT NULL, + action_reason TEXT, + mywhoosh_email_enc TEXT NOT NULL, + mywhoosh_password_enc TEXT NOT NULL, + garmin_email_enc TEXT NOT NULL, + garmin_password_enc TEXT NOT NULL, + created_at DATETIME, + updated_at DATETIME + ) + """ + ) + ) + conn.execute( + text( + """ + INSERT INTO sync_users ( + id, name, enabled, health_state, mywhoosh_state, garmin_state, + action_reason, mywhoosh_email_enc, mywhoosh_password_enc, + garmin_email_enc, garmin_password_enc, created_at, updated_at + ) + SELECT id, name, enabled, health_state, mywhoosh_state, garmin_state, + action_reason, mywhoosh_email_enc, mywhoosh_password_enc, + garmin_email_enc, garmin_password_enc, created_at, updated_at + FROM sync_users_old + """ + ) + ) + conn.execute(text("DROP TABLE sync_users_old")) + conn.execute( + text( + """ + INSERT INTO sync_users ( + id, name, enabled, health_state, mywhoosh_state, garmin_state, + mywhoosh_email_enc, mywhoosh_password_enc, garmin_email_enc, garmin_password_enc + ) VALUES ( + 1, 'Existing User', 1, 'healthy', 'connected', 'connected', + 'enc-mw-email', 'enc-mw-pass', 'enc-garmin-email', 'enc-garmin-pass' + ) + """ + ) + ) + + columns_before = {col["name"] for col in inspect(engine).get_columns("sync_users")} + assert "notify_email_enabled" not in columns_before + + initialize_schema(engine) + + columns_after = {col["name"] for col in inspect(engine).get_columns("sync_users")} + assert "notify_email_enabled" in columns_after + assert "notification_email" in columns_after + + with engine.connect() as conn: + row = conn.execute(text("SELECT name, notify_email_enabled, notification_email FROM sync_users")).one() + assert row.name == "Existing User" + assert row.notify_email_enabled == 0 + assert row.notification_email is None + finally: + engine.dispose() + + +def test_initialize_schema_is_idempotent() -> None: + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + try: + initialize_schema(engine) + initialize_schema(engine) + columns = {col["name"] for col in inspect(engine).get_columns("sync_users")} + assert "notify_email_enabled" in columns + finally: + engine.dispose() diff --git a/tests/notifications/test_emailer.py b/tests/notifications/test_emailer.py new file mode 100644 index 0000000..a023e6d --- /dev/null +++ b/tests/notifications/test_emailer.py @@ -0,0 +1,69 @@ +from unittest.mock import MagicMock, patch + +from app.notifications.emailer import EmailNotifier + + +def _notifier(**overrides) -> EmailNotifier: + defaults = dict( + host="smtp.example.com", + port=587, + username="user@example.com", + password="secret", + from_address="sync@example.com", + use_tls=True, + ) + defaults.update(overrides) + return EmailNotifier(**defaults) + + +def test_unconfigured_notifier_is_not_configured() -> None: + notifier = _notifier(host=None) + assert notifier.configured is False + + +def test_configured_notifier_is_configured() -> None: + assert _notifier().configured is True + + +def test_send_skips_silently_when_not_configured() -> None: + notifier = _notifier(host=None) + with patch("app.notifications.emailer.smtplib.SMTP") as smtp_cls: + notifier.send(to_address="user@example.com", subject="s", body="b") + smtp_cls.assert_not_called() + + +def test_send_uses_starttls_and_login_when_configured() -> None: + notifier = _notifier() + smtp_instance = MagicMock() + smtp_instance.__enter__.return_value = smtp_instance + with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance) as smtp_cls: + notifier.send(to_address="rider@example.com", subject="Action required", body="Check the dashboard") + + smtp_cls.assert_called_once_with("smtp.example.com", 587, timeout=10) + smtp_instance.starttls.assert_called_once() + smtp_instance.login.assert_called_once_with("user@example.com", "secret") + assert smtp_instance.send_message.call_count == 1 + sent_message = smtp_instance.send_message.call_args[0][0] + assert sent_message["To"] == "rider@example.com" + assert sent_message["From"] == "sync@example.com" + assert sent_message["Subject"] == "Action required" + + +def test_send_skips_login_without_credentials() -> None: + notifier = _notifier(username=None, password=None) + smtp_instance = MagicMock() + smtp_instance.__enter__.return_value = smtp_instance + with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance): + notifier.send(to_address="rider@example.com", subject="s", body="b") + + smtp_instance.login.assert_not_called() + + +def test_send_skips_starttls_when_disabled() -> None: + notifier = _notifier(use_tls=False) + smtp_instance = MagicMock() + smtp_instance.__enter__.return_value = smtp_instance + with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance): + notifier.send(to_address="rider@example.com", subject="s", body="b") + + smtp_instance.starttls.assert_not_called() diff --git a/tests/sync/fakes.py b/tests/sync/fakes.py index 234bbe7..f893920 100644 --- a/tests/sync/fakes.py +++ b/tests/sync/fakes.py @@ -28,3 +28,11 @@ class FakeGarminUploader: if self.error is not None: raise self.error return self.result + + +class FakeNotifier: + def __init__(self) -> None: + self.sent: list[dict] = [] + + def send(self, *, to_address: str, subject: str, body: str) -> None: + self.sent.append({"to_address": to_address, "subject": subject, "body": body}) diff --git a/tests/sync/test_manager.py b/tests/sync/test_manager.py index f367d57..8508e7d 100644 --- a/tests/sync/test_manager.py +++ b/tests/sync/test_manager.py @@ -10,7 +10,7 @@ from app.mywhoosh.client import MyWhooshDeviceConflictError from app.mywhoosh.models import MyWhooshActivity from app.sync.manager import SyncManager from tests.sync.conftest import FakeFitConverter, _create_user -from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient +from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient, FakeNotifier @pytest.mark.asyncio @@ -229,6 +229,82 @@ async def test_device_conflict_sets_distinct_action_reason(seeded_user: SyncUser assert reloaded.health_state == HealthState.ACTION_REQUIRED +@pytest.mark.asyncio +async def test_notifies_on_new_action_required_when_opted_in(session_factory, cipher, settings) -> None: + with session_factory() as session: + user = _create_user(session, cipher) + user.notify_email_enabled = True + user.notification_email = "alerts@example.com" + session.commit() + user_id = user.id + + notifier = FakeNotifier() + manager = SyncManager( + session_factory=session_factory, + credential_cipher=cipher, + settings=settings, + mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(), + garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(), + fit_converter=FakeFitConverter(), + notifier=notifier, + ) + + await manager.sync_user(user_id) + + assert len(notifier.sent) == 1 + assert notifier.sent[0]["to_address"] == "alerts@example.com" + assert "another device" in notifier.sent[0]["body"] + + +@pytest.mark.asyncio +async def test_does_not_notify_when_not_opted_in(session_factory, cipher, settings) -> None: + user_id = None + with session_factory() as session: + user = _create_user(session, cipher) + user_id = user.id + + notifier = FakeNotifier() + manager = SyncManager( + session_factory=session_factory, + credential_cipher=cipher, + settings=settings, + mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(), + garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(), + fit_converter=FakeFitConverter(), + notifier=notifier, + ) + + await manager.sync_user(user_id) + + assert notifier.sent == [] + + +@pytest.mark.asyncio +async def test_does_not_renotify_for_unresolved_unchanged_reason(session_factory, cipher, settings) -> None: + with session_factory() as session: + user = _create_user(session, cipher) + user.notify_email_enabled = True + user.notification_email = "alerts@example.com" + session.commit() + user_id = user.id + + notifier = FakeNotifier() + manager = SyncManager( + session_factory=session_factory, + credential_cipher=cipher, + settings=settings, + mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(), + garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(), + fit_converter=FakeFitConverter(), + notifier=notifier, + ) + + await manager.sync_user(user_id) + await manager.sync_user(user_id) + + assert len(notifier.sent) == 1 + + @pytest.mark.asyncio async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager( session_factory, cipher, settings, seeded_user: SyncUser diff --git a/tests/web/test_users.py b/tests/web/test_users.py index 66df812..af4a4b6 100644 --- a/tests/web/test_users.py +++ b/tests/web/test_users.py @@ -366,6 +366,106 @@ def test_update_user_rejects_empty_mywhoosh_email(client: TestClient) -> None: assert response.status_code == 400 +def test_create_user_without_notifications_requires_no_email(client: TestClient) -> None: + login(client) + page = client.get("/users/new") + csrf = extract_csrf(page.text) + response = client.post( + "/users", + data={"csrf_token": csrf, **_create_payload()}, + follow_redirects=False, + ) + assert response.status_code == 303 + + +def test_create_user_rejects_notify_enabled_without_email(client: TestClient) -> None: + login(client) + page = client.get("/users/new") + csrf = extract_csrf(page.text) + response = client.post( + "/users", + data={"csrf_token": csrf, **_create_payload(notify_email_enabled="on")}, + ) + assert response.status_code == 400 + + +def test_create_user_persists_notification_preferences(client: TestClient) -> None: + login(client) + page = client.get("/users/new") + csrf = extract_csrf(page.text) + response = client.post( + "/users", + data={ + "csrf_token": csrf, + **_create_payload(notify_email_enabled="on", notification_email="alerts@example.com"), + }, + follow_redirects=False, + ) + assert response.status_code == 303 + user_id = int(response.headers["location"].rsplit("/", 1)[-1]) + + with client.app.state.session_factory() as session: + user = UserRepository(session).get(user_id) + assert user is not None + assert user.notify_email_enabled is True + assert user.notification_email == "alerts@example.com" + + +def test_update_user_can_enable_notifications_without_reentering_passwords(client: TestClient) -> None: + login(client) + user_id = create_user_via_http(client) + + edit_page = client.get(f"/users/{user_id}/edit") + csrf = extract_csrf(edit_page.text) + response = client.post( + f"/users/{user_id}", + data={ + "csrf_token": csrf, + "name": "Max", + "mywhoosh_email": "max@example.com", + "mywhoosh_password": "", + "garmin_email": "max-garmin@example.com", + "garmin_password": "", + "enabled": "on", + "notify_email_enabled": "on", + "notification_email": "alerts@example.com", + }, + follow_redirects=True, + ) + assert response.status_code == 200 + + with client.app.state.session_factory() as session: + user = UserRepository(session).get(user_id) + assert user is not None + assert user.notify_email_enabled is True + assert user.notification_email == "alerts@example.com" + cipher = CredentialCipher(client.app.state.settings.credential_encryption_key) + assert cipher.decrypt(user.mywhoosh_password_enc) == "mw-secret" + assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret" + + +def test_update_user_rejects_notify_enabled_without_email(client: TestClient) -> None: + login(client) + user_id = create_user_via_http(client) + edit_page = client.get(f"/users/{user_id}/edit") + csrf = extract_csrf(edit_page.text) + response = client.post( + f"/users/{user_id}", + data={ + "csrf_token": csrf, + "name": "Max", + "mywhoosh_email": "max@example.com", + "mywhoosh_password": "", + "garmin_email": "max-garmin@example.com", + "garmin_password": "", + "enabled": "on", + "notify_email_enabled": "on", + "notification_email": "", + }, + ) + assert response.status_code == 400 + + def test_update_user_rejects_empty_garmin_email(client: TestClient) -> None: login(client) user_id = create_user_via_http(client)