1use axum::Router;
2use axum::extract::{Extension, Json, Path, Query};
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use axum::routing::get;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use allowthem_core::AllowThem;
10use allowthem_core::applications::{
11 Application, ApplicationCursor, CreateApplicationParams, UpdateApplication,
12};
13use allowthem_core::error::AuthError;
14use allowthem_core::types::{ApplicationId, ClientType};
15
16use super::{ManageError, ManageState};
17
18fn not_found_or_internal(e: AuthError) -> ManageError {
19 if matches!(e, AuthError::NotFound) {
20 ManageError::NotFound
21 } else {
22 ManageError::Internal(e.to_string())
23 }
24}
25
26fn parse_application_id(s: &str) -> Result<ApplicationId, ManageError> {
27 s.parse::<Uuid>()
28 .map(ApplicationId::from_uuid)
29 .map_err(|_| ManageError::NotFound)
30}
31
32#[derive(Deserialize)]
37pub struct ListQuery {
38 #[serde(default = "default_limit")]
39 limit: u32,
40 cursor: Option<String>,
41}
42
43fn default_limit() -> u32 {
44 20
45}
46
47#[derive(Serialize)]
48pub struct ApplicationPage {
49 pub items: Vec<Application>,
50 pub next_cursor: Option<String>,
51}
52
53pub async fn list_applications(
54 Extension(ath): Extension<AllowThem>,
55 Query(q): Query<ListQuery>,
56) -> Result<Json<ApplicationPage>, ManageError> {
57 let cursor = match q.cursor {
58 None => None,
59 Some(ref s) => {
60 let cur = ApplicationCursor::decode(s)
61 .ok_or_else(|| ManageError::Internal("invalid cursor".into()))?;
62 Some(cur)
63 }
64 };
65
66 let limit = q.limit.clamp(1, 200);
67 let mut rows = ath
69 .db()
70 .list_applications_paginated(limit + 1, cursor.as_ref())
71 .await
72 .map_err(|e| ManageError::Internal(e.to_string()))?;
73
74 let next_cursor = if rows.len() as u32 > limit {
75 rows.pop();
76 rows.last()
77 .map(|last| ApplicationCursor::from_app(last).encode())
78 } else {
79 None
80 };
81
82 Ok(Json(ApplicationPage {
83 items: rows,
84 next_cursor,
85 }))
86}
87
88#[derive(Deserialize)]
93pub struct CreateAppBody {
94 pub name: String,
95 pub client_type: ClientType,
96 pub redirect_uris: Vec<String>,
97 #[serde(default)]
98 pub is_trusted: bool,
99 pub logo_url: Option<String>,
100 pub primary_color: Option<String>,
101}
102
103#[derive(Serialize)]
104pub struct CreateAppResponse {
105 pub application: Application,
106 pub client_secret: Option<String>,
107}
108
109pub async fn create_application(
110 Extension(ath): Extension<AllowThem>,
111 Json(body): Json<CreateAppBody>,
112) -> Result<Response, ManageError> {
113 let (app, secret) = ath
114 .db()
115 .create_application(CreateApplicationParams {
116 name: body.name,
117 client_type: body.client_type,
118 redirect_uris: body.redirect_uris,
119 is_trusted: body.is_trusted,
120 created_by: None,
121 logo_url: body.logo_url,
122 primary_color: body.primary_color,
123 accent_hex: None,
124 accent_ink: None,
125 forced_mode: None,
126 font_css_url: None,
127 font_family: None,
128 splash_text: None,
129 splash_image_url: None,
130 splash_primitive: None,
131 splash_url: None,
132 shader_cell_scale: None,
133 })
134 .await
135 .map_err(|e| match e {
136 AuthError::InvalidRedirectUri(_) | AuthError::Validation(_) => {
137 ManageError::Internal(e.to_string())
138 }
139 _ => ManageError::Internal(e.to_string()),
140 })?;
141
142 let body = CreateAppResponse {
143 application: app,
144 client_secret: secret.map(|s| s.as_str().to_owned()),
145 };
146 Ok((StatusCode::CREATED, Json(body)).into_response())
147}
148
149pub async fn get_application(
154 Extension(ath): Extension<AllowThem>,
155 Path(id): Path<String>,
156) -> Result<Json<Application>, ManageError> {
157 let app_id = parse_application_id(&id)?;
158 let app = ath
159 .db()
160 .get_application(app_id)
161 .await
162 .map_err(not_found_or_internal)?;
163 Ok(Json(app))
164}
165
166#[derive(Deserialize)]
171pub struct UpdateAppBody {
172 pub name: String,
173 pub redirect_uris: Vec<String>,
174 #[serde(default)]
175 pub is_trusted: bool,
176 #[serde(default = "default_true")]
177 pub is_active: bool,
178 pub logo_url: Option<String>,
179 pub primary_color: Option<String>,
180}
181
182fn default_true() -> bool {
183 true
184}
185
186pub async fn update_application(
187 Extension(ath): Extension<AllowThem>,
188 Path(id): Path<String>,
189 Json(body): Json<UpdateAppBody>,
190) -> Result<StatusCode, ManageError> {
191 let app_id = parse_application_id(&id)?;
192 ath.db()
193 .update_application(
194 app_id,
195 UpdateApplication {
196 name: body.name,
197 redirect_uris: body.redirect_uris,
198 is_trusted: body.is_trusted,
199 is_active: body.is_active,
200 logo_url: body.logo_url,
201 primary_color: body.primary_color,
202 accent_hex: None,
203 accent_ink: None,
204 forced_mode: None,
205 font_css_url: None,
206 font_family: None,
207 splash_text: None,
208 splash_image_url: None,
209 splash_primitive: None,
210 splash_url: None,
211 shader_cell_scale: None,
212 },
213 )
214 .await
215 .map_err(not_found_or_internal)?;
216 Ok(StatusCode::NO_CONTENT)
217}
218
219pub async fn delete_application(
224 Extension(ath): Extension<AllowThem>,
225 Path(id): Path<String>,
226) -> Result<StatusCode, ManageError> {
227 let app_id = parse_application_id(&id)?;
228 ath.db()
229 .delete_application(app_id)
230 .await
231 .map_err(not_found_or_internal)?;
232 Ok(StatusCode::NO_CONTENT)
233}
234
235pub fn application_routes() -> Router<ManageState> {
240 Router::new()
241 .route("/", get(list_applications).post(create_application))
242 .route(
243 "/{id}",
244 get(get_application)
245 .put(update_application)
246 .delete(delete_application),
247 )
248}
249
250#[cfg(test)]
255mod tests {
256 use std::sync::Arc;
257
258 use super::*;
259 use axum::body::{Body, to_bytes};
260 use axum::http::Request;
261 use tower::ServiceExt;
262
263 use allowthem_core::AllowThemBuilder;
264
265 async fn make_handle() -> AllowThem {
266 AllowThemBuilder::new("sqlite::memory:")
267 .cookie_secure(false)
268 .build()
269 .await
270 .unwrap()
271 }
272
273 async fn make_dummy_state() -> ManageState {
274 use std::str::FromStr;
275 let opts = sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:")
276 .unwrap()
277 .pragma("foreign_keys", "ON");
278 let pool = sqlx::SqlitePool::connect_with(opts).await.unwrap();
279 let ctrl = Arc::new(crate::control_db::ControlDb::new(pool).await.unwrap());
280 ManageState::new(
281 ctrl,
282 crate::cache::HandleCache::new(1),
283 std::path::PathBuf::from("/tmp"),
284 Arc::new(crate::tenants::TenantBuilderConfig {
285 mfa_key: [0u8; 32],
286 signing_key: [0u8; 32],
287 csrf_key: [0u8; 32],
288 base_domain: "test.example.com".into(),
289 is_production: false,
290 email_sender: None,
291 event_sink: None,
292 event_sink_factory: None,
293 mau_sink: None,
294 email_sender_factory: None,
295 }),
296 1000,
297 Arc::new(crate::dns::MockDnsResolver::new()),
298 )
299 }
300
301 async fn app_router(handle: AllowThem) -> Router {
302 let state = make_dummy_state().await;
303 Router::<ManageState>::new()
304 .merge(application_routes())
305 .layer(axum::Extension(handle))
306 .with_state(state)
307 }
308
309 async fn make_app(handle: &AllowThem, name: &str) -> Application {
310 let (app, _) = handle
311 .db()
312 .create_application(CreateApplicationParams {
313 name: name.to_owned(),
314 client_type: ClientType::Public,
315 redirect_uris: vec!["https://example.com/callback".to_owned()],
316 is_trusted: false,
317 created_by: None,
318 logo_url: None,
319 primary_color: None,
320 accent_hex: None,
321 accent_ink: None,
322 forced_mode: None,
323 font_css_url: None,
324 font_family: None,
325 splash_text: None,
326 splash_image_url: None,
327 splash_primitive: None,
328 splash_url: None,
329 shader_cell_scale: None,
330 })
331 .await
332 .unwrap();
333 app
334 }
335
336 #[tokio::test]
341 async fn list_empty_returns_empty_vec() {
342 let h = make_handle().await;
343 let result = h.db().list_applications_paginated(20, None).await.unwrap();
344 assert!(result.is_empty());
345 }
346
347 #[tokio::test]
348 async fn list_returns_items_in_created_at_order() {
349 let h = make_handle().await;
350 make_app(&h, "alpha").await;
351 make_app(&h, "beta").await;
352 make_app(&h, "gamma").await;
353 let result = h.db().list_applications_paginated(20, None).await.unwrap();
354 assert_eq!(result.len(), 3);
355 let names: Vec<_> = result.iter().map(|a| a.name.as_str()).collect();
357 assert_eq!(names, vec!["alpha", "beta", "gamma"]);
358 }
359
360 #[tokio::test]
361 async fn list_cursor_paginates_correctly() {
362 let h = make_handle().await;
363 make_app(&h, "a1").await;
364 make_app(&h, "a2").await;
365 make_app(&h, "a3").await;
366
367 let page1 = h.db().list_applications_paginated(2, None).await.unwrap();
368 assert_eq!(page1.len(), 2);
369 let cursor = ApplicationCursor::from_app(page1.last().unwrap());
370
371 let page2 = h
372 .db()
373 .list_applications_paginated(2, Some(&cursor))
374 .await
375 .unwrap();
376 assert_eq!(page2.len(), 1);
377 assert_eq!(page2[0].name, "a3");
378 }
379
380 #[tokio::test]
381 async fn cursor_encodes_and_decodes() {
382 let h = make_handle().await;
383 let app = make_app(&h, "encode-test").await;
384 let cursor = ApplicationCursor::from_app(&app);
385 let encoded = cursor.encode();
386 let decoded = ApplicationCursor::decode(&encoded).unwrap();
387 assert_eq!(decoded.id, app.id);
388 }
389
390 #[tokio::test]
395 async fn http_list_empty_returns_200() {
396 let h = make_handle().await;
397 let app = app_router(h).await;
398 let req = Request::get("/").body(Body::empty()).unwrap();
399 let resp = app.oneshot(req).await.unwrap();
400 assert_eq!(resp.status(), StatusCode::OK);
401 let body = to_bytes(resp.into_body(), 4096).await.unwrap();
402 let page: serde_json::Value = serde_json::from_slice(&body).unwrap();
403 assert_eq!(page["items"].as_array().unwrap().len(), 0);
404 }
405
406 #[tokio::test]
407 async fn http_create_returns_201() {
408 let h = make_handle().await;
409 let app = app_router(h).await;
410 let body = serde_json::json!({
411 "name": "My App",
412 "client_type": "public",
413 "redirect_uris": ["https://example.com/cb"],
414 });
415 let req = Request::post("/")
416 .header("Content-Type", "application/json")
417 .body(Body::from(body.to_string()))
418 .unwrap();
419 let resp = app.oneshot(req).await.unwrap();
420 assert_eq!(resp.status(), StatusCode::CREATED);
421 }
422
423 #[tokio::test]
424 async fn http_get_missing_returns_404() {
425 let h = make_handle().await;
426 let app = app_router(h).await;
427 let missing_id = Uuid::new_v4();
428 let req = Request::get(format!("/{missing_id}"))
429 .body(Body::empty())
430 .unwrap();
431 let resp = app.oneshot(req).await.unwrap();
432 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
433 }
434
435 #[tokio::test]
436 async fn http_delete_returns_204() {
437 let h = make_handle().await;
438 let created = make_app(&h, "to-delete").await;
439 let app = app_router(h).await;
440 let req = Request::delete(format!("/{}", created.id))
441 .body(Body::empty())
442 .unwrap();
443 let resp = app.oneshot(req).await.unwrap();
444 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
445 }
446
447 #[tokio::test]
448 async fn http_update_returns_204() {
449 let h = make_handle().await;
450 let created = make_app(&h, "to-update").await;
451 let app = app_router(h).await;
452 let body = serde_json::json!({
453 "name": "Updated Name",
454 "redirect_uris": ["https://example.com/updated"],
455 "is_trusted": false,
456 "is_active": true,
457 });
458 let req = Request::put(format!("/{}", created.id))
459 .header("Content-Type", "application/json")
460 .body(Body::from(body.to_string()))
461 .unwrap();
462 let resp = app.oneshot(req).await.unwrap();
463 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
464 }
465}