mas_handlers/admin/v1/user_registration_tokens/
update.rs1use 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
26fn 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#[derive(Deserialize, JsonSchema)]
37#[serde(rename = "EditUserRegistrationTokenRequest")]
38pub struct Request {
39 #[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 #[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 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 let mut token = repo
113 .user_registration_token()
114 .lookup(id)
115 .await?
116 .ok_or(RouteError::NotFound(id))?;
117
118 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}