mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
9cd2bcea25
* fix: clear orphaned usr_to_group rows on service account creation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin service account creation over orphaned usr_to_group rows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 60c20e686cead73ff075512b15c6e2d6232beca6 This commit updates the EE repository reference after PR #723 was merged in windmill-ee-private. Previous ee-repo-ref: 5c2c553f960abcd7988fdac8830dd36c066160ad New ee-repo-ref: 60c20e686cead73ff075512b15c6e2d6232beca6 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>
1115 lines
36 KiB
Rust
1115 lines
36 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);
|
|
|
|
// --- 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(())
|
|
}
|
|
|
|
#[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(())
|
|
}
|