Skip to main content

allowthem_saas/manage/
applications.rs

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// ---------------------------------------------------------------------------
33// List
34// ---------------------------------------------------------------------------
35
36#[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    // fetch one extra to determine if there is a next page
68    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// ---------------------------------------------------------------------------
89// Create
90// ---------------------------------------------------------------------------
91
92#[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
149// ---------------------------------------------------------------------------
150// Get
151// ---------------------------------------------------------------------------
152
153pub 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// ---------------------------------------------------------------------------
167// Update
168// ---------------------------------------------------------------------------
169
170#[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
219// ---------------------------------------------------------------------------
220// Delete
221// ---------------------------------------------------------------------------
222
223pub 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
235// ---------------------------------------------------------------------------
236// Router
237// ---------------------------------------------------------------------------
238
239pub 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// ---------------------------------------------------------------------------
251// Tests
252// ---------------------------------------------------------------------------
253
254#[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    // -----------------------------------------------------------------------
337    // Core pagination tests (direct)
338    // -----------------------------------------------------------------------
339
340    #[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        // Verify ascending order
356        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    // -----------------------------------------------------------------------
391    // HTTP handler tests
392    // -----------------------------------------------------------------------
393
394    #[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}