mas_handlers/admin/v1/upstream_oauth_links/
delete.rs

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
6use aide::{OperationIo, transform::TransformOperation};
7use axum::{Json, response::IntoResponse};
8use hyper::StatusCode;
9use mas_axum_utils::record_error;
10use ulid::Ulid;
11
12use crate::{
13    admin::{call_context::CallContext, params::UlidPathParam, response::ErrorResponse},
14    impl_from_error_for_route,
15};
16
17#[derive(Debug, thiserror::Error, OperationIo)]
18#[aide(output_with = "Json<ErrorResponse>")]
19pub enum RouteError {
20    #[error(transparent)]
21    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
22
23    #[error("Upstream OAuth 2.0 Link ID {0} not found")]
24    NotFound(Ulid),
25}
26
27impl_from_error_for_route!(mas_storage::RepositoryError);
28
29impl IntoResponse for RouteError {
30    fn into_response(self) -> axum::response::Response {
31        let error = ErrorResponse::from_error(&self);
32        let sentry_event_id = record_error!(self, Self::Internal(_));
33        let status = match self {
34            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
35            Self::NotFound(_) => StatusCode::NOT_FOUND,
36        };
37        (status, sentry_event_id, Json(error)).into_response()
38    }
39}
40
41pub fn doc(operation: TransformOperation) -> TransformOperation {
42    operation
43        .id("deleteUpstreamOAuthLink")
44        .summary("Delete an upstream OAuth 2.0 link")
45        .tag("upstream-oauth-link")
46        .response_with::<204, (), _>(|t| t.description("Upstream OAuth 2.0 link was deleted"))
47        .response_with::<404, RouteError, _>(|t| {
48            let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
49            t.description("Upstream OAuth 2.0 link was not found")
50                .example(response)
51        })
52}
53
54#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.delete", skip_all)]
55pub async fn handler(
56    CallContext {
57        mut repo, clock, ..
58    }: CallContext,
59    id: UlidPathParam,
60) -> Result<StatusCode, RouteError> {
61    let link = repo
62        .upstream_oauth_link()
63        .lookup(*id)
64        .await?
65        .ok_or(RouteError::NotFound(*id))?;
66
67    repo.upstream_oauth_link().remove(&clock, link).await?;
68
69    repo.save().await?;
70
71    Ok(StatusCode::NO_CONTENT)
72}
73
74#[cfg(test)]
75mod tests {
76    use hyper::{Request, StatusCode};
77    use mas_data_model::UpstreamOAuthAuthorizationSessionState;
78    use sqlx::PgPool;
79    use ulid::Ulid;
80
81    use super::super::test_utils;
82    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
83
84    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
85    async fn test_delete(pool: PgPool) {
86        setup();
87        let mut state = TestState::from_pool(pool).await.unwrap();
88        let token = state.token_with_scope("urn:mas:admin").await;
89        let mut rng = state.rng();
90        let mut repo = state.repository().await.unwrap();
91
92        let alice = repo
93            .user()
94            .add(&mut rng, &state.clock, "alice".to_owned())
95            .await
96            .unwrap();
97
98        let provider = repo
99            .upstream_oauth_provider()
100            .add(
101                &mut rng,
102                &state.clock,
103                test_utils::oidc_provider_params("provider1"),
104            )
105            .await
106            .unwrap();
107
108        // Pretend it was linked by an authorization session
109        let session = repo
110            .upstream_oauth_session()
111            .add(
112                &mut rng,
113                &state.clock,
114                &provider,
115                String::new(),
116                None,
117                String::new(),
118            )
119            .await
120            .unwrap();
121
122        let link = repo
123            .upstream_oauth_link()
124            .add(
125                &mut rng,
126                &state.clock,
127                &provider,
128                String::from("subject1"),
129                None,
130            )
131            .await
132            .unwrap();
133
134        let session = repo
135            .upstream_oauth_session()
136            .complete_with_link(&state.clock, session, &link, None, None, None)
137            .await
138            .unwrap();
139
140        repo.upstream_oauth_link()
141            .associate_to_user(&link, &alice)
142            .await
143            .unwrap();
144
145        repo.save().await.unwrap();
146
147        let request = Request::delete(format!("/api/admin/v1/upstream-oauth-links/{}", link.id))
148            .bearer(&token)
149            .empty();
150        let response = state.request(request).await;
151        response.assert_status(StatusCode::NO_CONTENT);
152
153        // Verify that the link was deleted
154        let request = Request::get(format!("/api/admin/v1/upstream-oauth-links/{}", link.id))
155            .bearer(&token)
156            .empty();
157        let response = state.request(request).await;
158        response.assert_status(StatusCode::NOT_FOUND);
159
160        // Verify that the session was marked as unlinked
161        let mut repo = state.repository().await.unwrap();
162        let session = repo
163            .upstream_oauth_session()
164            .lookup(session.id)
165            .await
166            .unwrap()
167            .unwrap();
168        assert!(matches!(
169            session.state,
170            UpstreamOAuthAuthorizationSessionState::Unlinked { .. }
171        ));
172    }
173
174    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
175    async fn test_not_found(pool: PgPool) {
176        setup();
177        let mut state = TestState::from_pool(pool).await.unwrap();
178        let token = state.token_with_scope("urn:mas:admin").await;
179
180        let link_id = Ulid::nil();
181        let request = Request::delete(format!("/api/admin/v1/upstream-oauth-links/{link_id}"))
182            .bearer(&token)
183            .empty();
184        let response = state.request(request).await;
185        response.assert_status(StatusCode::NOT_FOUND);
186    }
187}