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(&mut rng, &state.clock, &provider, String::new(), None, None)
112            .await
113            .unwrap();
114
115        let link = repo
116            .upstream_oauth_link()
117            .add(
118                &mut rng,
119                &state.clock,
120                &provider,
121                String::from("subject1"),
122                None,
123            )
124            .await
125            .unwrap();
126
127        let session = repo
128            .upstream_oauth_session()
129            .complete_with_link(&state.clock, session, &link, None, None, None)
130            .await
131            .unwrap();
132
133        repo.upstream_oauth_link()
134            .associate_to_user(&link, &alice)
135            .await
136            .unwrap();
137
138        repo.save().await.unwrap();
139
140        let request = Request::delete(format!("/api/admin/v1/upstream-oauth-links/{}", link.id))
141            .bearer(&token)
142            .empty();
143        let response = state.request(request).await;
144        response.assert_status(StatusCode::NO_CONTENT);
145
146        // Verify that the link was deleted
147        let request = Request::get(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::NOT_FOUND);
152
153        // Verify that the session was marked as unlinked
154        let mut repo = state.repository().await.unwrap();
155        let session = repo
156            .upstream_oauth_session()
157            .lookup(session.id)
158            .await
159            .unwrap()
160            .unwrap();
161        assert!(matches!(
162            session.state,
163            UpstreamOAuthAuthorizationSessionState::Unlinked { .. }
164        ));
165    }
166
167    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
168    async fn test_not_found(pool: PgPool) {
169        setup();
170        let mut state = TestState::from_pool(pool).await.unwrap();
171        let token = state.token_with_scope("urn:mas:admin").await;
172
173        let link_id = Ulid::nil();
174        let request = Request::delete(format!("/api/admin/v1/upstream-oauth-links/{link_id}"))
175            .bearer(&token)
176            .empty();
177        let response = state.request(request).await;
178        response.assert_status(StatusCode::NOT_FOUND);
179    }
180}