Files
windmill/backend/windmill-api-integration-tests/tests/workspaces.rs
T
9a1c6e5081 feat: let a workspace withdraw operator schedule and trigger writes (#11226)
* feat: let a workspace withdraw operator schedule and trigger writes

Operators can create, edit and delete schedules and triggers today through the
API, CLI and MCP, while the operator_settings flags beside them only hide those
pages. An admin who wants operators to see what is scheduled without letting
them change it cannot express that. Add manage_schedules and manage_triggers as
enforced settings, gated at the schedule handlers and at the generic TriggerCrud
routes so every trigger kind is covered by one check.

They name capabilities operators already hold, so they are granted unless
withdrawn, and absence has to mean "never configured" rather than a value. The
read coalesces to true; the update endpoint merges into the stored jsonb with
the two fields as Option<bool>, so an omitted key keeps what is stored.
operator_settings is git-synced as a whole object, so a settings file written
before these keys existed reaches the endpoint on every pull, and a serde or SQL
default of either polarity would turn that pull into a silent withdrawal or
restoration.

The rights are read through a per-process cache, so withdrawing one publishes a
notify_event that drops the entry on every replica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsf6VC4MVLisiEoeQkgbr4

* feat: enforce operator write rights on the router and in the UI

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: close the capture gap and gate the trigger editors' write actions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate acl writes and the native trigger drawer behind manage rights

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse operator writes with 403 and gate sharing at the drawer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: resolve identity in the operator write gate only for writes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate the suspended-jobs actions and stop the route check refusing reads

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: explain the empty-state create button when operator writes are withdrawn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: audit operator settings changes and fold path writes into native rows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: open locked editors read-only and group the operator settings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: skip email and azure lookups on editor open while triggers are locked

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: state each operator-rights rationale once in comments

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: address CI review findings on operator write rights

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep capture move gated and skip it in the builders while locked

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep admin and operator exclusive when setting a workspace role

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: use the shared section component for operator settings groups

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-25 17:34:58 +00:00

1267 lines
41 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")
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let global_base = format!("http://localhost:{port}/api/workspaces");
// ===== Global endpoints =====
// --- list ---
let resp = authed(client().get(format!("{global_base}/list")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let list = resp.json::<Vec<serde_json::Value>>().await?;
assert!(list.iter().any(|w| w["id"] == "test-workspace"));
// --- list_as_superadmin ---
let resp = authed(client().get(format!("{global_base}/list_as_superadmin")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let list = resp.json::<Vec<serde_json::Value>>().await?;
assert!(list.iter().any(|w| w["id"] == "test-workspace"));
// --- users (user's workspaces) ---
let resp = authed(client().get(format!("{global_base}/users")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
let workspaces = body["workspaces"].as_array().unwrap();
assert!(workspaces.iter().any(|w| w["id"] == "test-workspace"));
// --- exists ---
let resp = authed(client().post(format!("{global_base}/exists")))
.json(&json!({"id": "test-workspace"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.json::<bool>().await?, true);
let resp = authed(client().post(format!("{global_base}/exists")))
.json(&json!({"id": "nonexistent-workspace"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.json::<bool>().await?, false);
// --- exists_username (validates username is available) ---
let resp = authed(client().post(format!("{global_base}/exists_username")))
.json(&json!({"id": "test-workspace", "username": "test-user"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let resp = authed(client().post(format!("{global_base}/exists_username")))
.json(&json!({"id": "test-workspace", "username": "available-user"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- allowed_domain_auto_invite ---
let resp = authed(client().get(format!("{global_base}/allowed_domain_auto_invite")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<bool>().await?;
// --- create workspace ---
let resp = authed(client().post(format!("{global_base}/create")))
.json(&json!({
"id": "new-test-ws",
"name": "New Test Workspace"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "create: {}", resp.text().await?);
// verify it exists
let resp = authed(client().post(format!("{global_base}/exists")))
.json(&json!({"id": "new-test-ws"}))
.send()
.await
.unwrap();
assert_eq!(resp.json::<bool>().await?, true);
// ===== Workspace-scoped endpoints (read) =====
// --- get_settings ---
let resp = authed(client().get(format!("{base}/get_settings")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert!(settings.is_object());
// --- get_deploy_to ---
let resp = authed(client().get(format!("{base}/get_deploy_to")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- is_premium ---
let resp = authed(client().get(format!("{base}/is_premium")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- default_app ---
let resp = authed(client().get(format!("{base}/default_app")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- default_scripts ---
let resp = authed(client().get(format!("{base}/default_scripts")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- list_pending_invites ---
let resp = authed(client().get(format!("{base}/list_pending_invites")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<Vec<serde_json::Value>>().await?;
// --- encryption_key ---
let resp = authed(client().get(format!("{base}/encryption_key")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- get_dependency_map ---
let resp = authed(client().get(format!("{base}/get_dependency_map")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- get_as_superadmin ---
let resp = authed(client().get(format!("{base}/get_as_superadmin")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["id"], "test-workspace");
// --- get_workspace_name ---
let resp = authed(client().get(format!("{base}/get_workspace_name")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let name = resp.text().await?;
assert_eq!(name, "test-workspace");
// --- get_usage ---
let resp = authed(client().get(format!("{base}/usage")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- get_used_triggers ---
let resp = authed(client().get(format!("{base}/used_triggers")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<serde_json::Value>().await?;
// --- get_secondary_storage_names ---
let resp = authed(client().get(format!("{base}/get_secondary_storage_names")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<Vec<String>>().await?;
// --- get_dependents (empty, no dependencies exist) ---
let resp = authed(client().get(format!("{base}/get_dependents/u/test-user/nonexistent")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let dependents = resp.json::<Vec<serde_json::Value>>().await?;
assert!(dependents.is_empty());
// --- get_dependents_amounts ---
let resp = authed(client().post(format!("{base}/get_dependents_amounts")))
.json(&json!(["u/test-user/some_script"]))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<Vec<serde_json::Value>>().await?;
// --- list_ducklakes ---
let resp = authed(client().get(format!("{base}/list_ducklakes")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<Vec<String>>().await?;
// --- list_datatables ---
let resp = authed(client().get(format!("{base}/list_datatables")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<Vec<String>>().await?;
// --- list_datatable_schemas ---
let resp = authed(client().get(format!("{base}/list_datatable_schemas")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<Vec<serde_json::Value>>().await?;
// ===== Workspace-scoped endpoints (mutations) =====
// --- update (edit_workspace) ---
let resp = authed(client().post(format!("{base}/update")))
.json(&json!({"name": "renamed-workspace", "owner": "test-user"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "update: {}", resp.text().await?);
// --- change_workspace_name ---
let resp = authed(client().post(format!("{base}/change_workspace_name")))
.json(&json!({"new_name": "Test Workspace Renamed"}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"change_workspace_name: {}",
resp.text().await?
);
// verify name changed
let resp = authed(client().get(format!("{base}/get_workspace_name")))
.send()
.await
.unwrap();
assert_eq!(resp.text().await?, "Test Workspace Renamed");
// --- change_workspace_color ---
let resp = authed(client().post(format!("{base}/change_workspace_color")))
.json(&json!({"color": "#FF5733"}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"change_workspace_color: {}",
resp.text().await?
);
// --- edit_webhook ---
let resp = authed(client().post(format!("{base}/edit_webhook")))
.json(&json!({"webhook": "https://example.com/hook"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "edit_webhook: {}", resp.text().await?);
// verify in settings
let resp = authed(client().get(format!("{base}/get_settings")))
.send()
.await
.unwrap();
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(settings["webhook"], "https://example.com/hook");
// clear webhook
let resp = authed(client().post(format!("{base}/edit_webhook")))
.json(&json!({"webhook": null}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp = authed(client().post(format!("{base}/edit_webhook")))
.json(&json!({"webhook": "http://127.0.0.1/hook"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
assert!(resp.text().await?.contains("ALLOW_PRIVATE_WEBHOOK_URLS"));
// --- edit_auto_invite (EE-gated) ---
let resp = authed(client().post(format!("{base}/edit_auto_invite")))
.json(&json!({"operator": false, "invite_all": false, "auto_add": false}))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 500,
"edit_auto_invite: unexpected status {}",
resp.status()
);
// --- edit_slack_command ---
let resp = authed(client().post(format!("{base}/edit_slack_command")))
.json(&json!({"slack_command_script": null}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"edit_slack_command: {}",
resp.text().await?
);
// --- edit_error_handler (new format) ---
let resp = authed(client().post(format!("{base}/edit_error_handler")))
.json(&json!({"path": null, "extra_args": null}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"edit_error_handler: {}",
resp.text().await?
);
// --- edit_success_handler (new format) ---
let resp = authed(client().post(format!("{base}/edit_success_handler")))
.json(&json!({"path": null, "extra_args": null}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"edit_success_handler: {}",
resp.text().await?
);
// --- edit_default_scripts ---
let resp = authed(client().post(format!("{base}/default_scripts")))
.json(&json!(null))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"edit_default_scripts: {}",
resp.text().await?
);
// --- edit_default_app (EE-gated, may return 200 or error) ---
let resp = authed(client().post(format!("{base}/edit_default_app")))
.json(&json!({}))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 400,
"edit_default_app: unexpected status {}",
resp.status()
);
// --- set_environment_variable ---
let resp = authed(client().post(format!("{base}/set_environment_variable")))
.json(&json!({"name": "TEST_ENV_VAR", "value": "test_value"}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"set_environment_variable: {}",
resp.text().await?
);
// --- edit_large_file_storage_config ---
let resp = authed(client().post(format!("{base}/edit_large_file_storage_config")))
.json(&json!({"large_file_storage": null}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"edit_large_file_storage_config: {}",
resp.text().await?
);
// --- edit_deploy_ui_config (EE-gated) ---
let resp = authed(client().post(format!("{base}/edit_deploy_ui_config")))
.json(&json!({"deploy_ui": null}))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 400,
"edit_deploy_ui_config: unexpected status {}",
resp.status()
);
// --- edit_git_sync_config (EE-gated) ---
let resp = authed(client().post(format!("{base}/edit_git_sync_config")))
.json(&json!({"git_sync_settings": null}))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 400,
"edit_git_sync_config: unexpected status {}",
resp.status()
);
// --- git_sync_deploy_mode (response shape + default when no git-sync configured) ---
let resp = authed(client().get(format!("{base}/git_sync_deploy_mode")))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"git_sync_deploy_mode: unexpected status"
);
let mode = resp.json::<serde_json::Value>().await?;
assert_eq!(mode["configured"], json!(false));
assert_eq!(mode["deploy_on_push"], json!(false));
// --- update_operator_settings ---
let resp = authed(client().post(format!("{base}/operator_settings")))
.json(&json!({}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"update_operator_settings: {}",
resp.text().await?
);
// --- edit_public_app_rate_limit ---
let resp = authed(client().post(format!("{base}/public_app_rate_limit")))
.json(&json!({"public_app_execution_limit_per_minute": null}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"edit_public_app_rate_limit: {}",
resp.text().await?
);
// --- rebuild_dependency_map ---
let resp = authed(client().post(format!("{base}/rebuild_dependency_map")))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"rebuild_dependency_map: {}",
resp.text().await?
);
// --- add_user ---
let resp = authed(client().post(format!("{base}/add_user")))
.json(&json!({
"email": "newuser@windmill.dev",
"is_admin": false,
"operator": false
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "add_user: {}", resp.text().await?);
// --- invite_user + list_pending_invites + delete_invite ---
let resp = authed(client().post(format!("{base}/invite_user")))
.json(&json!({
"email": "invited@example.com",
"is_admin": false,
"operator": false
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "invite_user: {}", resp.text().await?);
// verify invite shows in pending
let resp = authed(client().get(format!("{base}/list_pending_invites")))
.send()
.await
.unwrap();
let invites = resp.json::<Vec<serde_json::Value>>().await?;
assert!(
invites.iter().any(|i| i["email"] == "invited@example.com"),
"invite not found: {:?}",
invites
);
// delete invite
let resp = authed(client().post(format!("{base}/delete_invite")))
.json(&json!({
"email": "invited@example.com",
"is_admin": false,
"operator": false
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "delete_invite: {}", resp.text().await?);
// ===== Critical alerts (EE-gated, returns 404 in OSS) =====
// --- get critical_alerts ---
let resp = authed(client().get(format!("{base}/critical_alerts")))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 404,
"critical_alerts: unexpected status {}",
resp.status()
);
// --- acknowledge critical alert (nonexistent id) ---
let resp = authed(client().post(format!("{base}/critical_alerts/1/acknowledge")))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 404,
"acknowledge_critical_alert: unexpected status {}",
resp.status()
);
// --- acknowledge_all critical alerts ---
let resp = authed(client().post(format!("{base}/critical_alerts/acknowledge_all")))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 404,
"acknowledge_all_critical_alerts: unexpected status {}",
resp.status()
);
// --- mute critical alerts ---
let resp = authed(client().post(format!("{base}/critical_alerts/mute")))
.json(&json!({"mute_critical_alerts": false}))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 404,
"mute_critical_alerts: unexpected status {}",
resp.status()
);
// ===== Tarball export =====
// --- tarball (download workspace as tar archive) ---
let resp = authed(client().get(format!("{base}/tarball")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "tarball: {}", resp.status());
// ===== Fork operations (EE-only: CE limits workspace count to 2) =====
#[cfg(feature = "enterprise")]
{
let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces");
let resp = authed(client().post(format!("{new_ws_base}/create_fork")))
.json(&json!({
"id": "wm-fork-test-ws",
"name": "Forked Test Workspace"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?);
// verify fork exists
let resp = authed(client().post(format!("{global_base}/exists")))
.json(&json!({"id": "wm-fork-test-ws"}))
.send()
.await
.unwrap();
assert_eq!(resp.json::<bool>().await?, true);
// --- change_workspace_id ---
let fork_ws_base = format!("http://localhost:{port}/api/w/wm-fork-test-ws/workspaces");
let resp = authed(client().post(format!("{fork_ws_base}/change_workspace_id")))
.json(&json!({
"new_id": "wm-fork-renamed",
"new_name": "Renamed Fork"
}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"change_workspace_id: {}",
resp.text().await?
);
// verify renamed workspace exists
let resp = authed(client().post(format!("{global_base}/exists")))
.json(&json!({"id": "wm-fork-renamed"}))
.send()
.await
.unwrap();
assert_eq!(resp.json::<bool>().await?, true);
// Regression: changing a fork's workspace id must preserve its parent
// linkage. Dropping it leaves a wm-fork- workspace with no parent — a
// "fork of nothing" that can no longer be compared or merged.
let parent: Option<String> =
sqlx::query_scalar("SELECT parent_workspace_id FROM workspace WHERE id = $1")
.bind("wm-fork-renamed")
.fetch_one(&db)
.await?;
assert_eq!(
parent.as_deref(),
Some("new-test-ws"),
"renamed fork must keep its parent_workspace_id"
);
// --- create_fork over an existing (active) workspace id: clear 400, not a raw SQL 500 ---
let resp = authed(client().post(format!("{new_ws_base}/create_fork")))
.json(&json!({
"id": "wm-fork-renamed",
"name": "Conflicting Fork"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400, "create_fork over active workspace");
let body = resp.text().await?;
assert!(
body.contains("already exists"),
"create_fork conflict body: {body}"
);
// --- create_fork over an archived workspace id: error must mention it is archived ---
let resp = authed(client().post(format!(
"http://localhost:{port}/api/w/wm-fork-renamed/workspaces/archive"
)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "archive fork: {}", resp.text().await?);
let resp = authed(client().post(format!("{new_ws_base}/create_fork")))
.json(&json!({
"id": "wm-fork-renamed",
"name": "Conflicting Fork"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400, "create_fork over archived workspace");
let body = resp.text().await?;
assert!(
body.contains("archived"),
"create_fork archived-conflict body: {body}"
);
// --- hard delete frees up the id for a new fork ---
let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "delete fork: {}", resp.text().await?);
let resp = authed(client().post(format!("{new_ws_base}/create_fork")))
.json(&json!({
"id": "wm-fork-renamed",
"name": "Recreated Fork"
}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"create_fork after hard delete: {}",
resp.text().await?
);
// clean up renamed fork
let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
}
// --- archive workspace (on the newly created one, not our main test workspace) ---
let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces");
let resp = authed(client().post(format!("{new_ws_base}/archive")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "archive: {}", resp.text().await?);
// --- unarchive workspace (global) ---
let resp = authed(client().post(format!("{global_base}/unarchive/new-test-ws")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "unarchive: {}", resp.text().await?);
// --- delete workspace (global) ---
let resp = authed(client().delete(format!("{global_base}/delete/new-test-ws")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "delete: {}", resp.text().await?);
// verify deleted
let resp = authed(client().post(format!("{global_base}/exists")))
.json(&json!({"id": "new-test-ws"}))
.send()
.await
.unwrap();
assert_eq!(resp.json::<bool>().await?, false);
// --- create_workspace_require_superadmin ---
let resp = authed(client().get(format!("{global_base}/create_workspace_require_superadmin")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let instance_ai_config = json!({
"providers": {
"openai": {
"resource_path": "u/test-user/openai_instance",
"models": ["gpt-4o-mini"]
}
},
"default_model": { "provider": "openai", "model": "gpt-4o-mini" },
"metadata_model": { "provider": "openai", "model": "gpt-4o-mini" }
});
let workspace_ai_config = json!({
"providers": {
"anthropic": {
"resource_path": "u/test-user/anthropic_workspace",
"models": ["claude-3-5-haiku-latest"]
}
}
});
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(instance_ai_config)
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(settings["has_instance_ai_config"], true);
assert_eq!(settings["uses_instance_ai_config"], true);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["provider"],
"openai"
);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["models"][0],
"gpt-4o-mini"
);
assert_eq!(
settings["instance_ai_summary"]["metadata_model"]["model"],
"gpt-4o-mini"
);
sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2")
.bind(workspace_ai_config)
.bind("test-workspace")
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(settings["has_instance_ai_config"], true);
assert_eq!(settings["uses_instance_ai_config"], false);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["provider"],
"openai"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_info_ignores_empty_instance_ai_row(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(json!({}))
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_info")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert!(settings["providers"].is_null());
Ok(())
}
/// A workspace with no provider of its own is served the instance config, but the
/// `copilot_disabled` flag must still come from the workspace's own row.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_info_keeps_workspace_copilot_disabled_over_instance_fallback(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2")
.bind(json!({ "copilot_disabled": true }))
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(json!({
"providers": {
"openai": {
"resource_path": "u/test-user/openai_instance",
"models": ["gpt-4o-mini"]
}
}
}))
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_info")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(
settings["providers"]["openai"]["models"][0], "gpt-4o-mini",
"instance providers are still served"
);
assert_eq!(settings["copilot_disabled"], true);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_error_handler_instance_alerts_fallback(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let stored = || async {
sqlx::query_scalar!(
"SELECT error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = 'test-workspace'"
)
.fetch_one(&db)
.await
};
let resp = authed(client().post(format!("{base}/edit_error_handler")))
.json(&json!({"path": null, "fallback_to_instance_alerts": true}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "enable: {}", resp.text().await?);
assert!(stored().await?);
// A client that predates the setting (the CLI pushing settings.yaml) omits the field and
// must not silently turn it back off.
let resp = authed(client().post(format!("{base}/edit_error_handler")))
.json(&json!({"path": null, "extra_args": null}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "omitted: {}", resp.text().await?);
assert!(stored().await?);
sqlx::query!(
"UPDATE workspace SET parent_workspace_id = 'test-workspace' WHERE id = 'test-workspace'"
)
.execute(&db)
.await?;
let resp = authed(client().post(format!("{base}/edit_error_handler")))
.json(&json!({"path": null, "fallback_to_instance_alerts": true}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400, "fork must be rejected");
// The settings page stops offering the option once the workspace is a fork, so its next save
// sends `false`: that must go through rather than lock the whole error handler behind a 400.
let resp = authed(client().post(format!("{base}/edit_error_handler")))
.json(&json!({"path": null, "fallback_to_instance_alerts": false}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"disable on fork: {}",
resp.text().await?
);
assert!(!stored().await?);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_imports(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let resp = authed(client().get(format!("{base}/get_imports/u/test-user/nonexistent_script")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let imports = resp.json::<Vec<String>>().await?;
assert!(imports.is_empty());
Ok(())
}
/// A dbt project names a warehouse and nothing else, so the workspace setting is
/// the only place the connection is decided. Two things have to hold for that to
/// work at all: the setting round-trips as the MAP the resolver reads (an
/// envelope stored verbatim makes every warehouse name unresolvable), and the
/// route that serves the name to a worker with no database stays job-scoped.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_dbt_warehouses(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
let resp = authed(client().post(format!("{base}/workspaces/edit_dbt_warehouses")))
.json(&json!({
"dbt_warehouses": { "main": { "resource_path": "u/admin/wh", "target": "prod" } }
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp = authed(client().get(format!("{base}/workspaces/get_settings")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(
settings["dbt_warehouses"],
json!({ "main": { "resource_path": "u/admin/wh", "target": "prod" } })
);
// A user token is not a job token: the warehouses a workspace configures are
// a running job's business, not a browsable list.
let resp = authed(client().get(format!("{base}/dbt/warehouse/main")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
Ok(())
}
/// A service account created over an orphaned `usr_to_group` row must start with
/// exactly the memberships that were asked for.
///
/// `usr_to_group` has no FK to `usr`, so `leave_workspace` leaves group rows behind
/// for a deleted username. Recreating that username used to fail outright on the
/// duplicate `all` row; tolerating the duplicate alone would instead have handed the
/// new account every stale membership, including privileged ones.
///
/// Gated on `private` because `create_service_account` is EE-only — the OSS shim
/// (`windmill-api-workspaces/src/workspaces_oss.rs`) rejects the request outright.
#[cfg(feature = "private")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_create_service_account_drops_orphaned_group_memberships(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query(
"INSERT INTO group_ (workspace_id, name, summary) VALUES
('test-workspace', 'wm_deployers', 'deployers'),
('test-workspace', 'secrets', 'privileged')",
)
.execute(&db)
.await?;
// Stale rows from a prior user of this username: no matching `usr` row exists.
sqlx::query(
"INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES
('test-workspace', 'svc_acct', 'all'),
('test-workspace', 'svc_acct', 'wm_deployers'),
('test-workspace', 'svc_acct', 'secrets')",
)
.execute(&db)
.await?;
// Same username, different workspace, and very much alive — must not be touched.
sqlx::query(
"INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other', 'svc_acct')",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO group_ (workspace_id, name, summary) VALUES
('other-workspace', 'all', 'All users'),
('other-workspace', 'secrets', 'privileged')",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
VALUES ('other-workspace', 'other@windmill.dev', 'svc_acct', false, 'User')",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES
('other-workspace', 'svc_acct', 'all'),
('other-workspace', 'svc_acct', 'secrets')",
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let resp = authed(client().post(format!("{base}/create_service_account")))
.json(&json!({"username": "svc_acct", "is_admin": false, "operator": true, "add_to_deployers": false}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
201,
"creation over an orphaned row failed: {}",
resp.text().await?
);
let groups: Vec<String> = sqlx::query_scalar(
"SELECT group_ FROM usr_to_group WHERE workspace_id = $1 AND usr = $2 ORDER BY group_",
)
.bind("test-workspace")
.bind("svc_acct")
.fetch_all(&db)
.await?;
assert_eq!(
groups,
vec!["all".to_string()],
"service account inherited stale memberships"
);
let other: Vec<String> = sqlx::query_scalar(
"SELECT group_ FROM usr_to_group WHERE workspace_id = $1 AND usr = $2 ORDER BY group_",
)
.bind("other-workspace")
.bind("svc_acct")
.fetch_all(&db)
.await?;
assert_eq!(
other,
vec!["all".to_string(), "secrets".to_string()],
"cleanup escaped the workspace it was scoped to"
);
Ok(())
}
#[cfg(feature = "private")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_edit_auto_invite_preserves_instance_groups(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query(
r#"UPDATE workspace_settings
SET auto_invite = '{"instance_groups": ["eng"], "instance_groups_roles": {"eng": "developer"}}'
WHERE workspace_id = 'test-workspace'"#,
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
// enable, then disable
for body in [
json!({"operator": false, "invite_all": true, "auto_add": false}),
json!({}),
] {
let resp = authed(client().post(format!("{base}/edit_auto_invite")))
.json(&body)
.send()
.await?;
assert_eq!(resp.status(), 200, "{body}: {}", resp.text().await?);
let auto_invite: serde_json::Value = sqlx::query_scalar(
"SELECT auto_invite FROM workspace_settings WHERE workspace_id = 'test-workspace'",
)
.fetch_one(&db)
.await?;
assert_eq!(auto_invite["instance_groups"], json!(["eng"]), "{body}");
assert_eq!(
auto_invite["instance_groups_roles"],
json!({"eng": "developer"}),
"{body}"
);
}
Ok(())
}
/// Admin and operator are one role stored as two flags: granting one must clear the other, and a
/// request asking for both is refused, on update and on add.
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
async fn test_admin_and_operator_are_exclusive(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/test-workspace");
let flags = async || -> anyhow::Result<(bool, bool)> {
Ok(sqlx::query_as(
"SELECT is_admin, operator FROM usr WHERE workspace_id = 'test-workspace' AND username = 'operator'",
)
.fetch_one(&db)
.await?)
};
let resp = authed(client().post(format!("{api}/users/update/operator")))
.json(&json!({"is_admin": true}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
assert_eq!(flags().await?, (true, false));
let resp = authed(client().post(format!("{api}/users/update/operator")))
.json(&json!({"operator": true}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
assert_eq!(flags().await?, (false, true));
let resp = authed(client().post(format!("{api}/users/update/operator")))
.json(&json!({"is_admin": true, "operator": true}))
.send()
.await?;
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
assert_eq!(flags().await?, (false, true));
let resp = authed(client().post(format!("{api}/workspaces/add_user")))
.json(&json!({"email": "both@windmill.dev", "is_admin": true, "operator": true}))
.send()
.await?;
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
Ok(())
}