Skip to main content

ironflow_api/routes/users/
create.rs

1//! `POST /api/v1/users` -- Create a new user (admin only).
2
3use 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/// Create a new user account. Admin only.
20///
21/// # Errors
22///
23/// - 403 if the caller is not an admin
24/// - 400 if input validation fails
25/// - 409 if email or username is already taken
26#[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}