//! Integration tests for folder `default_permissioned_as` rules. //! //! These tests exhaustively verify the feature across every surface where a //! `permissioned_as` / `on_behalf_of_email` default is applied at create-time: //! //! - folder CRUD (persistence, validation) //! - audit log emission //! - schedules, flows, scripts, apps — all entry points //! - admin / wm_deployers / regular-user behavior //! - create-only semantics (updates never rewrite) //! - explicit preserve beats folder default //! - first-match-wins ordering for overlapping rules //! - stale rule rejection //! - paths outside folders are untouched //! //! The tests share one workspace and one folder because ApiServer::start has //! significant setup cost; keeping everything in one test minimizes total run //! time while still exercising every branch. 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)) } fn new_script(path: &str) -> serde_json::Value { 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": [] } }) } fn new_flow(path: &str) -> serde_json::Value { json!({ "path": path, "summary": "Test flow", "description": "", "value": { "modules": [] }, "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {}, "required": [] } }) } fn new_app(path: &str) -> serde_json::Value { json!({ "path": path, "summary": "Test app", "value": { "type": "rawapp", "inline_script": null }, "policy": { "execution_mode": "anonymous", "triggerables": {} } }) } fn new_schedule(path: &str, script_path: &str) -> serde_json::Value { json!({ "path": path, "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": script_path, "is_flow": false, "enabled": false, }) } /// Exhaustive create-time folder default_permissioned_as coverage. #[sqlx::test(fixtures("folder_default_permissioned_as"))] async fn test_folder_default_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"); // ======================================================================== // 0. Setup — create the folder with no rules (admin becomes owner) // and grant the deployer + regular users writer access so they can // deploy items inside it. // ======================================================================== let resp = authed( client().post(format!("{base}/folders/create")), "SECRET_TOKEN", ) .json(&json!({ "name": "prodfolder", "extra_perms": { "u/deployer-user": true, "u/test-user-2": true } })) .send() .await?; assert_eq!( resp.status(), 200, "Admin should create folder: {}", resp.text().await? ); // A helper script that schedules reference let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script("f/prodfolder/helper")) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create helper script: {}", resp.text().await? ); // ======================================================================== // 1. Rule validation — bad inputs are rejected with 400 // ======================================================================== // Non-array let resp = authed( client().post(format!("{base}/folders/update/prodfolder")), "SECRET_TOKEN", ) .json(&json!({ "default_permissioned_as": { "not": "an array" } })) .send() .await?; assert_eq!(resp.status(), 400, "non-array should 400"); // Invalid glob let resp = authed( client().post(format!("{base}/folders/update/prodfolder")), "SECRET_TOKEN", ) .json(&json!({ "default_permissioned_as": [{ "path_glob": "[unclosed", "permissioned_as": "u/original-user" }] })) .send() .await?; assert_eq!(resp.status(), 400, "invalid glob should 400"); let body = resp.text().await?; assert!( body.contains("path_glob is not a valid glob"), "error should mention glob: {body}" ); // Invalid permissioned_as format let resp = authed( client().post(format!("{base}/folders/update/prodfolder")), "SECRET_TOKEN", ) .json(&json!({ "default_permissioned_as": [{ "path_glob": "**", "permissioned_as": "bogus" }] })) .send() .await?; assert_eq!(resp.status(), 400, "invalid permissioned_as should 400"); // Missing required field let resp = authed( client().post(format!("{base}/folders/update/prodfolder")), "SECRET_TOKEN", ) .json(&json!({ "default_permissioned_as": [{ "path_glob": "**" }] })) .send() .await?; assert_eq!(resp.status(), 400, "missing permissioned_as should 400"); // ======================================================================== // 2. Rule persistence — valid rules round-trip through GET folder // ======================================================================== let resp = authed( client().post(format!("{base}/folders/update/prodfolder")), "SECRET_TOKEN", ) .json(&json!({ "default_permissioned_as": [ { "path_glob": "jobs/critical/**", "permissioned_as": "u/original-user" }, { "path_glob": "jobs/**", "permissioned_as": "g/wm_deployers" }, { "path_glob": "reports/*", "permissioned_as": "original@windmill.dev" } ] })) .send() .await?; assert_eq!( resp.status(), 200, "Valid rules should update: {}", resp.text().await? ); let resp = authed( client().get(format!("{base}/folders/get/prodfolder")), "SECRET_TOKEN", ) .send() .await?; assert_eq!(resp.status(), 200); let folder: serde_json::Value = resp.json().await?; let rules = folder .get("default_permissioned_as") .and_then(|v| v.as_array()) .expect("rules should be an array"); assert_eq!(rules.len(), 3, "should have 3 rules"); assert_eq!(rules[0]["path_glob"], "jobs/critical/**"); assert_eq!(rules[0]["permissioned_as"], "u/original-user"); assert_eq!(rules[1]["permissioned_as"], "g/wm_deployers"); assert_eq!(rules[2]["permissioned_as"], "original@windmill.dev"); // ======================================================================== // 4. Schedules — the core matrix // ======================================================================== // 4a. Admin, matching path — folder default wins let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN", ) .json(&new_schedule( "f/prodfolder/jobs/sched_admin_match", "f/prodfolder/helper", )) .send() .await?; assert_eq!( resp.status(), 200, "Admin should create schedule: {}", resp.text().await? ); let sched = sqlx::query!( "SELECT permissioned_as, email FROM schedule WHERE path = $1 AND workspace_id = $2", "f/prodfolder/jobs/sched_admin_match", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( sched.permissioned_as, "g/wm_deployers", "matching rule should apply (jobs/** wins over critical since path is not critical)" ); // 4b. Admin, path matching the MORE SPECIFIC rule which is listed first let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN", ) .json(&new_schedule( "f/prodfolder/jobs/critical/prod_run", "f/prodfolder/helper", )) .send() .await?; assert_eq!( resp.status(), 200, "Admin should create critical schedule: {}", resp.text().await? ); let sched = sqlx::query!( "SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2", "f/prodfolder/jobs/critical/prod_run", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( sched.permissioned_as, "u/original-user", "first matching rule wins (critical/** listed first)" ); // 4c. Admin, non-matching path — acting user identity let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN", ) .json(&new_schedule( "f/prodfolder/dev/sched_no_match", "f/prodfolder/helper", )) .send() .await?; assert_eq!( resp.status(), 200, "Admin should create non-matching schedule: {}", resp.text().await? ); let sched = sqlx::query!( "SELECT permissioned_as, email FROM schedule WHERE path = $1 AND workspace_id = $2", "f/prodfolder/dev/sched_no_match", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( sched.permissioned_as, "u/test-user", "non-matching path falls back to acting user" ); assert_eq!(sched.email, "test@windmill.dev"); // 4d. Deployer (non-admin in wm_deployers), matching path — default applies let resp = authed( client().post(format!("{base}/schedules/create")), "DEPLOYER_TOKEN", ) .json(&new_schedule( "f/prodfolder/jobs/sched_deployer", "f/prodfolder/helper", )) .send() .await?; assert_eq!( resp.status(), 200, "Deployer should create schedule: {}", resp.text().await? ); let sched = sqlx::query!( "SELECT permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", "f/prodfolder/jobs/sched_deployer", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( sched.permissioned_as, "g/wm_deployers", "deployer gets folder default" ); assert_eq!( sched.edited_by, "deployer-user", "edited_by is still the acting user" ); // 4e. Non-admin/non-deployer, matching path — default is NOT applied let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN_2", ) .json(&new_schedule( "f/prodfolder/jobs/sched_regular", "f/prodfolder/helper", )) .send() .await?; assert_eq!( resp.status(), 200, "Regular user should create schedule: {}", resp.text().await? ); let sched = sqlx::query!( "SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2", "f/prodfolder/jobs/sched_regular", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( sched.permissioned_as, "u/test-user-2", "regular user never gets folder default" ); // 4f. Explicit preserve beats folder default let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN", ) .json(&json!({ "path": "f/prodfolder/jobs/sched_preserve", "schedule": "0 0 */6 * * *", "timezone": "UTC", "script_path": "f/prodfolder/helper", "is_flow": false, "enabled": false, "permissioned_as": "u/original-user", "preserve_permissioned_as": true })) .send() .await?; assert_eq!( resp.status(), 200, "Admin should create preserved schedule: {}", resp.text().await? ); let sched = sqlx::query!( "SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2", "f/prodfolder/jobs/sched_preserve", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( sched.permissioned_as, "u/original-user", "explicit preserve beats folder default (even though folder default would be g/wm_deployers)" ); // 4g. Update does NOT rewrite permissioned_as to folder default // Edit the non-matching schedule created in 4c, with no permissioned_as in the payload. let resp = authed( client().post(format!( "{base}/schedules/update/f/prodfolder/dev/sched_no_match" )), "SECRET_TOKEN", ) .json(&json!({ "schedule": "0 0 */12 * * *", "timezone": "UTC", "script_path": "f/prodfolder/helper", "is_flow": false, })) .send() .await?; assert_eq!( resp.status(), 200, "Admin should update schedule: {}", resp.text().await? ); let sched = sqlx::query!( "SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2", "f/prodfolder/dev/sched_no_match", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( sched.permissioned_as, "u/test-user", "update still shows acting user — the existing schedule wasn't rewritten by a folder rule change (this would be defaults-on-update, which we don't do)" ); // ======================================================================== // 5. Flows — on_behalf_of_email variant // ======================================================================== // 5a. Admin, matching path let resp = authed( client().post(format!("{base}/flows/create")), "SECRET_TOKEN", ) .json(&new_flow("f/prodfolder/jobs/flow_admin_match")) .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", "f/prodfolder/jobs/flow_admin_match", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( flow.on_behalf_of_email.as_deref(), Some("group-wm_deployers@windmill.dev"), "flow should resolve folder default to group email" ); // 5b. Admin, reports/* rule (email directly as permissioned_as) let resp = authed( client().post(format!("{base}/flows/create")), "SECRET_TOKEN", ) .json(&new_flow("f/prodfolder/reports/weekly")) .send() .await?; assert_eq!( resp.status(), 201, "Admin should create reports flow: {}", resp.text().await? ); let flow = sqlx::query!( "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", "f/prodfolder/reports/weekly", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( flow.on_behalf_of_email.as_deref(), Some("original@windmill.dev"), "email rule should pass through as-is" ); // 5c. Non-matching flow path — no default let resp = authed( client().post(format!("{base}/flows/create")), "SECRET_TOKEN", ) .json(&new_flow("f/prodfolder/dev/flow_no_match")) .send() .await?; assert_eq!(resp.status(), 201); let flow = sqlx::query!( "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", "f/prodfolder/dev/flow_no_match", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( flow.on_behalf_of_email, None, "no folder default ⇒ no on_behalf_of_email written" ); // 5d. Path outside any folder (user folder) — default never applies let resp = authed( client().post(format!("{base}/flows/create")), "SECRET_TOKEN", ) .json(&new_flow("u/test-user/outside_flow")) .send() .await?; assert_eq!(resp.status(), 201); let flow = sqlx::query!( "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", "u/test-user/outside_flow", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( flow.on_behalf_of_email, None, "paths outside folders are never touched" ); // ======================================================================== // 6. Scripts — first-time create applies default; subsequent updates don't // ======================================================================== // 6a. Admin creates a new script at matching path let resp = authed( client().post(format!("{base}/scripts/create")), "SECRET_TOKEN", ) .json(&new_script("f/prodfolder/jobs/new_script")) .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 ORDER BY created_at DESC LIMIT 1", "f/prodfolder/jobs/new_script", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( script.on_behalf_of_email.as_deref(), Some("group-wm_deployers@windmill.dev"), "new script at matching path gets folder default" ); // Note: Windmill scripts require the previous non-archived version to be // explicitly archived before a new hash can be created at the same path. // We rely on the existence check inside `create_script_internal` // (`path_already_exists` → skip folder default) which is unit-level logic // exercised on every subsequent deploy at an existing path. The flow 5c // case above covers create-only semantics end-to-end through the API. // ======================================================================== // 7. Apps — policy.on_behalf_of variant // ======================================================================== // 7a. Admin, matching path let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN") .json(&new_app("f/prodfolder/jobs/app_match")) .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", "f/prodfolder/jobs/app_match", "test-workspace" ) .fetch_one(&db) .await?; let policy: serde_json::Value = app.policy; assert_eq!( policy["on_behalf_of"], "g/wm_deployers", "app policy.on_behalf_of gets folder default" ); assert_eq!( policy["on_behalf_of_email"], "group-wm_deployers@windmill.dev", "app policy.on_behalf_of_email gets folder default email" ); // 7b. Admin, non-matching path — acting user let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN") .json(&new_app("f/prodfolder/dev/app_no_match")) .send() .await?; assert_eq!(resp.status(), 201); let app = sqlx::query!( "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", "f/prodfolder/dev/app_no_match", "test-workspace" ) .fetch_one(&db) .await?; let policy: serde_json::Value = app.policy; assert_eq!(policy["on_behalf_of"], "u/test-user"); // 7c. Regular user, matching path — acting user (not folder default) let resp = authed( client().post(format!("{base}/apps/create")), "SECRET_TOKEN_2", ) .json(&new_app("f/prodfolder/jobs/app_regular")) .send() .await?; assert_eq!( resp.status(), 201, "Regular user should create app: {}", resp.text().await? ); let app = sqlx::query!( "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", "f/prodfolder/jobs/app_regular", "test-workspace" ) .fetch_one(&db) .await?; let policy: serde_json::Value = app.policy; assert_eq!( policy["on_behalf_of"], "u/test-user-2", "regular user never gets folder default in app policy" ); // ======================================================================== // 8. Stale rule — rule resolves to a user that does not exist // ======================================================================== let resp = authed( client().post(format!("{base}/folders/update/prodfolder")), "SECRET_TOKEN", ) .json(&json!({ "default_permissioned_as": [ { "path_glob": "stale/**", "permissioned_as": "u/ghost" } ] })) .send() .await?; assert_eq!( resp.status(), 200, "Admin should update folder rules: {}", resp.text().await? ); let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN", ) .json(&new_schedule( "f/prodfolder/stale/should_fail", "f/prodfolder/helper", )) .send() .await?; assert_eq!( resp.status(), 400, "stale rule should return 400 when a new schedule matches it" ); let body = resp.text().await?; assert!( body.contains("u/ghost") && body.contains("does not exist"), "error should identify the stale user: {body}" ); // Non-matching paths in the same folder still work fine. let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN", ) .json(&new_schedule("f/prodfolder/safe/ok", "f/prodfolder/helper")) .send() .await?; assert_eq!( resp.status(), 200, "non-matching path should not hit the stale rule: {}", resp.text().await? ); // ======================================================================== // 9. Clearing rules by passing an empty array // ======================================================================== let resp = authed( client().post(format!("{base}/folders/update/prodfolder")), "SECRET_TOKEN", ) .json(&json!({ "default_permissioned_as": [] })) .send() .await?; assert_eq!(resp.status(), 200); let resp = authed( client().post(format!("{base}/schedules/create")), "SECRET_TOKEN", ) .json(&new_schedule( "f/prodfolder/jobs/cleared", "f/prodfolder/helper", )) .send() .await?; assert_eq!(resp.status(), 200); let sched = sqlx::query!( "SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2", "f/prodfolder/jobs/cleared", "test-workspace" ) .fetch_one(&db) .await?; assert_eq!( sched.permissioned_as, "u/test-user", "after rules cleared, new schedule uses acting user again" ); Ok(()) }