Files
windmill/cli/test/settings_unit.test.ts
T
Alexander Petric 95d4c6a94d feat(cli): non-interactive Slack connect/disconnect + sync round-trip fixes (#8935)
* feat(cli): non-interactive Slack connect/disconnect

Extract create_slack_workspace_artifacts / create_slack_instance_artifacts
from the browser OAuth callbacks and expose them via two new endpoints that
accept a pre-minted xoxb bot token:

- POST /w/{workspace}/workspaces/connect_slack (admin)
- POST /oauth/connect_slack_instance (super-admin)

Both produce bit-for-bit identical DB state to the UI browser flow.

Wire three CLI commands as thin wrappers:
- wmill workspace connect-slack
- wmill workspace disconnect-slack
- wmill instance connect-slack

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

* fix(cli): round-trip stability for workspace settings handlers

wmill sync push was destroying UI-configured error_handler/success_handler
state on every deploy. Two orthogonal bugs:

(a) pushWorkspaceSettings called editErrorHandler with `path: undefined`
    when the YAML lacked the handler block, which the backend treats as a
    clear — so syncing settings.yaml that didn't mention the handler wiped
    the DB row. Fix: skip the call entirely when absent from YAML.

(b) edit_error_handler omitted muted_on_cancel / muted_on_user_path when
    false, but the CLI always sends them, causing perpetual deepEqual
    drift and a spurious editErrorHandler call on every sync push. Fix:
    always persist both booleans.

migrateToGroupedFormat now preserves explicit `null` on
error_handler / success_handler as a "clear remote" signal distinct from
absence. Widen ErrorHandlerConfig | null / SuccessHandlerConfig | null to
make this explicit in the type.

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

* feat(cli): sync support for workspace-level Slack OAuth override

Add slack_oauth_client_id and slack_oauth_client_secret to the v2 tarball
export and to pushWorkspaceSettings, so the workspace-level OAuth override
is now fully managed as code through settings.yaml.

Semantics:
  - both defined and truthy → setWorkspaceSlackOauthConfig (upsert)
  - both defined but falsy (e.g. empty strings) and remote has a value
    → deleteWorkspaceSlackOauthConfig
  - either omitted → leave remote alone ("not managed by git")

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

* refactor(cli): normalize workspace settings sync to "omit = clear"

Earlier commits on this branch introduced an "omit = keep" rule for
error_handler / success_handler / slack_oauth_client_{id,secret} that
diverged from every other workspace setting (webhook, deploy_to, etc. all
treat YAML as canonical: absence = clear). Normalize:

- v2 tarball always emits these 4 fields (null when remote is NULL) so
  round-trip is bijective and settings.yaml is a complete snapshot.
- pushWorkspaceSettings drops the absent-from-YAML guards; YAML is
  canonical. Absence and explicit null both clear the remote — same rule
  as every other field.
- set_slack_oauth_config / delete_slack_oauth_config now fire
  handle_deployment_metadata so UI mutations reach git-sync-enabled
  workspaces' committed settings.yaml.

Policy for users: pull before push (same as every other setting). On first
post-upgrade pull, explicit `null` keys appear for any workspace whose
handlers / oauth override are unset — one-time YAML diff, no semantic
change.

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

* test(cli): add unit + integration coverage for Slack settings sync

Unit tests (settings_unit.test.ts): cover migrateToGroupedFormat preserving
explicit `null` on error_handler / success_handler, and passthrough of
slack_oauth_client_id / _secret (both populated and null values).

Integration tests (slack_settings_sync.test.ts, skipped on CI per the same
convention as datatable_settings_sync.test.ts): exercise the full backend
via withTestBackend to verify

  1. pull emits null for unset error_handler / success_handler /
     slack_oauth_client_id / _secret;
  2. round-trip with all-null handlers is idempotent;
  3. push of populated slack_oauth_config upserts;
  4. omitting the slack_oauth keys from YAML clears remote (universal
     "omit = clear" rule);
  5. explicit null error_handler in YAML clears remote;
  6. round-trip preserves a populated error_handler exactly, including the
     always-persisted muted_on_cancel / muted_on_user_path booleans.

Also feature-gates `use crate::oauth2_oss::workspace_connect_slack` and its
route registration behind `cfg(feature = "oauth2")`: the import caused a
build failure on subsets of the workspace without the oauth2 feature,
surfaced by the integration test harness.

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

* chore: bump ee-repo-ref to 59b6123

Pins windmill-ee-private to the tip of branch alp/slack_cli, which
contains the companion EE changes (helper extraction, non-interactive
Slack connect handlers, git-sync for Slack settings mutations).

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

* Update SQLx metadata

* chore: regenerate system prompts for new slack CLI commands

Captures the new workspace connect-slack, workspace disconnect-slack,
and instance connect-slack commands in the auto-generated files that
CI enforces via system_prompts/check-freshness.sh.

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

* chore: update ee-repo-ref to b4a5ca11e3b96ff03793c2bd396dbc1fe6ea1022

This commit updates the EE repository reference after PR #550 was merged in windmill-ee-private.

Previous ee-repo-ref: d7e44d0519327ec9077625130365e887826f324b

New ee-repo-ref: b4a5ca11e3b96ff03793c2bd396dbc1fe6ea1022

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-04-24 17:14:08 +00:00

271 lines
9.1 KiB
TypeScript

/**
* Unit tests for settings.ts pure functions.
* Tests migrateToGroupedFormat which converts legacy flat settings to grouped format.
*/
import { expect, test, describe } from "bun:test";
import { migrateToGroupedFormat } from "../src/core/settings.ts";
// =============================================================================
// migrateToGroupedFormat
// =============================================================================
describe("migrateToGroupedFormat", () => {
test("migrates legacy auto_invite fields to grouped format", () => {
const legacy = {
name: "my-workspace",
auto_invite_enabled: true,
auto_invite_as: "operator",
auto_invite_mode: "add",
};
const result = migrateToGroupedFormat(legacy);
expect(result.auto_invite).toEqual({
enabled: true,
operator: true,
mode: "add",
});
});
test("migrates legacy auto_invite with non-operator role", () => {
const legacy = {
name: "ws",
auto_invite_enabled: true,
auto_invite_as: "developer",
auto_invite_mode: "invite",
};
const result = migrateToGroupedFormat(legacy);
expect(result.auto_invite).toEqual({
enabled: true,
operator: false,
mode: "invite",
});
});
test("migrates legacy auto_invite when disabled", () => {
const legacy = {
name: "ws",
auto_invite_enabled: false,
auto_invite_as: "operator",
};
const result = migrateToGroupedFormat(legacy);
expect(result.auto_invite!.enabled).toBe(false);
});
test("preserves already-grouped auto_invite", () => {
const grouped = {
name: "ws",
auto_invite: { enabled: true, operator: false, mode: "invite" as const },
};
const result = migrateToGroupedFormat(grouped);
expect(result.auto_invite).toEqual({
enabled: true,
operator: false,
mode: "invite",
});
});
test("migrates legacy error_handler string to grouped format", () => {
const legacy = {
name: "ws",
error_handler: "u/admin/error_handler",
error_handler_extra_args: { notify: true },
error_handler_muted_on_cancel: true,
};
const result = migrateToGroupedFormat(legacy);
expect(result.error_handler).toEqual({
path: "u/admin/error_handler",
extra_args: { notify: true },
muted_on_cancel: true,
});
});
test("preserves already-grouped error_handler", () => {
const grouped = {
name: "ws",
error_handler: {
path: "u/admin/handler",
extra_args: {},
muted_on_cancel: false,
},
};
const result = migrateToGroupedFormat(grouped);
expect(result.error_handler).toEqual({
path: "u/admin/handler",
extra_args: {},
muted_on_cancel: false,
});
});
test("migrates legacy success_handler string to grouped format", () => {
const legacy = {
name: "ws",
success_handler: "u/admin/on_success",
success_handler_extra_args: { channel: "#deploys" },
};
const result = migrateToGroupedFormat(legacy);
expect(result.success_handler).toEqual({
path: "u/admin/on_success",
extra_args: { channel: "#deploys" },
});
});
test("preserves already-grouped success_handler", () => {
const grouped = {
name: "ws",
success_handler: { path: "u/admin/handler", extra_args: {} },
};
const result = migrateToGroupedFormat(grouped);
expect(result.success_handler).toEqual({
path: "u/admin/handler",
extra_args: {},
});
});
test("copies non-legacy fields through", () => {
const settings = {
name: "my-workspace",
webhook: "https://example.com/hook",
deploy_to: "staging",
default_app: "u/admin/dashboard",
mute_critical_alerts: true,
color: "#ff0000",
};
const result = migrateToGroupedFormat(settings);
expect(result.name).toBe("my-workspace");
expect(result.webhook).toBe("https://example.com/hook");
expect(result.deploy_to).toBe("staging");
expect(result.default_app).toBe("u/admin/dashboard");
expect(result.mute_critical_alerts).toBe(true);
expect(result.color).toBe("#ff0000");
});
test("handles minimal settings with only name", () => {
const result = migrateToGroupedFormat({ name: "ws" });
expect(result.name).toBe("ws");
expect(result.auto_invite).toBeUndefined();
expect(result.error_handler).toBeUndefined();
expect(result.success_handler).toBeUndefined();
});
test("defaults name to empty string when missing", () => {
const result = migrateToGroupedFormat({});
expect(result.name).toBe("");
});
test("defaults auto_invite_mode to invite when missing", () => {
const legacy = {
name: "ws",
auto_invite_enabled: true,
auto_invite_as: "operator",
};
const result = migrateToGroupedFormat(legacy);
expect(result.auto_invite!.mode).toBe("invite");
});
test("defaults error_handler_muted_on_cancel to false when missing", () => {
const legacy = {
name: "ws",
error_handler: "u/admin/handler",
};
const result = migrateToGroupedFormat(legacy);
expect(result.error_handler!.muted_on_cancel).toBe(false);
});
test("preserves ai_config, large_file_storage, git_sync, default_scripts, operator_settings", () => {
const settings = {
name: "ws",
ai_config: { provider: "openai" },
large_file_storage: { type: "s3" },
git_sync: { enabled: true },
default_scripts: { python: "template.py" },
operator_settings: { hideCode: true },
};
const result = migrateToGroupedFormat(settings);
expect(result.ai_config).toEqual({ provider: "openai" });
expect(result.large_file_storage).toEqual({ type: "s3" });
expect(result.git_sync).toEqual({ enabled: true });
expect(result.default_scripts).toEqual({ python: "template.py" });
expect(result.operator_settings).toEqual({ hideCode: true });
});
test("does not include undefined fields in result", () => {
const result = migrateToGroupedFormat({ name: "ws" });
expect("webhook" in result).toBe(false);
expect("deploy_to" in result).toBe(false);
expect("color" in result).toBe(false);
expect("slack_team_id" in result).toBe(false);
expect("slack_name" in result).toBe(false);
expect("slack_command_script" in result).toBe(false);
});
test("copies slack fields through", () => {
const settings = {
name: "ws",
slack_team_id: "T12345",
slack_name: "my-team",
slack_command_script: "u/admin/slack_handler",
};
const result = migrateToGroupedFormat(settings);
expect(result.slack_team_id).toBe("T12345");
expect(result.slack_name).toBe("my-team");
expect(result.slack_command_script).toBe("u/admin/slack_handler");
});
// error_handler / success_handler: null must round-trip through migration so
// that `wmill sync push` can forward it to the backend as a "clear remote"
// signal. Before this fix, null fell through the truthy-object + string
// branches and ended up as undefined, making an explicit `error_handler: null`
// in settings.yaml a no-op under the universal "omit = clear" rule.
test("preserves explicit null error_handler", () => {
const result = migrateToGroupedFormat({ name: "ws", error_handler: null });
expect(result.error_handler).toBeNull();
expect("error_handler" in result).toBe(true);
});
test("preserves explicit null success_handler", () => {
const result = migrateToGroupedFormat({ name: "ws", success_handler: null });
expect(result.success_handler).toBeNull();
expect("success_handler" in result).toBe(true);
});
test("preserves both null handlers alongside a populated handler", () => {
const result = migrateToGroupedFormat({
name: "ws",
error_handler: { path: "u/admin/err", muted_on_cancel: false },
success_handler: null,
});
expect(result.error_handler).toEqual({
path: "u/admin/err",
muted_on_cancel: false,
});
expect(result.success_handler).toBeNull();
});
// slack_oauth_client_id / slack_oauth_client_secret are the workspace-level
// OAuth override. Pull always emits them (null when DB is NULL), and push
// forwards whatever is in YAML — both present upserts, anything else deletes.
test("copies slack_oauth_client_id / _secret through (populated)", () => {
const result = migrateToGroupedFormat({
name: "ws",
slack_oauth_client_id: "1234567890.1234567890",
slack_oauth_client_secret: "abcdef0123456789",
});
expect(result.slack_oauth_client_id).toBe("1234567890.1234567890");
expect(result.slack_oauth_client_secret).toBe("abcdef0123456789");
});
test("copies slack_oauth_client_id / _secret through (null)", () => {
const result = migrateToGroupedFormat({
name: "ws",
slack_oauth_client_id: null,
slack_oauth_client_secret: null,
});
// migrateToGroupedFormat only copies fields that are `!== undefined`;
// null is a valid value and must flow through so the push comparison sees
// "local is null, remote is null → equal" instead of treating absence as
// "not in YAML".
expect(result.slack_oauth_client_id).toBeNull();
expect(result.slack_oauth_client_secret).toBeNull();
});
});