mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
fb82748296
* fix: make on_behalf_of control permissions for scripts and flows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: inherit the recorded on-behalf-of identity when a preserving deploy omits it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep an omitted permissioned_as from re-versioning an unchanged script Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: derive the on-behalf-of principal from the email and reject mismatched pairs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop workspace deploys from carrying a source-workspace principal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct the onBehalfOfPermissionedAs param doc Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin that workspace deploys never carry a source-workspace principal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct the omitted-principal contract and refresh generated prompts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep external-superadmin principals on email-only redeploys Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: scope the recorded principal to its workspace and prefer real accounts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: carry the recorded principal correctly through drafts and set-permissioned-as Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: sweep draft identity pairs on email change and offboarding Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: leave group identities alone when sweeping a user's email Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: treat only g/ without an email as a group, and match the offboard preview Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop the group guard from skipping rows with no recorded principal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: state the group guard once instead of restating it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: make the permissioned_as the only stored on-behalf-of identity Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: skip resolving the on-behalf-of address for sync clients that discard it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address the local review of the identity refactor Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: resolve the on-behalf-of identity coherently across clones, offboarding and no-op deploys * test: pin that a fork keeps only the on-behalf-of identities that resolve in it * fix: decide a principal prefix-first everywhere and canonicalize bare addresses * fix: prefix a slash-containing address so a reader cannot take it for a group * fix: read an address as a username before the group- convention * fix: rewrite the canonical principal when an account's address moves * fix: keep the address form of a principal to accounts without a usr row * fix: reject an identity a job row cannot carry and read it uncached at dispatch * fix: count characters against the job identity width and cap the backfill * refactor: name the script/flow principal on_behalf_of, as apps do * docs: state the caller-must-authorize contract on the identity resolvers * fix: keep writing on_behalf_of_email until every worker reads the principal * fix: err high on the compatibility version and document the last resolver * fix: keep the compatibility address current through identity mutations * fix: carry the compatibility address with the principal on every copy path * chore: re-pin the EE ref to the companion branch merged with EE main * fix: key the dbt retry lookup on the stored principal * fix: keep a mixed-version address recoverable through a fork * fix: read a round-tripped address uncached so a redeploy is not rejected * fix: refuse an email change that would make a principal unenqueueable * chore: update ee-repo-ref to ac3d7d015296f041ae44ab6bc4953485f44d36e4 This commit updates the EE repository reference after PR #704 was merged in windmill-ee-private. Previous ee-repo-ref: 219b0b03905a1a0028054b3a4985724e77d09036 New ee-repo-ref: ac3d7d015296f041ae44ab6bc4953485f44d36e4 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
602 lines
21 KiB
Rust
602 lines
21 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?;
|
|
|
|
// A shared-path script that runs as the departing user. Both halves of its identity have to
|
|
// move: a worker predating MIN_VERSION_SUPPORTS_ON_BEHALF_OF_PRINCIPAL reads the address and
|
|
// nothing else, so a stale one keeps running it under someone who has just been offboarded.
|
|
sqlx::query!(
|
|
"INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, on_behalf_of, on_behalf_of_email)
|
|
VALUES ('test-workspace', 'f/shared/obo', 1099, 'def main(): pass', '', '', 'python3', 'test-user', NOW(), 'u/test-user-2', 'test2@windmill.dev')"
|
|
)
|
|
.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);
|
|
|
|
let obo = sqlx::query!(
|
|
"SELECT on_behalf_of, on_behalf_of_email FROM script WHERE path = 'f/shared/obo' AND workspace_id = 'test-workspace'"
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
(obo.on_behalf_of.as_deref(), obo.on_behalf_of_email.as_deref()),
|
|
(Some("u/test-user"), Some("test@windmill.dev")),
|
|
"the reassignment moves the whole identity, not just the half this release reads"
|
|
);
|
|
|
|
// 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(())
|
|
}
|