tinycongress_api/identity/http/
mod.rs1use 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#[derive(Debug, Deserialize)]
16pub struct SignupRequest {
17 pub username: String,
18 pub root_pubkey: String, }
20
21#[derive(Debug, Serialize, Deserialize)]
23pub struct SignupResponse {
24 pub account_id: Uuid,
25 pub root_kid: String,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
30pub struct ErrorResponse {
31 pub error: String,
32}
33
34pub fn router() -> Router {
36 Router::new().route("/auth/signup", post(signup))
37}
38
39async fn signup(
41 Extension(repo): Extension<Arc<dyn AccountRepo>>,
42 Json(req): Json<SignupRequest>,
43) -> impl IntoResponse {
44 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 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 let root_kid = derive_kid(&pubkey_bytes);
89
90 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 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}