Skip to content

Commit 6e96b3c

Browse files
committed
implement API for username changes
1 parent 95b7b48 commit 6e96b3c

4 files changed

Lines changed: 173 additions & 3 deletions

File tree

src/controllers/user/update.rs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
use crate::app::AppState;
22
use crate::auth::AuthCheck;
3-
use crate::controllers::helpers::OkResponse;
3+
use crate::controllers::helpers::{OkResponse, USERNAME_COOLDOWN, check_username};
44
use crate::email::EmailMessage;
55
use crate::models::NewEmail;
6-
use crate::schema::users;
6+
use crate::schema::{abandoned_usernames, users};
77
use crate::util::errors::{AppResult, bad_request, server_error};
8+
9+
use crates_io_database::models::NewAbandonedUsername;
10+
811
use axum::Json;
912
use axum::extract::Path;
13+
use chrono::Utc;
1014
use diesel::prelude::*;
11-
use diesel_async::RunQueryDsl;
15+
use diesel_async::{AsyncConnection, RunQueryDsl};
1216
use http::request::Parts;
1317
use lettre::Address;
1418
use minijinja::context;
@@ -24,6 +28,7 @@ pub struct UserUpdate {
2428

2529
#[derive(Deserialize, utoipa::ToSchema)]
2630
pub struct User {
31+
username: Option<String>,
2732
email: Option<String>,
2833
publish_notifications: Option<bool>,
2934
}
@@ -101,6 +106,33 @@ pub async fn update_user(
101106
}
102107
}
103108

109+
if let Some(newname) = &user_update.user.username
110+
&& newname != &user.username
111+
{
112+
conn.transaction(async |conn| -> AppResult<()> {
113+
// ensure this username can be adopted
114+
check_username(newname, conn).await?;
115+
116+
let now = Utc::now();
117+
diesel::insert_into(abandoned_usernames::table)
118+
.values(NewAbandonedUsername {
119+
username: &user.username,
120+
previous_user_id: Some(user.id),
121+
abandoned_at: &now,
122+
available_at: &(now + USERNAME_COOLDOWN),
123+
})
124+
.execute(conn)
125+
.await?;
126+
diesel::update(user)
127+
.set(users::username.eq(newname))
128+
.execute(conn)
129+
.await?;
130+
131+
Ok(())
132+
})
133+
.await?;
134+
}
135+
104136
if let Some(user_email) = &user_update.user.email {
105137
let user_email = user_email.trim();
106138

src/tests/routes/users/update.rs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
use crate::util::{RequestHelper, Response, TestApp};
2+
use chrono::{DateTime, TimeDelta, Utc};
3+
use claims::{assert_ge, assert_le, assert_ok};
4+
use crates_io_database::models::{AbandonedUsername, NewAbandonedUsername, User};
5+
use crates_io_database::schema::{abandoned_usernames, reserved_usernames};
6+
use diesel::HasQuery;
7+
use diesel::prelude::*;
8+
use diesel_async::RunQueryDsl;
29
use http::StatusCode;
310
use insta::assert_snapshot;
411
use serde_json::json;
@@ -26,6 +33,13 @@ impl crate::util::MockCookieUser {
2633
assert_eq!(response.status(), StatusCode::OK);
2734
assert_eq!(response.json(), json!({ "ok": true }));
2835
}
36+
37+
async fn request_username_update(&self, new_username: &str) -> Response<()> {
38+
let model = self.as_model();
39+
let body = json!({"user": { "username": new_username }});
40+
let url = format!("/api/v1/users/{}", model.id);
41+
self.put(&url, body.to_string()).await
42+
}
2943
}
3044

3145
/// Given a crates.io user, check to make sure that the user
@@ -111,6 +125,118 @@ async fn test_invalid_email_address() {
111125
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"invalid email address"}]}"#);
112126
}
113127

128+
/// Runs what should be a successful rename and ensures that the
129+
/// expected DB tables have been updated appropriately
130+
#[tokio::test(flavor = "multi_thread")]
131+
async fn test_change_username_happy_path() {
132+
let (app, _, user) = TestApp::init().with_user().await;
133+
let model = user.as_model();
134+
let mut conn = app.db_conn().await;
135+
136+
let old_username = &model.username;
137+
let new_username = "new-foo_username1";
138+
139+
// actuallly do the request
140+
let put_request_start = Utc::now(); // for checking the timestamps later
141+
let response: Response<()> = user.request_username_update(new_username).await;
142+
let put_request_end = Utc::now();
143+
144+
assert_snapshot!(response.status(), @"200 OK");
145+
assert_snapshot!(response.json(), @r#"{"ok":true}"#);
146+
147+
// ───── Postconditions ─────
148+
// 1. check that new abandoned username record was created
149+
let records: Vec<AbandonedUsername> = assert_ok!(
150+
AbandonedUsername::query()
151+
.filter(abandoned_usernames::username.eq(old_username))
152+
.load(&mut conn)
153+
.await
154+
);
155+
assert_eq!(records.len(), 1);
156+
let record = records.into_iter().next().unwrap();
157+
assert_eq!(record.previous_user_id, Some(model.id));
158+
assert_eq!(record.username, *old_username);
159+
assert_eq!(
160+
record.available_at,
161+
record.abandoned_at + TimeDelta::days(30)
162+
);
163+
164+
assert_ge!(record.abandoned_at, put_request_start);
165+
assert_le!(record.abandoned_at, put_request_end);
166+
167+
// 2. user record should have been updated
168+
let updated_user: User = assert_ok!(User::find(&conn, model.id).await);
169+
assert_eq!(updated_user.username, new_username);
170+
}
171+
172+
/// Check that invalid usernames are rejected
173+
#[tokio::test(flavor = "multi_thread")]
174+
async fn test_reject_prohibited_username_changes() {
175+
let (app, _, user) = TestApp::init().with_user().await;
176+
let mut conn = app.db_conn().await;
177+
let initial_model = User::find(&conn, user.as_model().id)
178+
.await
179+
.expect("initial user lookup");
180+
181+
// populate various tables with values to test against
182+
let _squatter = app.db_new_user("desirable-username").await;
183+
diesel::insert_into(reserved_usernames::table)
184+
.values(reserved_usernames::username.eq("superadminuser"))
185+
.execute(&mut conn)
186+
.await
187+
.expect("db setup");
188+
diesel::insert_into(abandoned_usernames::table)
189+
.values(NewAbandonedUsername {
190+
username: "on-cooldown",
191+
previous_user_id: None,
192+
abandoned_at: &DateTime::parse_from_rfc3339("2000-01-01T12:00:00Z")
193+
.unwrap()
194+
.to_utc(),
195+
available_at: &DateTime::parse_from_rfc3339("3000-01-01T12:00:00Z")
196+
.unwrap()
197+
.to_utc(),
198+
})
199+
.execute(&mut conn)
200+
.await
201+
.expect("db setup");
202+
203+
// ───── tests ─────
204+
// reject empty string "": it fails string validation
205+
let response = user.request_username_update("").await;
206+
assert_snapshot!(response.status(), @"400 Bad Request");
207+
assert_snapshot!(
208+
response.json(),
209+
@r#"{"errors":[{"detail":"username cannot be empty"}]}"#);
210+
211+
// reject "desirable-username": another user is using it
212+
let response = user.request_username_update("DeSiRaBlE_uSeRnAmE").await;
213+
assert_snapshot!(response.status(), @"400 Bad Request");
214+
assert_snapshot!(
215+
response.json(),
216+
@r#"{"errors":[{"detail":"the username `DeSiRaBlE_uSeRnAmE` is not available"}]}"#
217+
);
218+
219+
// reject "superadminuser": it is reserved
220+
let response = user.request_username_update("SuPeRaDmInUsEr").await;
221+
assert_snapshot!(response.status(), @"400 Bad Request");
222+
assert_snapshot!(
223+
response.json(),
224+
@r#"{"errors":[{"detail":"the username `SuPeRaDmInUsEr` is reserved"}]}"#);
225+
226+
// reject "on-cooldown": it is unavailable until the year 3000
227+
let response = user.request_username_update("On-CoOlDoWn").await;
228+
assert_snapshot!(response.status(), @"400 Bad Request");
229+
assert_snapshot!(
230+
response.text(),
231+
@r#"{"errors":[{"detail":"The username `On-CoOlDoWn` was recently in use. This username will be available after 3000-01-01T12:00:00Z."}]}"#
232+
);
233+
234+
// ───── Post-conditions ─────
235+
// user record should not have changed at all
236+
let final_model = assert_ok!(User::find(&conn, user.as_model().id).await);
237+
assert_eq!(initial_model.username, final_model.username);
238+
}
239+
114240
#[tokio::test(flavor = "multi_thread")]
115241
async fn test_invalid_json() {
116242
let (_app, _anon, user) = TestApp::init().with_user().await;

src/tests/snapshots/integration__openapi__openapi_internal_snapshot-2.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6667,6 +6667,12 @@ expression: response.json()
66676667
"boolean",
66686668
"null"
66696669
]
6670+
},
6671+
"username": {
6672+
"type": [
6673+
"string",
6674+
"null"
6675+
]
66706676
}
66716677
},
66726678
"type": "object"

src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5737,6 +5737,12 @@ expression: response.json()
57375737
"boolean",
57385738
"null"
57395739
]
5740+
},
5741+
"username": {
5742+
"type": [
5743+
"string",
5744+
"null"
5745+
]
57405746
}
57415747
},
57425748
"type": "object"

0 commit comments

Comments
 (0)