mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
435b25e6a4
* feat: add user offboarding flow with object reassignment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: require new_operator for permissioned_as when reassigning to folder Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update on_behalf_of_email on scripts/flows during offboarding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract offboarding to separate module and add integration tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: delete tokens, add operator preview counts, remove token reassignment UI Tokens are now always deleted during offboarding. Preview now shows scripts/flows/apps with on_behalf_of and schedules/triggers with permissioned_as referencing the departing user (even outside their path). Token reassignment UI removed since webhooks break on path changes anyway. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: rich preview with path lists, warnings, and downloadable report Preview now returns full path lists (not just counts) for owned objects and objects executing on behalf of the user. Adds warnings for: - HTTP triggers (webhook URLs will change) - Email triggers (addresses will change) - Broken $var:/$res: references in resources/variables Frontend provides "Export list" button to download affected content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add coverage for dynamic queries (triggers, extra_perms, operator schedules) Adds HTTP trigger, extra_perms reference, and shared schedule to test fixture. Tests verify that non-macro sqlx queries (trigger reassignment, extra_perms cleanup, operator schedule update) work correctly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove broken_references, add full dynamic query test coverage Remove broken_references field from preview (user's resources/variables are already in the owned paths list). Add shared HTTP trigger fixture to test all dynamic query paths: trigger operator preview (line 232), trigger permissioned_as update for non-user-path (line 951), and extra_perms cleanup on trigger tables (line 983). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add referencing field to preview for content/value path references Preview now includes a 'referencing' section listing scripts (by content), flows (by value JSON), apps (by policy/extra_perms), and resources (by value) that contain references to u/{username}/ paths. These references may break after reassignment. Shown in export list and as a warning in the UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rename objects to items in UI, detect on_behalf_of items in hasItems Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace remaining objects with items in UI text Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: rename operator to on_behalf_of, separate owned vs on-behalf UI sections - Rename new_operator to new_on_behalf_of_user in API and frontend - Rename op_ prefixed variables to obo_ in backend - UI now shows separate sections for owned items and items running on behalf, with the operator selector shown only when needed - canSubmit logic updated: operator needed for folder targets OR when on-behalf items exist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: CSV export, side-by-side layout, always accept new_on_behalf_of_user - Export affected items as CSV instead of text - Owned items and on-behalf items shown side by side in summary boxes - new_on_behalf_of_user always accepted (defaults to target user for user targets, required for folder targets) - On_behalf_of selector always visible, auto-defaults when user target is selected Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: proper pluralization and bottom-aligned counts in summary boxes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: stack info boxes vertically, referencing box as warning style at top Info boxes (owned, on-behalf, referencing) now one per row instead of side-by-side. Referencing box uses warning colors. Webhook/email trigger alerts shown below boxes. Proper pluralization in global modal too. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: CSV exports only referencing items, export button inside warning box Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: use ToggleButtonGroup for user/folder, add reassign toggle on remove - User/Folder selection now uses ToggleButtonGroup component - When removing a user, a "Reassign items before removing" toggle lets the admin skip reassignment and just delete directly - In reassign-only mode, the toggle is not shown (always reassigns) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: show token details with labels and scopes in preview Preview now returns token label, scopes, and expiration instead of just a count. Frontend shows a dedicated token box listing each token with its scopes. Test updated to verify token label in preview response. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: extract shared offboarding components, per-type trigger links, hash deep linking - Extract OffboardItemsBox, OffboardReassignControls, OffboardWorkspaceSection, and offboarding-utils.ts as shared components used by both workspace and global modals - Change triggers in OffboardAffectedPaths from Vec<String> to HashMap<String, Vec<String>> so frontend knows which trigger page to link to - Add hash-based deep linking to all 9 trigger pages and schedules page - Preserve URL hash in updateQueryFilters across all trigger pages - Only open editor drawer if the item is found in the list - Reassign toggle at top with warning alert when disabled (both modals) - Referencing items box uses yellow warning variant with expandable path links - Cleaner labels: "Move u/{username}/* items to", "Update triggers/runnables permissions to" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rename on_behalf_of section label to match flow advanced settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate sqlx query cache Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review issues for offboarding - Add 9 trigger tables to check_path_conflicts for user-friendly conflict messages - Fix submit button no-op when user has only on-behalf items (show target selector, fix canSubmit) - Only delete workspace user when reassignment entry exists (prevent orphaned objects) - Add $azure_kv: prefix to vault secret query (match rename_user pattern) - Use Svelte 5 onSelected callback instead of deprecated on:selected - Make ScriptBuilder section label conditional on canPreserve - Fix CSV export to include trigger paths via flattenPaths utility - Fix test_offboard_reassign_only to remove conflicts and assert on response - Parallelize workspace config fetches in global modal with Promise.all - Delete tokens when deleting workspace user - Return structured JSON from global offboard endpoint Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * sqlx * fix: address second round of PR review issues - Accumulate per-workspace OffboardSummary in global offboard instead of returning zeros - Delete workspace user unconditionally when delete_user=true (prevent orphaned usr rows) - Filter archived/deleted scripts in check_path_conflicts to match preview - Reset form state when workspace offboard modal reopens - Move hashHandled=true inside trigger-found guard on all 10 deep-link pages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: improve offboarding integration tests - Add second workspace to fixture for multi-workspace global offboard testing - Add test_global_offboard_execution: verifies items reassigned across 2 workspaces, user deleted from both, and password row deleted from instance - Add test_offboard_invalid_target: verifies 400 for nonexistent user, nonexistent folder, and invalid target format - Fix test_offboard_to_user: use single DELETE, add explicit new_on_behalf_of_user - Fix test_global_offboard_preview: assert 2 workspaces instead of 1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address third round of PR review issues - Fix ScriptBuilder tooltip to match conditional section label wording - Clear stale conflicts in global modal on reopen - Fix test_offboard_to_folder to assert on specific moved path, not pre-existing data - Allow deleting user with zero items (show Offboard button, skip reassignment) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add global token deletion warning in instance-level offboard modal Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * update sqlx * fix: add raw_app path and dependency_map path reassignment to offboarding Audit found these tables with user-scoped paths were not being updated: - raw_app: mirrors app paths, needs path reassignment - dependency_map: importer_path and imported_path reference user paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: move user cleanup to delete_workspace_user_internal, fix review issues - Move extra_perms, folder owners, drafts, favorites, inputs, captures cleanup into delete_workspace_user_internal so any user deletion gets proper cleanup (not just offboard path) - Fix flow INSERT missing labels and lock_error_logs columns (data loss) - Fix validate_target returning 404 instead of 400 for nonexistent targets - Fix canSubmit blocking delete when user has no items to reassign - Fix token preview query filtering out tokens without scopes - Fix token warning messages: workspace-level mentions webhooks/HTTP triggers, instance-level mentions API calls using credentials - Fix "Schedules and triggers" -> "Triggers and runnables" wording - Show token section at instance level only when tokens exist - Show Offboard button at instance level when user has no items but deleteUser=true Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
581 lines
20 KiB
Rust
581 lines
20 KiB
Rust
use serde_json::json;
|
|
use sqlx::{Pool, Postgres};
|
|
|
|
use windmill_test_utils::*;
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::new()
|
|
}
|
|
|
|
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
|
}
|
|
|
|
fn ws_url(port: u16, endpoint: &str) -> String {
|
|
format!("http://localhost:{port}/api/w/test-workspace/users/{endpoint}")
|
|
}
|
|
|
|
fn global_url(port: u16, endpoint: &str) -> String {
|
|
format!("http://localhost:{port}/api/users/{endpoint}")
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_preview(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
let resp = authed(client().get(ws_url(port, "offboard_preview/test-user-2")))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
assert_eq!(body["owned"]["scripts"].as_array().unwrap().len(), 3); // script_a, script_b, conflict_script
|
|
assert_eq!(body["owned"]["flows"].as_array().unwrap().len(), 1); // flow_a
|
|
assert_eq!(body["owned"]["resources"].as_array().unwrap().len(), 1); // res_a
|
|
assert_eq!(body["owned"]["variables"].as_array().unwrap().len(), 1); // var_a
|
|
assert_eq!(body["owned"]["schedules"].as_array().unwrap().len(), 1); // sched_a
|
|
assert_eq!(
|
|
body["owned"]["triggers"]["http_trigger"]
|
|
.as_array()
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
); // webhook_a (dynamic trigger query)
|
|
assert_eq!(body["tokens"].as_array().unwrap().len(), 1); // OFFBOARD_TOKEN_1
|
|
assert_eq!(body["tokens"][0]["label"], "offboard test");
|
|
assert_eq!(body["http_triggers"], 1); // webhook_a
|
|
assert_eq!(body["email_triggers"], 0);
|
|
// Operator references: schedule + trigger at f/test-folder/ run as u/test-user-2
|
|
assert_eq!(
|
|
body["executing_on_behalf"]["schedules"]
|
|
.as_array()
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
body["executing_on_behalf"]["triggers"]["http_trigger"]
|
|
.as_array()
|
|
.unwrap()
|
|
.len(),
|
|
1,
|
|
"shared trigger permissioned_as should be detected"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_to_user(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Remove conflict scripts so offboard can proceed
|
|
sqlx::query!(
|
|
"DELETE FROM script WHERE hash IN (1003, 1004) AND workspace_id = 'test-workspace'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "u/test-user",
|
|
"new_on_behalf_of_user": "test-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
assert!(body["conflicts"].as_array().map_or(true, |a| a.is_empty()));
|
|
assert!(body["summary"].is_object());
|
|
|
|
let summary = &body["summary"];
|
|
assert!(summary["scripts_reassigned"].as_i64().unwrap() > 0);
|
|
assert!(summary["flows_reassigned"].as_i64().unwrap() > 0);
|
|
assert!(summary["resources_reassigned"].as_i64().unwrap() > 0);
|
|
assert!(summary["variables_reassigned"].as_i64().unwrap() > 0);
|
|
assert!(summary["schedules_reassigned"].as_i64().unwrap() > 0);
|
|
|
|
// Verify scripts moved
|
|
let moved = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert!(moved > 0, "scripts should be under u/test-user now");
|
|
|
|
let old = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user-2/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert_eq!(old, 0, "no scripts should remain under u/test-user-2");
|
|
|
|
// Verify schedule permissioned_as updated
|
|
let perm = sqlx::query_scalar!(
|
|
"SELECT permissioned_as FROM schedule WHERE path = 'u/test-user/sched_a' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert_eq!(perm.as_deref(), Some("u/test-user"));
|
|
|
|
// Verify user deleted from workspace
|
|
let user_exists = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = 'test-user-2' AND workspace_id = 'test-workspace')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(true);
|
|
assert!(!user_exists, "user should be removed from workspace");
|
|
|
|
// Verify tokens revoked (default behavior when no reassign_tokens_to)
|
|
let token_count = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM token WHERE owner = 'u/test-user-2' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(1);
|
|
assert_eq!(token_count, 0, "tokens should be revoked");
|
|
|
|
// Verify HTTP trigger reassigned (dynamic trigger query)
|
|
let trigger_moved = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM http_trigger WHERE path = 'u/test-user/webhook_a' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert_eq!(trigger_moved, 1, "http trigger should be reassigned");
|
|
|
|
let trigger_perm = sqlx::query_scalar!(
|
|
"SELECT permissioned_as FROM http_trigger WHERE path = 'u/test-user/webhook_a' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
trigger_perm.as_deref(),
|
|
Some("u/test-user"),
|
|
"trigger permissioned_as should be updated"
|
|
);
|
|
|
|
// Verify extra_perms cleaned up (dynamic extra_perms query)
|
|
let extra_perms = sqlx::query_scalar!(
|
|
"SELECT extra_perms::text FROM script WHERE path = 'f/test-folder/shared_script' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert!(
|
|
!extra_perms
|
|
.unwrap_or_default()
|
|
.unwrap_or_default()
|
|
.contains("test-user-2"),
|
|
"extra_perms should no longer reference test-user-2"
|
|
);
|
|
|
|
// Verify operator schedule updated (schedule not under user's path)
|
|
let shared_sched_perm = sqlx::query_scalar!(
|
|
"SELECT permissioned_as FROM schedule WHERE path = 'f/test-folder/sched_shared' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
shared_sched_perm.as_deref(),
|
|
Some("u/test-user"),
|
|
"shared schedule permissioned_as should be updated"
|
|
);
|
|
|
|
// Verify shared trigger permissioned_as updated (line 951 - dynamic query)
|
|
let shared_trigger_perm = sqlx::query_scalar!(
|
|
"SELECT permissioned_as FROM http_trigger WHERE path = 'f/test-folder/webhook_shared' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
shared_trigger_perm.as_deref(),
|
|
Some("u/test-user"),
|
|
"shared trigger permissioned_as should be updated"
|
|
);
|
|
|
|
// Verify extra_perms cleaned on trigger (line 983 - dynamic query on trigger table)
|
|
let trigger_extra_perms = sqlx::query_scalar!(
|
|
"SELECT extra_perms::text FROM http_trigger WHERE path = 'f/test-folder/webhook_shared' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert!(
|
|
!trigger_extra_perms
|
|
.unwrap_or_default()
|
|
.unwrap_or_default()
|
|
.contains("test-user-2"),
|
|
"trigger extra_perms should no longer reference test-user-2"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_to_folder(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "f/test-folder",
|
|
"new_on_behalf_of_user": "test-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
assert!(body["conflicts"].as_array().map_or(true, |a| a.is_empty()));
|
|
|
|
// Verify specific script moved to folder (not just pre-existing shared_script)
|
|
let script_a_moved = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM script WHERE path = 'f/test-folder/script_a' AND workspace_id = 'test-workspace')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(false);
|
|
assert!(script_a_moved, "script_a should be moved to f/test-folder/");
|
|
|
|
// Verify no scripts remain under old path
|
|
let old_scripts = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user-2/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(1);
|
|
assert_eq!(
|
|
old_scripts, 0,
|
|
"no scripts should remain under u/test-user-2"
|
|
);
|
|
|
|
// Verify schedule permissioned_as is u/test-user (operator), not g/f/test-folder
|
|
let perm = sqlx::query_scalar!(
|
|
"SELECT permissioned_as FROM schedule WHERE path = 'f/test-folder/sched_a' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
assert_eq!(perm.as_deref(), Some("u/test-user"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_reassign_only(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Remove conflict scripts so reassignment can proceed
|
|
sqlx::query!(
|
|
"DELETE FROM script WHERE hash IN (1003, 1004) AND workspace_id = 'test-workspace'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "u/test-user",
|
|
"new_on_behalf_of_user": "test-user",
|
|
"delete_user": false
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
assert!(
|
|
body["conflicts"].as_array().map_or(true, |c| c.is_empty()),
|
|
"should have no conflicts"
|
|
);
|
|
assert!(body["summary"].is_object(), "should have a summary");
|
|
|
|
// Verify user still exists in workspace
|
|
let user_exists = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = 'test-user-2' AND workspace_id = 'test-workspace')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(false);
|
|
assert!(
|
|
user_exists,
|
|
"user should still exist when delete_user=false"
|
|
);
|
|
|
|
// Verify items were actually reassigned
|
|
let moved = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert!(moved > 0, "scripts should be under u/test-user now");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_conflicts(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// conflict_script exists under both u/test-user-2/ and u/test-user/
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "u/test-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
let conflicts = body["conflicts"]
|
|
.as_array()
|
|
.expect("conflicts should be array");
|
|
assert!(!conflicts.is_empty(), "should have path conflicts");
|
|
assert!(
|
|
conflicts
|
|
.iter()
|
|
.any(|c| c.as_str().unwrap().contains("conflict_script")),
|
|
"conflict should mention conflict_script"
|
|
);
|
|
// No summary when conflicts exist
|
|
assert!(body["summary"].is_null());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_tokens_deleted(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Remove conflict_script so offboard can proceed
|
|
sqlx::query!("DELETE FROM script WHERE hash = 1004 AND workspace_id = 'test-workspace'")
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "u/test-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
// Verify tokens are deleted
|
|
let token_count = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM token WHERE owner = 'u/test-user-2' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(1);
|
|
assert_eq!(token_count, 0, "tokens should be deleted after offboarding");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_folder_requires_operator(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Missing new_on_behalf_of_user when reassigning to folder should fail
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "f/test-folder",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_global_offboard_preview(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
let resp = authed(client().get(global_url(port, "offboard_preview/test2@windmill.dev")))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
let workspaces = body["workspaces"]
|
|
.as_array()
|
|
.expect("should have workspaces");
|
|
assert_eq!(workspaces.len(), 2, "user is in two workspaces");
|
|
|
|
// Both workspaces should have the user's items
|
|
for ws in workspaces {
|
|
assert_eq!(ws["username"], "test-user-2");
|
|
assert!(
|
|
!ws["preview"]["owned"]["scripts"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty(),
|
|
"workspace {} should have owned scripts",
|
|
ws["workspace_id"]
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_global_offboard_execution(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Remove conflict scripts so offboard can proceed in test-workspace
|
|
sqlx::query!(
|
|
"DELETE FROM script WHERE hash IN (1003, 1004) AND workspace_id = 'test-workspace'"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(client().post(global_url(port, "offboard/test2@windmill.dev")))
|
|
.json(&json!({
|
|
"reassignments": {
|
|
"test-workspace": {
|
|
"reassign_to": "u/test-user",
|
|
"new_on_behalf_of_user": "test-user"
|
|
},
|
|
"test-workspace-2": {
|
|
"reassign_to": "u/test-user",
|
|
"new_on_behalf_of_user": "test-user"
|
|
}
|
|
},
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
assert!(
|
|
body["conflicts"].as_array().map_or(true, |c| c.is_empty()),
|
|
"should have no conflicts"
|
|
);
|
|
assert!(body["summary"].is_object(), "should have a summary");
|
|
let summary = &body["summary"];
|
|
assert!(
|
|
summary["scripts_reassigned"].as_i64().unwrap() > 0,
|
|
"should have reassigned scripts across workspaces"
|
|
);
|
|
|
|
// Verify items moved in workspace 1
|
|
let ws1_moved = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert!(ws1_moved > 0, "scripts should be moved in test-workspace");
|
|
|
|
// Verify items moved in workspace 2
|
|
let ws2_moved = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace-2' AND NOT archived AND NOT deleted"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(0);
|
|
assert!(ws2_moved > 0, "scripts should be moved in test-workspace-2");
|
|
|
|
// Verify user deleted from both workspaces
|
|
let ws1_user = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM usr WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(true);
|
|
assert!(!ws1_user, "user should be removed from test-workspace");
|
|
|
|
let ws2_user = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM usr WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace-2')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(true);
|
|
assert!(!ws2_user, "user should be removed from test-workspace-2");
|
|
|
|
// Verify global auth deleted (password row)
|
|
let password_exists = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM password WHERE email = 'test2@windmill.dev')"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(true);
|
|
assert!(!password_exists, "password should be deleted from instance");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))]
|
|
async fn test_offboard_invalid_target(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Reassign to nonexistent user
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "u/nonexistent-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
400,
|
|
"should reject reassignment to nonexistent user"
|
|
);
|
|
|
|
// Reassign to nonexistent folder
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "f/nonexistent-folder",
|
|
"new_on_behalf_of_user": "test-user",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
400,
|
|
"should reject reassignment to nonexistent folder"
|
|
);
|
|
|
|
// Invalid target format
|
|
let resp = authed(client().post(ws_url(port, "offboard/test-user-2")))
|
|
.json(&json!({
|
|
"reassign_to": "invalid-format",
|
|
"delete_user": true
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "should reject invalid target format");
|
|
|
|
Ok(())
|
|
}
|