1use axum::extract::{Path, State};
5use axum::response::IntoResponse;
6use ironflow_auth::extractor::Authenticated;
7use ironflow_engine::error::EngineError;
8use ironflow_store::entities::ApiKeyScope;
9
10use crate::authorization::AuthorizeExt;
11use crate::entities::WorkflowPauseResponse;
12use crate::error::ApiError;
13use crate::response::ok;
14use crate::state::AppState;
15
16#[cfg_attr(
24 feature = "openapi",
25 utoipa::path(
26 post,
27 path = "/api/v1/workflows/{name}/pause",
28 tags = ["workflows"],
29 params(("name" = String, Path, description = "Workflow name")),
30 responses(
31 (status = 200, description = "Workflow paused", body = WorkflowPauseResponse),
32 (status = 401, description = "Unauthorized"),
33 (status = 403, description = "Forbidden"),
34 (status = 404, description = "Workflow not found")
35 ),
36 security(("Bearer" = []))
37 )
38)]
39pub async fn pause_workflow(
40 auth: Authenticated,
41 State(state): State<AppState>,
42 Path(name): Path<String>,
43) -> Result<impl IntoResponse, ApiError> {
44 auth.require(ApiKeyScope::Admin, true)?;
45
46 let pause = state
47 .engine
48 .pause_workflow(&name, Some(auth.user_id))
49 .await
50 .map_err(|err| map_engine_error(err, &name))?;
51
52 Ok(ok(WorkflowPauseResponse::from(pause)))
53}
54
55#[cfg_attr(
61 feature = "openapi",
62 utoipa::path(
63 post,
64 path = "/api/v1/workflows/{name}/resume",
65 tags = ["workflows"],
66 params(("name" = String, Path, description = "Workflow name")),
67 responses(
68 (status = 200, description = "Workflow resumed", body = WorkflowPauseResponse),
69 (status = 401, description = "Unauthorized"),
70 (status = 403, description = "Forbidden"),
71 (status = 404, description = "Workflow not found")
72 ),
73 security(("Bearer" = []))
74 )
75)]
76pub async fn resume_workflow(
77 auth: Authenticated,
78 State(state): State<AppState>,
79 Path(name): Path<String>,
80) -> Result<impl IntoResponse, ApiError> {
81 auth.require(ApiKeyScope::Admin, true)?;
82
83 state
84 .engine
85 .resume_workflow(&name, Some(auth.user_id))
86 .await
87 .map_err(|err| map_engine_error(err, &name))?;
88
89 Ok(ok(WorkflowPauseResponse::resumed(&name)))
90}
91
92fn map_engine_error(err: EngineError, name: &str) -> ApiError {
94 match err {
95 EngineError::InvalidWorkflow(_) => ApiError::WorkflowNotFound(name.to_string()),
96 EngineError::Store(err) => ApiError::from(err),
97 other => ApiError::Internal(other.to_string()),
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use std::sync::Arc;
104
105 use axum::Router;
106 use axum::body::Body;
107 use axum::http::{Request, StatusCode};
108 use axum::routing::post;
109 use http_body_util::BodyExt;
110 use ironflow_core::providers::claude::ClaudeCodeProvider;
111 use ironflow_engine::context::WorkflowContext;
112 use ironflow_engine::engine::{Engine, ExecutionMode};
113 use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
114 use ironflow_store::entities::EventKind;
115 use ironflow_store::memory::InMemoryStore;
116 use ironflow_store::store::RunStore;
117 use ironflow_store::user_store::UserStore;
118 use serde_json::{Value as JsonValue, from_slice};
119 use tower::ServiceExt;
120
121 use super::*;
122 use crate::routes::test_helpers::{
123 await_audit_entry, create_user_auth_header, state_with_engine,
124 };
125
126 struct Deploy;
127
128 impl WorkflowHandler for Deploy {
129 fn name(&self) -> &str {
130 "deploy"
131 }
132
133 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
134 Box::pin(async move { Ok(()) })
135 }
136 }
137
138 fn test_state(store: Arc<InMemoryStore>) -> AppState {
139 state_with(store, false)
140 }
141
142 fn state_with(store: Arc<InMemoryStore>, audit_log: bool) -> AppState {
145 let provider = Arc::new(ClaudeCodeProvider::new());
146 let mut engine =
147 Engine::new(store.clone(), provider).with_execution_mode(ExecutionMode::Workers);
148 engine.register(Deploy).unwrap();
149 state_with_engine(store, engine, audit_log)
150 }
151
152 async fn send(
153 state: AppState,
154 action: &str,
155 name: &str,
156 admin: bool,
157 ) -> (StatusCode, JsonValue) {
158 let auth_header = create_user_auth_header(&state, "testuser", admin).await;
159 let app = Router::new()
160 .route("/{name}/pause", post(pause_workflow))
161 .route("/{name}/resume", post(resume_workflow))
162 .with_state(state);
163
164 let req = Request::builder()
165 .method("POST")
166 .uri(format!("/{name}/{action}"))
167 .header("authorization", auth_header)
168 .body(Body::empty())
169 .unwrap();
170
171 let resp = app.oneshot(req).await.unwrap();
172 let status = resp.status();
173 let body = resp.into_body().collect().await.unwrap().to_bytes();
174 (status, from_slice(&body).unwrap())
175 }
176
177 #[tokio::test]
178 async fn pause_workflow_returns_paused() {
179 let store = Arc::new(InMemoryStore::new());
180
181 let (status, body) = send(test_state(store.clone()), "pause", "deploy", true).await;
182
183 assert_eq!(status, StatusCode::OK);
184 assert_eq!(body["data"]["workflow_name"], "deploy");
185 assert!(body["data"]["paused_at"].is_string());
186 assert!(body["data"]["paused_by"].is_string());
187 let pauses = store.list_workflow_pauses().await.unwrap();
188 assert_eq!(pauses.len(), 1);
189 assert_eq!(pauses[0].workflow_name, "deploy");
190 }
191
192 #[tokio::test]
193 async fn pause_unknown_workflow_returns_404() {
194 let store = Arc::new(InMemoryStore::new());
195
196 let (status, _) = send(test_state(store.clone()), "pause", "unknown", true).await;
197
198 assert_eq!(status, StatusCode::NOT_FOUND);
199 assert!(store.list_workflow_pauses().await.unwrap().is_empty());
200 }
201
202 #[tokio::test]
203 async fn pause_workflow_non_admin_returns_403() {
204 let store = Arc::new(InMemoryStore::new());
205
206 let (status, _) = send(test_state(store.clone()), "pause", "deploy", false).await;
207
208 assert_eq!(status, StatusCode::FORBIDDEN);
209 assert!(store.list_workflow_pauses().await.unwrap().is_empty());
210 }
211
212 #[tokio::test]
213 async fn resume_workflow_returns_not_paused() {
214 let store = Arc::new(InMemoryStore::new());
215 store.pause_workflow("deploy", None).await.unwrap();
216
217 let (status, body) = send(test_state(store.clone()), "resume", "deploy", true).await;
218
219 assert_eq!(status, StatusCode::OK);
220 assert_eq!(body["data"]["workflow_name"], "deploy");
221 assert!(body["data"].get("paused_at").is_none());
222 assert!(store.list_workflow_pauses().await.unwrap().is_empty());
223 }
224
225 #[tokio::test]
226 async fn resume_workflow_not_paused_is_accepted() {
227 let store = Arc::new(InMemoryStore::new());
228
229 let (status, body) = send(test_state(store), "resume", "deploy", true).await;
230
231 assert_eq!(status, StatusCode::OK);
232 assert!(body["data"].get("paused_at").is_none());
233 }
234
235 #[tokio::test]
236 async fn resume_unknown_workflow_returns_404() {
237 let store = Arc::new(InMemoryStore::new());
238
239 let (status, _) = send(test_state(store), "resume", "unknown", true).await;
240
241 assert_eq!(status, StatusCode::NOT_FOUND);
242 }
243
244 #[tokio::test]
245 async fn resume_workflow_non_admin_returns_403() {
246 let store = Arc::new(InMemoryStore::new());
247 store.pause_workflow("deploy", None).await.unwrap();
248
249 let (status, _) = send(test_state(store.clone()), "resume", "deploy", false).await;
250
251 assert_eq!(status, StatusCode::FORBIDDEN);
252 assert_eq!(store.list_workflow_pauses().await.unwrap().len(), 1);
253 }
254
255 #[tokio::test]
256 async fn pause_and_resume_are_recorded_in_the_audit_log_with_the_admin() {
257 let store = Arc::new(InMemoryStore::new());
258 let state = state_with(store.clone(), true);
259
260 let (status, _) = send(state.clone(), "pause", "deploy", true).await;
261 assert_eq!(status, StatusCode::OK);
262 let (status, _) = send(state, "resume", "deploy", true).await;
263 assert_eq!(status, StatusCode::OK);
264
265 let admin = store
266 .find_user_by_username("testuser")
267 .await
268 .unwrap()
269 .expect("admin created by send");
270 let paused = await_audit_entry(&store, EventKind::WorkflowPaused, None).await;
271 assert_eq!(paused.user_id, Some(admin.id));
272 assert_eq!(paused.payload["workflow_name"], "deploy");
273 let resumed = await_audit_entry(&store, EventKind::WorkflowResumed, None).await;
274 assert_eq!(resumed.user_id, Some(admin.id));
275 assert_eq!(resumed.payload["user_id"], admin.id.to_string());
276 }
277}