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