auth für user

This commit is contained in:
Bastian Wagner
2026-08-15 21:34:40 +02:00
parent adfe14dfa8
commit f7b04337ce
11 changed files with 773 additions and 0 deletions

108
tests/auth/test_account.py Normal file
View File

@@ -0,0 +1,108 @@
from cryptography.fernet import Fernet
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.auth.account import authenticate_self_service
from app.db.models import Base
from app.db.repositories import UserRepository
from app.security.credentials import CredentialCipher
def _make_session_and_cipher():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
cipher = CredentialCipher(Fernet.generate_key().decode("ascii"))
return factory(), cipher
def _seed_user(session, cipher, **overrides):
values = dict(
name="Max",
enabled=True,
mywhoosh_email_enc=cipher.encrypt("max@mywhoosh.example"),
mywhoosh_password_enc=cipher.encrypt("mw-secret"),
garmin_email_enc=cipher.encrypt("max@garmin.example"),
garmin_password_enc=cipher.encrypt("garmin-secret"),
)
values.update(overrides)
return UserRepository(session).create(**values)
def test_authenticates_with_mywhoosh_credentials() -> None:
session, cipher = _make_session_and_cipher()
user = _seed_user(session, cipher)
result = authenticate_self_service(session, cipher, "max@mywhoosh.example", "mw-secret")
assert result is not None
assert result.id == user.id
def test_authenticates_with_garmin_credentials() -> None:
session, cipher = _make_session_and_cipher()
user = _seed_user(session, cipher)
result = authenticate_self_service(session, cipher, "max@garmin.example", "garmin-secret")
assert result is not None
assert result.id == user.id
def test_rejects_wrong_password() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "wrong") is None
def test_rejects_unknown_email() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "nobody@example.com", "mw-secret") is None
def test_rejects_mixed_email_and_password_from_different_accounts() -> None:
"""A MyWhoosh email paired with the Garmin password (or vice versa) for
the same user must not authenticate -- each pair is checked together."""
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "garmin-secret") is None
assert authenticate_self_service(session, cipher, "max@garmin.example", "mw-secret") is None
def test_rejects_empty_password() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "") is None
def test_picks_correct_user_among_several() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(
session,
cipher,
name="Anna",
mywhoosh_email_enc=cipher.encrypt("anna@mywhoosh.example"),
mywhoosh_password_enc=cipher.encrypt("anna-secret"),
garmin_email_enc=cipher.encrypt("anna@garmin.example"),
garmin_password_enc=cipher.encrypt("anna-garmin-secret"),
)
bob = _seed_user(
session,
cipher,
name="Bob",
mywhoosh_email_enc=cipher.encrypt("bob@mywhoosh.example"),
mywhoosh_password_enc=cipher.encrypt("bob-secret"),
garmin_email_enc=cipher.encrypt("bob@garmin.example"),
garmin_password_enc=cipher.encrypt("bob-garmin-secret"),
)
result = authenticate_self_service(session, cipher, "bob@mywhoosh.example", "bob-secret")
assert result is not None
assert result.id == bob.id

View File

@@ -0,0 +1,320 @@
from fastapi.testclient import TestClient
from app.db.repositories import UserRepository
from app.security.credentials import CredentialCipher
def extract_csrf(html: str) -> str:
marker = 'name="csrf_token" value="'
start = html.index(marker) + len(marker)
end = html.index('"', start)
return html[start:end]
def admin_login(client: TestClient) -> None:
page = client.get("/login")
csrf = extract_csrf(page.text)
response = client.post(
"/login",
data={"password": "admin-secret", "csrf_token": csrf},
follow_redirects=False,
)
assert response.status_code == 303
def create_user_via_admin(client: TestClient, **overrides) -> int:
admin_login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
payload = {
"csrf_token": csrf,
"name": "Max",
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "mw-secret",
"garmin_email": "max@garmin.example",
"garmin_password": "garmin-secret",
"enabled": "on",
}
payload.update(overrides)
response = client.post("/users", data=payload, follow_redirects=False)
assert response.status_code == 303
user_id = int(response.headers["location"].rsplit("/", 1)[-1])
client.cookies.clear()
return user_id
def account_login(client: TestClient, *, email: str, password: str):
page = client.get("/account-login")
csrf = extract_csrf(page.text)
return client.post(
"/account-login",
data={"csrf_token": csrf, "email": email, "password": password},
follow_redirects=False,
)
def test_login_with_mywhoosh_credentials_succeeds(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@mywhoosh.example", password="mw-secret")
assert response.status_code == 303
assert response.headers["location"] == "/account"
def test_login_with_garmin_credentials_succeeds(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@garmin.example", password="garmin-secret")
assert response.status_code == 303
assert response.headers["location"] == "/account"
def test_login_with_wrong_password_is_rejected(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@mywhoosh.example", password="wrong")
assert response.status_code == 401
assert "Invalid email or password" in response.text
def test_login_never_makes_the_stored_password_appear_in_response(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@mywhoosh.example", password="wrong")
assert "mw-secret" not in response.text
def test_account_detail_requires_login(client: TestClient) -> None:
response = client.get("/account", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/account-login"
def test_account_detail_shows_own_status_only(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
response = client.get("/account")
assert response.status_code == 200
assert "Max" in response.text
assert "mw-secret" not in response.text
assert "garmin-secret" not in response.text
def test_account_edit_page_prefills_emails_not_passwords(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
response = client.get("/account/edit")
assert response.status_code == 200
assert "max@mywhoosh.example" in response.text
assert "max@garmin.example" in response.text
assert "mw-secret" not in response.text
assert "garmin-secret" not in response.text
def test_account_update_blank_password_preserves_existing_password(client: TestClient) -> None:
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
follow_redirects=False,
)
assert response.status_code == 303
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
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_account_update_can_set_new_password(client: TestClient) -> None:
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "new-mw-secret",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
follow_redirects=False,
)
assert response.status_code == 303
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
assert cipher.decrypt(user.mywhoosh_password_enc) == "new-mw-secret"
def test_account_update_cannot_change_name_or_enabled(client: TestClient) -> None:
"""Self-service editing must not expose name/enabled -- those stay
administrative decisions, not something the account owner can flip."""
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
follow_redirects=False,
)
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user.name == "Max"
assert user.enabled is True
def test_account_update_persists_notification_preferences(client: TestClient) -> None:
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
"notify_email_enabled": "on",
"notification_email": "alerts@example.com",
},
follow_redirects=False,
)
assert response.status_code == 303
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user.notify_email_enabled is True
assert user.notification_email == "alerts@example.com"
def test_account_edit_rejects_invalid_csrf(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
response = client.post(
"/account/edit",
data={
"csrf_token": "invalid-token",
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
)
assert response.status_code == 403
def test_logout_clears_session(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account-logout", data={"csrf_token": csrf}, follow_redirects=False)
assert response.status_code == 303
response = client.get("/account", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/account-login"
def test_cannot_view_other_users_account(client: TestClient) -> None:
"""Each self-service session is bound to the user_id captured at login;
another user created afterwards must not be reachable from it."""
create_user_via_admin(client, name="Max")
account_login(client, email="max@mywhoosh.example", password="mw-secret")
with client.app.state.session_factory() as session:
UserRepository(session).create(
name="Other",
enabled=True,
mywhoosh_email_enc="unused",
mywhoosh_password_enc="unused",
garmin_email_enc="unused",
garmin_password_enc="unused",
)
response = client.get("/account")
assert response.status_code == 200
assert "Max" in response.text
assert "Other" not in response.text
def test_account_sync_triggers_own_user_only(app, client: TestClient, fake_sync_manager) -> None:
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account/sync", data={"csrf_token": csrf})
assert response.status_code == 200
assert fake_sync_manager.user_calls == [user_id]
def test_account_sync_reports_already_running(app, client: TestClient, fake_sync_manager) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager
fake_sync_manager.raise_already_running = True
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account/sync", data={"csrf_token": csrf})
assert response.status_code == 409
assert "already running" in response.text.lower()
def test_account_sync_requires_login(client: TestClient) -> None:
response = client.post("/account/sync", data={"csrf_token": "whatever"}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/account-login"
def test_account_sync_rejects_invalid_csrf(app, client: TestClient, fake_sync_manager) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager
response = client.post("/account/sync", data={"csrf_token": "invalid-token"})
assert response.status_code == 403
assert fake_sync_manager.user_calls == []