Files
windmill/backend/windmill-api-integration-tests/tests/workspaces.rs
T
hugocasa b070f56c5e feat: detect server-handled git-sync so CLI picks git push vs wmill sync push (#10201)
* feat: detect server-handled git-sync so CLI picks git push vs wmill sync push

Add a non-admin GET /w/{w}/workspaces/git_sync_deploy_mode endpoint returning
{configured, deploy_on_push}, so any workspace member (not just admins, who
alone can read get_settings) can tell whether pushing to the git remote deploys
via server-side auto-pull. Surface it through `wmill gitsync-settings status`
and align the deploy guidance/skills to prefer git push when the repo deploys on
push, falling back to `wmill sync push` otherwise.

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

* fix: address review — clean JSON output, warn on git_sync parse failure

- gitsync-settings status --json-output now uses console.log so the JSON pipes
  cleanly to jq (log.info wraps it in ANSI color codes)
- get_git_sync_deploy_mode logs a warning on git_sync deserialize failure instead
  of silently reporting configured=false, and documents why it is not EE-gated

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

* fix: address codex review — license-gate and branch-match deploy-on-push

- get_git_sync_deploy_mode now reports deploy_on_push only on Enterprise-licensed
  instances (auto-pull can't run on CE/downgrade) and returns auto_pull_branches
  so the client knows which tracked branches actually deploy on push
- gitsync-settings status matches the local git branch against auto_pull_branches
  before recommending git push, so an untracked branch falls back to wmill sync push
- add an integration assertion for the endpoint's default (no git-sync) shape

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

* docs: point per-topic skills at the canonical Deploying section

The git-push-vs-wmill-sync-push decision lives in core.ts (AGENTS.wmill.md),
which is already in context. Have the per-topic skills reference the Deploying
section instead of re-encoding the detection, so there is one source of truth
and no drift (the compressed version also wrongly implied `gitsync-settings
status` detects the CI-workflow path, which only core.ts's filesystem check does).

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

* fix: match remote+branch server-side for deploy-on-push detection

Codex flagged that a workspace-level auto-pull signal recommends `git push` even
when the local checkout is a different repo/branch than the one that auto-deploys.
Match precisely instead, without exposing anything sensitive:

- git_sync_deploy_mode takes optional remote+branch query params. The backend
  normalizes each auto-pull repo's URL to host/path (dropping embedded
  user:token credentials by rebuilding from parsed components, never scrubbing
  the string) and compares to the caller's remote; deploy_on_push is true only on
  a licensed instance where an auto-pull repo matches that remote and tracked
  branch. The response is two booleans — no repo URLs or branches leave the server.
- Branchless (default-branch) and fork/sync_forks repos stay a safe fallback to
  `wmill sync push` rather than a wrong git-push recommendation.
- CLI status sends `git remote get-url` + current branch (new getGitRemoteUrl
  helper, --remote flag) and reports the matched result.
- Unit-test the URL normalization/credential-stripping directly, since a
  regression there would be a token-handling bug.

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

* fix: address codex security findings in deploy-mode detection

- Strip credentials from the local remote client-side before sending, so a
  token embedded in the URL never reaches the server's request-URI logs
- Fetch the remote via spawnSync arg array (not an interpolated shell string),
  removing a command-injection path from a caller-supplied --remote value
- Use the remote's push URL (`git remote get-url --push`) and recommend the
  qualified `git push <remote> <branch>`, so the pushed target matches the one
  the server checked
- Keep the port in remote normalization so different services on the same host
  don't collide into a false match
- Unit-test credential stripping (CLI) and port distinctness (backend)

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

* feat: resolve $var repos and fork sync_forks in deploy-mode detection

- Interpolate $var:/$res: references in the repo url/branch the same way the
  auto-pull poller does (system context, cached, only when a field is a
  reference), so variable-backed git URLs match instead of falling through
- For a fork workspace, evaluate the root ancestor's git-sync settings and treat
  its wm-fork/<base>/<id> branch as deploying when the root repo has
  auto_pull.enabled && sync_forks and its base matches the tracked branch
- Read settings/resources on the plain pool (a fork member may not belong to the
  root workspace); only booleans are returned
- Unit-test the fork/branch matching (base + sync_forks + workspace-id suffix)

A blank tracked branch (repo default) still needs a network ls-remote to resolve,
so it stays a safe fallback to `wmill sync push` rather than a wrong git push.

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

* refactor: share one git-repo resolver between poller and deploy-mode

The auto-pull poller and the deploy-mode endpoint both resolved a git-sync repo
resource (system context, $var:/$res: interpolation) with duplicated boilerplate.
Extract windmill_store::resources::resolve_git_repository_resource and have both
call it, so the interpolation lives in one place. Drops the endpoint's local
resolve_repo_url_branch helper and its raw SQL query (and cache entry).

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

* fix: address codex review — fork false-positives, shell-safety, auth contract

- Fork deploy detection now mirrors reconcile_fork_branch_pull: the wm-fork branch
  must route to this workspace (first existing of the id candidates) and the repo
  must be in the fork's own inherited settings, so a multi-repo root or an
  ambiguous id can't produce a false deploy_on_push
- Recommended deploy command is shell-quoted (branch/remote names may contain
  metacharacters and the output is agent-executed)
- Remote normalization folds only the host; repo paths stay case-sensitive
- Document the system/RLS-bypassing contract on the shared resolve helper and
  restore the head-fetch doc; fix the overclaiming integration-test comment
- Dev-workspace label and default-branch cases remain documented safe fallbacks

Also restores 5 sqlx cache entries an earlier cleanup dropped.

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

* fix: require a runnable auto-pull delivery path for deploy_on_push

enabled auto-pull alone doesn't mean a push deploys: a webhook-only repo with no
active hook (failed registration), or a repo that only polling could serve on an
SSH URL (the poller rejects SSH), delivers nothing. Gate deploy_on_push on an
actual delivery path — active webhook, or a pollable non-app HTTPS repo — per the
repo's auto-pull mode. Unit-tested across modes/webhook/URL-scheme/app.

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

* fix: read fresh repo config for on-demand deploy-mode detection

resolve_git_repository_resource took an implicit allow_cache=true (right for the
poller loop). An on-demand status could then match against a stale url/branch
cached by an earlier poll. Make allow_cache a parameter: poller keeps true, the
deploy-mode endpoint passes false so it reflects the current git-sync config.

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

* fix: defer the deploy fallback instead of assuming wmill sync push

When backend auto-pull doesn't match the checkout, `status` no longer flatly
recommends `wmill sync push` — a CI workflow may still deploy on push. It now
reports the backend signal and points at the Deploying guidance (check CI → git
push, else wmill sync push; record the choice as a `Deploy mode:` line in
AGENTS.md). deploy_command is null in JSON when undetermined. This resolves the
CI-backed false recommendation without the CLI re-implementing CI detection.

Also fix two review nits: restore the deploys_on_push_branch doc comment (it had
drifted onto has_runnable_delivery) and correct the app-repo comment (their
exclusion from the poll path is a conservative safe under-report, not "can't be
polled").

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

* refactor: drop the ambiguous-fork-id disambiguation from deploy-mode

The existence-query resolution guarded a very narrow case (a suffix owned by both
a coexisting wm-fork-<suffix> and <suffix> workspace, queried from the wrong one).
Not worth the per-fork query; keep the cheap candidate-family check plus the
inherited-repo membership test, which already close the real fork false-positive.

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

* refactor: drop remote-URL matching; disambiguate deploy-mode by repo count

Matching the caller's git remote URL against each repo dragged in the whole
remote-URL surface (sending the URL, credential stripping, shell-safe remote
handling, fetch/push URL, port/case normalization) — and the risk that came with
it. Replace it with a simpler rule that fits the actual question:

- deploy_on_push is true only when exactly ONE licensed, deliverable auto-pull
  repo tracks the pushed branch. With a single synced repo the local checkout is
  unambiguously it; with several we can't tell which is the caller's, so we
  return false and the CLI asks the user.
- The endpoint takes only `branch` (no `remote`); status no longer reads or
  sends the git remote.
- On the fallback, status now tells the agent to ASK the user how the repo
  deploys (CI git-push vs wmill sync push) and record it in AGENTS.md, instead of
  assuming wmill sync push. Guidance updated to match.

Removes normalize_git_remote (+url dep), getGitRemoteUrl, stripGitRemoteCredentials,
shellQuote, the --remote flag, and their tests.

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

* refactor: share fork-branch routing between reconciler and deploy-mode

Deploy-mode detection was re-deriving fork/dev routing (root walk, wm-fork/dev
branch parsing, descendant resolution, inherited-repo check) that the auto-pull
reconciler already owns — the source of repeated edge-case bugs. Extract it into
windmill_common::workspaces::resolve_fork_branch_target and have both the endpoint
and reconcile_fork_branch_pull (EE) call it, so they can't drift and dev
workspaces are handled by construction.

Endpoint now resolves the root via the canonical cached fork_ancestor_chain
(dropping a duplicate CTE) and routes forks/dev workspaces through the shared
resolver. The .sqlx cache is unchanged (the moved queries already existed).

Bumps ee-repo-ref for windmill-labs/windmill-ee-private companion.

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

* fix: exclude archived roots and frame deploy_on_push:false as unconfirmed

- deploy_on_push now requires the root workspace to be live; polling and webhook
  delivery both exclude deleted roots, so an archived root (or anything beneath
  one) with retained git-sync no longer reports deployable
- status and the OpenAPI now describe false as "not confirmed" (it also covers
  ambiguity and conservative false-negatives), not a definite no — the CLI asks
  the user rather than asserting the push won't deploy

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

* chore: bump ee-repo-ref for EE branch merge of main

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:44:24 +02:00

921 lines
28 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_deploy_to (EE-gated) ---
let resp = authed(client().post(format!("{base}/edit_deploy_to")))
.json(&json!({"deploy_to": null}))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 400,
"edit_deploy_to: unexpected status {}",
resp.status()
);
// --- 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_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(())
}