- C1: drop module-level app singleton in app/main.py so importing the package no longer validates Settings or creates DATA_DIR; run uvicorn with --factory in the Dockerfile. pytest now collects and passes with no ambient env vars. - I2: add missing app/auth, app/security, app/web __init__.py so setuptools discovers all five packages. - I3: resolve the Jinja2 template directory relative to __file__ instead of the process CWD. - I4: add .gitignore covering .env, data/, .venv/, caches and build artifacts so example deployment secrets cannot be committed. - I5: assert UserRepository.list_enabled() excludes disabled users. - M6: encode both operands before hmac.compare_digest in validate_csrf so a non-ASCII token yields 403 instead of an unhandled 500. - M9: remove unused relationship / HealthState imports. - M11: make session cookie https_only configurable via SESSION_HTTPS_ONLY (default unchanged: false). - M13: dispose SQLAlchemy engines in the db_session and client fixtures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
362 lines
11 KiB
Python
362 lines
11 KiB
Python
from fastapi.testclient import TestClient
|
|
|
|
from app.db.repositories import UserRepository
|
|
from app.security.credentials import CredentialCipher
|
|
|
|
|
|
def 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 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 create_user_via_http(client: TestClient) -> int:
|
|
page = client.get("/users/new")
|
|
csrf = extract_csrf(page.text)
|
|
response = client.post(
|
|
"/users",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"name": "Max",
|
|
"mywhoosh_email": "max@example.com",
|
|
"mywhoosh_password": "mw-secret",
|
|
"garmin_email": "max-garmin@example.com",
|
|
"garmin_password": "garmin-secret",
|
|
"enabled": "on",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert response.status_code == 303
|
|
location = response.headers["location"]
|
|
return int(location.rsplit("/", 1)[-1])
|
|
|
|
|
|
def test_create_user_encrypts_credentials_and_never_renders_them(client: TestClient) -> None:
|
|
login(client)
|
|
page = client.get("/users/new")
|
|
assert page.status_code == 200
|
|
csrf = extract_csrf(page.text)
|
|
response = client.post(
|
|
"/users",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"name": "Max",
|
|
"mywhoosh_email": "max@example.com",
|
|
"mywhoosh_password": "mw-secret",
|
|
"garmin_email": "max-garmin@example.com",
|
|
"garmin_password": "garmin-secret",
|
|
"enabled": "on",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
assert response.status_code == 200
|
|
assert "mw-secret" not in response.text
|
|
assert "garmin-secret" not in response.text
|
|
|
|
|
|
def test_create_user_persists_encrypted_credentials(client: TestClient) -> None:
|
|
login(client)
|
|
user_id = create_user_via_http(client)
|
|
|
|
with client.app.state.session_factory() as session:
|
|
user = UserRepository(session).get(user_id)
|
|
assert user is not None
|
|
assert user.mywhoosh_email_enc != "max@example.com"
|
|
assert user.mywhoosh_password_enc != "mw-secret"
|
|
|
|
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
|
|
assert cipher.decrypt(user.mywhoosh_email_enc) == "max@example.com"
|
|
assert cipher.decrypt(user.mywhoosh_password_enc) == "mw-secret"
|
|
assert cipher.decrypt(user.garmin_email_enc) == "max-garmin@example.com"
|
|
assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret"
|
|
|
|
|
|
def test_edit_page_never_renders_passwords(client: TestClient) -> None:
|
|
login(client)
|
|
user_id = create_user_via_http(client)
|
|
|
|
page = client.get(f"/users/{user_id}/edit")
|
|
assert page.status_code == 200
|
|
assert "mw-secret" not in page.text
|
|
assert "garmin-secret" not in page.text
|
|
# emails may be shown for display
|
|
assert "max@example.com" in page.text
|
|
assert "max-garmin@example.com" in page.text
|
|
|
|
|
|
def test_edit_user_blank_password_preserves_existing_password(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 Updated",
|
|
"mywhoosh_email": "max@example.com",
|
|
"mywhoosh_password": "",
|
|
"garmin_email": "max-garmin@example.com",
|
|
"garmin_password": "",
|
|
"enabled": "on",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
assert response.status_code == 200
|
|
assert "mw-secret" not in response.text
|
|
assert "garmin-secret" not in response.text
|
|
|
|
with client.app.state.session_factory() as session:
|
|
user = UserRepository(session).get(user_id)
|
|
assert user is not None
|
|
assert user.name == "Max Updated"
|
|
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_edit_user_replaces_password_when_submitted(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": "new-mw-secret",
|
|
"garmin_email": "max-garmin@example.com",
|
|
"garmin_password": "",
|
|
"enabled": "on",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
assert response.status_code == 200
|
|
assert "new-mw-secret" not in response.text
|
|
|
|
with client.app.state.session_factory() as session:
|
|
user = UserRepository(session).get(user_id)
|
|
assert user is not None
|
|
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
|
|
assert cipher.decrypt(user.mywhoosh_password_enc) == "new-mw-secret"
|
|
assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret"
|
|
|
|
|
|
def test_user_detail_page_shows_no_secrets(client: TestClient) -> None:
|
|
login(client)
|
|
user_id = create_user_via_http(client)
|
|
|
|
response = client.get(f"/users/{user_id}")
|
|
assert response.status_code == 200
|
|
assert "mw-secret" not in response.text
|
|
assert "garmin-secret" not in response.text
|
|
assert "max@example.com" not in response.text
|
|
assert "max-garmin@example.com" not in response.text
|
|
assert "Max" in response.text
|
|
|
|
|
|
def test_unknown_user_returns_404_for_detail(client: TestClient) -> None:
|
|
login(client)
|
|
response = client.get("/users/999999")
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_unknown_user_returns_404_for_edit(client: TestClient) -> None:
|
|
login(client)
|
|
response = client.get("/users/999999/edit")
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_unknown_user_returns_404_for_update(client: TestClient) -> None:
|
|
login(client)
|
|
page = client.get("/users/new")
|
|
csrf = extract_csrf(page.text)
|
|
response = client.post(
|
|
"/users/999999",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"name": "Nobody",
|
|
"mywhoosh_email": "a@example.com",
|
|
"mywhoosh_password": "",
|
|
"garmin_email": "b@example.com",
|
|
"garmin_password": "",
|
|
"enabled": "on",
|
|
},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_create_user_requires_admin(client: TestClient) -> None:
|
|
response = client.get("/users/new", follow_redirects=False)
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/login"
|
|
|
|
|
|
def test_create_user_rejects_invalid_csrf(client: TestClient) -> None:
|
|
login(client)
|
|
response = client.post(
|
|
"/users",
|
|
data={
|
|
"csrf_token": "invalid-token",
|
|
"name": "Max",
|
|
"mywhoosh_email": "max@example.com",
|
|
"mywhoosh_password": "mw-secret",
|
|
"garmin_email": "max-garmin@example.com",
|
|
"garmin_password": "garmin-secret",
|
|
"enabled": "on",
|
|
},
|
|
)
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_create_user_rejects_non_ascii_csrf_token(client: TestClient) -> None:
|
|
login(client)
|
|
response = client.post(
|
|
"/users",
|
|
data={
|
|
"csrf_token": "invalid-tokeü",
|
|
"name": "Max",
|
|
"mywhoosh_email": "max@example.com",
|
|
"mywhoosh_password": "mw-secret",
|
|
"garmin_email": "max-garmin@example.com",
|
|
"garmin_password": "garmin-secret",
|
|
"enabled": "on",
|
|
},
|
|
)
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_dashboard_lists_created_user_without_secrets(client: TestClient) -> None:
|
|
login(client)
|
|
create_user_via_http(client)
|
|
|
|
response = client.get("/")
|
|
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 _create_payload(**overrides: str) -> dict:
|
|
payload = {
|
|
"name": "Max",
|
|
"mywhoosh_email": "max@example.com",
|
|
"mywhoosh_password": "mw-secret",
|
|
"garmin_email": "max-garmin@example.com",
|
|
"garmin_password": "garmin-secret",
|
|
"enabled": "on",
|
|
}
|
|
payload.update(overrides)
|
|
return payload
|
|
|
|
|
|
def test_create_user_rejects_empty_mywhoosh_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(mywhoosh_email="")},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_create_user_rejects_empty_mywhoosh_password(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(mywhoosh_password="")},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_create_user_rejects_empty_garmin_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(garmin_email="")},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_create_user_rejects_empty_garmin_password(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(garmin_password="")},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_create_user_rejects_whitespace_only_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(mywhoosh_email=" ")},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_update_user_rejects_empty_mywhoosh_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": "",
|
|
"mywhoosh_password": "",
|
|
"garmin_email": "max-garmin@example.com",
|
|
"garmin_password": "",
|
|
"enabled": "on",
|
|
},
|
|
)
|
|
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)
|
|
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": "",
|
|
"garmin_password": "",
|
|
"enabled": "on",
|
|
},
|
|
)
|
|
assert response.status_code == 400
|