//! Integration tests for preserve_on_behalf_of functionality. //! //! Tests verify that when deploying scripts, flows, apps, schedules, and triggers: //! - Admin users can preserve the original on_behalf_of/email values //! - Users in the wm_deployers group can preserve these values //! - Regular users cannot preserve and their email is used instead use serde_json::json; use sqlx::{Pool, Postgres}; use windmill_test_utils::*; fn client() -> reqwest::Client { reqwest::Client::new() } fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { builder.header("Authorization", format!("Bearer {}", token)) } // ============================================================================ // Script Tests // ============================================================================ fn new_script_with_on_behalf_of( path: &str, on_behalf_of_email: Option<&str>, preserve: bool, ) -> serde_json::Value { let mut script = json!({ "path": path, "summary": "Test script", "description": "", "content": "export async function main() { return 42; }", "language": "deno", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {}, "required": [] } }); if let Some(email) = on_behalf_of_email { script["on_behalf_of_email"] = json!(email); } if preserve { script["preserve_on_behalf_of"] = json!(true); } script } // ============================================================================ // Flow Tests // ============================================================================ fn new_flow_with_on_behalf_of( path: &str, on_behalf_of_email: Option<&str>, preserve: bool, ) -> serde_json::Value { let mut flow = json!({ "path": path, "summary": "Test flow", "description": "", "value": { "modules": [] }, "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {}, "required": [] } }); if let Some(email) = on_behalf_of_email { flow["on_behalf_of_email"] = json!(email); } if preserve { flow["preserve_on_behalf_of"] = json!(true); } flow } // ============================================================================ // App Tests // ============================================================================ fn new_app_with_on_behalf_of( path: &str, on_behalf_of: Option<&str>, on_behalf_of_email: Option<&str>, preserve: bool, ) -> serde_json::Value { let mut policy = json!({ "execution_mode": "anonymous", "triggerables": {} }); if let Some(obo) = on_behalf_of { policy["on_behalf_of"] = json!(obo); } if let Some(email) = on_behalf_of_email { policy["on_behalf_of_email"] = json!(email); } let mut app = json!({ "path": path, "summary": "Test app", "value": { "type": "rawapp", "inline_script": null }, "policy": policy }); if preserve { app["preserve_on_behalf_of"] = json!(true); } app } // ============================================================================ // HTTP Trigger Helpers // ============================================================================ #[cfg(feature = "http_trigger")] fn new_http_trigger( path: &str, script_path: &str, route_path: &str, permissioned_as: Option<&str>, preserve: bool, ) -> serde_json::Value { let mut trigger = json!({ "path": path, "script_path": script_path, "is_flow": false, "route_path": route_path, "request_type": "async", "authentication_method": "none", "http_method": "post", "is_static_website": false, "workspaced_route": false, "wrap_body": false, "raw_string": false }); if let Some(e) = permissioned_as { trigger["permissioned_as"] = json!(e); } if preserve { trigger["preserve_permissioned_as"] = json!(true); } trigger } // ============================================================================ // WebSocket Trigger Helpers // ============================================================================ #[cfg(feature = "websocket")] fn new_websocket_trigger( path: &str, script_path: &str, permissioned_as: Option<&str>, preserve: bool, ) -> serde_json::Value { let mut trigger = json!({ "path": path, "script_path": script_path, "is_flow": false, "url": "wss://echo.websocket.org", "filters": [], "can_return_message": false, "can_return_error_result": false }); if let Some(e) = permissioned_as { trigger["permissioned_as"] = json!(e); } if preserve { trigger["preserve_permissioned_as"] = json!(true); } trigger } // ============================================================================ // Comprehensive Test // ============================================================================ /// Comprehensive test for preserve_on_behalf_of functionality. /// Tests all entity types in a single test to minimize overhead. #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_preserve_on_behalf_of(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // ======================================== // 1. Script: Admin preserves on_behalf_of_email // ======================================== let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/test-user/script_admin_preserve", Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create script: {}", resp.text().await? ); let script = sqlx::query!( "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2", "u/test-user/script_admin_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( script.on_behalf_of_email.as_deref(), Some("original@windmill.dev"), "Admin should preserve on_behalf_of_email" ); // ======================================== // 2. Script: Deployer (wm_deployers group) preserves on_behalf_of_email // ======================================== let resp = authed( client().post(format!("{base}/scripts/create")), "DEPLOYER_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/deployer-user/script_deployer_preserve", Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Deployer should create script: {}", resp.text().await? ); let script = sqlx::query!( "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2", "u/deployer-user/script_deployer_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( script.on_behalf_of_email.as_deref(), Some("original@windmill.dev"), "Deployer should preserve on_behalf_of_email" ); // ======================================== // 3. Script: Non-admin without wm_deployers cannot preserve // ======================================== let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2", ) .json(&new_script_with_on_behalf_of( "u/test-user-2/script_no_preserve", Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Non-admin should create script: {}", resp.text().await? ); let script = sqlx::query!( "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2", "u/test-user-2/script_no_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( script.on_behalf_of_email.as_deref(), Some("test2@windmill.dev"), "Non-admin should have their own email as on_behalf_of_email" ); // ======================================== // 4. Flow: Admin preserves on_behalf_of_email // ======================================== let resp = authed( client().post(format!("{base}/flows/create")), "SECRET_TOKEN", ) .json(&new_flow_with_on_behalf_of( "u/test-user/flow_admin_preserve", Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create flow: {}", resp.text().await? ); let flow = sqlx::query!( "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", "u/test-user/flow_admin_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( flow.on_behalf_of_email.as_deref(), Some("original@windmill.dev"), "Admin should preserve flow on_behalf_of_email" ); // ======================================== // 5. Flow: Deployer preserves on_behalf_of_email // ======================================== let resp = authed( client().post(format!("{base}/flows/create")), "DEPLOYER_TOKEN", ) .json(&new_flow_with_on_behalf_of( "u/deployer-user/flow_deployer_preserve", Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Deployer should create flow: {}", resp.text().await? ); let flow = sqlx::query!( "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", "u/deployer-user/flow_deployer_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( flow.on_behalf_of_email.as_deref(), Some("original@windmill.dev"), "Deployer should preserve flow on_behalf_of_email" ); // ======================================== // 6. Flow: Non-admin cannot preserve // ======================================== let resp = authed( client().post(format!("{base}/flows/create")), "SECRET_TOKEN_2", ) .json(&new_flow_with_on_behalf_of( "u/test-user-2/flow_no_preserve", Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Non-admin should create flow: {}", resp.text().await? ); let flow = sqlx::query!( "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", "u/test-user-2/flow_no_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( flow.on_behalf_of_email.as_deref(), Some("test2@windmill.dev"), "Non-admin should have their own email as flow on_behalf_of_email" ); // ======================================== // 7. App: Admin preserves on_behalf_of // ======================================== let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN") .json(&new_app_with_on_behalf_of( "u/test-user/app_admin_preserve", Some("u/original-user"), Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create app: {}", resp.text().await? ); let app = sqlx::query!( "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", "u/test-user/app_admin_preserve", "test-workspace" ) .fetch_one(&db) .await?; let policy = app.policy; assert_eq!( policy.get("on_behalf_of").and_then(|v| v.as_str()), Some("u/original-user"), "Admin should preserve app on_behalf_of" ); assert_eq!( policy.get("on_behalf_of_email").and_then(|v| v.as_str()), Some("original@windmill.dev"), "Admin should preserve app on_behalf_of_email" ); // ======================================== // 8. App: Deployer preserves on_behalf_of // ======================================== let resp = authed( client().post(format!("{base}/apps/create")), "DEPLOYER_TOKEN", ) .json(&new_app_with_on_behalf_of( "u/deployer-user/app_deployer_preserve", Some("u/original-user"), Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Deployer should create app: {}", resp.text().await? ); let app = sqlx::query!( "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", "u/deployer-user/app_deployer_preserve", "test-workspace" ) .fetch_one(&db) .await?; let policy = app.policy; assert_eq!( policy.get("on_behalf_of").and_then(|v| v.as_str()), Some("u/original-user"), "Deployer should preserve app on_behalf_of" ); assert_eq!( policy.get("on_behalf_of_email").and_then(|v| v.as_str()), Some("original@windmill.dev"), "Deployer should preserve app on_behalf_of_email" ); // ======================================== // 9. App: Non-admin cannot preserve // ======================================== let resp = authed( client().post(format!("{base}/apps/create")), "SECRET_TOKEN_2", ) .json(&new_app_with_on_behalf_of( "u/test-user-2/app_no_preserve", Some("u/original-user"), Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Non-admin should create app: {}", resp.text().await? ); let app = sqlx::query!( "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", "u/test-user-2/app_no_preserve", "test-workspace" ) .fetch_one(&db) .await?; let policy = app.policy; assert_eq!( policy.get("on_behalf_of").and_then(|v| v.as_str()), Some("u/test-user-2"), "Non-admin should have their own permissioned_as as app on_behalf_of" ); assert_eq!( policy.get("on_behalf_of_email").and_then(|v| v.as_str()), Some("test2@windmill.dev"), "Non-admin should have their own email as app on_behalf_of_email" ); // ======================================== // 10. Schedule: Admin preserves email and edited_by // ======================================== // First create a script for the schedule to reference let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/test-user/scheduled_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create scheduled script: {}", resp.text().await? ); let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN", ) .json(&json!({ "path": "u/test-user/schedule_admin_preserve", "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": "u/test-user/scheduled_script", "is_flow": false, "enabled": false, "permissioned_as": "u/original-user", "preserve_permissioned_as": true })) .send() .await?; assert_eq!( resp.status(), 200, "Admin should create schedule: {}", resp.text().await? ); let schedule = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/test-user/schedule_admin_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( schedule.email, "original@windmill.dev", "Admin should preserve schedule email" ); assert_eq!( schedule.permissioned_as, "u/original-user", "Admin should preserve schedule permissioned_as" ); assert_eq!( schedule.edited_by, "test-user", "edited_by should be the deploying user (admin)" ); // ======================================== // 11. Schedule: Deployer preserves email and edited_by // ======================================== // Create script for deployer let resp = authed( client().post(format!("{base}/scripts/create")), "DEPLOYER_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/deployer-user/scheduled_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create scheduled script: {}", resp.text().await? ); let resp = authed( client().post(format!("{base}/schedules/create")), "DEPLOYER_TOKEN", ) .json(&json!({ "path": "u/deployer-user/schedule_deployer_preserve", "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": "u/deployer-user/scheduled_script", "is_flow": false, "enabled": false, "permissioned_as": "u/original-user", "preserve_permissioned_as": true })) .send() .await?; assert_eq!( resp.status(), 200, "Deployer should create schedule: {}", resp.text().await? ); let schedule = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/deployer-user/schedule_deployer_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( schedule.email, "original@windmill.dev", "Deployer should preserve schedule email" ); assert_eq!( schedule.permissioned_as, "u/original-user", "Deployer should preserve schedule permissioned_as" ); assert_eq!( schedule.edited_by, "deployer-user", "edited_by should be the deploying user (deployer)" ); // ======================================== // 12. Schedule: Non-admin cannot preserve // ======================================== // Create script for test-user-2 let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2", ) .json(&new_script_with_on_behalf_of( "u/test-user-2/scheduled_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create scheduled script: {}", resp.text().await? ); let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN_2", ) .json(&json!({ "path": "u/test-user-2/schedule_no_preserve", "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": "u/test-user-2/scheduled_script", "is_flow": false, "enabled": false, "permissioned_as": "u/original-user", "preserve_permissioned_as": true })) .send() .await?; assert_eq!( resp.status(), 200, "Non-admin should create schedule: {}", resp.text().await? ); let schedule = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/test-user-2/schedule_no_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( schedule.email, "test2@windmill.dev", "Non-admin should have their own email" ); assert_eq!( schedule.permissioned_as, "u/test-user-2", "Non-admin should have their own permissioned_as" ); assert_eq!( schedule.edited_by, "test-user-2", "Non-admin should have their own username as edited_by" ); // ======================================== // 13. Script: Without preserve flag, email is NOT preserved // ======================================== let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/test-user/script_no_flag", Some("original@windmill.dev"), false, // preserve_on_behalf_of = false )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create script: {}", resp.text().await? ); let script = sqlx::query!( "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2", "u/test-user/script_no_flag", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( script.on_behalf_of_email.as_deref(), Some("test@windmill.dev"), "Without preserve flag, admin's email should be used" ); Ok(()) } /// Helper to build a script JSON with custom content (to avoid hash conflicts on same path) fn script_json( path: &str, content: &str, on_behalf_of_email: Option<&str>, preserve: bool, ) -> serde_json::Value { let mut script = json!({ "path": path, "summary": "Test script", "description": "", "content": content, "language": "deno", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {}, "required": [] } }); if let Some(email) = on_behalf_of_email { script["on_behalf_of_email"] = json!(email); } if preserve { script["preserve_on_behalf_of"] = json!(true); } script } /// Test script update preserves on_behalf_of_email correctly #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_script_update_preserves_on_behalf_of(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // ======================================== // Admin updates with preserve flag // ======================================== // Original-user creates initial version let resp = authed( client().post(format!("{base}/scripts/create")), "ORIGINAL_TOKEN", ) .json(&script_json( "u/original-user/script_to_update", "export async function main() { return 1; }", Some("original@windmill.dev"), false, )) .send() .await?; assert_eq!(resp.status(), 201); let parent_hash: String = resp.text().await?; // Admin creates new version with preserve, passing parent_hash let mut update = script_json( "u/original-user/script_to_update", "export async function main() { return 2; }", Some("original@windmill.dev"), true, ); update["parent_hash"] = json!(parent_hash); let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&update) .send() .await?; assert_eq!( resp.status(), 201, "Admin should update script: {}", resp.text().await? ); let script = sqlx::query!( "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", "u/original-user/script_to_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( script.on_behalf_of_email.as_deref(), Some("original@windmill.dev"), "Admin update should preserve script on_behalf_of_email" ); // ======================================== // Deployer updates with preserve flag // ======================================== // Admin creates initial version under deployer's path with preserve let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&script_json( "u/deployer-user/script_deploy_update", "export async function main() { return 3; }", Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!(resp.status(), 201); let parent_hash: String = resp.text().await?; // Deployer creates new version with preserve let mut update = script_json( "u/deployer-user/script_deploy_update", "export async function main() { return 4; }", Some("original@windmill.dev"), true, ); update["parent_hash"] = json!(parent_hash); let resp = authed( client().post(format!("{base}/scripts/create")), "DEPLOYER_TOKEN", ) .json(&update) .send() .await?; assert_eq!( resp.status(), 201, "Deployer should update script: {}", resp.text().await? ); let script = sqlx::query!( "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", "u/deployer-user/script_deploy_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( script.on_behalf_of_email.as_deref(), Some("original@windmill.dev"), "Deployer update should preserve script on_behalf_of_email" ); // ======================================== // Non-admin cannot preserve on update // ======================================== // Admin creates initial version under non-admin's path with preserve let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&script_json( "u/test-user-2/script_nonadmin_update", "export async function main() { return 5; }", Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!(resp.status(), 201); let parent_hash: String = resp.text().await?; // Non-admin creates new version with preserve (should be denied) let mut update = script_json( "u/test-user-2/script_nonadmin_update", "export async function main() { return 6; }", Some("original@windmill.dev"), true, ); update["parent_hash"] = json!(parent_hash); let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2", ) .json(&update) .send() .await?; assert_eq!( resp.status(), 201, "Non-admin should update script: {}", resp.text().await? ); let script = sqlx::query!( "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", "u/test-user-2/script_nonadmin_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( script.on_behalf_of_email.as_deref(), Some("test2@windmill.dev"), "Non-admin update should overwrite script on_behalf_of_email with their own" ); Ok(()) } /// Test flow update preserves on_behalf_of_email correctly #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_flow_update_preserves_on_behalf_of(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // Create initial flow with original email let resp = authed( client().post(format!("{base}/flows/create")), "ORIGINAL_TOKEN", ) .json(&new_flow_with_on_behalf_of( "u/original-user/flow_to_update", Some("original@windmill.dev"), false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create flow: {}", resp.text().await? ); // Admin updates with preserve flag let resp = authed( client().post(format!( "{base}/flows/update/u/original-user/flow_to_update" )), "SECRET_TOKEN", ) .json(&json!({ "path": "u/original-user/flow_to_update", "summary": "Updated flow", "description": "", "value": { "modules": [] }, "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {}, "required": [] }, "on_behalf_of_email": "original@windmill.dev", "preserve_on_behalf_of": true })) .send() .await?; assert_eq!( resp.status(), 200, "Admin should update flow: {}", resp.text().await? ); let flow = sqlx::query!( "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", "u/original-user/flow_to_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( flow.on_behalf_of_email.as_deref(), Some("original@windmill.dev"), "Admin update should preserve flow on_behalf_of_email" ); // ======================================== // Deployer updates with preserve flag // ======================================== // Admin creates flow under deployer's path with preserve let resp = authed( client().post(format!("{base}/flows/create")), "SECRET_TOKEN", ) .json(&new_flow_with_on_behalf_of( "u/deployer-user/flow_deploy_update", Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create flow: {}", resp.text().await? ); // Deployer updates at their own path with preserve let resp = authed( client().post(format!( "{base}/flows/update/u/deployer-user/flow_deploy_update" )), "DEPLOYER_TOKEN", ) .json(&json!({ "path": "u/deployer-user/flow_deploy_update", "summary": "Deployer updated flow", "description": "", "value": { "modules": [] }, "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {}, "required": [] }, "on_behalf_of_email": "original@windmill.dev", "preserve_on_behalf_of": true })) .send() .await?; assert_eq!( resp.status(), 200, "Deployer should update flow: {}", resp.text().await? ); let flow = sqlx::query!( "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", "u/deployer-user/flow_deploy_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( flow.on_behalf_of_email.as_deref(), Some("original@windmill.dev"), "Deployer update should preserve flow on_behalf_of_email" ); // ======================================== // Non-admin cannot preserve on update // ======================================== // Admin creates flow under non-admin's path with preserve let resp = authed( client().post(format!("{base}/flows/create")), "SECRET_TOKEN", ) .json(&new_flow_with_on_behalf_of( "u/test-user-2/flow_nonadmin_update", Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create flow: {}", resp.text().await? ); // Non-admin updates at their own path with preserve (should be denied) let resp = authed( client().post(format!( "{base}/flows/update/u/test-user-2/flow_nonadmin_update" )), "SECRET_TOKEN_2", ) .json(&json!({ "path": "u/test-user-2/flow_nonadmin_update", "summary": "Non-admin updated flow", "description": "", "value": { "modules": [] }, "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {}, "required": [] }, "on_behalf_of_email": "original@windmill.dev", "preserve_on_behalf_of": true })) .send() .await?; assert_eq!( resp.status(), 200, "Non-admin should update flow: {}", resp.text().await? ); let flow = sqlx::query!( "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", "u/test-user-2/flow_nonadmin_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( flow.on_behalf_of_email.as_deref(), Some("test2@windmill.dev"), "Non-admin update should overwrite flow on_behalf_of_email with their own" ); Ok(()) } /// Test app update preserves on_behalf_of correctly #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_app_update_preserves_on_behalf_of(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // Create initial app let resp = authed( client().post(format!("{base}/apps/create")), "ORIGINAL_TOKEN", ) .json(&new_app_with_on_behalf_of( "u/original-user/app_to_update", Some("u/original-user"), Some("original@windmill.dev"), false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create app: {}", resp.text().await? ); // Admin updates with preserve flag let resp = authed( client().post(format!("{base}/apps/update/u/original-user/app_to_update")), "SECRET_TOKEN", ) .json(&json!({ "summary": "Updated app", "policy": { "execution_mode": "anonymous", "triggerables": {}, "on_behalf_of": "u/original-user", "on_behalf_of_email": "original@windmill.dev" }, "preserve_on_behalf_of": true })) .send() .await?; assert_eq!( resp.status(), 200, "Admin should update app: {}", resp.text().await? ); let app = sqlx::query!( "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", "u/original-user/app_to_update", "test-workspace" ) .fetch_one(&db) .await?; let policy = app.policy; assert_eq!( policy.get("on_behalf_of").and_then(|v| v.as_str()), Some("u/original-user"), "Admin update should preserve app on_behalf_of" ); assert_eq!( policy.get("on_behalf_of_email").and_then(|v| v.as_str()), Some("original@windmill.dev"), "Admin update should preserve app on_behalf_of_email" ); // ======================================== // Deployer updates with preserve flag // ======================================== // Admin creates app under deployer's path with preserve let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN") .json(&new_app_with_on_behalf_of( "u/deployer-user/app_deploy_update", Some("u/original-user"), Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create app: {}", resp.text().await? ); // Deployer updates at their own path with preserve let resp = authed( client().post(format!( "{base}/apps/update/u/deployer-user/app_deploy_update" )), "DEPLOYER_TOKEN", ) .json(&json!({ "summary": "Deployer updated app", "policy": { "execution_mode": "anonymous", "triggerables": {}, "on_behalf_of": "u/original-user", "on_behalf_of_email": "original@windmill.dev" }, "preserve_on_behalf_of": true })) .send() .await?; assert_eq!( resp.status(), 200, "Deployer should update app: {}", resp.text().await? ); let app = sqlx::query!( "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", "u/deployer-user/app_deploy_update", "test-workspace" ) .fetch_one(&db) .await?; let policy = app.policy; assert_eq!( policy.get("on_behalf_of").and_then(|v| v.as_str()), Some("u/original-user"), "Deployer update should preserve app on_behalf_of" ); assert_eq!( policy.get("on_behalf_of_email").and_then(|v| v.as_str()), Some("original@windmill.dev"), "Deployer update should preserve app on_behalf_of_email" ); // ======================================== // Non-admin cannot preserve on update // ======================================== // Admin creates app under non-admin's path with preserve let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN") .json(&new_app_with_on_behalf_of( "u/test-user-2/app_nonadmin_update", Some("u/original-user"), Some("original@windmill.dev"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create app: {}", resp.text().await? ); // Non-admin updates at their own path with preserve (should be denied) let resp = authed( client().post(format!( "{base}/apps/update/u/test-user-2/app_nonadmin_update" )), "SECRET_TOKEN_2", ) .json(&json!({ "summary": "Non-admin updated app", "policy": { "execution_mode": "anonymous", "triggerables": {}, "on_behalf_of": "u/original-user", "on_behalf_of_email": "original@windmill.dev" }, "preserve_on_behalf_of": true })) .send() .await?; assert_eq!( resp.status(), 200, "Non-admin should update app: {}", resp.text().await? ); let app = sqlx::query!( "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", "u/test-user-2/app_nonadmin_update", "test-workspace" ) .fetch_one(&db) .await?; let policy = app.policy; assert_eq!( policy.get("on_behalf_of").and_then(|v| v.as_str()), Some("u/test-user-2"), "Non-admin update should overwrite app on_behalf_of with their own" ); assert_eq!( policy.get("on_behalf_of_email").and_then(|v| v.as_str()), Some("test2@windmill.dev"), "Non-admin update should overwrite app on_behalf_of_email with their own" ); Ok(()) } /// Test schedule update preserves email/edited_by correctly #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_schedule_update_preserves_email(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // Create script for schedule let resp = authed( client().post(format!("{base}/scripts/create")), "ORIGINAL_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/original-user/scheduled_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create script: {}", resp.text().await? ); // Create initial schedule let resp = authed( client().post(format!("{base}/schedules/create")), "ORIGINAL_TOKEN", ) .json(&json!({ "path": "u/original-user/schedule_to_update", "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": "u/original-user/scheduled_script", "is_flow": false, "enabled": false })) .send() .await?; assert_eq!( resp.status(), 200, "Should create schedule: {}", resp.text().await? ); // Verify initial state let schedule = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/original-user/schedule_to_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!(schedule.email, "original@windmill.dev"); assert_eq!(schedule.permissioned_as, "u/original-user"); assert_eq!(schedule.edited_by, "original-user"); // Admin updates with preserve flag let resp = authed( client().post(format!( "{base}/schedules/update/u/original-user/schedule_to_update" )), "SECRET_TOKEN", ) .json(&json!({ "schedule": "0 0 */12 * * *", "timezone": "UTC", "permissioned_as": "u/original-user", "preserve_permissioned_as": true })) .send() .await?; assert_eq!( resp.status(), 200, "Admin should update schedule: {}", resp.text().await? ); let schedule = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/original-user/schedule_to_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( schedule.email, "original@windmill.dev", "Admin update should preserve schedule email for backwards compat" ); assert_eq!( schedule.permissioned_as, "u/original-user", "Admin update should preserve schedule permissioned_as" ); assert_eq!( schedule.edited_by, "test-user", "edited_by should be the deploying user (admin)" ); // ======================================== // Deployer updates with preserve flag // ======================================== // Create script under deployer's path let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/deployer-user/sched_deploy_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create script: {}", resp.text().await? ); // Admin creates schedule under deployer's path with preserve let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN", ) .json(&json!({ "path": "u/deployer-user/schedule_deploy_update", "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": "u/deployer-user/sched_deploy_script", "is_flow": false, "enabled": false, "permissioned_as": "u/original-user", "preserve_permissioned_as": true })) .send() .await?; assert_eq!( resp.status(), 200, "Admin should create schedule: {}", resp.text().await? ); // Deployer updates at their own path with preserve let resp = authed( client().post(format!( "{base}/schedules/update/u/deployer-user/schedule_deploy_update" )), "DEPLOYER_TOKEN", ) .json(&json!({ "schedule": "0 0 */8 * * *", "timezone": "UTC", "permissioned_as": "u/original-user", "preserve_permissioned_as": true })) .send() .await?; assert_eq!( resp.status(), 200, "Deployer should update schedule: {}", resp.text().await? ); let schedule = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/deployer-user/schedule_deploy_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( schedule.email, "original@windmill.dev", "Deployer update should preserve schedule email for backwards compat" ); assert_eq!( schedule.permissioned_as, "u/original-user", "Deployer update should preserve schedule permissioned_as" ); assert_eq!( schedule.edited_by, "deployer-user", "edited_by should be the deploying user (deployer)" ); // ======================================== // Non-admin cannot preserve on update // ======================================== // Create script under non-admin's path let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/test-user-2/sched_nonadmin_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create script: {}", resp.text().await? ); // Admin creates schedule under non-admin's path with preserve let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN", ) .json(&json!({ "path": "u/test-user-2/schedule_nonadmin_update", "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": "u/test-user-2/sched_nonadmin_script", "is_flow": false, "enabled": false, "permissioned_as": "u/original-user", "preserve_permissioned_as": true })) .send() .await?; assert_eq!( resp.status(), 200, "Admin should create schedule: {}", resp.text().await? ); // Non-admin updates at their own path with preserve (should be denied) let resp = authed( client().post(format!( "{base}/schedules/update/u/test-user-2/schedule_nonadmin_update" )), "SECRET_TOKEN_2", ) .json(&json!({ "schedule": "0 0 */4 * * *", "timezone": "UTC", "permissioned_as": "u/original-user", "preserve_permissioned_as": true })) .send() .await?; assert_eq!( resp.status(), 200, "Non-admin should update schedule: {}", resp.text().await? ); let schedule = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/test-user-2/schedule_nonadmin_update", "test-workspace" ) .fetch_one(&db) .await?; // When preserve is denied, resolve_permissioned_as uses the authed user's values assert_eq!( schedule.email, "test2@windmill.dev", "Non-admin update should overwrite schedule email with their own" ); assert_eq!( schedule.permissioned_as, "u/test-user-2", "Non-admin update should overwrite schedule permissioned_as with their own" ); Ok(()) } // ============================================================================ // HTTP Trigger Tests // ============================================================================ // All trigger types share the same BaseTriggerData.resolve_permissioned_as() // and resolve_edited_by() code path. Testing HTTP triggers validates the // preservation logic for all trigger types (WebSocket, MQTT, PostgreSQL, // Kafka, NATS, SQS, GCP, Email). /// HTTP Trigger: admin preserve_permissioned_as tests (HTTP triggers require admin) #[cfg(feature = "http_trigger")] #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_http_trigger_preserve_permissioned_as(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // Create script for triggers to reference (admin-only for HTTP triggers) let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/test-user/trigger_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create script: {}", resp.text().await? ); // ======================================== // 1. Admin preserves email // ======================================== let resp = authed( client().post(format!("{base}/http_triggers/create")), "SECRET_TOKEN", ) .json(&new_http_trigger( "u/test-user/http_admin_preserve", "u/test-user/trigger_script", "admin-preserve", Some("u/original-user"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create http trigger: {}", resp.text().await? ); let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM http_trigger WHERE path = $1 AND workspace_id = $2", "u/test-user/http_admin_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( trigger.permissioned_as, "u/original-user", "Admin should preserve http trigger email" ); assert_eq!( trigger.edited_by, "test-user", "edited_by should be the deploying user (admin)" ); // ======================================== // 2. Without preserve flag, email is NOT preserved // ======================================== let resp = authed( client().post(format!("{base}/http_triggers/create")), "SECRET_TOKEN", ) .json(&new_http_trigger( "u/test-user/http_no_flag", "u/test-user/trigger_script", "no-flag", Some("u/original-user"), false, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create http trigger: {}", resp.text().await? ); let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM http_trigger WHERE path = $1 AND workspace_id = $2", "u/test-user/http_no_flag", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( trigger.permissioned_as, "u/test-user", "Without preserve flag, admin's own email should be used" ); assert_eq!( trigger.edited_by, "test-user", "Without preserve flag, admin's own username should be used" ); Ok(()) } /// HTTP Trigger update: admin preserves email/edited_by (HTTP triggers require admin) #[cfg(feature = "http_trigger")] #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_http_trigger_update_preserves_email(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // Create script for the trigger (admin creates everything for HTTP triggers) let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/test-user/http_update_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create script: {}", resp.text().await? ); // Admin creates initial trigger without preserve (sets admin's own email) let resp = authed( client().post(format!("{base}/http_triggers/create")), "SECRET_TOKEN", ) .json(&new_http_trigger( "u/test-user/http_to_update", "u/test-user/http_update_script", "to-update", Some("u/original-user"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create http trigger: {}", resp.text().await? ); // Verify initial state let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM http_trigger WHERE path = $1 AND workspace_id = $2", "u/test-user/http_to_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!(trigger.permissioned_as, "u/original-user"); assert_eq!(trigger.edited_by, "test-user"); // Admin updates with preserve flag let resp = authed( client().post(format!( "{base}/http_triggers/update/u/test-user/http_to_update" )), "SECRET_TOKEN", ) .json(&new_http_trigger( "u/test-user/http_to_update", "u/test-user/http_update_script", "to-update", Some("u/original-user"), true, )) .send() .await?; assert_eq!( resp.status(), 200, "Admin should update http trigger: {}", resp.text().await? ); let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM http_trigger WHERE path = $1 AND workspace_id = $2", "u/test-user/http_to_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( trigger.permissioned_as, "u/original-user", "Admin update should preserve http trigger email" ); assert_eq!( trigger.edited_by, "test-user", "edited_by should be the deploying user (admin)" ); Ok(()) } // ============================================================================ // WebSocket Trigger Tests // ============================================================================ /// WebSocket Trigger: admin, deployer, and non-admin preserve_permissioned_as tests #[cfg(feature = "websocket")] #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_websocket_trigger_preserve_permissioned_as(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // Create scripts for triggers to reference for (token, path) in [ ("SECRET_TOKEN", "u/test-user/ws_script"), ("DEPLOYER_TOKEN", "u/deployer-user/ws_script"), ("SECRET_TOKEN_2", "u/test-user-2/ws_script"), ] { let resp = authed(client().post(format!("{base}/scripts/create")), token) .json(&new_script_with_on_behalf_of(path, None, false)) .send() .await?; assert_eq!( resp.status(), 201, "Should create script {path}: {}", resp.text().await? ); } // ======================================== // 1. Admin preserves email // ======================================== let resp = authed( client().post(format!("{base}/websocket_triggers/create")), "SECRET_TOKEN", ) .json(&new_websocket_trigger( "u/test-user/ws_admin_preserve", "u/test-user/ws_script", Some("u/original-user"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create websocket trigger: {}", resp.text().await? ); let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", "u/test-user/ws_admin_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( trigger.permissioned_as, "u/original-user", "Admin should preserve websocket trigger email" ); assert_eq!( trigger.edited_by, "test-user", "edited_by should be the deploying user (admin)" ); // ======================================== // 2. Deployer preserves email // ======================================== let resp = authed( client().post(format!("{base}/websocket_triggers/create")), "DEPLOYER_TOKEN", ) .json(&new_websocket_trigger( "u/deployer-user/ws_deployer_preserve", "u/deployer-user/ws_script", Some("u/original-user"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Deployer should create websocket trigger: {}", resp.text().await? ); let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", "u/deployer-user/ws_deployer_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( trigger.permissioned_as, "u/original-user", "Deployer should preserve websocket trigger email" ); assert_eq!( trigger.edited_by, "deployer-user", "edited_by should be the deploying user (deployer)" ); // ======================================== // 3. Non-admin cannot preserve // ======================================== let resp = authed( client().post(format!("{base}/websocket_triggers/create")), "SECRET_TOKEN_2", ) .json(&new_websocket_trigger( "u/test-user-2/ws_no_preserve", "u/test-user-2/ws_script", Some("u/original-user"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Non-admin should create websocket trigger: {}", resp.text().await? ); let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", "u/test-user-2/ws_no_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( trigger.permissioned_as, "u/test-user-2", "Non-admin should have their own email" ); assert_eq!( trigger.edited_by, "test-user-2", "Non-admin should have their own username as edited_by" ); Ok(()) } /// WebSocket Trigger update: admin preserves email/edited_by #[cfg(feature = "websocket")] #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_websocket_trigger_update_preserves_email(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // Create script let resp = authed( client().post(format!("{base}/scripts/create")), "ORIGINAL_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/original-user/ws_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create script: {}", resp.text().await? ); // Create initial trigger let resp = authed( client().post(format!("{base}/websocket_triggers/create")), "ORIGINAL_TOKEN", ) .json(&new_websocket_trigger( "u/original-user/ws_to_update", "u/original-user/ws_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create websocket trigger: {}", resp.text().await? ); // Verify initial state let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", "u/original-user/ws_to_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!(trigger.permissioned_as, "u/original-user"); assert_eq!(trigger.edited_by, "original-user"); // Admin updates with preserve flag let resp = authed( client().post(format!( "{base}/websocket_triggers/update/u/original-user/ws_to_update" )), "SECRET_TOKEN", ) .json(&new_websocket_trigger( "u/original-user/ws_to_update", "u/original-user/ws_script", Some("u/original-user"), true, )) .send() .await?; assert_eq!( resp.status(), 200, "Admin should update websocket trigger: {}", resp.text().await? ); let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", "u/original-user/ws_to_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( trigger.permissioned_as, "u/original-user", "Admin update should preserve websocket trigger email" ); assert_eq!( trigger.edited_by, "test-user", "edited_by should be the deploying user (admin)" ); // ======================================== // Deployer updates with preserve flag // ======================================== // Create script under deployer's path let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/deployer-user/ws_deploy_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create script: {}", resp.text().await? ); // Admin creates trigger under deployer's path with preserve let resp = authed( client().post(format!("{base}/websocket_triggers/create")), "SECRET_TOKEN", ) .json(&new_websocket_trigger( "u/deployer-user/ws_deploy_update", "u/deployer-user/ws_deploy_script", Some("u/original-user"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create websocket trigger: {}", resp.text().await? ); // Deployer updates at their own path with preserve let resp = authed( client().post(format!( "{base}/websocket_triggers/update/u/deployer-user/ws_deploy_update" )), "DEPLOYER_TOKEN", ) .json(&new_websocket_trigger( "u/deployer-user/ws_deploy_update", "u/deployer-user/ws_deploy_script", Some("u/original-user"), true, )) .send() .await?; assert_eq!( resp.status(), 200, "Deployer should update websocket trigger: {}", resp.text().await? ); let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", "u/deployer-user/ws_deploy_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( trigger.permissioned_as, "u/original-user", "Deployer update should preserve websocket trigger email" ); assert_eq!( trigger.edited_by, "deployer-user", "edited_by should be the deploying user (deployer)" ); // ======================================== // Non-admin cannot preserve on update // ======================================== // Create script under non-admin's path let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/test-user-2/ws_nonadmin_script", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create script: {}", resp.text().await? ); // Admin creates trigger under non-admin's path with preserve let resp = authed( client().post(format!("{base}/websocket_triggers/create")), "SECRET_TOKEN", ) .json(&new_websocket_trigger( "u/test-user-2/ws_nonadmin_update", "u/test-user-2/ws_nonadmin_script", Some("u/original-user"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create websocket trigger: {}", resp.text().await? ); // Non-admin updates at their own path with preserve (should be denied) let resp = authed( client().post(format!( "{base}/websocket_triggers/update/u/test-user-2/ws_nonadmin_update" )), "SECRET_TOKEN_2", ) .json(&new_websocket_trigger( "u/test-user-2/ws_nonadmin_update", "u/test-user-2/ws_nonadmin_script", Some("u/original-user"), true, )) .send() .await?; assert_eq!( resp.status(), 200, "Non-admin should update websocket trigger: {}", resp.text().await? ); let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", "u/test-user-2/ws_nonadmin_update", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( trigger.permissioned_as, "u/test-user-2", "Non-admin update should overwrite websocket trigger email with their own" ); assert_eq!( trigger.edited_by, "test-user-2", "Non-admin update should overwrite websocket trigger edited_by with their own" ); Ok(()) } /// Schedule: Admin sets permissioned_as to a group #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_schedule_group_permissioned_as(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // Create a script for the schedule let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/test-user/scheduled_script_group", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create script: {}", resp.text().await? ); // Admin creates schedule with group-based permissioned_as let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN", ) .json(&json!({ "path": "u/test-user/schedule_group_perm", "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": "u/test-user/scheduled_script_group", "is_flow": false, "enabled": false, "permissioned_as": "g/all", "preserve_permissioned_as": true })) .send() .await?; assert_eq!( resp.status(), 200, "Admin should create schedule with group permissioned_as: {}", resp.text().await? ); let schedule = sqlx::query!( "SELECT permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/test-user/schedule_group_perm", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( schedule.permissioned_as, "g/all", "Admin should preserve group-based permissioned_as" ); assert_eq!( schedule.edited_by, "test-user", "edited_by should be the deploying user, not the group" ); Ok(()) } /// HTTP Trigger: Admin sets permissioned_as to a group #[cfg(feature = "http_trigger")] #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_http_trigger_group_permissioned_as(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // Create a script first let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/test-user/http_handler_group", None, false, )) .send() .await?; assert_eq!( resp.status(), 201, "Should create script: {}", resp.text().await? ); let resp = authed( client().post(format!("{base}/http_triggers/create")), "SECRET_TOKEN", ) .json(&new_http_trigger( "u/test-user/http_trigger_group_perm", "u/test-user/http_handler_group", "group-perm", Some("g/all"), true, )) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create trigger with group permissioned_as: {}", resp.text().await? ); let trigger = sqlx::query!( "SELECT permissioned_as, edited_by FROM http_trigger WHERE path = $1 AND workspace_id = $2", "u/test-user/http_trigger_group_perm", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( trigger.permissioned_as, "g/all", "Admin should preserve group-based permissioned_as on trigger" ); assert_eq!( trigger.edited_by, "test-user", "edited_by should be the deploying user, not the group" ); Ok(()) } // ============================================================================ // Schedule Create/Update Permission Tests (without preserve) // ============================================================================ // Verify that schedule create and update correctly set permissioned_as, email, // edited_by on the schedule, and that the pushed job has correct created_by, // permissioned_as, and permissioned_as_email fields. /// Helper to create a schedule and return the schedule + job fields async fn create_schedule_and_get_job( base: &str, token: &str, schedule_path: &str, script_path: &str, db: &Pool, ) -> anyhow::Result<( // schedule fields String, // email String, // permissioned_as String, // edited_by // job fields String, // created_by String, // permissioned_as String, // permissioned_as_email )> { let resp = authed(client().post(format!("{base}/schedules/create")), token) .json(&json!({ "path": schedule_path, "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": script_path, "is_flow": false, "enabled": true })) .send() .await?; assert_eq!( resp.status(), 200, "Should create schedule: {}", resp.text().await? ); let schedule = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", schedule_path, "test-workspace" ) .fetch_one(db) .await?; // Wait briefly for the job to be pushed tokio::time::sleep(std::time::Duration::from_millis(200)).await; let job = sqlx::query!( r#"SELECT created_by, permissioned_as, permissioned_as_email FROM v2_job WHERE workspace_id = 'test-workspace' AND trigger_kind = 'schedule' AND trigger = $1 ORDER BY created_at DESC LIMIT 1"#, schedule_path ) .fetch_one(db) .await?; Ok(( schedule.email, schedule.permissioned_as, schedule.edited_by, job.created_by, job.permissioned_as, job.permissioned_as_email, )) } /// Normal user creates a schedule — all fields should reflect that user #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_schedule_permissions_normal_user(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // Create script owned by the normal user let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2", ) .json(&new_script_with_on_behalf_of( "u/test-user-2/sched_perm_script", None, false, )) .send() .await?; assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?); let (sched_email, sched_pa, sched_edited_by, _job_created_by, job_pa, job_pa_email) = create_schedule_and_get_job( &base, "SECRET_TOKEN_2", "u/test-user-2/normal_user_schedule", "u/test-user-2/sched_perm_script", &db, ) .await?; assert_eq!(sched_email, "test2@windmill.dev", "schedule email"); assert_eq!(sched_pa, "u/test-user-2", "schedule permissioned_as"); assert_eq!(sched_edited_by, "test-user-2", "schedule edited_by"); assert_eq!(job_pa, "u/test-user-2", "job permissioned_as"); assert_eq!( job_pa_email, "test2@windmill.dev", "job permissioned_as_email" ); // Now update the schedule (normal edit, no preserve) — fields should stay as the same user let resp = authed( client().post(format!( "{base}/schedules/update/u/test-user-2/normal_user_schedule" )), "SECRET_TOKEN_2", ) .json(&json!({ "schedule": "0 0 */12 * * *", "timezone": "UTC" })) .send() .await?; assert_eq!( resp.status(), 200, "update schedule: {}", resp.text().await? ); let schedule = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/test-user-2/normal_user_schedule", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!(schedule.email, "test2@windmill.dev", "email after update"); assert_eq!( schedule.permissioned_as, "u/test-user-2", "permissioned_as after update" ); assert_eq!(schedule.edited_by, "test-user-2", "edited_by after update"); Ok(()) } /// Workspace admin creates a schedule — all fields should reflect the admin #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_schedule_permissions_workspace_admin(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // test-user is admin + superadmin in workspace let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/test-user/admin_sched_script", None, false, )) .send() .await?; assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?); let (sched_email, sched_pa, sched_edited_by, _job_created_by, job_pa, job_pa_email) = create_schedule_and_get_job( &base, "SECRET_TOKEN", "u/test-user/admin_schedule", "u/test-user/admin_sched_script", &db, ) .await?; assert_eq!(sched_email, "test@windmill.dev", "schedule email"); assert_eq!(sched_pa, "u/test-user", "schedule permissioned_as"); assert_eq!(sched_edited_by, "test-user", "schedule edited_by"); assert_eq!(job_pa, "u/test-user", "job permissioned_as"); assert_eq!( job_pa_email, "test@windmill.dev", "job permissioned_as_email" ); // Admin edits a schedule owned by normal user — should take over ownership let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2", ) .json(&new_script_with_on_behalf_of( "u/test-user-2/admin_edit_target_script", None, false, )) .send() .await?; assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?); // Normal user creates it let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN_2", ) .json(&json!({ "path": "u/test-user-2/admin_edit_target", "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": "u/test-user-2/admin_edit_target_script", "is_flow": false, "enabled": false })) .send() .await?; assert_eq!( resp.status(), 200, "create schedule: {}", resp.text().await? ); // Admin edits it (no preserve) let resp = authed( client().post(format!( "{base}/schedules/update/u/test-user-2/admin_edit_target" )), "SECRET_TOKEN", ) .json(&json!({ "schedule": "0 0 */12 * * *", "timezone": "UTC" })) .send() .await?; assert_eq!(resp.status(), 200, "admin update: {}", resp.text().await?); let schedule = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/test-user-2/admin_edit_target", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( schedule.email, "test@windmill.dev", "admin edit takes over email" ); assert_eq!( schedule.permissioned_as, "u/test-user", "admin edit takes over permissioned_as" ); assert_eq!(schedule.edited_by, "test-user", "admin edit sets edited_by"); Ok(()) } /// Superadmin NOT in workspace creates a schedule — uses email as permissioned_as #[sqlx::test(fixtures("preserve_on_behalf_of"))] async fn test_schedule_permissions_superadmin_not_in_workspace( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let base = format!("http://localhost:{port}/api/w/test-workspace"); // Superadmin not in workspace creates a script let resp = authed( client().post(format!("{base}/scripts/create")), "EXTERNAL_SUPERADMIN_TOKEN", ) .json(&new_script_with_on_behalf_of( "u/superadmin-external/sa_sched_script", None, false, )) .send() .await?; assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?); // Superadmin creates a schedule let resp = authed( client().post(format!("{base}/schedules/create")), "EXTERNAL_SUPERADMIN_TOKEN", ) .json(&json!({ "path": "u/superadmin-external/sa_schedule", "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": "u/superadmin-external/sa_sched_script", "is_flow": false, "enabled": false })) .send() .await?; assert_eq!( resp.status(), 200, "superadmin create schedule: {}", resp.text().await? ); let schedule = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/superadmin-external/sa_schedule", "test-workspace" ) .fetch_one(&db) .await?; // Superadmin not in workspace: username_to_permissioned_as uses the email directly // since the authed username for a superadmin not in workspace IS the email assert_eq!( schedule.email, "superadmin-external@windmill.dev", "schedule email should be superadmin email" ); assert_eq!( schedule.permissioned_as, schedule.email.clone(), "permissioned_as should match email for superadmin not in workspace" ); // Update by the same superadmin let resp = authed( client().post(format!( "{base}/schedules/update/u/superadmin-external/sa_schedule" )), "EXTERNAL_SUPERADMIN_TOKEN", ) .json(&json!({ "schedule": "0 0 */12 * * *", "timezone": "UTC" })) .send() .await?; assert_eq!( resp.status(), 200, "superadmin update: {}", resp.text().await? ); let schedule_after = sqlx::query!( "SELECT email, permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "u/superadmin-external/sa_schedule", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( schedule_after.permissioned_as, schedule.permissioned_as, "permissioned_as should remain the same after self-edit" ); assert_eq!( schedule_after.email, schedule.email, "email should remain the same after self-edit" ); Ok(()) }