mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 16:01:42 +00:00
7363d2c217
The blast-radius guard added in #9866 forced `all_ahead_items_visible` true for any fork/target admin. But `filter_visible_diffs` keeps a modified/conflict row (one that exists in the source AND the fork) only when the caller can see it on both sides, so an ahead diff can be dropped for a source-side visibility gap even when the caller is a fork admin. Forcing the flag on fork-admin alone then wrongly reported "all ahead items visible", letting the UI enable deployment from an incomplete comparison. Gate the guard on admin of BOTH the source and the fork (superadmin satisfies both), which is what actually guarantees full visibility of every item on every side. Adds a regression test where a fork admin who is only a plain member of the parent (no access to the item's folder) must still get `all_ahead_items_visible = false`, plus the superadmin sanity path. Also restores the SQLx offline cache entry for the phantom-trigger test INSERT that #9866 landed without (CI/`SQLX_OFFLINE=true` builds failed on it), and adds entries for the new test's all-literal queries. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1772 lines
67 KiB
Rust
1772 lines
67 KiB
Rust
use serde_json::json;
|
|
use sqlx::{Pool, Postgres};
|
|
|
|
use windmill_test_utils::*;
|
|
|
|
/// Comprehensive integration test for the compare_workspaces endpoint.
|
|
///
|
|
/// This test validates workspace fork comparison functionality by:
|
|
/// 1. Setting up a parent workspace with all item types (scripts, flows, apps, resources, variables, resource_types, folders)
|
|
/// 2. Creating a fork of the workspace
|
|
/// 3. Making various changes in both workspaces (new items, modifications, conflicts, deletions, renames)
|
|
/// 4. Populating the workspace_diff table to simulate Git sync tracking
|
|
/// 5. Calling compare_workspaces and verifying all aspects of the comparison
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_compare_workspaces_comprehensive(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let client = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN".to_string(),
|
|
);
|
|
let base_url = format!("http://localhost:{port}/api");
|
|
|
|
// ==============================================================
|
|
// PHASE 1: Setup Parent Workspace with All Item Types
|
|
// ==============================================================
|
|
|
|
// Create folder first (other items will use it)
|
|
sqlx::query!(
|
|
"INSERT INTO folder (workspace_id, name, display_name, owners, summary, created_by)
|
|
VALUES ('test-workspace', 'shared', 'Shared Folder', ARRAY['test@windmill.dev']::varchar[], 'Test folder', 'test@windmill.dev')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Create scripts
|
|
sqlx::query!(
|
|
"INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted)
|
|
VALUES
|
|
('test-workspace', 'f/shared/original_script', 12345, 'def main(): pass', 'Original', '', 'python3', 'test@windmill.dev', NOW(), false, false, false, false),
|
|
('test-workspace', 'f/shared/to_modify_parent', 22222, 'def main(): return 1', 'To modify in parent', '', 'python3', 'test@windmill.dev', NOW(), false, false, false, false),
|
|
('test-workspace', 'f/shared/to_modify_fork', 33333, 'def main(): return 2', 'To modify in fork', '', 'python3', 'test@windmill.dev', NOW(), false, false, false, false),
|
|
('test-workspace', 'f/shared/to_conflict', 44444, 'def main(): return 3', 'To conflict', '', 'python3', 'test@windmill.dev', NOW(), false, false, false, false),
|
|
('test-workspace', 'f/shared/to_delete', 55555, 'def main(): return 4', 'To delete', '', 'python3', 'test@windmill.dev', NOW(), false, false, false, false)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Create flow
|
|
sqlx::query!(
|
|
"INSERT INTO flow (workspace_id, path, summary, description, value, schema, edited_by, edited_at, archived)
|
|
VALUES ('test-workspace', 'f/shared/original_flow', 'Flow summary', '', $1, NULL, 'test@windmill.dev', NOW(), false)",
|
|
json!({"modules": []})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Create resource
|
|
sqlx::query!(
|
|
"INSERT INTO resource (workspace_id, path, value, resource_type, description, created_by)
|
|
VALUES
|
|
('test-workspace', 'f/shared/db_config', $1, 'postgresql', '', 'test@windmill.dev'),
|
|
('test-workspace', 'f/shared/old_name', $2, 'generic', '', 'test@windmill.dev'),
|
|
('test-workspace', 'f/shared/resource_to_modify', $3, 'generic', '', 'test@windmill.dev')",
|
|
json!({"host": "localhost"}),
|
|
json!({}),
|
|
json!({"key": "value"})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Create variable
|
|
sqlx::query!(
|
|
"INSERT INTO variable (workspace_id, path, value, is_secret, description)
|
|
VALUES
|
|
('test-workspace', 'f/shared/api_key', 'secret123', false, 'Test key'),
|
|
('test-workspace', 'f/shared/variable_to_modify', 'original', false, 'To modify')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Create resource type
|
|
sqlx::query!(
|
|
"INSERT INTO resource_type (workspace_id, name, schema, description, created_by)
|
|
VALUES ('test-workspace', 'custom_db', $1, 'Custom DB type', 'test@windmill.dev')",
|
|
json!({"type": "object"})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Create app
|
|
sqlx::query!(
|
|
"INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms)
|
|
VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let app_id = sqlx::query_scalar!(
|
|
"SELECT id FROM app WHERE path = 'f/shared/dashboard' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
|
|
sqlx::query!(
|
|
"INSERT INTO app_version (app_id, value, created_by, created_at)
|
|
VALUES ($1, $2, 'test@windmill.dev', NOW())",
|
|
app_id,
|
|
json!({"grid": []})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// ==============================================================
|
|
// PHASE 2: Create Fork
|
|
// ==============================================================
|
|
|
|
let fork_response = client
|
|
.client()
|
|
.post(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/create_fork"
|
|
))
|
|
.json(&json!({
|
|
"id": "wm-fork-test-workspace",
|
|
"name": "Test Fork",
|
|
"color": "#0000ff"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
|
|
let status = fork_response.status();
|
|
assert!(
|
|
status.is_success(),
|
|
"Fork creation should succeed: {}",
|
|
status
|
|
);
|
|
|
|
// Verify fork was created
|
|
let fork_exists = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM workspace WHERE id = 'wm-fork-test-workspace')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert!(fork_exists.unwrap_or(false), "Fork workspace should exist");
|
|
|
|
// ==============================================================
|
|
// PHASE 3: Make Changes in Both Workspaces
|
|
// ==============================================================
|
|
|
|
// Scenario 1: New script in parent (ahead)
|
|
sqlx::query!(
|
|
"INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted)
|
|
VALUES ('test-workspace', 'f/shared/new_in_parent', 54321, 'def main(): return \"new\"', 'New in parent', '', 'python3', 'test@windmill.dev', NOW(), false, false, false, false)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Scenario 2: New script in fork (behind)
|
|
sqlx::query!(
|
|
"INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted)
|
|
VALUES ('wm-fork-test-workspace', 'f/shared/new_in_fork', 99999, 'def main(): return \"fork\"', 'New in fork', '', 'python3', 'test@windmill.dev', NOW(), false, false, false, false)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Scenario 3: Modify script in parent (ahead)
|
|
sqlx::query!(
|
|
"UPDATE script
|
|
SET content = 'def main(): return \"modified\"', summary = 'Modified in parent'
|
|
WHERE workspace_id = 'test-workspace' AND path = 'f/shared/to_modify_parent'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Scenario 4: Modify script in fork (behind)
|
|
sqlx::query!(
|
|
"UPDATE script
|
|
SET content = 'def main(): return \"fork_modified\"', summary = 'Modified in fork'
|
|
WHERE workspace_id = 'wm-fork-test-workspace' AND path = 'f/shared/to_modify_fork'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Scenario 5: Conflict - modify in both workspaces
|
|
sqlx::query!(
|
|
"UPDATE flow SET value = $1
|
|
WHERE workspace_id = 'test-workspace' AND path = 'f/shared/original_flow'",
|
|
json!({"modules": [{"id": "a"}]})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
sqlx::query!(
|
|
"UPDATE flow SET value = $1
|
|
WHERE workspace_id = 'wm-fork-test-workspace' AND path = 'f/shared/original_flow'",
|
|
json!({"modules": [{"id": "b"}]})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Scenario 6: Delete (archive) in fork
|
|
sqlx::query!(
|
|
"UPDATE script SET archived = true
|
|
WHERE workspace_id = 'wm-fork-test-workspace' AND path = 'f/shared/to_delete'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Scenario 7: Rename in parent (resource)
|
|
sqlx::query!(
|
|
"UPDATE resource SET path = 'f/shared/new_name'
|
|
WHERE workspace_id = 'test-workspace' AND path = 'f/shared/old_name'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Scenario 8: Modify app in parent
|
|
sqlx::query!(
|
|
"UPDATE app SET summary = 'Modified dashboard app'
|
|
WHERE workspace_id = 'test-workspace' AND path = 'f/shared/dashboard'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Scenario 9: Modify resource in fork
|
|
sqlx::query!(
|
|
"UPDATE resource SET value = $1
|
|
WHERE workspace_id = 'wm-fork-test-workspace' AND path = 'f/shared/resource_to_modify'",
|
|
json!({"key": "modified_value"})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Modify variable in parent
|
|
sqlx::query!(
|
|
"UPDATE variable SET value = 'modified_value'
|
|
WHERE workspace_id = 'test-workspace' AND path = 'f/shared/variable_to_modify'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Create new resource type in parent
|
|
sqlx::query!(
|
|
"INSERT INTO resource_type (workspace_id, name, schema, description, created_by)
|
|
VALUES ('test-workspace', 'new_type', $1, 'New type in parent', 'test@windmill.dev')",
|
|
json!({"type": "string"})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Modify folder in fork (display_name)
|
|
sqlx::query!(
|
|
"UPDATE folder SET display_name = 'Modified Shared Folder'
|
|
WHERE workspace_id = 'wm-fork-test-workspace' AND name = 'shared'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// ==============================================================
|
|
// PHASE 4: Populate workspace_diff Table
|
|
// ==============================================================
|
|
|
|
// New in parent (ahead)
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/shared/new_in_parent', 'script', 1, 0, NULL),
|
|
('test-workspace', 'wm-fork-test-workspace', 'new_type', 'resource_type', 1, 0, NULL)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// New in fork (behind)
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/new_in_fork', 'script', 0, 1, NULL)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Modified in parent (ahead)
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/shared/to_modify_parent', 'script', 1, 0, NULL),
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/shared/dashboard', 'app', 1, 0, NULL),
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/shared/variable_to_modify', 'variable', 1, 0, NULL)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Modified in fork (behind)
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/shared/to_modify_fork', 'script', 0, 1, NULL),
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/shared/resource_to_modify', 'resource', 0, 1, NULL),
|
|
('test-workspace', 'wm-fork-test-workspace', 'shared', 'folder', 0, 1, NULL)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Conflict (both ahead and behind)
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/shared/original_flow', 'flow', 1, 1, NULL),
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/shared/to_conflict', 'script', 1, 1, NULL)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Deleted in fork (exists only in parent)
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/to_delete', 'script', 1, 0, NULL)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Renamed in parent (two entries)
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/shared/old_name', 'resource', 0, 1, NULL),
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/shared/new_name', 'resource', 1, 0, NULL)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Add an unchanged item to verify it gets filtered out
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/original_script', 'script', 0, 0, NULL)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// ==============================================================
|
|
// PHASE 5: Call compare_workspaces and Verify Results
|
|
// ==============================================================
|
|
|
|
let comparison: serde_json::Value = client
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
|
|
// Verify basic structure
|
|
assert!(
|
|
!comparison["skipped_comparison"].as_bool().unwrap_or(true),
|
|
"Should not skip comparison"
|
|
);
|
|
assert!(comparison["diffs"].is_array(), "Should have diffs array");
|
|
assert!(
|
|
comparison["summary"].is_object(),
|
|
"Should have summary object"
|
|
);
|
|
|
|
let diffs = comparison["diffs"].as_array().unwrap();
|
|
let summary = &comparison["summary"];
|
|
|
|
// ==============================================================
|
|
// Summary Assertions
|
|
// ==============================================================
|
|
|
|
// Total diffs (excluding unchanged items which should be deleted)
|
|
let total_diffs = summary["total_diffs"].as_u64().unwrap();
|
|
assert!(total_diffs > 0, "Should have at least some diffs");
|
|
|
|
// Verify ahead/behind counts
|
|
let total_ahead = summary["total_ahead"].as_u64().unwrap();
|
|
let total_behind = summary["total_behind"].as_u64().unwrap();
|
|
assert!(total_ahead > 0, "Should have items ahead");
|
|
assert!(total_behind > 0, "Should have items behind");
|
|
|
|
// Verify conflicts (items that are both ahead and behind)
|
|
let conflicts = summary["conflicts"].as_u64().unwrap();
|
|
assert!(conflicts >= 1, "Should have at least 1 conflict (flow)");
|
|
|
|
// Verify per-item-type counts
|
|
assert!(
|
|
summary["scripts_changed"].as_u64().unwrap() > 0,
|
|
"Should have script changes"
|
|
);
|
|
assert!(
|
|
summary["flows_changed"].as_u64().unwrap() > 0,
|
|
"Should have flow changes"
|
|
);
|
|
assert!(
|
|
summary["apps_changed"].as_u64().unwrap() > 0,
|
|
"Should have app changes"
|
|
);
|
|
assert!(
|
|
summary["resources_changed"].as_u64().unwrap() > 0,
|
|
"Should have resource changes"
|
|
);
|
|
assert!(
|
|
summary["variables_changed"].as_u64().unwrap() > 0,
|
|
"Should have variable changes"
|
|
);
|
|
assert!(
|
|
summary["resource_types_changed"].as_u64().unwrap() > 0,
|
|
"Should have resource_type changes"
|
|
);
|
|
// Note: folders_changed may be 0 if folder comparison didn't detect changes
|
|
// assert!(summary["folders_changed"].as_u64().unwrap() > 0, "Should have folder changes");
|
|
|
|
// ==============================================================
|
|
// Individual Diff Assertions
|
|
// ==============================================================
|
|
|
|
// Scenario 1: New in parent
|
|
let new_in_parent = diffs
|
|
.iter()
|
|
.find(|d| d["path"] == "f/shared/new_in_parent" && d["kind"] == "script")
|
|
.expect("Should find new_in_parent diff");
|
|
assert_eq!(
|
|
new_in_parent["ahead"].as_i64().unwrap(),
|
|
1,
|
|
"new_in_parent should be ahead"
|
|
);
|
|
assert_eq!(
|
|
new_in_parent["behind"].as_i64().unwrap(),
|
|
0,
|
|
"new_in_parent should not be behind"
|
|
);
|
|
assert_eq!(
|
|
new_in_parent["has_changes"].as_bool().unwrap(),
|
|
true,
|
|
"new_in_parent should have changes"
|
|
);
|
|
assert_eq!(
|
|
new_in_parent["exists_in_source"].as_bool().unwrap(),
|
|
true,
|
|
"new_in_parent should exist in source"
|
|
);
|
|
assert_eq!(
|
|
new_in_parent["exists_in_fork"].as_bool().unwrap(),
|
|
false,
|
|
"new_in_parent should not exist in fork"
|
|
);
|
|
|
|
// Scenario 2: New in fork
|
|
let new_in_fork = diffs
|
|
.iter()
|
|
.find(|d| d["path"] == "f/shared/new_in_fork" && d["kind"] == "script")
|
|
.expect("Should find new_in_fork diff");
|
|
assert_eq!(
|
|
new_in_fork["ahead"].as_i64().unwrap(),
|
|
0,
|
|
"new_in_fork should not be ahead"
|
|
);
|
|
assert_eq!(
|
|
new_in_fork["behind"].as_i64().unwrap(),
|
|
1,
|
|
"new_in_fork should be behind"
|
|
);
|
|
assert_eq!(
|
|
new_in_fork["has_changes"].as_bool().unwrap(),
|
|
true,
|
|
"new_in_fork should have changes"
|
|
);
|
|
assert_eq!(
|
|
new_in_fork["exists_in_source"].as_bool().unwrap(),
|
|
false,
|
|
"new_in_fork should not exist in source"
|
|
);
|
|
assert_eq!(
|
|
new_in_fork["exists_in_fork"].as_bool().unwrap(),
|
|
true,
|
|
"new_in_fork should exist in fork"
|
|
);
|
|
|
|
// Scenario 5: Conflict
|
|
let conflict_flow = diffs
|
|
.iter()
|
|
.find(|d| d["path"] == "f/shared/original_flow" && d["kind"] == "flow")
|
|
.expect("Should find conflict flow diff");
|
|
assert!(
|
|
conflict_flow["ahead"].as_i64().unwrap() > 0,
|
|
"conflict should be ahead"
|
|
);
|
|
assert!(
|
|
conflict_flow["behind"].as_i64().unwrap() > 0,
|
|
"conflict should be behind"
|
|
);
|
|
assert_eq!(
|
|
conflict_flow["has_changes"].as_bool().unwrap(),
|
|
true,
|
|
"conflict should have changes"
|
|
);
|
|
assert_eq!(
|
|
conflict_flow["exists_in_source"].as_bool().unwrap(),
|
|
true,
|
|
"conflict should exist in source"
|
|
);
|
|
assert_eq!(
|
|
conflict_flow["exists_in_fork"].as_bool().unwrap(),
|
|
true,
|
|
"conflict should exist in fork"
|
|
);
|
|
|
|
// Scenario 6: Deleted in fork
|
|
let deleted = diffs
|
|
.iter()
|
|
.find(|d| d["path"] == "f/shared/to_delete" && d["kind"] == "script")
|
|
.expect("Should find deleted diff");
|
|
assert_eq!(
|
|
deleted["exists_in_source"].as_bool().unwrap(),
|
|
true,
|
|
"deleted should exist in source"
|
|
);
|
|
assert_eq!(
|
|
deleted["exists_in_fork"].as_bool().unwrap(),
|
|
false,
|
|
"deleted should not exist in fork (archived)"
|
|
);
|
|
assert_eq!(
|
|
deleted["has_changes"].as_bool().unwrap(),
|
|
true,
|
|
"deleted should have changes"
|
|
);
|
|
|
|
// Scenario 7: Rename (should show as two entries)
|
|
let old_name = diffs
|
|
.iter()
|
|
.find(|d| d["path"] == "f/shared/old_name" && d["kind"] == "resource");
|
|
let new_name = diffs
|
|
.iter()
|
|
.find(|d| d["path"] == "f/shared/new_name" && d["kind"] == "resource");
|
|
|
|
// At least one of these should exist (depending on how the comparison handles renames)
|
|
assert!(
|
|
old_name.is_some() || new_name.is_some(),
|
|
"Should find at least one rename-related diff"
|
|
);
|
|
|
|
// ==============================================================
|
|
// Database State Assertions
|
|
// ==============================================================
|
|
|
|
// Verify has_changes was cached for items that have changes
|
|
let cached_new_in_parent = sqlx::query!(
|
|
"SELECT has_changes, exists_in_source, exists_in_fork FROM workspace_diff
|
|
WHERE path = 'f/shared/new_in_parent' AND kind = 'script' AND source_workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
cached_new_in_parent.has_changes,
|
|
Some(true),
|
|
"has_changes should be cached as true"
|
|
);
|
|
assert_eq!(
|
|
cached_new_in_parent.exists_in_source,
|
|
Some(true),
|
|
"exists_in_source should be cached"
|
|
);
|
|
assert_eq!(
|
|
cached_new_in_parent.exists_in_fork,
|
|
Some(false),
|
|
"exists_in_fork should be cached"
|
|
);
|
|
|
|
// Verify unchanged items were deleted from workspace_diff
|
|
let unchanged_original_script = sqlx::query!(
|
|
"SELECT has_changes FROM workspace_diff
|
|
WHERE path = 'f/shared/original_script' AND kind = 'script' AND source_workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
|
|
// The unchanged item should either be deleted or marked as has_changes = false
|
|
// Based on the code, items with has_changes = false are deleted
|
|
if let Some(record) = unchanged_original_script {
|
|
assert_ne!(
|
|
record.has_changes,
|
|
Some(false),
|
|
"unchanged items with has_changes=false should be deleted"
|
|
);
|
|
}
|
|
|
|
// ==============================================================
|
|
// Lazy Evaluation Test
|
|
// ==============================================================
|
|
|
|
// Create a new diff entry with NULL has_changes
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/lazy_test', 'script', 1, 0, NULL)
|
|
ON CONFLICT DO NOTHING"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Call the endpoint again
|
|
let _comparison2: serde_json::Value = client
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
|
|
// Verify the lazy_test entry was evaluated (should be deleted since it doesn't exist)
|
|
let lazy_test = sqlx::query!(
|
|
"SELECT has_changes FROM workspace_diff
|
|
WHERE path = 'f/shared/lazy_test' AND kind = 'script' AND source_workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
|
|
// Should be deleted since the item doesn't actually exist in either workspace
|
|
assert!(
|
|
lazy_test.is_none(),
|
|
"Non-existent item should be deleted from workspace_diff"
|
|
);
|
|
|
|
// ==============================================================
|
|
// Stale Archived Cache Test (regression)
|
|
// ==============================================================
|
|
//
|
|
// Unlike the lazy_test above (has_changes = NULL → always re-evaluated), a
|
|
// cached `has_changes = true` row is trusted without re-running the per-kind
|
|
// comparison. It can go stale: after a rename the old path keeps only
|
|
// archived versions, and for lock-gen languages the `has_changes = NULL`
|
|
// reset is deferred to the dependency job — so until that runs the archived
|
|
// old path lingers as a live "ahead" change carrying `exists_in_fork = true`.
|
|
// The visibility check treats archived as non-existent and finds nothing, so
|
|
// even this superadmin used to get `all_ahead_items_visible = false`. The fix
|
|
// re-validates such rows and drops the archived (== non-existent) item.
|
|
sqlx::query!(
|
|
"INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted)
|
|
VALUES ('wm-fork-test-workspace', 'f/shared/renamed_away', 67890, 'def main(): return 1', '', '', 'python3', 'test@windmill.dev', NOW(), true, false, false, false)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)
|
|
VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/renamed_away', 'script', 1, 0, true, false, true)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let comparison3: serde_json::Value = client
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
|
|
// The archived item must be dropped (not surfaced) and must not trip the
|
|
// "changes not visible to your user" warning for a superadmin.
|
|
assert_eq!(
|
|
comparison3["all_ahead_items_visible"].as_bool(),
|
|
Some(true),
|
|
"archived (renamed-away) item must not trip the 'changes not visible' warning: {comparison3}"
|
|
);
|
|
assert!(
|
|
!comparison3["diffs"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|d| d["path"] == "f/shared/renamed_away"),
|
|
"archived item should be dropped, not surfaced as a diff: {comparison3}"
|
|
);
|
|
let stale_archived = sqlx::query!(
|
|
"SELECT has_changes FROM workspace_diff
|
|
WHERE path = 'f/shared/renamed_away' AND kind = 'script' AND source_workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert!(
|
|
stale_archived.is_none(),
|
|
"stale archived diff row should be re-evaluated and deleted"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Trigger/schedule diffs go through the same `compare_workspaces` flow as
|
|
/// scripts/flows once tally tracks them. The compare_two_trigger_or_schedule
|
|
/// helper strips runtime fields (mode/enabled/server_id/last_server_ping/
|
|
/// edited_at-by/email/error/extra_perms/permissioned_as), so:
|
|
/// - a real config change shows `has_changes = true`
|
|
/// - a runtime-only change (mode toggle, enabled flip) shows `has_changes = false`
|
|
/// and the row is deleted from `workspace_diff`
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_compare_workspaces_trigger_and_schedule(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let client = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN".to_string(),
|
|
);
|
|
let base_url = format!("http://localhost:{port}/api");
|
|
|
|
// Parent + fork workspaces (fork created via INSERT to bypass
|
|
// clone_triggers_and_schedules — we want to control the rows manually).
|
|
sqlx::query!(
|
|
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
|
|
VALUES ('wm-fork-test-workspace', 'Fork', 'test-user', 'test-workspace')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!("INSERT INTO workspace_settings (workspace_id) VALUES ('wm-fork-test-workspace')")
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_key(workspace_id, kind, key)
|
|
VALUES ('wm-fork-test-workspace', 'cloud', 'test-key')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO usr(workspace_id, email, username, is_admin, role)
|
|
VALUES ('wm-fork-test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// ------ Schedule: identical config in parent and fork, except `enabled`.
|
|
// Should be filtered out (no real diff).
|
|
sqlx::query!(
|
|
"INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled,
|
|
script_path, args, is_flow, email, timezone, summary, permissioned_as)
|
|
VALUES
|
|
('test-workspace', 'f/sch/runtime_only', 'test-user', NOW(), '0 * * * * *', true,
|
|
'f/scripts/x', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user'),
|
|
('wm-fork-test-workspace', 'f/sch/runtime_only', 'test-user', NOW(), '0 * * * * *', false,
|
|
'f/scripts/x', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// ------ Schedule: config change (script_path) in fork. Should diff.
|
|
sqlx::query!(
|
|
"INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled,
|
|
script_path, args, is_flow, email, timezone, summary, permissioned_as)
|
|
VALUES
|
|
('test-workspace', 'f/sch/config_change', 'test-user', NOW(), '0 * * * * *', false,
|
|
'f/scripts/parent_path', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user'),
|
|
('wm-fork-test-workspace', 'f/sch/config_change', 'test-user', NOW(), '0 * * * * *', false,
|
|
'f/scripts/fork_path', '{}', false, 'test@windmill.dev', 'UTC', 'sch', 'u/test-user')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// ------ HTTP trigger: identical config except `mode`. Should be filtered out.
|
|
sqlx::query!(
|
|
"INSERT INTO http_trigger (workspace_id, path, edited_by, edited_at, route_path,
|
|
route_path_key, script_path, is_flow, http_method, request_type,
|
|
authentication_method, mode, permissioned_as)
|
|
VALUES
|
|
('test-workspace', 'f/rt/runtime_only', 'test-user', NOW(), 'foo', 'foo',
|
|
'f/scripts/y', false, 'get', 'sync',
|
|
'none', 'enabled', 'u/test-user'),
|
|
('wm-fork-test-workspace', 'f/rt/runtime_only', 'test-user', NOW(), 'foo', 'foo',
|
|
'f/scripts/y', false, 'get', 'sync',
|
|
'none', 'disabled', 'u/test-user')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// ------ HTTP trigger: config change (route_path) in fork. Should diff.
|
|
sqlx::query!(
|
|
"INSERT INTO http_trigger (workspace_id, path, edited_by, edited_at, route_path,
|
|
route_path_key, script_path, is_flow, http_method, request_type,
|
|
authentication_method, mode, permissioned_as)
|
|
VALUES
|
|
('test-workspace', 'f/rt/config_change', 'test-user', NOW(), 'parent', 'parent',
|
|
'f/scripts/y', false, 'get', 'sync',
|
|
'none', 'disabled', 'u/test-user'),
|
|
('wm-fork-test-workspace', 'f/rt/config_change', 'test-user', NOW(), 'fork', 'fork',
|
|
'f/scripts/y', false, 'get', 'sync',
|
|
'none', 'disabled', 'u/test-user')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Seed workspace_diff with NULL has_changes so compare_workspaces evaluates them lazily.
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/sch/runtime_only', 'schedule', 0, 1, NULL),
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/sch/config_change', 'schedule', 0, 1, NULL),
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/rt/runtime_only', 'http_trigger', 0, 1, NULL),
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/rt/config_change', 'http_trigger', 0, 1, NULL)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let comparison: serde_json::Value = client
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
|
|
let diffs = comparison["diffs"].as_array().unwrap();
|
|
|
|
// The runtime-only rows should be filtered out (compare_two_trigger_or_schedule
|
|
// returned has_changes=false → row deleted from workspace_diff).
|
|
assert!(
|
|
!diffs.iter().any(|d| d["path"] == "f/sch/runtime_only"),
|
|
"schedule with only enabled-flag difference should be filtered out"
|
|
);
|
|
assert!(
|
|
!diffs.iter().any(|d| d["path"] == "f/rt/runtime_only"),
|
|
"http_trigger with only mode difference should be filtered out"
|
|
);
|
|
|
|
// The config-change rows should be present with has_changes=true.
|
|
let sch_change = diffs
|
|
.iter()
|
|
.find(|d| d["path"] == "f/sch/config_change" && d["kind"] == "schedule")
|
|
.expect("schedule with config change should appear in diffs");
|
|
assert_eq!(sch_change["has_changes"].as_bool().unwrap(), true);
|
|
assert_eq!(sch_change["exists_in_source"].as_bool().unwrap(), true);
|
|
assert_eq!(sch_change["exists_in_fork"].as_bool().unwrap(), true);
|
|
|
|
let rt_change = diffs
|
|
.iter()
|
|
.find(|d| d["path"] == "f/rt/config_change" && d["kind"] == "http_trigger")
|
|
.expect("http_trigger with config change should appear in diffs");
|
|
assert_eq!(rt_change["has_changes"].as_bool().unwrap(), true);
|
|
|
|
// Summary counts.
|
|
let summary = &comparison["summary"];
|
|
assert_eq!(summary["schedules_changed"].as_u64().unwrap(), 1);
|
|
assert_eq!(summary["triggers_changed"].as_u64().unwrap(), 1);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Regression for the "superadmin-still-sees-the-warning" case in WIN-1975.
|
|
///
|
|
/// `compare_workspaces` historically trusted `authed.is_admin` for RLS — but
|
|
/// that flag is derived from the *token's* cached `super_admin` column at
|
|
/// auth time (windmill-api-auth/src/auth.rs), not from a live
|
|
/// `password.super_admin` read. A user who is *currently* an instance
|
|
/// superadmin can have a token from before the promotion (or via a session
|
|
/// refresh race) where `token.super_admin = false`. If they're also not a
|
|
/// workspace admin in the source workspace (only in the fork),
|
|
/// `authed.is_admin` lands as `false` and source-scoped RLS gets applied to
|
|
/// fork-side visibility queries — same bug as the regular non-admin case.
|
|
///
|
|
/// With the fix, `load_workspace_authed` re-checks `is_super_admin_email`
|
|
/// against `password.super_admin` at request time, so the fork-scoped authed
|
|
/// gets `is_admin = true` and RLS bypass kicks back in for the fork queries.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_compare_workspaces_stale_superadmin_token(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base_url = format!("http://localhost:{port}/api");
|
|
|
|
// Promote test-user-2 to instance superadmin AFTER their token was issued
|
|
// (base.sql inserts SECRET_TOKEN_2 with super_admin=false). The token row
|
|
// keeps super_admin=false; password.super_admin flips to true.
|
|
sqlx::query!("UPDATE password SET super_admin = true WHERE email = 'test2@windmill.dev'")
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let stale_super = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN_2".to_string(),
|
|
);
|
|
|
|
// Fork test-workspace.
|
|
let resp = stale_super
|
|
.client()
|
|
.post(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/create_fork"
|
|
))
|
|
.json(&json!({
|
|
"id": "wm-fork-stale-super",
|
|
"name": "Stale Super Fork",
|
|
"color": "#0000ff"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"fork creation failed: {} — {}",
|
|
resp.status(),
|
|
resp.text().await?
|
|
);
|
|
|
|
// Fork-only folder + script, with empty extra_perms so the only way to
|
|
// see them is via fork's folder-based RLS or admin bypass.
|
|
sqlx::query!(
|
|
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)
|
|
VALUES ('wm-fork-stale-super', 'folder2', 'folder2', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')",
|
|
json!({"u/test-user-2": true})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms)
|
|
VALUES ('wm-fork-stale-super', 'f/folder2/myscript', 333333, 'echo 1', '', '', 'bash', 'test-user-2', NOW(), false, false, false, false, $1)",
|
|
json!({})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES ('test-workspace', 'wm-fork-stale-super', 'f/folder2/myscript', 'script', 1, 0, NULL)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM skip_workspace_diff_tally")
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let comparison: serde_json::Value = stale_super
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-stale-super"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
comparison["all_ahead_items_visible"].as_bool(),
|
|
Some(true),
|
|
"current superadmin with stale token should still see ahead items: {comparison}"
|
|
);
|
|
let diffs = comparison["diffs"].as_array().unwrap();
|
|
assert!(
|
|
diffs
|
|
.iter()
|
|
.any(|d| d["path"] == "f/folder2/myscript" && d["kind"] == "script"),
|
|
"fork-only script should appear in diffs; got {diffs:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// End-to-end regression for WIN-1975 against the real EE tally path.
|
|
/// Reproduces the reporter's exact steps with the API: fork → create script
|
|
/// in folder1 → rename to folder2 → compare. Folder2 only exists in the
|
|
/// fork, so before the fix the source-scoped authed in `filter_visible_diffs`
|
|
/// hid the script and the response set `all_ahead_items_visible = false`.
|
|
///
|
|
/// Gated on `private` because the OSS build of `handle_deployment_metadata`
|
|
/// is a no-op (`windmill-git-sync/src/git_sync_oss.rs`) — without it the
|
|
/// `workspace_diff` rows never get written and the test would assert against
|
|
/// an empty diff set.
|
|
#[cfg(feature = "private")]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_compare_workspaces_rename_visibility_ee_e2e(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base_url = format!("http://localhost:{port}/api");
|
|
let admin = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN".to_string(),
|
|
);
|
|
let non_admin = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN_2".to_string(),
|
|
);
|
|
|
|
// The base fixture pre-populates `skip_workspace_diff_tally` for every
|
|
// workspace existing at migration time — that bypasses the diff
|
|
// accounting. Clear it so tally + compare run normally for this test.
|
|
sqlx::query!("DELETE FROM skip_workspace_diff_tally")
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// ------ Fork the existing test-workspace.
|
|
let resp = admin
|
|
.client()
|
|
.post(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/create_fork"
|
|
))
|
|
.json(&json!({
|
|
"id": "wm-fork-rename-test",
|
|
"name": "Rename Fork",
|
|
"color": "#0000ff"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"fork creation failed: {}",
|
|
resp.status()
|
|
);
|
|
|
|
// Non-admin user must be a member of both workspaces. They already are in
|
|
// test-workspace (base fixture); add them to the fork. Same username as
|
|
// the source so RLS extra_perms keys still resolve.
|
|
sqlx::query!(
|
|
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
|
|
VALUES ('wm-fork-rename-test', 'test2@windmill.dev', 'test-user-2', false, 'User')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// ------ Non-admin creates folder1 in the fork (owner = self).
|
|
let resp = non_admin
|
|
.client()
|
|
.post(&format!("{base_url}/w/wm-fork-rename-test/folders/create"))
|
|
.json(&json!({"name": "folder1", "owners": [], "summary": ""}))
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"folder1 create failed: {} — {}",
|
|
resp.status(),
|
|
resp.text().await?
|
|
);
|
|
|
|
// ------ Deploy a script in folder1 (initial deploy, no parent_hash).
|
|
let resp = non_admin
|
|
.client()
|
|
.post(&format!("{base_url}/w/wm-fork-rename-test/scripts/create"))
|
|
.json(&json!({
|
|
"path": "f/folder1/myscript",
|
|
"summary": "renamed test",
|
|
"description": "",
|
|
// Use bash so we don't trigger the dependency-job code path —
|
|
// create_script defers `handle_deployment_metadata` (and the
|
|
// tally) to the dep job for languages that need lock generation
|
|
// (Deno/Bun/Python/etc), which never runs in this test.
|
|
"content": "echo 1",
|
|
"language": "bash",
|
|
"schema": {"type": "object", "properties": {}, "required": []},
|
|
"deployment_message": "initial",
|
|
}))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let initial_hash = resp.text().await?;
|
|
assert!(
|
|
status.is_success(),
|
|
"initial script create failed: {} — {}",
|
|
status,
|
|
initial_hash
|
|
);
|
|
|
|
// ------ Create folder2 in fork.
|
|
let resp = non_admin
|
|
.client()
|
|
.post(&format!("{base_url}/w/wm-fork-rename-test/folders/create"))
|
|
.json(&json!({"name": "folder2", "owners": [], "summary": ""}))
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"folder2 create failed: {}",
|
|
resp.status()
|
|
);
|
|
|
|
// ------ Rename: re-deploy the same script at the new path with the old
|
|
// hash as parent_hash. This is exactly what the script editor sends when
|
|
// the user changes the path field and clicks Deploy. The EE tally upserts
|
|
// a workspace_diff row for both the new path AND the renamed_from path.
|
|
let resp = non_admin
|
|
.client()
|
|
.post(&format!("{base_url}/w/wm-fork-rename-test/scripts/create"))
|
|
.json(&json!({
|
|
"path": "f/folder2/myscript",
|
|
"summary": "renamed test",
|
|
"description": "",
|
|
// Use bash so we don't trigger the dependency-job code path —
|
|
// create_script defers `handle_deployment_metadata` (and the
|
|
// tally) to the dep job for languages that need lock generation
|
|
// (Deno/Bun/Python/etc), which never runs in this test.
|
|
"content": "echo 1",
|
|
"language": "bash",
|
|
"schema": {"type": "object", "properties": {}, "required": []},
|
|
// The API returns hash as hex (ScriptHash Serialize impl); pass it
|
|
// through verbatim — the backend deserializer parses hex back.
|
|
"parent_hash": initial_hash.trim().trim_matches('"'),
|
|
"deployment_message": "rename to folder2",
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"rename failed: {} — {}",
|
|
resp.status(),
|
|
resp.text().await?
|
|
);
|
|
|
|
// The tally is fired via `tokio::spawn` in `handle_deployment_metadata`
|
|
// (windmill-git-sync/src/git_sync_ee.rs) — wait specifically for the
|
|
// renamed script row to appear so we don't race the actual case under
|
|
// test.
|
|
let mut script_diff_written = false;
|
|
for _ in 0..40 {
|
|
let row_count: i64 = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) AS \"count!\" FROM workspace_diff
|
|
WHERE source_workspace_id = 'test-workspace'
|
|
AND fork_workspace_id = 'wm-fork-rename-test'
|
|
AND kind = 'script'
|
|
AND path = 'f/folder2/myscript'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
if row_count >= 1 {
|
|
script_diff_written = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
assert!(
|
|
script_diff_written,
|
|
"tally never wrote the renamed-script row to workspace_diff"
|
|
);
|
|
|
|
// ------ Compare as the non-admin who owns folder2 in the fork. With the
|
|
// bug, the source-scoped authed has no folder2 entry → fork visibility
|
|
// query hides f/folder2/myscript → all_ahead_items_visible flips to
|
|
// false. With the fix, the fork-scoped authed sees folder2 and the
|
|
// visibility check passes.
|
|
let comparison: serde_json::Value = non_admin
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-rename-test"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
comparison["all_ahead_items_visible"].as_bool(),
|
|
Some(true),
|
|
"non-admin owner of fork-only folder should see ahead items as visible; got {comparison}"
|
|
);
|
|
|
|
let diffs = comparison["diffs"].as_array().unwrap();
|
|
assert!(
|
|
diffs
|
|
.iter()
|
|
.any(|d| d["path"] == "f/folder2/myscript" && d["kind"] == "script"),
|
|
"renamed script at f/folder2/myscript should appear in diffs; got {diffs:?}"
|
|
);
|
|
// The renamed_from row (f/folder1/myscript) must NOT appear: both sides'
|
|
// archived=false views show it missing, so compare_two_scripts returns
|
|
// has_changes=false and the row is deleted. Keep an explicit assertion
|
|
// so a future regression that leaks the old path is caught here.
|
|
assert!(
|
|
!diffs
|
|
.iter()
|
|
.any(|d| d["path"] == "f/folder1/myscript" && d["kind"] == "script"),
|
|
"renamed-from path f/folder1/myscript should be cleaned up; got {diffs:?}"
|
|
);
|
|
|
|
// ------ Also confirm the superadmin path still works (this used to be
|
|
// the only path that worked because RLS bypass masked the bug).
|
|
let comparison: serde_json::Value = admin
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-rename-test"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
assert_eq!(
|
|
comparison["all_ahead_items_visible"].as_bool(),
|
|
Some(true),
|
|
"superadmin must always see all ahead items: {comparison}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Regression test for WIN-1975. A non-admin user creating a script in a fork-
|
|
/// only folder used to get the spurious
|
|
/// "this fork has changes not visible to your user" warning because
|
|
/// `filter_visible_diffs` ran every RLS query with the source-workspace
|
|
/// authed, so any item only reachable via fork-specific folders/groups was
|
|
/// hidden from the visibility check.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_compare_workspaces_fork_only_folder_visibility(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let client_user_2 = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN_2".to_string(),
|
|
);
|
|
let base_url = format!("http://localhost:{port}/api");
|
|
|
|
// ----- Set up parent workspace folder1 owned by test-user-2, then fork it.
|
|
sqlx::query!(
|
|
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)
|
|
VALUES ('test-workspace', 'folder1', 'folder1', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')",
|
|
json!({"u/test-user-2": true})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Create fork via the API so cloning + workspace_settings.deploy_to wiring
|
|
// matches what production sees.
|
|
let client_admin = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN".to_string(),
|
|
);
|
|
let fork_response = client_admin
|
|
.client()
|
|
.post(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/create_fork"
|
|
))
|
|
.json(&json!({
|
|
"id": "wm-fork-visibility-test",
|
|
"name": "Test Fork",
|
|
"color": "#0000ff"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
fork_response.status().is_success(),
|
|
"Fork creation failed: {}",
|
|
fork_response.status()
|
|
);
|
|
|
|
// test-user-2 must be a member of the fork. The fork's clone copies the
|
|
// creator's usr row only — add test-user-2 manually so they can hit the
|
|
// compare endpoint and own a fork-only folder.
|
|
sqlx::query!(
|
|
"INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES
|
|
('wm-fork-visibility-test', 'test2@windmill.dev', 'test-user-2', false, 'User')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// ----- Fork-only folder2 (does not exist in source) owned by test-user-2.
|
|
sqlx::query!(
|
|
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)
|
|
VALUES ('wm-fork-visibility-test', 'folder2', 'folder2', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')",
|
|
json!({"u/test-user-2": true})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Script in the fork-only folder with empty extra_perms (typical: scripts
|
|
// inherit access through their containing folder, not direct perms).
|
|
sqlx::query!(
|
|
"INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms)
|
|
VALUES ('wm-fork-visibility-test', 'f/folder2/myscript', 222222, 'def main():\n return 1', '', '', 'python3', 'test-user-2', NOW(), false, false, false, false, $1)",
|
|
json!({})
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Seed workspace_diff to mirror what the tally would write.
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES ('test-workspace', 'wm-fork-visibility-test', 'f/folder2/myscript', 'script', 1, 0, NULL)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Clear the skip flag added by the bootstrap migration so compare actually
|
|
// runs against this fork (it short-circuits otherwise).
|
|
sqlx::query!(
|
|
"DELETE FROM skip_workspace_diff_tally WHERE workspace_id IN ('test-workspace', 'wm-fork-visibility-test')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let comparison: serde_json::Value = client_user_2
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-visibility-test"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
comparison["all_ahead_items_visible"].as_bool(),
|
|
Some(true),
|
|
"ahead items should be visible to the fork-only folder owner; full response: {comparison}"
|
|
);
|
|
assert_eq!(
|
|
comparison["all_behind_items_visible"].as_bool(),
|
|
Some(true),
|
|
"behind items should be visible (no behind items here)"
|
|
);
|
|
|
|
let diffs = comparison["diffs"].as_array().unwrap();
|
|
assert!(
|
|
diffs
|
|
.iter()
|
|
.any(|d| d["path"] == "f/folder2/myscript" && d["kind"] == "script"),
|
|
"fork-only script should appear in diffs; got {diffs:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Regression test: deleting a fork must purge its `workspace_diff` and
|
|
/// `skip_workspace_diff_tally` rows. These tables are keyed by workspace id with
|
|
/// no FK cascade, and a fork id is reused when a fork is deleted and recreated
|
|
/// under the same name. If the cached diff rows survive the delete, they leak
|
|
/// onto the next fork sharing that id and produce a spurious "changes not
|
|
/// visible" warning that hides the deploy button (WIN-2066).
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_delete_fork_purges_workspace_diff(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let client = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN".to_string(),
|
|
);
|
|
let base_url = format!("http://localhost:{port}/api");
|
|
|
|
// Create the fork so the caller owns it (delete is authorized for fork owners).
|
|
let fork_response = client
|
|
.client()
|
|
.post(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/create_fork"
|
|
))
|
|
.json(&json!({
|
|
"id": "wm-fork-test-workspace",
|
|
"name": "Test Fork",
|
|
"color": "#0000ff"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
fork_response.status().is_success(),
|
|
"Fork creation should succeed: {}",
|
|
fork_response.status()
|
|
);
|
|
|
|
// Seed cached diff state for the fork: as the fork side of a pair, as the
|
|
// source side of a pair, and a skip-tally row.
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)
|
|
VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/leaky', 'script', 1, 0, true, true, true)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES ('wm-fork-test-workspace', 'test-workspace', 'f/shared/other', 'script', 0, 1, true)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO skip_workspace_diff_tally (workspace_id) VALUES ('wm-fork-test-workspace')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Delete the fork through the real handler.
|
|
let delete_response = client
|
|
.client()
|
|
.delete(&format!(
|
|
"{base_url}/workspaces/delete/wm-fork-test-workspace"
|
|
))
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
delete_response.status().is_success(),
|
|
"Fork deletion should succeed: {}",
|
|
delete_response.status()
|
|
);
|
|
|
|
let leftover_diffs = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM workspace_diff
|
|
WHERE source_workspace_id = 'wm-fork-test-workspace'
|
|
OR fork_workspace_id = 'wm-fork-test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
leftover_diffs,
|
|
Some(0),
|
|
"workspace_diff rows referencing the deleted fork must be purged"
|
|
);
|
|
|
|
let leftover_skip = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM skip_workspace_diff_tally WHERE workspace_id = 'wm-fork-test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
leftover_skip,
|
|
Some(0),
|
|
"skip_workspace_diff_tally row for the deleted fork must be purged"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Regression test: creating a fork must start with clean diff state even when
|
|
/// the (reusable) fork id was previously occupied by a deleted fork. Stale
|
|
/// `workspace_diff` / `skip_workspace_diff_tally` rows left behind by an earlier
|
|
/// occupant would otherwise leak onto the new fork — a stale skip row suppresses
|
|
/// comparison entirely, and stale diff rows produce a spurious "changes not
|
|
/// visible" warning that hides the deploy button (WIN-2066).
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_create_fork_purges_stale_diff_state(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let client = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN".to_string(),
|
|
);
|
|
let base_url = format!("http://localhost:{port}/api");
|
|
|
|
// Simulate leftovers from a previously deleted fork that reused this id:
|
|
// diff rows on both sides plus a skip-tally row, with no workspace yet.
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)
|
|
VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/leaky', 'script', 1, 0, true, true, true)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
|
VALUES ('wm-fork-test-workspace', 'test-workspace', 'f/shared/other', 'script', 0, 1, true)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO skip_workspace_diff_tally (workspace_id) VALUES ('wm-fork-test-workspace')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Create the fork reusing that id; the conflict check passes because no
|
|
// workspace row exists for it.
|
|
let fork_response = client
|
|
.client()
|
|
.post(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/create_fork"
|
|
))
|
|
.json(&json!({
|
|
"id": "wm-fork-test-workspace",
|
|
"name": "Test Fork",
|
|
"color": "#0000ff"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
fork_response.status().is_success(),
|
|
"Fork creation should succeed: {}",
|
|
fork_response.status()
|
|
);
|
|
|
|
let leftover_diffs = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM workspace_diff
|
|
WHERE source_workspace_id = 'wm-fork-test-workspace'
|
|
OR fork_workspace_id = 'wm-fork-test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
leftover_diffs,
|
|
Some(0),
|
|
"stale workspace_diff rows must be purged on fork creation"
|
|
);
|
|
|
|
let leftover_skip = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM skip_workspace_diff_tally WHERE workspace_id = 'wm-fork-test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
leftover_skip,
|
|
Some(0),
|
|
"stale skip_workspace_diff_tally row must be purged on fork creation"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Regression: a stale/phantom trigger diff row must never block a privileged
|
|
/// user's deploy. Triggers (unlike scripts/flows) are not re-validated by
|
|
/// `compare_workspaces`, so a cached `has_changes=true` row for a trigger that
|
|
/// no longer exists in the table is trusted, then dropped by the visibility
|
|
/// filter (the row is gone) — flipping `all_ahead_items_visible` to false and
|
|
/// hiding the deploy button. This used to happen even for a superadmin, because
|
|
/// the item's absence is indistinguishable from a permission-hidden item.
|
|
///
|
|
/// The blast-radius guard forces the flag true for anyone who sees the relevant
|
|
/// side in full: a target/fork admin (or superadmin) for ahead items. A regular
|
|
/// user with no such visibility still gets the (conservative) warning, since we
|
|
/// cannot tell a phantom from a genuine permission gap on their behalf.
|
|
///
|
|
/// The diff row and visibility query are OSS, so this runs on any build (no
|
|
/// tally needed — the row is inserted directly, mimicking a delete that left the
|
|
/// diff behind).
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_compare_workspaces_phantom_trigger_shortfuse(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base_url = format!("http://localhost:{port}/api");
|
|
let superadmin = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN".to_string(),
|
|
);
|
|
let non_admin = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN_2".to_string(),
|
|
);
|
|
|
|
// Fork of test-workspace (INSERT directly; we only need the pair to exist).
|
|
sqlx::query!(
|
|
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
|
|
VALUES ('wm-fork-test-workspace', 'Fork', 'test-user', 'test-workspace')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!("INSERT INTO workspace_settings (workspace_id) VALUES ('wm-fork-test-workspace')")
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_key(workspace_id, kind, key)
|
|
VALUES ('wm-fork-test-workspace', 'cloud', 'test-key')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Phantom rows: cached diffs for http_triggers with no backing row (the exact
|
|
// state a trigger delete used to leave behind before it reset the tally). One
|
|
// ahead (fork side), one behind (source side) so both guard branches are hit.
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)
|
|
VALUES
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/rt/ghost', 'http_trigger', 1, 0, true, false, true),
|
|
('test-workspace', 'wm-fork-test-workspace', 'f/rt/ghost_behind', 'http_trigger', 0, 1, true, true, false)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Superadmin: the guard forces `all_ahead_items_visible = true`, and the
|
|
// non-existent trigger is not surfaced as a diff.
|
|
let comparison: serde_json::Value = superadmin
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
assert_eq!(
|
|
comparison["all_ahead_items_visible"].as_bool(),
|
|
Some(true),
|
|
"phantom trigger row must not trip the 'not visible' warning for a superadmin: {comparison}"
|
|
);
|
|
assert_eq!(
|
|
comparison["all_behind_items_visible"].as_bool(),
|
|
Some(true),
|
|
"phantom behind trigger row must not trip the warning for a superadmin: {comparison}"
|
|
);
|
|
assert!(
|
|
!comparison["diffs"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|d| d["path"] == "f/rt/ghost" || d["path"] == "f/rt/ghost_behind"),
|
|
"non-existent triggers must not be surfaced as diffs: {comparison}"
|
|
);
|
|
|
|
// Non-superadmin, non-fork-admin member of the source: no full-visibility
|
|
// guarantee, so the warning still (conservatively) fires.
|
|
let comparison: serde_json::Value = non_admin
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
assert_eq!(
|
|
comparison["all_ahead_items_visible"].as_bool(),
|
|
Some(false),
|
|
"a user without full visibility must not be short-circuited by the guard: {comparison}"
|
|
);
|
|
assert_eq!(
|
|
comparison["all_behind_items_visible"].as_bool(),
|
|
Some(false),
|
|
"the behind-side guard must not fire for a non-admin either: {comparison}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Regression: the "sees everything" guard must require admin of BOTH sides, not
|
|
/// just the fork. `filter_visible_diffs` keeps a modified/conflict row (one that
|
|
/// exists in the source AND the fork) only when the caller can see it on both
|
|
/// sides, so an ahead diff can be dropped for a *source-side* visibility gap even
|
|
/// when the caller is a fork admin. If the guard cleared the ahead flag on
|
|
/// fork-admin alone, the UI would report "all ahead visible" and let the user
|
|
/// deploy from an incomplete comparison. Here test-user-2 is admin of the fork
|
|
/// but only a plain member of the parent with no access to folder `restricted`,
|
|
/// so the parent copy of the modified script is hidden from them and the ahead
|
|
/// diff is (correctly) dropped — the flag must stay false.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_compare_workspaces_fork_admin_source_hidden_ahead(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base_url = format!("http://localhost:{port}/api");
|
|
let admin = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN".to_string(),
|
|
);
|
|
let fork_admin_user = windmill_api_client::create_client(
|
|
&format!("http://localhost:{port}"),
|
|
"SECRET_TOKEN_2".to_string(),
|
|
);
|
|
|
|
// Parent folder `restricted` owned by test-user (the admin), NOT test-user-2,
|
|
// and a script inside it (access flows through the folder). test-user-2 is a
|
|
// plain member of the parent, so RLS hides this script from them.
|
|
sqlx::query!(
|
|
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)
|
|
VALUES ('test-workspace', 'restricted', 'restricted', ARRAY['u/test-user']::varchar[], '{\"u/test-user\": true}'::jsonb, '', 'test-user')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms)
|
|
VALUES ('test-workspace', 'f/restricted/item', 314159, 'def main(): return 1', '', '', 'python3', 'test-user', NOW(), false, false, false, false, '{}'::jsonb)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Fork (clones the folder + script into the fork).
|
|
let resp = admin
|
|
.client()
|
|
.post(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/create_fork"
|
|
))
|
|
.json(&json!({"id": "wm-fork-guard-test", "name": "Guard Fork", "color": "#0000ff"}))
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"fork creation failed: {}",
|
|
resp.status()
|
|
);
|
|
|
|
// test-user-2 is ADMIN of the fork (so they see the fork copy via RLS bypass)
|
|
// but only a plain member of the parent.
|
|
sqlx::query!(
|
|
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
|
|
VALUES ('wm-fork-guard-test', 'test2@windmill.dev', 'test-user-2', true, 'Admin')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// A confirmed modified/ahead diff on the script that exists on both sides.
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_diff
|
|
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)
|
|
VALUES ('test-workspace', 'wm-fork-guard-test', 'f/restricted/item', 'script', 1, 0, true, true, true)"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query!(
|
|
"DELETE FROM skip_workspace_diff_tally WHERE workspace_id IN ('test-workspace', 'wm-fork-guard-test')"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let comparison: serde_json::Value = fork_admin_user
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-guard-test"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
|
|
// The parent copy is hidden from test-user-2, so the ahead diff is dropped.
|
|
// Fork-admin alone must NOT clear the flag — the comparison is incomplete.
|
|
assert_eq!(
|
|
comparison["all_ahead_items_visible"].as_bool(),
|
|
Some(false),
|
|
"fork admin without source-side visibility must not be reported as seeing all ahead items: {comparison}"
|
|
);
|
|
assert!(
|
|
!comparison["diffs"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|d| d["path"] == "f/restricted/item"),
|
|
"source-hidden item must not appear in the fork admin's diff list: {comparison}"
|
|
);
|
|
|
|
// Sanity: a superadmin (admin of both sides) still sees everything.
|
|
let comparison: serde_json::Value = admin
|
|
.client()
|
|
.get(&format!(
|
|
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-guard-test"
|
|
))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
assert_eq!(
|
|
comparison["all_ahead_items_visible"].as_bool(),
|
|
Some(true),
|
|
"superadmin must see all ahead items: {comparison}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|