mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
b070f56c5e
* 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>
921 lines
28 KiB
Rust
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(())
|
|
}
|