ironflow_api/routes/users/
create.rs1use axum::Json;
4use axum::extract::State;
5use axum::http::StatusCode;
6use axum::response::IntoResponse;
7use validator::Validate;
8
9use ironflow_auth::extractor::Authenticated;
10use ironflow_auth::password;
11use ironflow_store::entities::NewUser;
12use ironflow_store::error::StoreError;
13
14use crate::entities::{CreateUserRequest, UserResponse};
15use crate::error::ApiError;
16use crate::response::ok;
17use crate::state::AppState;
18
19#[cfg_attr(
27 feature = "openapi",
28 utoipa::path(
29 post,
30 path = "/api/v1/users",
31 tags = ["users"],
32 request_body(content = CreateUserRequest, description = "User account details"),
33 responses(
34 (status = 201, description = "User created successfully", body = UserResponse),
35 (status = 400, description = "Invalid input"),
36 (status = 401, description = "Unauthorized"),
37 (status = 403, description = "Forbidden (not an admin)"),
38 (status = 409, description = "Email or username already taken")
39 ),
40 security(("Bearer" = []))
41 )
42)]
43pub async fn create_user(
44 auth: Authenticated,
45 State(state): State<AppState>,
46 Json(req): Json<CreateUserRequest>,
47) -> Result<impl IntoResponse, ApiError> {
48 if !auth.is_admin() {
49 return Err(ApiError::Forbidden);
50 }
51
52 req.validate()
53 .map_err(|e| ApiError::BadRequest(e.to_string()))?;
54
55 let hash =
56 password::hash(&req.password).map_err(|_| ApiError::Internal("hashing failed".into()))?;
57
58 let user = state
59 .store
60 .create_user(NewUser {
61 email: req.email,
62 username: req.username,
63 password_hash: hash,
64 is_admin: Some(req.is_admin),
65 })
66 .await
67 .map_err(|e| match e {
68 StoreError::DuplicateEmail(_) => ApiError::DuplicateEmail,
69 StoreError::DuplicateUsername(_) => ApiError::DuplicateUsername,
70 other => ApiError::Store(other),
71 })?;
72
73 Ok((StatusCode::CREATED, ok(UserResponse::from(user))))
74}
75
76#[cfg(test)]
77mod tests {
78 use axum::Router;
79 use axum::body::Body;
80 use axum::http::{Request, StatusCode};
81 use axum::routing::post;
82 use ironflow_auth::jwt::{AccessToken, JwtConfig};
83 use ironflow_core::providers::claude::ClaudeCodeProvider;
84 use ironflow_engine::context::WorkflowContext;
85 use ironflow_engine::engine::Engine;
86 use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
87 use ironflow_engine::notify::Event;
88 use ironflow_store::memory::InMemoryStore;
89 use ironflow_store::store::Store;
90 use serde_json::{json, to_string};
91 use std::sync::Arc;
92 use tokio::sync::broadcast;
93 use tower::ServiceExt;
94 use uuid::Uuid;
95
96 use super::*;
97
98 struct TestWorkflow;
99
100 impl WorkflowHandler for TestWorkflow {
101 fn name(&self) -> &str {
102 "test-workflow"
103 }
104
105 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
106 Box::pin(async move { Ok(()) })
107 }
108 }
109
110 fn test_jwt_config() -> Arc<JwtConfig> {
111 Arc::new(JwtConfig {
112 secret: "test-secret-for-user-tests".to_string(),
113 access_token_ttl_secs: 900,
114 refresh_token_ttl_secs: 604800,
115 cookie_domain: None,
116 cookie_secure: false,
117 })
118 }
119
120 fn test_state() -> AppState {
121 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
122 let provider = Arc::new(ClaudeCodeProvider::new());
123 let mut engine = Engine::new(store.clone(), provider);
124 engine
125 .register(TestWorkflow)
126 .expect("failed to register test workflow");
127 let (event_sender, _) = broadcast::channel::<Event>(1);
128 AppState::new(
129 store,
130 Arc::new(engine),
131 test_jwt_config(),
132 "test-worker-token".to_string(),
133 event_sender,
134 )
135 }
136
137 fn make_auth_header(user_id: Uuid, is_admin: bool, state: &AppState) -> String {
138 let token =
139 AccessToken::for_user(user_id, "testuser", is_admin, &state.jwt_config).unwrap();
140 format!("Bearer {}", token.0)
141 }
142
143 #[tokio::test]
144 async fn create_user_as_admin() {
145 let state = test_state();
146 let admin_id = Uuid::now_v7();
147 let auth_header = make_auth_header(admin_id, true, &state);
148 let app = Router::new()
149 .route("/", post(create_user))
150 .with_state(state);
151
152 let req = Request::builder()
153 .uri("/")
154 .method("POST")
155 .header("content-type", "application/json")
156 .header("authorization", auth_header)
157 .body(Body::from(
158 to_string(&json!({
159 "email": "new@example.com",
160 "username": "newuser",
161 "password": "password123",
162 "is_admin": false
163 }))
164 .unwrap(),
165 ))
166 .unwrap();
167
168 let resp = app.oneshot(req).await.unwrap();
169 assert_eq!(resp.status(), StatusCode::CREATED);
170 }
171
172 #[tokio::test]
173 async fn create_user_as_member_forbidden() {
174 let state = test_state();
175 let member_id = Uuid::now_v7();
176 let auth_header = make_auth_header(member_id, false, &state);
177 let app = Router::new()
178 .route("/", post(create_user))
179 .with_state(state);
180
181 let req = Request::builder()
182 .uri("/")
183 .method("POST")
184 .header("content-type", "application/json")
185 .header("authorization", auth_header)
186 .body(Body::from(
187 to_string(&json!({
188 "email": "new@example.com",
189 "username": "newuser",
190 "password": "password123",
191 "is_admin": false
192 }))
193 .unwrap(),
194 ))
195 .unwrap();
196
197 let resp = app.oneshot(req).await.unwrap();
198 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
199 }
200
201 #[tokio::test]
202 async fn create_user_invalid_email() {
203 let state = test_state();
204 let admin_id = Uuid::now_v7();
205 let auth_header = make_auth_header(admin_id, true, &state);
206 let app = Router::new()
207 .route("/", post(create_user))
208 .with_state(state);
209
210 let req = Request::builder()
211 .uri("/")
212 .method("POST")
213 .header("content-type", "application/json")
214 .header("authorization", auth_header)
215 .body(Body::from(
216 to_string(&json!({
217 "email": "invalid",
218 "username": "newuser",
219 "password": "password123",
220 "is_admin": false
221 }))
222 .unwrap(),
223 ))
224 .unwrap();
225
226 let resp = app.oneshot(req).await.unwrap();
227 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
228 }
229}