mas_handlers/admin/v1/user_emails/
add.rs1use std::str::FromStr as _;
7
8use aide::{NoApi, OperationIo, transform::TransformOperation};
9use axum::{Json, response::IntoResponse};
10use hyper::StatusCode;
11use mas_storage::{
12 BoxRng,
13 queue::{ProvisionUserJob, QueueJobRepositoryExt as _},
14 user::UserEmailFilter,
15};
16use schemars::JsonSchema;
17use serde::Deserialize;
18use ulid::Ulid;
19
20use crate::{
21 admin::{
22 call_context::CallContext,
23 model::UserEmail,
24 response::{ErrorResponse, SingleResponse},
25 },
26 impl_from_error_for_route,
27};
28
29#[derive(Debug, thiserror::Error, OperationIo)]
30#[aide(output_with = "Json<ErrorResponse>")]
31pub enum RouteError {
32 #[error(transparent)]
33 Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
34
35 #[error("User email {0:?} already in use")]
36 EmailAlreadyInUse(String),
37
38 #[error("Email {email:?} is not valid")]
39 EmailNotValid {
40 email: String,
41
42 #[source]
43 source: lettre::address::AddressError,
44 },
45
46 #[error("User ID {0} not found")]
47 UserNotFound(Ulid),
48}
49
50impl_from_error_for_route!(mas_storage::RepositoryError);
51
52impl IntoResponse for RouteError {
53 fn into_response(self) -> axum::response::Response {
54 let error = ErrorResponse::from_error(&self);
55 let status = match self {
56 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
57 Self::EmailAlreadyInUse(_) => StatusCode::CONFLICT,
58 Self::EmailNotValid { .. } => StatusCode::BAD_REQUEST,
59 Self::UserNotFound(_) => StatusCode::NOT_FOUND,
60 };
61 (status, Json(error)).into_response()
62 }
63}
64
65#[derive(Deserialize, JsonSchema)]
67#[serde(rename = "AddUserEmailRequest")]
68pub struct Request {
69 #[schemars(with = "crate::admin::schema::Ulid")]
71 user_id: Ulid,
72
73 #[schemars(email)]
75 email: String,
76}
77
78pub fn doc(operation: TransformOperation) -> TransformOperation {
79 operation
80 .id("addUserEmail")
81 .summary("Add a user email")
82 .description(r"Add an email address to a user.
83Note that this endpoint ignores any policy which would normally prevent the email from being added.")
84 .tag("user-email")
85 .response_with::<201, Json<SingleResponse<UserEmail>>, _>(|t| {
86 let [sample, ..] = UserEmail::samples();
87 let response = SingleResponse::new_canonical(sample);
88 t.description("User email was created").example(response)
89 })
90 .response_with::<409, RouteError, _>(|t| {
91 let response = ErrorResponse::from_error(&RouteError::EmailAlreadyInUse(
92 "alice@example.com".to_owned(),
93 ));
94 t.description("Email already in use").example(response)
95 })
96 .response_with::<400, RouteError, _>(|t| {
97 let response = ErrorResponse::from_error(&RouteError::EmailNotValid {
98 email: "not a valid email".to_owned(),
99 source: lettre::address::AddressError::MissingParts,
100 });
101 t.description("Email is not valid").example(response)
102 })
103 .response_with::<404, RouteError, _>(|t| {
104 let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
105 t.description("User was not found").example(response)
106 })
107}
108
109#[tracing::instrument(name = "handler.admin.v1.user_emails.add", skip_all, err)]
110pub async fn handler(
111 CallContext {
112 mut repo, clock, ..
113 }: CallContext,
114 NoApi(mut rng): NoApi<BoxRng>,
115 Json(params): Json<Request>,
116) -> Result<(StatusCode, Json<SingleResponse<UserEmail>>), RouteError> {
117 let user = repo
119 .user()
120 .lookup(params.user_id)
121 .await?
122 .ok_or(RouteError::UserNotFound(params.user_id))?;
123
124 if let Err(source) = lettre::Address::from_str(¶ms.email) {
126 return Err(RouteError::EmailNotValid {
127 email: params.email,
128 source,
129 });
130 }
131
132 let count = repo
134 .user_email()
135 .count(UserEmailFilter::new().for_email(¶ms.email))
136 .await?;
137
138 if count > 0 {
139 return Err(RouteError::EmailAlreadyInUse(params.email));
140 }
141
142 let user_email = repo
144 .user_email()
145 .add(&mut rng, &clock, &user, params.email)
146 .await?;
147
148 repo.queue_job()
150 .schedule_job(&mut rng, &clock, ProvisionUserJob::new_for_id(user.id))
151 .await?;
152
153 repo.save().await?;
154
155 Ok((
156 StatusCode::CREATED,
157 Json(SingleResponse::new_canonical(user_email.into())),
158 ))
159}
160
161#[cfg(test)]
162mod tests {
163 use hyper::{Request, StatusCode};
164 use insta::assert_json_snapshot;
165 use sqlx::PgPool;
166 use ulid::Ulid;
167
168 use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
169 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
170 async fn test_create(pool: PgPool) {
171 setup();
172 let mut state = TestState::from_pool(pool).await.unwrap();
173 let token = state.token_with_scope("urn:mas:admin").await;
174 let mut rng = state.rng();
175
176 let mut repo = state.repository().await.unwrap();
178 let alice = repo
179 .user()
180 .add(&mut rng, &state.clock, "alice".to_owned())
181 .await
182 .unwrap();
183 repo.save().await.unwrap();
184
185 let request = Request::post("/api/admin/v1/user-emails")
186 .bearer(&token)
187 .json(serde_json::json!({
188 "email": "alice@example.com",
189 "user_id": alice.id,
190 }));
191 let response = state.request(request).await;
192 response.assert_status(StatusCode::CREATED);
193 let body: serde_json::Value = response.json();
194 assert_json_snapshot!(body, @r###"
195 {
196 "data": {
197 "type": "user-email",
198 "id": "01FSHN9AG07HNEZXNQM2KNBNF6",
199 "attributes": {
200 "created_at": "2022-01-16T14:40:00Z",
201 "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
202 "email": "alice@example.com"
203 },
204 "links": {
205 "self": "/api/admin/v1/user-emails/01FSHN9AG07HNEZXNQM2KNBNF6"
206 }
207 },
208 "links": {
209 "self": "/api/admin/v1/user-emails/01FSHN9AG07HNEZXNQM2KNBNF6"
210 }
211 }
212 "###);
213 }
214
215 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
216 async fn test_user_not_found(pool: PgPool) {
217 setup();
218 let mut state = TestState::from_pool(pool).await.unwrap();
219 let token = state.token_with_scope("urn:mas:admin").await;
220
221 let request = Request::post("/api/admin/v1/user-emails")
222 .bearer(&token)
223 .json(serde_json::json!({
224 "email": "alice@example.com",
225 "user_id": Ulid::nil(),
226 }));
227 let response = state.request(request).await;
228 response.assert_status(StatusCode::NOT_FOUND);
229 let body: serde_json::Value = response.json();
230 assert_json_snapshot!(body, @r###"
231 {
232 "errors": [
233 {
234 "title": "User ID 00000000000000000000000000 not found"
235 }
236 ]
237 }
238 "###);
239 }
240
241 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
242 async fn test_email_already_exists(pool: PgPool) {
243 setup();
244 let mut state = TestState::from_pool(pool).await.unwrap();
245 let token = state.token_with_scope("urn:mas:admin").await;
246 let mut rng = state.rng();
247
248 let mut repo = state.repository().await.unwrap();
249 let alice = repo
250 .user()
251 .add(&mut rng, &state.clock, "alice".to_owned())
252 .await
253 .unwrap();
254 repo.user_email()
255 .add(
256 &mut rng,
257 &state.clock,
258 &alice,
259 "alice@example.com".to_owned(),
260 )
261 .await
262 .unwrap();
263 repo.save().await.unwrap();
264
265 let request = Request::post("/api/admin/v1/user-emails")
266 .bearer(&token)
267 .json(serde_json::json!({
268 "email": "alice@example.com",
269 "user_id": alice.id,
270 }));
271 let response = state.request(request).await;
272 response.assert_status(StatusCode::CONFLICT);
273 let body: serde_json::Value = response.json();
274 assert_json_snapshot!(body, @r###"
275 {
276 "errors": [
277 {
278 "title": "User email \"alice@example.com\" already in use"
279 }
280 ]
281 }
282 "###);
283 }
284
285 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
286 async fn test_invalid_email(pool: PgPool) {
287 setup();
288 let mut state = TestState::from_pool(pool).await.unwrap();
289 let token = state.token_with_scope("urn:mas:admin").await;
290 let mut rng = state.rng();
291
292 let mut repo = state.repository().await.unwrap();
293 let alice = repo
294 .user()
295 .add(&mut rng, &state.clock, "alice".to_owned())
296 .await
297 .unwrap();
298 repo.save().await.unwrap();
299
300 let request = Request::post("/api/admin/v1/user-emails")
301 .bearer(&token)
302 .json(serde_json::json!({
303 "email": "invalid-email",
304 "user_id": alice.id,
305 }));
306 let response = state.request(request).await;
307 response.assert_status(StatusCode::BAD_REQUEST);
308 let body: serde_json::Value = response.json();
309 assert_json_snapshot!(body, @r###"
310 {
311 "errors": [
312 {
313 "title": "Email \"invalid-email\" is not valid"
314 },
315 {
316 "title": "Missing domain or user"
317 }
318 ]
319 }
320 "###);
321 }
322}