mail notification

This commit is contained in:
Bastian Wagner
2026-08-15 20:47:02 +02:00
parent 7c9e19ba0b
commit 2aba1265af
16 changed files with 519 additions and 2 deletions

View File

@@ -7,3 +7,12 @@ DATA_DIR=/data
DATABASE_URL=sqlite:////data/app.db DATABASE_URL=sqlite:////data/app.db
# Set to true only when TLS terminates in front of this service. # Set to true only when TLS terminates in front of this service.
SESSION_HTTPS_ONLY=false 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

View File

@@ -15,6 +15,12 @@ class Settings(BaseSettings):
database_url: str | None = Field(default=None, alias="DATABASE_URL") database_url: str | None = Field(default=None, alias="DATABASE_URL")
sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES") sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES")
session_https_only: bool = Field(default=False, alias="SESSION_HTTPS_ONLY") 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") @model_validator(mode="after")
def derive_paths(self) -> "Settings": def derive_paths(self) -> "Settings":

View File

@@ -53,6 +53,8 @@ class SyncUser(Base):
mywhoosh_password_enc: Mapped[str] = mapped_column(Text, nullable=False) mywhoosh_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
garmin_email_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) 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) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)

View File

@@ -1,9 +1,21 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from app.db.models import Base 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: def create_db_engine(database_url: str) -> Engine:
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {} 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: def initialize_schema(engine: Engine) -> None:
Base.metadata.create_all(engine) 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}"))

View File

@@ -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.fit.rewriter import convert_fit_device
from app.garmin.uploader import GarminUploader from app.garmin.uploader import GarminUploader
from app.mywhoosh.client import MyWhooshClient from app.mywhoosh.client import MyWhooshClient
from app.notifications.emailer import EmailNotifier
from app.security.credentials import CredentialCipher from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager from app.sync.manager import SyncManager
from app.sync.scheduler import SyncScheduler from app.sync.scheduler import SyncScheduler
@@ -33,6 +34,15 @@ def create_app(settings: Settings | None = None) -> FastAPI:
def garmin_factory(email, password, tokenstore): def garmin_factory(email, password, tokenstore):
return GarminUploader(email=email, password=password, tokenstore=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( sync_manager = SyncManager(
session_factory=app.state.session_factory, session_factory=app.state.session_factory,
credential_cipher=cipher, credential_cipher=cipher,
@@ -40,6 +50,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
mywhoosh_factory=mywhoosh_factory, mywhoosh_factory=mywhoosh_factory,
garmin_factory=garmin_factory, garmin_factory=garmin_factory,
fit_converter=convert_fit_device, fit_converter=convert_fit_device,
notifier=notifier,
) )
app.state.sync_manager = sync_manager app.state.sync_manager = sync_manager

View File

View File

@@ -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)

View File

@@ -64,6 +64,7 @@ class SyncManager:
mywhoosh_factory: Callable[[MyWhooshTokenStore], Any], mywhoosh_factory: Callable[[MyWhooshTokenStore], Any],
garmin_factory: Callable[[str, str, Path], Any], garmin_factory: Callable[[str, str, Path], Any],
fit_converter: Callable[[Path, Path], Any], fit_converter: Callable[[Path, Path], Any],
notifier: Any = None,
) -> None: ) -> None:
self.session_factory = session_factory self.session_factory = session_factory
self.credential_cipher = credential_cipher self.credential_cipher = credential_cipher
@@ -71,9 +72,24 @@ class SyncManager:
self.mywhoosh_factory = mywhoosh_factory self.mywhoosh_factory = mywhoosh_factory
self.garmin_factory = garmin_factory self.garmin_factory = garmin_factory
self.fit_converter = fit_converter self.fit_converter = fit_converter
self.notifier = notifier
self._locks: dict[int, asyncio.Lock] = {} self._locks: dict[int, asyncio.Lock] = {}
self._locks_guard = 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 def _lock_for(self, user_id: int) -> asyncio.Lock:
async with self._locks_guard: async with self._locks_guard:
return self._locks.setdefault(user_id, asyncio.Lock()) return self._locks.setdefault(user_id, asyncio.Lock())
@@ -102,6 +118,12 @@ class SyncManager:
if user is None: if user is None:
raise ValueError(f"user {user_id} not found") 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) sync_run_repo = SyncRunRepository(session)
run = sync_run_repo.start(user_id) run = sync_run_repo.start(user_id)
@@ -366,6 +388,8 @@ class SyncManager:
summary_error=summary_error, summary_error=summary_error,
) )
session.commit() 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( return SyncOutcome(
user_id=user.id, user_id=user.id,
status=status.value, status=status.value,

View File

@@ -9,3 +9,5 @@ class UserFormData:
garmin_email: str garmin_email: str
garmin_password: str garmin_password: str
enabled: bool enabled: bool
notify_email_enabled: bool = False
notification_email: str = ""

View File

@@ -96,6 +96,8 @@ def create_user(
garmin_email: str = Form(""), garmin_email: str = Form(""),
garmin_password: str = Form(""), garmin_password: str = Form(""),
enabled: str | None = Form(None), enabled: str | None = Form(None),
notify_email_enabled: str | None = Form(None),
notification_email: str = Form(""),
): ):
require_admin(request) require_admin(request)
validate_csrf(request, csrf_token) validate_csrf(request, csrf_token)
@@ -110,7 +112,11 @@ def create_user(
garmin_email=garmin_email, garmin_email=garmin_email,
garmin_password=garmin_password, garmin_password=garmin_password,
enabled=enabled is not None, 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) cipher = _cipher(request)
with request.app.state.session_factory() as session: with request.app.state.session_factory() as session:
repository = UserRepository(session) repository = UserRepository(session)
@@ -121,6 +127,8 @@ def create_user(
mywhoosh_password_enc=cipher.encrypt(form.mywhoosh_password), mywhoosh_password_enc=cipher.encrypt(form.mywhoosh_password),
garmin_email_enc=cipher.encrypt(form.garmin_email.strip()), garmin_email_enc=cipher.encrypt(form.garmin_email.strip()),
garmin_password_enc=cipher.encrypt(form.garmin_password), 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 user_id = user.id
return RedirectResponse(f"/users/{user_id}", status_code=303) return RedirectResponse(f"/users/{user_id}", status_code=303)
@@ -175,6 +183,8 @@ def update_user(
garmin_email: str = Form(""), garmin_email: str = Form(""),
garmin_password: str = Form(""), garmin_password: str = Form(""),
enabled: str | None = Form(None), enabled: str | None = Form(None),
notify_email_enabled: str | None = Form(None),
notification_email: str = Form(""),
): ):
require_admin(request) require_admin(request)
validate_csrf(request, csrf_token) validate_csrf(request, csrf_token)
@@ -187,7 +197,11 @@ def update_user(
garmin_email=garmin_email, garmin_email=garmin_email,
garmin_password=garmin_password, garmin_password=garmin_password,
enabled=enabled is not None, 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) cipher = _cipher(request)
with request.app.state.session_factory() as session: with request.app.state.session_factory() as session:
repository = UserRepository(session) repository = UserRepository(session)
@@ -197,6 +211,8 @@ def update_user(
"enabled": form.enabled, "enabled": form.enabled,
"mywhoosh_email_enc": cipher.encrypt(form.mywhoosh_email.strip()), "mywhoosh_email_enc": cipher.encrypt(form.mywhoosh_email.strip()),
"garmin_email_enc": cipher.encrypt(form.garmin_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: if form.mywhoosh_password:
values["mywhoosh_password_enc"] = cipher.encrypt(form.mywhoosh_password) values["mywhoosh_password_enc"] = cipher.encrypt(form.mywhoosh_password)

View File

@@ -36,6 +36,16 @@
Enabled Enabled
</label> </label>
<label for="notify_email_enabled">
<input type="checkbox" id="notify_email_enabled" name="notify_email_enabled"
{% if user and user.notify_email_enabled %}checked{% endif %}>
Email me when this account needs attention
</label>
<label for="notification_email">Notification email</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ user.notification_email if user and user.notification_email else '' }}">
<button type="submit">{% if user %}Save{% else %}Create{% endif %}</button> <button type="submit">{% if user %}Save{% else %}Create{% endif %}</button>
</form> </form>
</div> </div>

109
tests/db/test_session.py Normal file
View File

@@ -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()

View File

@@ -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()

View File

@@ -28,3 +28,11 @@ class FakeGarminUploader:
if self.error is not None: if self.error is not None:
raise self.error raise self.error
return self.result 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})

View File

@@ -10,7 +10,7 @@ from app.mywhoosh.client import MyWhooshDeviceConflictError
from app.mywhoosh.models import MyWhooshActivity from app.mywhoosh.models import MyWhooshActivity
from app.sync.manager import SyncManager from app.sync.manager import SyncManager
from tests.sync.conftest import FakeFitConverter, _create_user 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 @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 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 @pytest.mark.asyncio
async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager( async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager(
session_factory, cipher, settings, seeded_user: SyncUser session_factory, cipher, settings, seeded_user: SyncUser

View File

@@ -366,6 +366,106 @@ def test_update_user_rejects_empty_mywhoosh_email(client: TestClient) -> None:
assert response.status_code == 400 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: def test_update_user_rejects_empty_garmin_email(client: TestClient) -> None:
login(client) login(client)
user_id = create_user_via_http(client) user_id = create_user_via_http(client)