Skip to content

Commit 344794b

Browse files
committed
Admin API to delete user emails
1 parent 3959bc2 commit 344794b

File tree

5 files changed

+192
-9
lines changed

5 files changed

+192
-9
lines changed

crates/handlers/src/admin/v1/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ where
8686
)
8787
.api_route(
8888
"/user-emails/{id}",
89-
get_with(self::user_emails::get, self::user_emails::get_doc),
89+
get_with(self::user_emails::get, self::user_emails::get_doc)
90+
.delete_with(self::user_emails::delete, self::user_emails::delete_doc),
9091
)
9192
.api_route(
9293
"/user-sessions",
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
use aide::{transform::TransformOperation, NoApi, OperationIo};
7+
use axum::{response::IntoResponse, Json};
8+
use hyper::StatusCode;
9+
use mas_storage::{
10+
queue::{ProvisionUserJob, QueueJobRepositoryExt as _},
11+
BoxRng,
12+
};
13+
use ulid::Ulid;
14+
15+
use crate::{
16+
admin::{call_context::CallContext, params::UlidPathParam, response::ErrorResponse},
17+
impl_from_error_for_route,
18+
};
19+
20+
#[derive(Debug, thiserror::Error, OperationIo)]
21+
#[aide(output_with = "Json<ErrorResponse>")]
22+
pub enum RouteError {
23+
#[error(transparent)]
24+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
25+
26+
#[error("User email ID {0} not found")]
27+
NotFound(Ulid),
28+
}
29+
30+
impl_from_error_for_route!(mas_storage::RepositoryError);
31+
32+
impl IntoResponse for RouteError {
33+
fn into_response(self) -> axum::response::Response {
34+
let error = ErrorResponse::from_error(&self);
35+
let status = match self {
36+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
37+
Self::NotFound(_) => StatusCode::NOT_FOUND,
38+
};
39+
(status, Json(error)).into_response()
40+
}
41+
}
42+
43+
pub fn doc(operation: TransformOperation) -> TransformOperation {
44+
operation
45+
.id("deleteUserEmail")
46+
.summary("Delete a user email")
47+
.tag("user-email")
48+
.response_with::<204, (), _>(|t| t.description("User email was found"))
49+
.response_with::<404, RouteError, _>(|t| {
50+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
51+
t.description("User email was not found").example(response)
52+
})
53+
}
54+
55+
#[tracing::instrument(name = "handler.admin.v1.user_emails.delete", skip_all, err)]
56+
pub async fn handler(
57+
CallContext {
58+
mut repo, clock, ..
59+
}: CallContext,
60+
NoApi(mut rng): NoApi<BoxRng>,
61+
id: UlidPathParam,
62+
) -> Result<StatusCode, RouteError> {
63+
let email = repo
64+
.user_email()
65+
.lookup(*id)
66+
.await?
67+
.ok_or(RouteError::NotFound(*id))?;
68+
69+
let job = ProvisionUserJob::new_for_id(email.user_id);
70+
repo.user_email().remove(email).await?;
71+
72+
// Schedule a job to update the user
73+
repo.queue_job().schedule_job(&mut rng, &clock, job).await?;
74+
75+
repo.save().await?;
76+
77+
Ok(StatusCode::NO_CONTENT)
78+
}
79+
80+
#[cfg(test)]
81+
mod tests {
82+
use hyper::{Request, StatusCode};
83+
use sqlx::PgPool;
84+
use ulid::Ulid;
85+
86+
use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};
87+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
88+
async fn test_delete(pool: PgPool) {
89+
setup();
90+
let mut state = TestState::from_pool(pool).await.unwrap();
91+
let token = state.token_with_scope("urn:mas:admin").await;
92+
let mut rng = state.rng();
93+
94+
// Provision a user and an email
95+
let mut repo = state.repository().await.unwrap();
96+
let alice = repo
97+
.user()
98+
.add(&mut rng, &state.clock, "alice".to_owned())
99+
.await
100+
.unwrap();
101+
let mas_data_model::UserEmail { id, .. } = repo
102+
.user_email()
103+
.add(
104+
&mut rng,
105+
&state.clock,
106+
&alice,
107+
"[email protected]".to_owned(),
108+
)
109+
.await
110+
.unwrap();
111+
112+
repo.save().await.unwrap();
113+
114+
let request = Request::delete(format!("/api/admin/v1/user-emails/{id}"))
115+
.bearer(&token)
116+
.empty();
117+
let response = state.request(request).await;
118+
response.assert_status(StatusCode::NO_CONTENT);
119+
120+
// Verify that the email was deleted
121+
let request = Request::get(format!("/api/admin/v1/user-emails/{id}"))
122+
.bearer(&token)
123+
.empty();
124+
let response = state.request(request).await;
125+
response.assert_status(StatusCode::NOT_FOUND);
126+
}
127+
128+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
129+
async fn test_not_found(pool: PgPool) {
130+
setup();
131+
let mut state = TestState::from_pool(pool).await.unwrap();
132+
let token = state.token_with_scope("urn:mas:admin").await;
133+
134+
let email_id = Ulid::nil();
135+
let request = Request::delete(format!("/api/admin/v1/user-emails/{email_id}"))
136+
.bearer(&token)
137+
.empty();
138+
let response = state.request(request).await;
139+
response.assert_status(StatusCode::NOT_FOUND);
140+
}
141+
}

crates/handlers/src/admin/v1/user_emails/get.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub enum RouteError {
2424
#[error(transparent)]
2525
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
2626

27-
#[error("OAuth 2.0 session ID {0} not found")]
27+
#[error("User email ID {0} not found")]
2828
NotFound(Ulid),
2929
}
3030

@@ -62,15 +62,13 @@ pub async fn handler(
6262
CallContext { mut repo, .. }: CallContext,
6363
id: UlidPathParam,
6464
) -> Result<Json<SingleResponse<UserEmail>>, RouteError> {
65-
let session = repo
65+
let email = repo
6666
.user_email()
6767
.lookup(*id)
6868
.await?
6969
.ok_or(RouteError::NotFound(*id))?;
7070

71-
Ok(Json(SingleResponse::new_canonical(UserEmail::from(
72-
session,
73-
))))
71+
Ok(Json(SingleResponse::new_canonical(UserEmail::from(email))))
7472
}
7573

7674
#[cfg(test)]
@@ -142,8 +140,8 @@ mod tests {
142140
let mut state = TestState::from_pool(pool).await.unwrap();
143141
let token = state.token_with_scope("urn:mas:admin").await;
144142

145-
let session_id = Ulid::nil();
146-
let request = Request::get(format!("/api/admin/v1/user-emails/{session_id}"))
143+
let email_id = Ulid::nil();
144+
let request = Request::get(format!("/api/admin/v1/user-emails/{email_id}"))
147145
.bearer(&token)
148146
.empty();
149147
let response = state.request(request).await;

crates/handlers/src/admin/v1/user_emails/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
// SPDX-License-Identifier: AGPL-3.0-only
44
// Please see LICENSE in the repository root for full details.
55

6+
mod delete;
67
mod get;
78
mod list;
89

910
pub use self::{
11+
delete::{doc as delete_doc, handler as delete},
1012
get::{doc as get_doc, handler as get},
1113
list::{doc as list_doc, handler as list},
1214
};

docs/api/spec.json

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1521,7 +1521,48 @@
15211521
"example": {
15221522
"errors": [
15231523
{
1524-
"title": "OAuth 2.0 session ID 00000000000000000000000000 not found"
1524+
"title": "User email ID 00000000000000000000000000 not found"
1525+
}
1526+
]
1527+
}
1528+
}
1529+
}
1530+
}
1531+
}
1532+
},
1533+
"delete": {
1534+
"tags": [
1535+
"user-email"
1536+
],
1537+
"summary": "Delete a user email",
1538+
"operationId": "deleteUserEmail",
1539+
"parameters": [
1540+
{
1541+
"in": "path",
1542+
"name": "id",
1543+
"required": true,
1544+
"schema": {
1545+
"title": "The ID of the resource",
1546+
"$ref": "#/components/schemas/ULID"
1547+
},
1548+
"style": "simple"
1549+
}
1550+
],
1551+
"responses": {
1552+
"204": {
1553+
"description": "User email was found"
1554+
},
1555+
"404": {
1556+
"description": "User email was not found",
1557+
"content": {
1558+
"application/json": {
1559+
"schema": {
1560+
"$ref": "#/components/schemas/ErrorResponse"
1561+
},
1562+
"example": {
1563+
"errors": [
1564+
{
1565+
"title": "User email ID 00000000000000000000000000 not found"
15251566
}
15261567
]
15271568
}

0 commit comments

Comments
 (0)