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