-
Notifications
You must be signed in to change notification settings - Fork 70
Fix: prevent username collision in set-password signup path #668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
342f76c
62d90ef
503bc92
3a6a57f
cbf2f22
3dd05dc
fa35510
4405df1
5dd6ca7
78d7a95
9979575
911bd20
33f30bc
ab0a449
336e1fb
2f3c8a3
702ff41
f854954
4fb550c
68dd3de
7ba1470
275dfc6
69ab6e2
81c04b0
c6f7948
9b40680
3a3ec12
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -166,3 +166,82 @@ def test_set_password_signup_duplicate_email(mock_db, client): | |
| assert resp.status_code == 400 | ||
| data = resp.get_json() | ||
| assert data["error"] == "User already exists" | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Tests — username collision handling | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @patch("routes.auth.is_admin_email", return_value=False) | ||
| @patch("routes.auth.db") | ||
| @patch("routes.auth.create_access_token", return_value="mock-jwt-token") | ||
| def test_set_password_signup_username_collision_resolved(mock_token, mock_db, mock_admin, client): | ||
| """When the email prefix is already taken as a username, _unique_username | ||
| must generate a suffixed variant instead of silently inserting a duplicate.""" | ||
| otp_record = { | ||
| "email": "alice@example.com", | ||
| "verified": True, | ||
| "verified_at": datetime.now(timezone.utc), | ||
| } | ||
|
|
||
| inserted_doc = MagicMock(inserted_id="new-id-456") | ||
| mock_db.users.insert_one.return_value = inserted_doc | ||
|
|
||
| # Simulate: first find_one (existing-user check) → None, | ||
| # second find_one (_unique_username "alice" taken) → existing record, | ||
| # third find_one (_unique_username "alice1" free) → None, | ||
| # fourth find_one (OTP verification) → otp_record. | ||
| mock_db.users.find_one.side_effect = [ | ||
| None, # existing user by email — not found | ||
| {"_id": "x"}, # "alice" username already taken | ||
| None, # "alice1" username is free | ||
| ] | ||
|
Comment on lines
+195
to
+199
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test mocks # Simulate: first find_one (existing-user check) → None,
# find (_unique_username candidates) → returns 'alice' as taken,
# insert_one → success,
# second find_one (OTP verification) → otp_record.
mock_db.users.find_one.side_effect = [
None, # existing user by email — not found
otp_record, # OTP verification
]
mock_db.users.find.return_value = [{"username": "alice"}]
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @pradeeban , I will convert this PR to a draft for now. I will analyze it thoroughly and implement it properly later. Thank you. |
||
| mock_db.email_otps.find_one.return_value = otp_record | ||
|
|
||
| resp = _post_set_password(client, { | ||
| "email": "alice@example.com", | ||
| "password": "securepassword123", | ||
| "purpose": "signup", | ||
| }) | ||
|
|
||
| assert resp.status_code == 200 | ||
|
|
||
| # Extract the username that was actually persisted | ||
| call_args = mock_db.users.insert_one.call_args[0][0] | ||
| assert call_args["username"] == "alice1", ( | ||
| f"Expected 'alice1' but got '{call_args['username']}'" | ||
| ) | ||
|
|
||
|
|
||
| @patch("routes.auth.db") | ||
| def test_unique_username_no_collision(mock_db): | ||
| """_unique_username returns the base name unchanged when it is free.""" | ||
| from routes.auth import _unique_username | ||
|
|
||
| mock_db.users.find_one.return_value = None | ||
| assert _unique_username("bob") == "bob" | ||
|
|
||
|
|
||
| @patch("routes.auth.db") | ||
| def test_unique_username_strips_at_sign(mock_db): | ||
| """_unique_username must strip '@' so the result cannot be parsed as email.""" | ||
| from routes.auth import _unique_username | ||
|
|
||
| mock_db.users.find_one.return_value = None | ||
| result = _unique_username("user@domain") | ||
| assert "@" not in result | ||
|
|
||
|
|
||
| @patch("routes.auth.db") | ||
| def test_unique_username_increments_until_free(mock_db): | ||
| """_unique_username must keep incrementing the suffix until a slot is free.""" | ||
| from routes.auth import _unique_username | ||
|
|
||
| # "carol" and "carol1" are taken; "carol2" is free | ||
| # "carol" and "carol1" are taken; "carol2" is free | ||
| mock_db.users.find.return_value = [ | ||
| {"username": "carol"}, | ||
| {"username": "carol1"}, | ||
| ] | ||
| assert _unique_username("carol") == "carol2" | ||
Uh oh!
There was an error while loading. Please reload this page.