From cb2957f02edc8fad2b99c52d23f187cfc20adc75 Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:35:38 +0100 Subject: [PATCH] add test for protection rulesets (#7889) --- backend/tests/protection_rules.rs | 208 ++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 backend/tests/protection_rules.rs diff --git a/backend/tests/protection_rules.rs b/backend/tests/protection_rules.rs new file mode 100644 index 0000000000..31198f9c9c --- /dev/null +++ b/backend/tests/protection_rules.rs @@ -0,0 +1,208 @@ +//! Integration tests for workspace protection rulesets. +//! +//! Tests verify that DisableDirectDeployment protection rules correctly +//! block/allow operations based on user permissions. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::workspaces::invalidate_protection_rules_cache; + +mod common; +use common::*; + +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, summary: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": summary, + "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, summary: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": summary, + "description": "", + "value": { "modules": [] }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }) +} + +/// Comprehensive test for protection rules functionality. +/// Tests all essential cases in a single test to avoid cache interference. +#[sqlx::test(fixtures("base"))] +async fn test_protection_rules(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + invalidate_protection_rules_cache("test-workspace"); + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // ======================================== + // 1. Without protection rule, non-admin can create scripts and flows + // ======================================== + + let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2") + .json(&new_script("u/test-user-2/script_no_rule", "No rule")) + .send() + .await?; + assert_eq!(resp.status(), 201, "Should create script without rule: {}", resp.text().await?); + + let resp = authed(client().post(format!("{base}/flows/create")), "SECRET_TOKEN_2") + .json(&new_flow("u/test-user-2/flow_no_rule", "No rule")) + .send() + .await?; + assert_eq!(resp.status(), 201, "Should create flow without rule: {}", resp.text().await?); + + // ======================================== + // 2. Non-admin cannot create protection rules + // ======================================== + + let resp = authed(client().post(format!("{base}/workspaces/protection_rules")), "SECRET_TOKEN_2") + .json(&json!({ + "name": "unauthorized-rule", + "rules": ["DisableDirectDeployment"], + "bypass_users": [], + "bypass_groups": [] + })) + .send() + .await?; + assert!(!resp.status().is_success(), "Non-admin should not create rules: {}", resp.status()); + + // ======================================== + // 3. Admin creates protection rule + // ======================================== + + let resp = authed(client().post(format!("{base}/workspaces/protection_rules")), "SECRET_TOKEN") + .json(&json!({ + "name": "test-rule", + "rules": ["DisableDirectDeployment"], + "bypass_users": [], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "Admin should create rule: {}", resp.text().await?); + + // ======================================== + // 4. With rule, non-admin is blocked from creating scripts/flows + // ======================================== + + let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2") + .json(&new_script("u/test-user-2/blocked_script", "Blocked")) + .send() + .await?; + assert!(!resp.status().is_success(), "Non-admin should be blocked from scripts: {}", resp.status()); + let body = resp.text().await?; + assert!(body.contains("blocked") || body.contains("Blocked"), "Error should mention blocking: {}", body); + + let resp = authed(client().post(format!("{base}/flows/create")), "SECRET_TOKEN_2") + .json(&new_flow("u/test-user-2/blocked_flow", "Blocked")) + .send() + .await?; + assert!(!resp.status().is_success(), "Non-admin should be blocked from flows: {}", resp.status()); + + // ======================================== + // 5. Admin bypasses protection rule + // ======================================== + + let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN") + .json(&new_script("u/test-user/admin_script", "Admin")) + .send() + .await?; + assert_eq!(resp.status(), 201, "Admin should bypass rule: {}", resp.text().await?); + + // ======================================== + // 6. Update rule to bypass test-user-2 + // ======================================== + + let resp = authed(client().post(format!("{base}/workspaces/protection_rules/test-rule")), "SECRET_TOKEN") + .json(&json!({ + "rules": ["DisableDirectDeployment"], + "bypass_users": ["test-user-2"], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "Should update rule: {}", resp.text().await?); + + // Invalidate cache to pick up the update + invalidate_protection_rules_cache("test-workspace"); + + // ======================================== + // 7. Bypassed user can now create + // ======================================== + + let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2") + .json(&new_script("u/test-user-2/bypassed_script", "Bypassed")) + .send() + .await?; + assert_eq!(resp.status(), 201, "Bypassed user should create: {}", resp.text().await?); + + // ======================================== + // 8. Non-bypassed user (test-user-3) is still blocked + // ======================================== + + let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_3") + .json(&new_script("u/test-user-3/still_blocked", "Blocked")) + .send() + .await?; + assert!(!resp.status().is_success(), "Non-bypassed user should be blocked: {}", resp.status()); + + // ======================================== + // 9. Delete rule + // ======================================== + + let resp = authed(client().delete(format!("{base}/workspaces/protection_rules/test-rule")), "SECRET_TOKEN") + .send() + .await?; + assert_eq!(resp.status(), 200, "Should delete rule: {}", resp.text().await?); + + // Invalidate cache to pick up the deletion + invalidate_protection_rules_cache("test-workspace"); + + // ======================================== + // 10. After deletion, non-admin can create again + // ======================================== + + let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_3") + .json(&new_script("u/test-user-3/after_delete", "After delete")) + .send() + .await?; + assert_eq!(resp.status(), 201, "Should create after rule deletion: {}", resp.text().await?); + + // ======================================== + // 11. Verify rule list is empty + // ======================================== + + let resp = authed(client().get(format!("{base}/workspaces/protection_rules")), "SECRET_TOKEN") + .send() + .await?; + assert_eq!(resp.status(), 200); + let rules: Vec = resp.json().await?; + assert!(rules.is_empty(), "Should have no rules after deletion"); + + Ok(()) +}