LCOV - code coverage report
Current view: top level - service/src/identity/http - mod.rs (source / functions) Coverage Total Hit
Test: Rust Backend Coverage Lines: 97.2 % 252 245
Test Date: 2025-12-20 21:58:40 Functions: 95.5 % 22 21

            Line data    Source code
       1              : //! HTTP handlers for identity system
       2              : 
       3              : use std::sync::Arc;
       4              : 
       5              : use axum::{
       6              :     extract::Extension, http::StatusCode, response::IntoResponse, routing::post, Json, Router,
       7              : };
       8              : use serde::{Deserialize, Serialize};
       9              : use uuid::Uuid;
      10              : 
      11              : use super::repo::{AccountRepo, AccountRepoError};
      12              : use tc_crypto::{decode_base64url_native as decode_base64url, derive_kid};
      13              : 
      14              : /// Signup request payload
      15              : #[derive(Debug, Deserialize)]
      16              : pub struct SignupRequest {
      17              :     pub username: String,
      18              :     pub root_pubkey: String, // base64url encoded
      19              : }
      20              : 
      21              : /// Signup response
      22              : #[derive(Debug, Serialize, Deserialize)]
      23              : pub struct SignupResponse {
      24              :     pub account_id: Uuid,
      25              :     pub root_kid: String,
      26              : }
      27              : 
      28              : /// Error response
      29              : #[derive(Debug, Serialize, Deserialize)]
      30              : pub struct ErrorResponse {
      31              :     pub error: String,
      32              : }
      33              : 
      34              : /// Create identity router
      35           15 : pub fn router() -> Router {
      36           15 :     Router::new().route("/auth/signup", post(signup))
      37           15 : }
      38              : 
      39              : /// Handle signup request
      40           13 : async fn signup(
      41           13 :     Extension(repo): Extension<Arc<dyn AccountRepo>>,
      42           13 :     Json(req): Json<SignupRequest>,
      43           13 : ) -> impl IntoResponse {
      44              :     // Validate username
      45           13 :     let username = req.username.trim();
      46           13 :     if username.is_empty() {
      47            2 :         return (
      48            2 :             StatusCode::BAD_REQUEST,
      49            2 :             Json(ErrorResponse {
      50            2 :                 error: "Username cannot be empty".to_string(),
      51            2 :             }),
      52            2 :         )
      53            2 :             .into_response();
      54           11 :     }
      55              : 
      56           11 :     if username.len() > 64 {
      57            0 :         return (
      58            0 :             StatusCode::BAD_REQUEST,
      59            0 :             Json(ErrorResponse {
      60            0 :                 error: "Username too long".to_string(),
      61            0 :             }),
      62            0 :         )
      63            0 :             .into_response();
      64           11 :     }
      65              : 
      66              :     // Decode and validate public key
      67           11 :     let Ok(pubkey_bytes) = decode_base64url(&req.root_pubkey) else {
      68            2 :         return (
      69            2 :             StatusCode::BAD_REQUEST,
      70            2 :             Json(ErrorResponse {
      71            2 :                 error: "Invalid base64url encoding for root_pubkey".to_string(),
      72            2 :             }),
      73            2 :         )
      74            2 :             .into_response();
      75              :     };
      76              : 
      77            9 :     if pubkey_bytes.len() != 32 {
      78            1 :         return (
      79            1 :             StatusCode::BAD_REQUEST,
      80            1 :             Json(ErrorResponse {
      81            1 :                 error: "root_pubkey must be 32 bytes (Ed25519)".to_string(),
      82            1 :             }),
      83            1 :         )
      84            1 :             .into_response();
      85            8 :     }
      86              : 
      87              :     // Derive KID from public key
      88            8 :     let root_kid = derive_kid(&pubkey_bytes);
      89              : 
      90              :     // Create account via repository
      91            8 :     match repo.create(username, &req.root_pubkey, &root_kid).await {
      92            4 :         Ok(account) => (
      93            4 :             StatusCode::CREATED,
      94            4 :             Json(SignupResponse {
      95            4 :                 account_id: account.id,
      96            4 :                 root_kid: account.root_kid,
      97            4 :             }),
      98            4 :         )
      99            4 :             .into_response(),
     100            4 :         Err(e) => match e {
     101            2 :             AccountRepoError::DuplicateUsername => (
     102            2 :                 StatusCode::CONFLICT,
     103            2 :                 Json(ErrorResponse {
     104            2 :                     error: "Username already taken".to_string(),
     105            2 :                 }),
     106            2 :             )
     107            2 :                 .into_response(),
     108            1 :             AccountRepoError::DuplicateKey => (
     109            1 :                 StatusCode::CONFLICT,
     110            1 :                 Json(ErrorResponse {
     111            1 :                     error: "Public key already registered".to_string(),
     112            1 :                 }),
     113            1 :             )
     114            1 :                 .into_response(),
     115            1 :             AccountRepoError::Database(db_err) => {
     116            1 :                 tracing::error!("Signup failed: {}", db_err);
     117            1 :                 (
     118            1 :                     StatusCode::INTERNAL_SERVER_ERROR,
     119            1 :                     Json(ErrorResponse {
     120            1 :                         error: "Internal server error".to_string(),
     121            1 :                     }),
     122            1 :                 )
     123            1 :                     .into_response()
     124              :             }
     125              :         },
     126              :     }
     127           13 : }
     128              : 
     129              : #[cfg(test)]
     130              : mod tests {
     131              :     use super::*;
     132              :     use crate::identity::repo::mock::MockAccountRepo;
     133              :     use axum::{
     134              :         body::{to_bytes, Body},
     135              :         http::{Request, StatusCode},
     136              :     };
     137              :     use sqlx::Error as SqlxError;
     138              :     use tc_crypto::{derive_kid, encode_base64url};
     139              :     use tower::ServiceExt;
     140              : 
     141            7 :     fn test_router(repo: Arc<dyn AccountRepo>) -> Router {
     142            7 :         Router::new()
     143            7 :             .route("/auth/signup", post(signup))
     144            7 :             .layer(Extension(repo))
     145            7 :     }
     146              : 
     147            5 :     fn encoded_pubkey(byte: u8) -> (String, String) {
     148            5 :         let pubkey_bytes = [byte; 32];
     149            5 :         let encoded = encode_base64url(&pubkey_bytes);
     150            5 :         let kid = derive_kid(&pubkey_bytes);
     151            5 :         (encoded, kid)
     152            5 :     }
     153              : 
     154              :     #[tokio::test]
     155            1 :     async fn test_signup_success() {
     156            1 :         let mock_repo = Arc::new(MockAccountRepo::new());
     157            1 :         let app = test_router(mock_repo.clone());
     158              : 
     159            1 :         let (root_pubkey, expected_kid) = encoded_pubkey(1);
     160              : 
     161            1 :         let response = app
     162            1 :             .oneshot(
     163            1 :                 Request::builder()
     164            1 :                     .method("POST")
     165            1 :                     .uri("/auth/signup")
     166            1 :                     .header("content-type", "application/json")
     167            1 :                     .body(Body::from(format!(
     168            1 :                         r#"{{"username": "alice", "root_pubkey": "{root_pubkey}"}}"#
     169            1 :                     )))
     170            1 :                     .expect("request builder"),
     171            1 :             )
     172            1 :             .await
     173            1 :             .expect("response");
     174              : 
     175            1 :         let (parts, body) = response.into_parts();
     176            1 :         assert_eq!(parts.status, StatusCode::CREATED);
     177              : 
     178            1 :         let body_bytes = to_bytes(body, 1024 * 1024).await.expect("body bytes");
     179            1 :         let payload: SignupResponse = serde_json::from_slice(&body_bytes).expect("json payload");
     180              : 
     181            1 :         assert_eq!(payload.root_kid, expected_kid);
     182              : 
     183            1 :         let calls = mock_repo.calls();
     184            1 :         assert_eq!(calls.len(), 1);
     185            1 :         let (username, captured_pubkey, captured_kid) = &calls[0];
     186            1 :         assert_eq!(username, "alice");
     187            1 :         assert_eq!(captured_pubkey, &root_pubkey);
     188            1 :         assert_eq!(captured_kid, &expected_kid);
     189            1 :     }
     190              : 
     191              :     #[tokio::test]
     192            1 :     async fn test_signup_empty_username() {
     193            1 :         let mock_repo = Arc::new(MockAccountRepo::new());
     194            1 :         let app = test_router(mock_repo);
     195              : 
     196            1 :         let (root_pubkey, _) = encoded_pubkey(2);
     197              : 
     198            1 :         let response = app
     199            1 :             .oneshot(
     200            1 :                 Request::builder()
     201            1 :                     .method("POST")
     202            1 :                     .uri("/auth/signup")
     203            1 :                     .header("content-type", "application/json")
     204            1 :                     .body(Body::from(format!(
     205            1 :                         r#"{{"username": "", "root_pubkey": "{root_pubkey}"}}"#
     206            1 :                     )))
     207            1 :                     .expect("request builder"),
     208            1 :             )
     209            1 :             .await
     210            1 :             .expect("response");
     211              : 
     212            1 :         assert_eq!(response.status(), StatusCode::BAD_REQUEST);
     213            1 :     }
     214              : 
     215              :     #[tokio::test]
     216            1 :     async fn test_signup_invalid_base64_pubkey() {
     217            1 :         let mock_repo = Arc::new(MockAccountRepo::new());
     218            1 :         let app = test_router(mock_repo);
     219              : 
     220            1 :         let response = app
     221            1 :             .oneshot(
     222            1 :                 Request::builder()
     223            1 :                     .method("POST")
     224            1 :                     .uri("/auth/signup")
     225            1 :                     .header("content-type", "application/json")
     226            1 :                     .body(Body::from(
     227            1 :                         r#"{"username": "alice", "root_pubkey": "!!!not-base64!!!"}"#,
     228            1 :                     ))
     229            1 :                     .expect("request builder"),
     230            1 :             )
     231            1 :             .await
     232            1 :             .expect("response");
     233              : 
     234            1 :         let (parts, body) = response.into_parts();
     235            1 :         assert_eq!(parts.status, StatusCode::BAD_REQUEST);
     236            1 :         let body_bytes = to_bytes(body, 1024 * 1024).await.expect("body bytes");
     237            1 :         let payload: ErrorResponse = serde_json::from_slice(&body_bytes).expect("json payload");
     238            1 :         assert!(payload
     239            1 :             .error
     240            1 :             .contains("Invalid base64url encoding for root_pubkey"));
     241            1 :     }
     242              : 
     243              :     #[tokio::test]
     244            1 :     async fn test_signup_pubkey_wrong_length() {
     245            1 :         let mock_repo = Arc::new(MockAccountRepo::new());
     246            1 :         let app = test_router(mock_repo);
     247              : 
     248              :         // Valid base64 but only encodes 4 bytes.
     249            1 :         let short_pubkey = encode_base64url(&[9u8; 4]);
     250              : 
     251            1 :         let response = app
     252            1 :             .oneshot(
     253            1 :                 Request::builder()
     254            1 :                     .method("POST")
     255            1 :                     .uri("/auth/signup")
     256            1 :                     .header("content-type", "application/json")
     257            1 :                     .body(Body::from(format!(
     258            1 :                         r#"{{"username": "alice", "root_pubkey": "{short_pubkey}"}}"#
     259            1 :                     )))
     260            1 :                     .expect("request builder"),
     261            1 :             )
     262            1 :             .await
     263            1 :             .expect("response");
     264              : 
     265            1 :         let (parts, body) = response.into_parts();
     266            1 :         assert_eq!(parts.status, StatusCode::BAD_REQUEST);
     267            1 :         let body_bytes = to_bytes(body, 1024 * 1024).await.expect("body bytes");
     268            1 :         let payload: ErrorResponse = serde_json::from_slice(&body_bytes).expect("json payload");
     269            1 :         assert!(payload
     270            1 :             .error
     271            1 :             .contains("root_pubkey must be 32 bytes (Ed25519)"));
     272            1 :     }
     273              : 
     274              :     #[tokio::test]
     275            1 :     async fn test_signup_duplicate_username() {
     276            1 :         let mock_repo = Arc::new(MockAccountRepo::new());
     277            1 :         mock_repo.set_create_result(Err(AccountRepoError::DuplicateUsername));
     278            1 :         let app = test_router(mock_repo);
     279              : 
     280            1 :         let (root_pubkey, _) = encoded_pubkey(3);
     281              : 
     282            1 :         let response = app
     283            1 :             .oneshot(
     284            1 :                 Request::builder()
     285            1 :                     .method("POST")
     286            1 :                     .uri("/auth/signup")
     287            1 :                     .header("content-type", "application/json")
     288            1 :                     .body(Body::from(format!(
     289            1 :                         r#"{{"username": "alice", "root_pubkey": "{root_pubkey}"}}"#
     290            1 :                     )))
     291            1 :                     .expect("request builder"),
     292            1 :             )
     293            1 :             .await
     294            1 :             .expect("response");
     295              : 
     296            1 :         assert_eq!(response.status(), StatusCode::CONFLICT);
     297            1 :     }
     298              : 
     299              :     #[tokio::test]
     300            1 :     async fn test_signup_duplicate_key() {
     301            1 :         let mock_repo = Arc::new(MockAccountRepo::new());
     302            1 :         mock_repo.set_create_result(Err(AccountRepoError::DuplicateKey));
     303            1 :         let app = test_router(mock_repo);
     304              : 
     305            1 :         let (root_pubkey, _) = encoded_pubkey(4);
     306              : 
     307            1 :         let response = app
     308            1 :             .oneshot(
     309            1 :                 Request::builder()
     310            1 :                     .method("POST")
     311            1 :                     .uri("/auth/signup")
     312            1 :                     .header("content-type", "application/json")
     313            1 :                     .body(Body::from(format!(
     314            1 :                         r#"{{"username": "alice", "root_pubkey": "{root_pubkey}"}}"#
     315            1 :                     )))
     316            1 :                     .expect("request builder"),
     317            1 :             )
     318            1 :             .await
     319            1 :             .expect("response");
     320              : 
     321            1 :         assert_eq!(response.status(), StatusCode::CONFLICT);
     322            1 :     }
     323              : 
     324              :     #[tokio::test]
     325            1 :     async fn test_signup_database_error_returns_500() {
     326            1 :         let mock_repo = Arc::new(MockAccountRepo::new());
     327            1 :         mock_repo.set_create_result(Err(AccountRepoError::Database(SqlxError::Io(
     328            1 :             std::io::Error::new(std::io::ErrorKind::Other, "boom"),
     329            1 :         ))));
     330            1 :         let app = test_router(mock_repo);
     331              : 
     332            1 :         let (root_pubkey, _) = encoded_pubkey(5);
     333              : 
     334            1 :         let response = app
     335            1 :             .oneshot(
     336            1 :                 Request::builder()
     337            1 :                     .method("POST")
     338            1 :                     .uri("/auth/signup")
     339            1 :                     .header("content-type", "application/json")
     340            1 :                     .body(Body::from(format!(
     341            1 :                         r#"{{"username": "alice", "root_pubkey": "{root_pubkey}"}}"#
     342            1 :                     )))
     343            1 :                     .expect("request builder"),
     344            1 :             )
     345            1 :             .await
     346            1 :             .expect("response");
     347              : 
     348            1 :         let (parts, body) = response.into_parts();
     349            1 :         assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR);
     350              : 
     351            1 :         let body_bytes = to_bytes(body, 1024 * 1024).await.expect("body bytes");
     352            1 :         let payload: ErrorResponse = serde_json::from_slice(&body_bytes).expect("json payload");
     353            1 :         assert!(payload.error.contains("Internal server error"));
     354            1 :     }
     355              : }
        

Generated by: LCOV version 2.0-1