Skip to content

Commit 2d46a3d

Browse files
Tested successful email update workflow
1 parent 8f8f301 commit 2d46a3d

File tree

8 files changed

+139
-21
lines changed

8 files changed

+139
-21
lines changed

main.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,12 @@ async def read_home(
169169

170170
@app.get("/login")
171171
async def read_login(
172-
params: dict = Depends(common_unauthenticated_parameters)
172+
params: dict = Depends(common_unauthenticated_parameters),
173+
email_updated: Optional[str] = "false"
173174
):
174175
if params["user"]:
175176
return RedirectResponse(url="/dashboard", status_code=302)
177+
params["email_updated"] = email_updated
176178
return templates.TemplateResponse(params["request"], "authentication/login.html", params)
177179

178180

@@ -256,14 +258,16 @@ async def read_dashboard(
256258

257259
@app.get("/profile")
258260
async def read_profile(
259-
params: dict = Depends(common_authenticated_parameters)
261+
params: dict = Depends(common_authenticated_parameters),
262+
email_update_requested: Optional[str] = "false"
260263
):
261264
# Add image constraints to the template context
262265
params.update({
263266
"max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024), # Convert bytes to MB
264267
"min_dimension": MIN_DIMENSION,
265268
"max_dimension": MAX_DIMENSION,
266-
"allowed_formats": list(ALLOWED_CONTENT_TYPES.keys())
269+
"allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()),
270+
"email_update_requested": email_update_requested
267271
})
268272
return templates.TemplateResponse(params["request"], "users/profile.html", params)
269273

routers/authentication.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ async def request_email_update(
338338
)
339339

340340
return RedirectResponse(
341-
url="/settings?email_update_requested=true",
341+
url="/profile?email_update_requested=true",
342342
status_code=303
343343
)
344344

@@ -370,6 +370,6 @@ async def confirm_email_update(
370370
session.commit()
371371

372372
return RedirectResponse(
373-
url="/settings?email_updated=true",
373+
url="/login?email_updated=true",
374374
status_code=303
375375
)

templates/authentication/login.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66

77
{% block auth_content %}
88
<div class="login-form">
9+
10+
{% if email_updated == "true" %}
11+
<div class="alert alert-success" role="alert">
12+
Your email address has been successfully updated. Please login with your new email address.
13+
</div>
14+
{% endif %}
15+
916
<form method="POST" action="{{ url_for('login') }}" class="needs-validation" novalidate>
1017
<!-- Email Input -->
1118
<div class="mb-3">

templates/users/profile.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
<div class="container mt-5">
99
<h1 class="mb-4">User Profile</h1>
1010

11+
{% if email_update_requested == "true" %}
12+
<div class="alert alert-info" role="alert">
13+
Please check your current email address for a confirmation link to complete the email update.
14+
</div>
15+
{% endif %}
16+
1117
<!-- Basic Information -->
1218
<div class="card mb-4" id="basic-info">
1319
<div class="card-header">
@@ -102,7 +108,7 @@ <h1 class="mb-4">User Profile</h1>
102108
<p class="text-danger">This action cannot be undone. Please confirm your password to delete your account.</p>
103109
<div class="mb-3">
104110
<label for="confirm_delete_password" class="form-label">Password</label>
105-
<input type="password" class="form-control" id="confirm_delete_password" name="confirm_delete_password">
111+
<input type="password" class="form-control" id="confirm_delete_password" name="confirm_delete_password" autocomplete="off">
106112
</div>
107113
<button type="submit" class="btn btn-danger">Delete Account</button>
108114
</form>

tests/test_auth.py

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import re
22
import string
33
import random
4-
from datetime import timedelta
4+
from datetime import timedelta, UTC, datetime
55
from urllib.parse import urlparse, parse_qs
66
from starlette.datastructures import URLPath
77
from main import app
@@ -13,8 +13,13 @@
1313
validate_token,
1414
generate_password_reset_url,
1515
COMPILED_PASSWORD_PATTERN,
16-
convert_python_regex_to_html
16+
convert_python_regex_to_html,
17+
generate_email_update_url,
18+
send_email_update_confirmation,
19+
get_user_from_email_update_token
1720
)
21+
from unittest.mock import patch, MagicMock
22+
from utils.models import User, EmailUpdateToken
1823

1924

2025
def test_convert_python_regex_to_html() -> None:
@@ -146,3 +151,100 @@ def test_password_pattern() -> None:
146151
# No special character
147152
password = "aA1" * 3
148153
assert re.match(COMPILED_PASSWORD_PATTERN, password) is None
154+
155+
def test_email_update_url_generation() -> None:
156+
"""
157+
Tests that the email update confirmation URL is correctly formatted and contains
158+
the required query parameters.
159+
"""
160+
test_user_id = 123
161+
test_token = "abc123"
162+
test_new_email = "[email protected]"
163+
164+
url = generate_email_update_url(test_user_id, test_token, test_new_email)
165+
166+
# Parse the URL
167+
parsed = urlparse(url)
168+
query_params = parse_qs(parsed.query)
169+
170+
# Get the actual path from the FastAPI app
171+
confirm_email_path: URLPath = app.url_path_for("confirm_email_update")
172+
173+
# Verify URL path
174+
assert parsed.path == str(confirm_email_path)
175+
176+
# Verify query parameters
177+
assert "user_id" in query_params
178+
assert "token" in query_params
179+
assert "new_email" in query_params
180+
assert query_params["user_id"][0] == str(test_user_id)
181+
assert query_params["token"][0] == test_token
182+
assert query_params["new_email"][0] == test_new_email
183+
184+
@patch('resend.Emails.send')
185+
def test_send_email_update_confirmation(mock_send: MagicMock) -> None:
186+
"""
187+
Tests the email update confirmation sending functionality.
188+
"""
189+
# Mock session and dependencies
190+
session = MagicMock()
191+
session.exec.return_value.first.return_value = None # No existing token
192+
193+
current_email = "[email protected]"
194+
new_email = "[email protected]"
195+
user_id = 123
196+
197+
# Mock successful email send
198+
mock_send.return_value = {"id": "test_email_id"}
199+
200+
# Test successful email sending
201+
send_email_update_confirmation(current_email, new_email, user_id, session)
202+
203+
# Verify session interactions
204+
assert session.add.called
205+
assert session.commit.called
206+
207+
# Verify email was sent with correct parameters
208+
mock_send.assert_called_once()
209+
call_args = mock_send.call_args[0][0]
210+
assert call_args["to"] == [current_email]
211+
assert call_args["subject"] == "Confirm Email Update"
212+
assert "from" in call_args
213+
assert "html" in call_args
214+
215+
# Test existing token case
216+
session.reset_mock()
217+
session.exec.return_value.first.return_value = EmailUpdateToken() # Existing token
218+
219+
send_email_update_confirmation(current_email, new_email, user_id, session)
220+
221+
# Verify no new token was created or email sent
222+
assert not session.add.called
223+
assert not session.commit.called
224+
assert mock_send.call_count == 1 # Still just one call from before
225+
226+
def test_get_user_from_email_update_token() -> None:
227+
"""
228+
Tests retrieving a user using an email update token.
229+
"""
230+
session = MagicMock()
231+
232+
# Test valid token
233+
mock_user = User(id=1, email="[email protected]")
234+
mock_token = EmailUpdateToken(
235+
user_id=1,
236+
token="valid_token",
237+
expires_at=datetime.now(UTC) + timedelta(hours=1),
238+
used=False
239+
)
240+
session.exec.return_value.first.return_value = (mock_user, mock_token)
241+
242+
user, token = get_user_from_email_update_token(1, "valid_token", session)
243+
assert user == mock_user
244+
assert token == mock_token
245+
246+
# Test invalid token
247+
session.exec.return_value.first.return_value = None
248+
user, token = get_user_from_email_update_token(1, "invalid_token", session)
249+
assert user is None
250+
assert token is None

tests/test_user.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ def test_update_profile_unauthorized(unauth_client: TestClient):
1717
response: Response = unauth_client.post(
1818
app.url_path_for("update_profile"),
1919
data={
20-
"name": "New Name",
21-
"email": "[email protected]",
20+
"name": "New Name"
2221
},
2322
files={
2423
"avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg")
@@ -40,8 +39,7 @@ def test_update_profile_authorized(mock_validate, auth_client: TestClient, test_
4039
response: Response = auth_client.post(
4140
app.url_path_for("update_profile"),
4241
data={
43-
"name": "Updated Name",
44-
"email": "[email protected]",
42+
"name": "Updated Name"
4543
},
4644
files={
4745
"avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg")
@@ -54,7 +52,6 @@ def test_update_profile_authorized(mock_validate, auth_client: TestClient, test_
5452
# Verify changes in database
5553
session.refresh(test_user)
5654
assert test_user.name == "Updated Name"
57-
assert test_user.email == "[email protected]"
5855
assert test_user.avatar_data == MOCK_IMAGE_DATA
5956
assert test_user.avatar_content_type == MOCK_CONTENT_TYPE
6057

@@ -67,8 +64,7 @@ def test_update_profile_without_avatar(auth_client: TestClient, test_user: User,
6764
response: Response = auth_client.post(
6865
app.url_path_for("update_profile"),
6966
data={
70-
"name": "Updated Name",
71-
"email": "[email protected]",
67+
"name": "Updated Name"
7268
},
7369
follow_redirects=False
7470
)
@@ -78,7 +74,6 @@ def test_update_profile_without_avatar(auth_client: TestClient, test_user: User,
7874
# Verify changes in database
7975
session.refresh(test_user)
8076
assert test_user.name == "Updated Name"
81-
assert test_user.email == "[email protected]"
8277

8378

8479
def test_delete_account_unauthorized(unauth_client: TestClient):
@@ -130,8 +125,7 @@ def test_get_avatar_authorized(mock_validate, auth_client: TestClient, test_user
130125
auth_client.post(
131126
app.url_path_for("update_profile"),
132127
data={
133-
"name": test_user.name,
134-
"email": test_user.email,
128+
"name": test_user.name
135129
},
136130
files={
137131
"avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg")
@@ -167,8 +161,7 @@ def test_update_profile_invalid_image(mock_validate, auth_client: TestClient):
167161
response: Response = auth_client.post(
168162
app.url_path_for("update_profile"),
169163
data={
170-
"name": "Updated Name",
171-
"email": "[email protected]",
164+
"name": "Updated Name"
172165
},
173166
files={
174167
"avatar_file": ("test_avatar.jpg", b"invalid image data", "image/jpeg")

utils/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ def send_email_update_confirmation(
473473
user_id, token.token, new_email)
474474

475475
# Render the email template
476-
template = templates.get_template("emails/update_email.html")
476+
template = templates.get_template("emails/update_email_email.html")
477477
html_content = template.render({
478478
"confirmation_url": confirmation_url,
479479
"current_email": current_email,

utils/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,12 @@ class User(SQLModel, table=True):
215215
"cascade": "all, delete-orphan"
216216
}
217217
)
218+
email_update_tokens: Mapped[List["EmailUpdateToken"]] = Relationship(
219+
back_populates="user",
220+
sa_relationship_kwargs={
221+
"cascade": "all, delete-orphan"
222+
}
223+
)
218224
password: Mapped[Optional[UserPassword]] = Relationship(
219225
back_populates="user"
220226
)

0 commit comments

Comments
 (0)