/*! * Integration tests for workspace dependencies git sync. * * These tests verify that creating, archiving, and deleting workspace dependencies * triggers deployment callback jobs with the correct arguments for git sync. * * Run with enterprise features: * ```bash * cargo test --test workspace_dependencies_git_sync --features enterprise,private * ``` */ use serde_json::json; use sqlx::{Pool, Postgres}; use std::time::Duration; #[allow(unused_imports)] use windmill_test_utils::*; /// Row shape for querying deployment callback jobs from v2_job_queue #[derive(Debug)] #[allow(dead_code)] struct DeploymentCallbackJob { id: uuid::Uuid, runnable_path: Option, args: Option, kind: String, } /// Poll for deployment callback jobs in the queue for a given script path #[allow(dead_code)] async fn get_deployment_callback_jobs( db: &Pool, script_path: &str, timeout: Duration, ) -> anyhow::Result> { let deadline = tokio::time::Instant::now() + timeout; loop { let rows = sqlx::query_as!( DeploymentCallbackJob, r#" SELECT j.id, j.runnable_path, j.args, j.kind::text AS "kind!" FROM v2_job j JOIN v2_job_queue q ON j.id = q.id WHERE j.runnable_path = $1 AND j.kind = 'deploymentcallback' ORDER BY j.created_at DESC "#, script_path, ) .fetch_all(db) .await?; if !rows.is_empty() { return Ok(rows); } if tokio::time::Instant::now() >= deadline { // Return empty if timeout - caller will handle assertion return Ok(vec![]); } tokio::time::sleep(Duration::from_millis(100)).await; } } /// Configure git sync for the test workspace with workspace dependencies enabled #[allow(dead_code)] async fn setup_git_sync_config(db: &Pool, sync_script_path: &str) -> anyhow::Result<()> { let git_sync_config = json!({ "include_type": ["workspacedependencies"], "include_path": ["**"], "repositories": [{ "script_path": sync_script_path, "git_repo_resource_path": "$res:u/test-user/test_git_repo", "use_individual_branch": false, "group_by_folder": false }] }); sqlx::query!( "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", git_sync_config, "test-workspace" ) .execute(db) .await?; Ok(()) } /// Create a git repository resource for testing #[allow(dead_code)] async fn create_git_repo_resource(db: &Pool) -> anyhow::Result<()> { sqlx::query( r#" INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) VALUES ('test-workspace', 'u/test-user/test_git_repo', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user') ON CONFLICT (workspace_id, path) DO NOTHING "#, ) .bind(json!({ "url": "https://github.com/test/test.git", "branch": "main", "token": "test-token" })) .execute(db) .await?; Ok(()) } /// Create a dummy sync script for testing (with version >= 28103 for debouncing support) #[allow(dead_code)] async fn create_sync_script(db: &Pool, path: &str) -> anyhow::Result { let hash: i64 = rand::random::().unsigned_abs() as i64; sqlx::query( r#" INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, kind, lock) VALUES ('test-workspace', $1, $2, 'sync script', '', 'export function main(items: any[]) { return { synced: items.length }; }', 'test-user', 'bun', 'script', '') "#, ) .bind(hash) .bind(path) .execute(db) .await?; Ok(hash) } /// Create a folder for the versioned script path #[allow(dead_code)] async fn create_folder(db: &Pool, name: &str) -> anyhow::Result<()> { sqlx::query( r#" INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) VALUES ('test-workspace', $1, $1, ARRAY['u/test-user'], '{}'::jsonb, 'test-user') ON CONFLICT (workspace_id, name) DO NOTHING "#, ) .bind(name) .execute(db) .await?; Ok(()) } // ============================================================================ // Tests // ============================================================================ /// Test that creating a workspace dependency triggers a git sync deployment callback /// with the correct path_type and path arguments. #[cfg(all(feature = "enterprise", feature = "private"))] #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_create_workspace_dependencies_triggers_git_sync( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; // Setup: Create folder, git repo resource, sync script, and configure git sync create_folder(&db, "28103").await?; create_git_repo_resource(&db).await?; let sync_script_path = "f/28103/test_sync_script"; create_sync_script(&db, sync_script_path).await?; setup_git_sync_config(&db, sync_script_path).await?; // Start API server let (client, _port, _server) = init_client(db.clone()).await; // Create workspace dependency via API let response = client .client() .post(format!( "{}/w/test-workspace/workspace_dependencies/create", client.baseurl() )) .json(&json!({ "workspace_id": "test-workspace", "language": "python3", "name": "test-deps", "content": "requests==2.28.0\nnumpy==1.24.0" })) .send() .await?; assert!( response.status().is_success(), "Failed to create workspace dependency: {:?}", response.text().await ); // Wait for deployment callback job to be created tokio::time::sleep(Duration::from_millis(500)).await; // Query for deployment callback jobs let jobs = get_deployment_callback_jobs(&db, sync_script_path, Duration::from_secs(5)).await?; assert!( !jobs.is_empty(), "Expected at least one deployment callback job to be created" ); // Verify the job arguments let job = &jobs[0]; let args = job.args.as_ref().expect("Job should have args"); // Check that path_type is "workspace_dependencies" (or check items array) // The exact structure depends on whether debouncing is enabled if let Some(items) = args.get("items") { // Debounced format: items is an array let items_arr = items.as_array().expect("items should be an array"); assert!(!items_arr.is_empty(), "items array should not be empty"); let item = &items_arr[0]; assert_eq!( item.get("path_type").and_then(|v| v.as_str()), Some("workspace_dependencies"), "path_type should be 'workspace_dependencies'" ); // Path should be "dependencies/test-deps.requirements.in" or similar let path = item.get("path").and_then(|v| v.as_str()).unwrap_or(""); assert!( path.contains("dependencies") || path.contains("requirements"), "path should contain dependencies or requirements: got {}", path ); } else if let Some(path_type) = args.get("path_type") { // Non-debounced format: path_type is a direct field assert_eq!( path_type.as_str(), Some("workspace_dependencies"), "path_type should be 'workspace_dependencies'" ); } else { panic!( "Job args should contain either 'items' array or 'path_type' field: {:?}", args ); } Ok(()) } /// Test that archiving a workspace dependency triggers a git sync deployment callback #[cfg(all(feature = "enterprise", feature = "private"))] #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_archive_workspace_dependencies_triggers_git_sync( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; // Setup create_folder(&db, "28103").await?; create_git_repo_resource(&db).await?; let sync_script_path = "f/28103/test_sync_script_archive"; create_sync_script(&db, sync_script_path).await?; setup_git_sync_config(&db, sync_script_path).await?; let (client, _port, _server) = init_client(db.clone()).await; // First create a workspace dependency let create_response = client .client() .post(format!( "{}/w/test-workspace/workspace_dependencies/create", client.baseurl() )) .json(&json!({ "workspace_id": "test-workspace", "language": "python3", "name": "archive-test-deps", "content": "flask==2.0.0" })) .send() .await?; assert!(create_response.status().is_success()); // Wait a bit for the create job to be processed tokio::time::sleep(Duration::from_millis(300)).await; // Now archive it let archive_response = client .client() .post(format!( "{}/w/test-workspace/workspace_dependencies/archive/python3?name=archive-test-deps", client.baseurl() )) .send() .await?; assert!( archive_response.status().is_success(), "Failed to archive workspace dependency: {:?}", archive_response.text().await ); // Wait for deployment callback job tokio::time::sleep(Duration::from_millis(500)).await; // Verify at least one deployment callback job exists // (create test already validates create triggers git sync; this test validates archive does too) let jobs = get_deployment_callback_jobs(&db, sync_script_path, Duration::from_secs(5)).await?; assert!( !jobs.is_empty(), "Expected at least one deployment callback job after archive" ); Ok(()) } /// Test that workspace dependencies are NOT synced when workspacedependencies type is excluded #[cfg(all(feature = "enterprise", feature = "private"))] #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_workspace_dependencies_respects_include_type_filter( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; // Setup with git sync that EXCLUDES workspacedependencies create_folder(&db, "28103").await?; create_git_repo_resource(&db).await?; let sync_script_path = "f/28103/test_sync_script_filter"; create_sync_script(&db, sync_script_path).await?; // Configure git sync to only include scripts (not workspace dependencies) let git_sync_config = json!({ "include_type": ["script"], // Note: workspacedependencies is NOT included "include_path": ["**"], "repositories": [{ "script_path": sync_script_path, "git_repo_resource_path": "$res:u/test-user/test_git_repo", "use_individual_branch": false, "group_by_folder": false }] }); sqlx::query!( "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", git_sync_config, "test-workspace" ) .execute(&db) .await?; let (client, _port, _server) = init_client(db.clone()).await; // Create workspace dependency let response = client .client() .post(format!( "{}/w/test-workspace/workspace_dependencies/create", client.baseurl() )) .json(&json!({ "workspace_id": "test-workspace", "language": "python3", "name": "filtered-deps", "content": "django==4.0.0" })) .send() .await?; assert!(response.status().is_success()); // Wait a bit tokio::time::sleep(Duration::from_secs(1)).await; // Should NOT have any deployment callback jobs because workspacedependencies is filtered out let jobs = get_deployment_callback_jobs(&db, sync_script_path, Duration::from_millis(500)).await?; assert!( jobs.is_empty(), "Expected NO deployment callback jobs when workspacedependencies is not in include_type, got {}", jobs.len() ); Ok(()) } /// Test that the commit message is correctly generated for workspace dependencies #[cfg(all(feature = "enterprise", feature = "private"))] #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_workspace_dependencies_commit_message(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; // Setup create_folder(&db, "28103").await?; create_git_repo_resource(&db).await?; let sync_script_path = "f/28103/test_sync_script_msg"; create_sync_script(&db, sync_script_path).await?; setup_git_sync_config(&db, sync_script_path).await?; let (client, _port, _server) = init_client(db.clone()).await; // Create workspace dependency let response = client .client() .post(format!( "{}/w/test-workspace/workspace_dependencies/create", client.baseurl() )) .json(&json!({ "workspace_id": "test-workspace", "language": "bun", "name": null, // unnamed/default dependency "content": "lodash: ^4.17.21" })) .send() .await?; assert!(response.status().is_success()); tokio::time::sleep(Duration::from_millis(500)).await; let jobs = get_deployment_callback_jobs(&db, sync_script_path, Duration::from_secs(5)).await?; assert!(!jobs.is_empty()); let job = &jobs[0]; let args = job.args.as_ref().expect("Job should have args"); // Check commit message format if let Some(items) = args.get("items") { let items_arr = items.as_array().expect("items should be an array"); if !items_arr.is_empty() { let commit_msg = items_arr[0] .get("commit_msg") .and_then(|v| v.as_str()) .unwrap_or(""); assert!( commit_msg.contains("[WM]"), "Commit message should contain '[WM]' prefix: {}", commit_msg ); assert!( commit_msg.to_lowercase().contains("workspace") || commit_msg.to_lowercase().contains("dependency") || commit_msg.to_lowercase().contains("deployed"), "Commit message should mention workspace dependency or deployed: {}", commit_msg ); } } else if let Some(commit_msg) = args.get("commit_msg").and_then(|v| v.as_str()) { assert!( commit_msg.contains("[WM]"), "Commit message should contain '[WM]' prefix: {}", commit_msg ); } Ok(()) } // ============================================================================ // Promotion-mode debounce-key tests // ============================================================================ /// Configure git sync in promotion mode (one branch per object), with explicit /// include_type and include_path lists so script deploys fire the callback. #[allow(dead_code)] async fn setup_promotion_git_sync_config( db: &Pool, sync_script_path: &str, group_by_folder: bool, ) -> anyhow::Result<()> { let git_sync_config = json!({ "include_type": ["script"], "include_path": ["**"], "repositories": [{ "script_path": sync_script_path, "git_repo_resource_path": "$res:u/test-user/test_git_repo", "use_individual_branch": true, "group_by_folder": group_by_folder }] }); sqlx::query!( "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", git_sync_config, "test-workspace" ) .execute(db) .await?; Ok(()) } /// Create a script via the API (triggering handle_deployment_metadata). #[allow(dead_code)] async fn create_test_script( client: &windmill_api_client::Client, path: &str, ) -> anyhow::Result<()> { let resp = client .client() .post(format!( "{}/w/test-workspace/scripts/create", client.baseurl() )) .json(&json!({ "path": path, "summary": "", "description": "", // bash has no lock step, so handle_deployment_metadata runs "content": "echo hi", "language": "bash" })) .send() .await?; let status = resp.status(); let body = resp.text().await.unwrap_or_default(); anyhow::ensure!( status.is_success(), "failed to create script {}: {} {}", path, status, body ); Ok(()) } /// Poll the `debounce_key` table until `expected` appears, or timeout. #[allow(dead_code)] async fn wait_for_debounce_key( db: &Pool, expected: &str, timeout: Duration, ) -> anyhow::Result> { let deadline = tokio::time::Instant::now() + timeout; loop { let keys: Vec = sqlx::query_scalar!("SELECT key FROM debounce_key WHERE key LIKE 'git_sync:%'") .fetch_all(db) .await?; if keys.iter().any(|k| k == expected) { return Ok(keys); } if tokio::time::Instant::now() >= deadline { return Ok(keys); } tokio::time::sleep(Duration::from_millis(100)).await; } } /// Promotion mode with one branch per object: the debounce key must scope to /// (path_type, path) so rapid re-edits of the same script collapse while /// different scripts run in parallel. #[cfg(all(feature = "enterprise", feature = "private"))] #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_promotion_individual_branch_debounces_per_path( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; create_folder(&db, "28103").await?; create_folder(&db, "target").await?; create_git_repo_resource(&db).await?; let sync_script_path = "f/28103/test_sync_individual_branch"; create_sync_script(&db, sync_script_path).await?; setup_promotion_git_sync_config(&db, sync_script_path, false).await?; let (client, _port, _server) = init_client(db.clone()).await; create_test_script(&client, "f/target/alpha").await?; create_test_script(&client, "f/target/beta").await?; // Wait for both deployment callbacks to resolve a debounce key. The // alpha key is polled first as a warm-up, then we also wait for beta // so the assertions don't race the second spawned callback. // Keys are namespaced by the repo's resource path so multiple promotion // repos don't collide on the same key. let expected_alpha = "git_sync:$res:u/test-user/test_git_repo:script:f/target/alpha"; let expected_beta = "git_sync:$res:u/test-user/test_git_repo:script:f/target/beta"; let _ = wait_for_debounce_key(&db, expected_alpha, Duration::from_secs(5)).await?; let keys = wait_for_debounce_key(&db, expected_beta, Duration::from_secs(5)).await?; assert!( keys.iter().any(|k| k == expected_alpha), "expected per-path debounce key {expected_alpha} in {keys:?}" ); assert!( keys.iter().any(|k| k == expected_beta), "expected per-path debounce key {expected_beta} in {keys:?}" ); Ok(()) } /// Create a second git repository resource for multi-repo tests. #[allow(dead_code)] async fn create_second_git_repo_resource(db: &Pool) -> anyhow::Result<()> { sqlx::query( r#" INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) VALUES ('test-workspace', 'u/test-user/test_git_repo_2', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user') ON CONFLICT (workspace_id, path) DO NOTHING "#, ) .bind(json!({ "url": "https://github.com/test/test2.git", "branch": "main", "token": "test-token-2" })) .execute(db) .await?; Ok(()) } /// Configure git sync with TWO promotion-mode repositories pointing at distinct /// git repo resources. Both repos use the same sync script and the same item /// filters — they only differ in the repo they target. #[allow(dead_code)] async fn setup_two_promotion_repos_config( db: &Pool, sync_script_path: &str, group_by_folder: bool, ) -> anyhow::Result<()> { let git_sync_config = json!({ "include_type": ["script"], "include_path": ["**"], "repositories": [ { "script_path": sync_script_path, "git_repo_resource_path": "$res:u/test-user/test_git_repo", "use_individual_branch": true, "group_by_folder": group_by_folder }, { "script_path": sync_script_path, "git_repo_resource_path": "$res:u/test-user/test_git_repo_2", "use_individual_branch": true, "group_by_folder": group_by_folder } ] }); sqlx::query!( "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", git_sync_config, "test-workspace" ) .execute(db) .await?; Ok(()) } /// Regression test for: when two promotion-mode repos are configured with the /// same `use_individual_branch=true` settings (i.e. a primary and a secondary /// promotion repo), deploying a single script must enqueue ONE deployment /// callback per repo. Both callbacks must remain in the queue — neither may /// be debounced into oblivion by the other. /// /// The bug this guards against: the debounce key for promotion mode was /// derived only from (path_type, path) and omitted any per-repo identifier, /// so the second repo's push hit ON CONFLICT in `upsert_debounce_key` and /// `complete_debounced_job` flagged the first repo's job as `status='skipped'` /// — silently dropping one of the two pushes. #[cfg(all(feature = "enterprise", feature = "private"))] #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_two_promotion_repos_both_enqueue_callback(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; create_folder(&db, "28103").await?; create_folder(&db, "target").await?; create_git_repo_resource(&db).await?; create_second_git_repo_resource(&db).await?; let sync_script_path = "f/28103/test_sync_two_promotion_repos"; create_sync_script(&db, sync_script_path).await?; setup_two_promotion_repos_config(&db, sync_script_path, false).await?; let (client, _port, _server) = init_client(db.clone()).await; // Deploy a single script — handle_deployment_metadata should iterate // both repos and create one callback job per repo. create_test_script(&client, "f/target/alpha").await?; // Both callbacks should reach the queue. With the bug, only one survives // (the other is moved to v2_job_completed with status='skipped'). let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let mut last_jobs: Vec = vec![]; loop { last_jobs = get_deployment_callback_jobs(&db, sync_script_path, Duration::from_millis(200)).await?; if last_jobs.len() >= 2 { break; } if tokio::time::Instant::now() >= deadline { break; } tokio::time::sleep(Duration::from_millis(100)).await; } // Inspect what landed in v2_job_completed so failure messages explain why. let skipped: Vec<(uuid::Uuid, String)> = sqlx::query_as( r#" SELECT c.id, c.status::text FROM v2_job_completed c JOIN v2_job j ON j.id = c.id WHERE j.runnable_path = $1 AND j.kind = 'deploymentcallback' "#, ) .bind(sync_script_path) .fetch_all(&db) .await?; assert_eq!( last_jobs.len(), 2, "expected 2 deployment callback jobs in v2_job_queue (one per promotion repo), got {} queued + {:?} completed", last_jobs.len(), skipped, ); // Per-repo args sanity check: the two jobs must target different repos. let mut repo_paths: Vec = last_jobs .iter() .filter_map(|j| { j.args .as_ref() .and_then(|a| a.get("repo_url_resource_path")) .and_then(|v| v.as_str()) .map(|s| s.to_string()) }) .collect(); repo_paths.sort(); repo_paths.dedup(); assert_eq!( repo_paths.len(), 2, "expected callbacks to target two distinct repos, got: {:?}", last_jobs.iter().map(|j| &j.args).collect::>() ); // No callback should have been silently skipped via debouncing collision. assert!( skipped.iter().all(|(_, s)| s != "skipped"), "no deployment callback should be marked skipped, got: {:?}", skipped, ); Ok(()) } /// Promotion mode with group_by_folder: items destined for the same per-folder /// branch must share one debounce key so they accumulate into a single sync /// job; scripts in different folders must get distinct keys. #[cfg(all(feature = "enterprise", feature = "private"))] #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_promotion_group_by_folder_debounces_per_folder( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; create_folder(&db, "28103").await?; create_folder(&db, "grouped").await?; create_folder(&db, "other").await?; create_git_repo_resource(&db).await?; let sync_script_path = "f/28103/test_sync_group_by_folder"; create_sync_script(&db, sync_script_path).await?; setup_promotion_git_sync_config(&db, sync_script_path, true).await?; let (client, _port, _server) = init_client(db.clone()).await; // Two scripts in the same folder — should share one debounce key. create_test_script(&client, "f/grouped/alpha").await?; create_test_script(&client, "f/grouped/beta").await?; // One in a different folder — should get its own key. create_test_script(&client, "f/other/gamma").await?; // Keys are namespaced by the repo's resource path. let expected_grouped = "git_sync:$res:u/test-user/test_git_repo:folder:f/grouped"; let expected_other = "git_sync:$res:u/test-user/test_git_repo:folder:f/other"; // Wait for BOTH folder keys to appear, not just the first one. let keys = wait_for_debounce_key(&db, expected_other, Duration::from_secs(5)).await?; assert!( keys.iter().any(|k| k == expected_grouped), "expected per-folder debounce key {expected_grouped} in {keys:?}" ); assert!( keys.iter().any(|k| k == expected_other), "expected per-folder debounce key {expected_other} in {keys:?}" ); // Paths within the same folder must NOT leak as their own keys. assert!( !keys .iter() .any(|k| k.contains(":script:f/grouped/") || k.contains(":script:f/other/")), "group_by_folder mode should not emit per-path keys, got: {keys:?}" ); Ok(()) }