Files
Ruben Fiszel 9cd2bcea25 clear orphaned usr_to_group rows on service account creation (#10660)
* 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>
2026-08-12 14:53:47 +00:00

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(())
}