mas_handlers/admin/v1/user_registration_tokens/
update.rs

1// Copyright 2025 New Vector Ltd.
2// Copyright 2025 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7use aide::{OperationIo, transform::TransformOperation};
8use axum::{Json, response::IntoResponse};
9use chrono::{DateTime, Utc};
10use hyper::StatusCode;
11use mas_axum_utils::record_error;
12use schemars::JsonSchema;
13use serde::{Deserialize, Deserializer};
14use ulid::Ulid;
15
16use crate::{
17    admin::{
18        call_context::CallContext,
19        model::{Resource, UserRegistrationToken},
20        params::UlidPathParam,
21        response::{ErrorResponse, SingleResponse},
22    },
23    impl_from_error_for_route,
24};
25
26// Any value that is present is considered Some value, including null.
27fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
28where
29    T: Deserialize<'de>,
30    D: Deserializer<'de>,
31{
32    Deserialize::deserialize(deserializer).map(Some)
33}
34
35/// # JSON payload for the `PUT /api/admin/v1/user-registration-tokens/{id}` endpoint
36#[derive(Deserialize, JsonSchema)]
37#[serde(rename = "EditUserRegistrationTokenRequest")]
38pub struct Request {
39    /// New expiration date for the token, or null to remove expiration
40    #[serde(
41        skip_serializing_if = "Option::is_none",
42        default,
43        deserialize_with = "deserialize_some"
44    )]
45    #[expect(clippy::option_option)]
46    expires_at: Option<Option<DateTime<Utc>>>,
47
48    /// New usage limit for the token, or null to remove the limit
49    #[expect(clippy::option_option)]
50    #[serde(
51        skip_serializing_if = "Option::is_none",
52        default,
53        deserialize_with = "deserialize_some"
54    )]
55    usage_limit: Option<Option<u32>>,
56}
57
58#[derive(Debug, thiserror::Error, OperationIo)]
59#[aide(output_with = "Json<ErrorResponse>")]
60pub enum RouteError {
61    #[error(transparent)]
62    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
63
64    #[error("Registration token with ID {0} not found")]
65    NotFound(Ulid),
66}
67
68impl_from_error_for_route!(mas_storage::RepositoryError);
69
70impl IntoResponse for RouteError {
71    fn into_response(self) -> axum::response::Response {
72        let error = ErrorResponse::from_error(&self);
73        let sentry_event_id = record_error!(self, Self::Internal(_));
74        let status = match self {
75            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
76            Self::NotFound(_) => StatusCode::NOT_FOUND,
77        };
78        (status, sentry_event_id, Json(error)).into_response()
79    }
80}
81
82pub fn doc(operation: TransformOperation) -> TransformOperation {
83    operation
84        .id("updateUserRegistrationToken")
85        .summary("Update a user registration token")
86        .description("Update properties of a user registration token such as expiration and usage limit. To set a field to null (removing the limit/expiration), include the field with a null value. To leave a field unchanged, omit it from the request body.")
87        .tag("user-registration-token")
88        .response_with::<200, Json<SingleResponse<UserRegistrationToken>>, _>(|t| {
89            // Get the valid token sample
90            let [valid_token, _] = UserRegistrationToken::samples();
91            let id = valid_token.id();
92            let response = SingleResponse::new(valid_token, format!("/api/admin/v1/user-registration-tokens/{id}"));
93            t.description("Registration token was updated").example(response)
94        })
95        .response_with::<404, RouteError, _>(|t| {
96            let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
97            t.description("Registration token was not found").example(response)
98        })
99}
100
101#[tracing::instrument(name = "handler.admin.v1.user_registration_tokens.update", skip_all)]
102pub async fn handler(
103    CallContext {
104        mut repo, clock, ..
105    }: CallContext,
106    id: UlidPathParam,
107    Json(request): Json<Request>,
108) -> Result<Json<SingleResponse<UserRegistrationToken>>, RouteError> {
109    let id = *id;
110
111    // Get the token
112    let mut token = repo
113        .user_registration_token()
114        .lookup(id)
115        .await?
116        .ok_or(RouteError::NotFound(id))?;
117
118    // Update expiration if present in the request
119    if let Some(expires_at) = request.expires_at {
120        token = repo
121            .user_registration_token()
122            .set_expiry(token, expires_at)
123            .await?;
124    }
125
126    // Update usage limit if present in the request
127    if let Some(usage_limit) = request.usage_limit {
128        token = repo
129            .user_registration_token()
130            .set_usage_limit(token, usage_limit)
131            .await?;
132    }
133
134    repo.save().await?;
135
136    Ok(Json(SingleResponse::new(
137        UserRegistrationToken::new(token, clock.now()),
138        format!("/api/admin/v1/user-registration-tokens/{id}"),
139    )))
140}
141
142#[cfg(test)]
143mod tests {
144    use chrono::Duration;
145    use hyper::{Request, StatusCode};
146    use mas_storage::Clock as _;
147    use serde_json::json;
148    use sqlx::PgPool;
149
150    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
151
152    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
153    async fn test_update_expiry(pool: PgPool) {
154        setup();
155        let mut state = TestState::from_pool(pool).await.unwrap();
156        let token = state.token_with_scope("urn:mas:admin").await;
157
158        let mut repo = state.repository().await.unwrap();
159
160        // Create a token without expiry
161        let registration_token = repo
162            .user_registration_token()
163            .add(
164                &mut state.rng(),
165                &state.clock,
166                "test_update_expiry".to_owned(),
167                None,
168                None,
169            )
170            .await
171            .unwrap();
172
173        repo.save().await.unwrap();
174
175        // Update with an expiry date
176        let future_date = state.clock.now() + Duration::days(30);
177        let request = Request::put(format!(
178            "/api/admin/v1/user-registration-tokens/{}",
179            registration_token.id
180        ))
181        .bearer(&token)
182        .json(json!({
183            "expires_at": future_date
184        }));
185
186        let response = state.request(request).await;
187        response.assert_status(StatusCode::OK);
188        let body: serde_json::Value = response.json();
189
190        // Verify expiry was updated
191        insta::assert_json_snapshot!(body, @r#"
192        {
193          "data": {
194            "type": "user-registration_token",
195            "id": "01FSHN9AG0MZAA6S4AF7CTV32E",
196            "attributes": {
197              "token": "test_update_expiry",
198              "valid": true,
199              "usage_limit": null,
200              "times_used": 0,
201              "created_at": "2022-01-16T14:40:00Z",
202              "last_used_at": null,
203              "expires_at": "2022-02-15T14:40:00Z",
204              "revoked_at": null
205            },
206            "links": {
207              "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
208            }
209          },
210          "links": {
211            "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
212          }
213        }
214        "#);
215
216        // Now remove the expiry
217        let request = Request::put(format!(
218            "/api/admin/v1/user-registration-tokens/{}",
219            registration_token.id
220        ))
221        .bearer(&token)
222        .json(json!({
223            "expires_at": null
224        }));
225
226        let response = state.request(request).await;
227        response.assert_status(StatusCode::OK);
228        let body: serde_json::Value = response.json();
229
230        // Verify expiry was removed
231        insta::assert_json_snapshot!(body, @r#"
232        {
233          "data": {
234            "type": "user-registration_token",
235            "id": "01FSHN9AG0MZAA6S4AF7CTV32E",
236            "attributes": {
237              "token": "test_update_expiry",
238              "valid": true,
239              "usage_limit": null,
240              "times_used": 0,
241              "created_at": "2022-01-16T14:40:00Z",
242              "last_used_at": null,
243              "expires_at": null,
244              "revoked_at": null
245            },
246            "links": {
247              "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
248            }
249          },
250          "links": {
251            "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
252          }
253        }
254        "#);
255    }
256
257    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
258    async fn test_update_usage_limit(pool: PgPool) {
259        setup();
260        let mut state = TestState::from_pool(pool).await.unwrap();
261        let token = state.token_with_scope("urn:mas:admin").await;
262
263        let mut repo = state.repository().await.unwrap();
264
265        // Create a token with usage limit
266        let registration_token = repo
267            .user_registration_token()
268            .add(
269                &mut state.rng(),
270                &state.clock,
271                "test_update_limit".to_owned(),
272                Some(5),
273                None,
274            )
275            .await
276            .unwrap();
277
278        repo.save().await.unwrap();
279
280        // Update the usage limit
281        let request = Request::put(format!(
282            "/api/admin/v1/user-registration-tokens/{}",
283            registration_token.id
284        ))
285        .bearer(&token)
286        .json(json!({
287            "usage_limit": 10
288        }));
289
290        let response = state.request(request).await;
291        response.assert_status(StatusCode::OK);
292        let body: serde_json::Value = response.json();
293
294        // Verify usage limit was updated
295        insta::assert_json_snapshot!(body, @r#"
296        {
297          "data": {
298            "type": "user-registration_token",
299            "id": "01FSHN9AG0MZAA6S4AF7CTV32E",
300            "attributes": {
301              "token": "test_update_limit",
302              "valid": true,
303              "usage_limit": 10,
304              "times_used": 0,
305              "created_at": "2022-01-16T14:40:00Z",
306              "last_used_at": null,
307              "expires_at": null,
308              "revoked_at": null
309            },
310            "links": {
311              "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
312            }
313          },
314          "links": {
315            "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
316          }
317        }
318        "#);
319
320        // Now remove the usage limit
321        let request = Request::put(format!(
322            "/api/admin/v1/user-registration-tokens/{}",
323            registration_token.id
324        ))
325        .bearer(&token)
326        .json(json!({
327            "usage_limit": null
328        }));
329
330        let response = state.request(request).await;
331        response.assert_status(StatusCode::OK);
332        let body: serde_json::Value = response.json();
333
334        // Verify usage limit was removed
335        insta::assert_json_snapshot!(body, @r#"
336        {
337          "data": {
338            "type": "user-registration_token",
339            "id": "01FSHN9AG0MZAA6S4AF7CTV32E",
340            "attributes": {
341              "token": "test_update_limit",
342              "valid": true,
343              "usage_limit": null,
344              "times_used": 0,
345              "created_at": "2022-01-16T14:40:00Z",
346              "last_used_at": null,
347              "expires_at": null,
348              "revoked_at": null
349            },
350            "links": {
351              "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
352            }
353          },
354          "links": {
355            "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
356          }
357        }
358        "#);
359    }
360
361    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
362    async fn test_update_multiple_fields(pool: PgPool) {
363        setup();
364        let mut state = TestState::from_pool(pool).await.unwrap();
365        let token = state.token_with_scope("urn:mas:admin").await;
366
367        let mut repo = state.repository().await.unwrap();
368
369        // Create a token
370        let registration_token = repo
371            .user_registration_token()
372            .add(
373                &mut state.rng(),
374                &state.clock,
375                "test_update_multiple".to_owned(),
376                None,
377                None,
378            )
379            .await
380            .unwrap();
381
382        repo.save().await.unwrap();
383
384        // Update both fields
385        let future_date = state.clock.now() + Duration::days(30);
386        let request = Request::put(format!(
387            "/api/admin/v1/user-registration-tokens/{}",
388            registration_token.id
389        ))
390        .bearer(&token)
391        .json(json!({
392            "expires_at": future_date,
393            "usage_limit": 20
394        }));
395
396        let response = state.request(request).await;
397        response.assert_status(StatusCode::OK);
398        let body: serde_json::Value = response.json();
399
400        // Both fields were updated
401        insta::assert_json_snapshot!(body, @r#"
402        {
403          "data": {
404            "type": "user-registration_token",
405            "id": "01FSHN9AG0MZAA6S4AF7CTV32E",
406            "attributes": {
407              "token": "test_update_multiple",
408              "valid": true,
409              "usage_limit": 20,
410              "times_used": 0,
411              "created_at": "2022-01-16T14:40:00Z",
412              "last_used_at": null,
413              "expires_at": "2022-02-15T14:40:00Z",
414              "revoked_at": null
415            },
416            "links": {
417              "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
418            }
419          },
420          "links": {
421            "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
422          }
423        }
424        "#);
425    }
426
427    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
428    async fn test_update_no_fields(pool: PgPool) {
429        setup();
430        let mut state = TestState::from_pool(pool).await.unwrap();
431        let token = state.token_with_scope("urn:mas:admin").await;
432
433        let mut repo = state.repository().await.unwrap();
434
435        // Create a token
436        let registration_token = repo
437            .user_registration_token()
438            .add(
439                &mut state.rng(),
440                &state.clock,
441                "test_update_none".to_owned(),
442                Some(5),
443                Some(state.clock.now() + Duration::days(30)),
444            )
445            .await
446            .unwrap();
447
448        repo.save().await.unwrap();
449
450        // Send empty update
451        let request = Request::put(format!(
452            "/api/admin/v1/user-registration-tokens/{}",
453            registration_token.id
454        ))
455        .bearer(&token)
456        .json(json!({}));
457
458        let response = state.request(request).await;
459        response.assert_status(StatusCode::OK);
460        let body: serde_json::Value = response.json();
461
462        // It shouldn't have updated the token
463        insta::assert_json_snapshot!(body, @r#"
464        {
465          "data": {
466            "type": "user-registration_token",
467            "id": "01FSHN9AG0MZAA6S4AF7CTV32E",
468            "attributes": {
469              "token": "test_update_none",
470              "valid": true,
471              "usage_limit": 5,
472              "times_used": 0,
473              "created_at": "2022-01-16T14:40:00Z",
474              "last_used_at": null,
475              "expires_at": "2022-02-15T14:40:00Z",
476              "revoked_at": null
477            },
478            "links": {
479              "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
480            }
481          },
482          "links": {
483            "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
484          }
485        }
486        "#);
487    }
488
489    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
490    async fn test_update_unknown_token(pool: PgPool) {
491        setup();
492        let mut state = TestState::from_pool(pool).await.unwrap();
493        let token = state.token_with_scope("urn:mas:admin").await;
494
495        // Try to update a non-existent token
496        let request =
497            Request::put("/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081")
498                .bearer(&token)
499                .json(json!({
500                    "usage_limit": 5
501                }));
502
503        let response = state.request(request).await;
504        response.assert_status(StatusCode::NOT_FOUND);
505        let body: serde_json::Value = response.json();
506
507        assert_eq!(
508            body["errors"][0]["title"],
509            "Registration token with ID 01040G2081040G2081040G2081 not found"
510        );
511    }
512}