tinycongress_api/identity/http/
mod.rs

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