mas_handlers/admin/v1/user_registration_tokens/
update.rs

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