Files
windmill/cli/test/slack_settings_sync.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

447 lines
14 KiB
TypeScript

/**
* Slack settings sync tests.
*
* Covers the "omit = clear" normalization applied to error_handler,
* success_handler, slack_oauth_client_id, and slack_oauth_client_secret:
*
* - pull always emits these 4 fields (null when remote is NULL) so that
* settings.yaml is a complete snapshot of remote state.
* - push treats the YAML as canonical: absence and explicit null both clear;
* a populated value upserts.
* - Round-trip (pull → push with no edits) is idempotent.
*
* Mirrors the structure of `datatable_settings_sync.test.ts`. Requires a live
* backend (skipped on CI by `shouldSkipOnCI()`).
*/
import { expect, test } from "bun:test";
import { writeFile, readFile } from "node:fs/promises";
import { parse, stringify } from "yaml";
import { withTestBackend } from "./test_backend.ts";
import { shouldSkipOnCI } from "./cargo_backend.ts";
import { addWorkspace } from "../workspace.ts";
// When the remote has no error_handler / success_handler / slack_oauth_client_*
// configured, a sync pull should still emit the keys — as `null` — so that the
// YAML is self-describing and round-trip is bijective.
test.skipIf(shouldSkipOnCI())(
"Slack sync: pull emits null for unset handlers + oauth override",
async () => {
await withTestBackend(async (backend, tempDir) => {
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "slack_pull_null_test",
token: backend.token,
};
await addWorkspace(testWorkspace, {
force: true,
configDir: backend.testConfigDir,
});
// Clear any prior state from other tests.
await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/edit_error_handler`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
},
);
await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/edit_success_handler`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
},
);
await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/slack_oauth_config`,
{ method: "DELETE" },
);
await writeFile(
`${tempDir}/wmill.yaml`,
`defaultTs: bun
includes:
- "**"
includeSettings: true`,
"utf-8",
);
const pullResult = await backend.runCLICommand(
["sync", "pull", "--yes"],
tempDir,
);
expect(pullResult.code).toEqual(0);
const settingsYaml = await readFile(`${tempDir}/settings.yaml`, "utf-8");
const settings = parse(settingsYaml) as Record<string, unknown>;
// All four keys MUST be present and null — not absent.
expect("error_handler" in settings).toBe(true);
expect(settings.error_handler).toBeNull();
expect("success_handler" in settings).toBe(true);
expect(settings.success_handler).toBeNull();
expect("slack_oauth_client_id" in settings).toBe(true);
expect(settings.slack_oauth_client_id).toBeNull();
expect("slack_oauth_client_secret" in settings).toBe(true);
expect(settings.slack_oauth_client_secret).toBeNull();
});
},
);
// After pull, pushing back without any edits must not mutate remote state
// (no churn on muted_on_* booleans, no spurious editErrorHandler calls).
test.skipIf(shouldSkipOnCI())(
"Slack sync: round-trip with all-null handlers is idempotent",
async () => {
await withTestBackend(async (backend, tempDir) => {
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "slack_roundtrip_null_test",
token: backend.token,
};
await addWorkspace(testWorkspace, {
force: true,
configDir: backend.testConfigDir,
});
await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/slack_oauth_config`,
{ method: "DELETE" },
);
await writeFile(
`${tempDir}/wmill.yaml`,
`defaultTs: bun
includes:
- "**"
includeSettings: true`,
"utf-8",
);
const pullResult = await backend.runCLICommand(
["sync", "pull", "--yes"],
tempDir,
);
expect(pullResult.code).toEqual(0);
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes"],
tempDir,
);
expect(pushResult.code).toEqual(0);
// Remote state must match the pre-push snapshot.
const settingsResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/get_settings`,
);
expect(settingsResp.ok).toBe(true);
const settings = await settingsResp.json();
expect(settings.error_handler ?? null).toBeNull();
expect(settings.success_handler ?? null).toBeNull();
expect(settings.slack_oauth_client_id ?? null).toBeNull();
expect(settings.slack_oauth_client_secret ?? null).toBeNull();
});
},
);
// Push with an explicit slack_oauth_client_id / _secret pair upserts the
// workspace-level OAuth override; the subsequent pull reflects the new state.
test.skipIf(shouldSkipOnCI())(
"Slack sync: push of populated slack_oauth_config upserts",
async () => {
await withTestBackend(async (backend, tempDir) => {
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "slack_push_oauth_test",
token: backend.token,
};
await addWorkspace(testWorkspace, {
force: true,
configDir: backend.testConfigDir,
});
await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/slack_oauth_config`,
{ method: "DELETE" },
);
await writeFile(
`${tempDir}/wmill.yaml`,
`defaultTs: bun
includes:
- "**"
includeSettings: true`,
"utf-8",
);
const pullResult = await backend.runCLICommand(
["sync", "pull", "--yes"],
tempDir,
);
expect(pullResult.code).toEqual(0);
const settingsYaml = await readFile(`${tempDir}/settings.yaml`, "utf-8");
const localSettings = parse(settingsYaml) as Record<string, unknown>;
localSettings.slack_oauth_client_id = "1234567890.1234567890";
localSettings.slack_oauth_client_secret = "deadbeefcafebabe";
await writeFile(
`${tempDir}/settings.yaml`,
stringify(localSettings),
"utf-8",
);
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes"],
tempDir,
);
expect(pushResult.code).toEqual(0);
const settingsResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/get_settings`,
);
expect(settingsResp.ok).toBe(true);
const settings = await settingsResp.json();
expect(settings.slack_oauth_client_id).toEqual("1234567890.1234567890");
expect(settings.slack_oauth_client_secret).toEqual("deadbeefcafebabe");
});
},
);
// Deleting the two slack_oauth keys from settings.yaml (leaving them absent)
// must clear the remote — same rule as every other workspace setting.
test.skipIf(shouldSkipOnCI())(
"Slack sync: omitting slack_oauth_config from YAML clears remote",
async () => {
await withTestBackend(async (backend, tempDir) => {
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "slack_omit_clear_test",
token: backend.token,
};
await addWorkspace(testWorkspace, {
force: true,
configDir: backend.testConfigDir,
});
// Pre-seed the workspace with a workspace-level OAuth override.
const setResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/slack_oauth_config`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
slack_oauth_client_id: "1111111111.2222222222",
slack_oauth_client_secret: "feedfacefeedfacefeedface",
}),
},
);
expect(setResp.ok).toBe(true);
await writeFile(
`${tempDir}/wmill.yaml`,
`defaultTs: bun
includes:
- "**"
includeSettings: true`,
"utf-8",
);
const pullResult = await backend.runCLICommand(
["sync", "pull", "--yes"],
tempDir,
);
expect(pullResult.code).toEqual(0);
const settingsYaml = await readFile(`${tempDir}/settings.yaml`, "utf-8");
const localSettings = parse(settingsYaml) as Record<string, unknown>;
// Remove both keys entirely — "omit = clear" under the universal rule.
delete localSettings.slack_oauth_client_id;
delete localSettings.slack_oauth_client_secret;
await writeFile(
`${tempDir}/settings.yaml`,
stringify(localSettings),
"utf-8",
);
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes"],
tempDir,
);
expect(pushResult.code).toEqual(0);
const settingsResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/get_settings`,
);
expect(settingsResp.ok).toBe(true);
const settings = await settingsResp.json();
expect(settings.slack_oauth_client_id ?? null).toBeNull();
expect(settings.slack_oauth_client_secret ?? null).toBeNull();
});
},
);
// Explicit null in YAML must also clear — same outcome as omission; the two
// representations converge on the "clear remote" semantic.
test.skipIf(shouldSkipOnCI())(
"Slack sync: explicit null error_handler clears remote",
async () => {
await withTestBackend(async (backend, tempDir) => {
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "slack_null_clear_test",
token: backend.token,
};
await addWorkspace(testWorkspace, {
force: true,
configDir: backend.testConfigDir,
});
// Pre-seed an error handler on the workspace.
const setResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/edit_error_handler`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "script/hub/19741/workspace-or-schedule-error-handler-slack",
extra_args: null,
muted_on_cancel: false,
muted_on_user_path: false,
}),
},
);
expect(setResp.ok).toBe(true);
await writeFile(
`${tempDir}/wmill.yaml`,
`defaultTs: bun
includes:
- "**"
includeSettings: true`,
"utf-8",
);
const pullResult = await backend.runCLICommand(
["sync", "pull", "--yes"],
tempDir,
);
expect(pullResult.code).toEqual(0);
const settingsYaml = await readFile(`${tempDir}/settings.yaml`, "utf-8");
const localSettings = parse(settingsYaml) as Record<string, unknown>;
expect(localSettings.error_handler).not.toBeNull(); // sanity: it was populated
// Explicit null — must clear.
localSettings.error_handler = null;
await writeFile(
`${tempDir}/settings.yaml`,
stringify(localSettings),
"utf-8",
);
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes"],
tempDir,
);
expect(pushResult.code).toEqual(0);
const settingsResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/get_settings`,
);
expect(settingsResp.ok).toBe(true);
const settings = await settingsResp.json();
expect(settings.error_handler ?? null).toBeNull();
});
},
);
// Round-trip for a populated error_handler: pull should capture it exactly
// (including the always-persisted muted_on_* booleans), and re-push without
// edits should be a no-op.
test.skipIf(shouldSkipOnCI())(
"Slack sync: round-trip preserves populated error_handler",
async () => {
await withTestBackend(async (backend, tempDir) => {
const testWorkspace = {
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "slack_handler_roundtrip_test",
token: backend.token,
};
await addWorkspace(testWorkspace, {
force: true,
configDir: backend.testConfigDir,
});
const handlerPath =
"script/hub/19741/workspace-or-schedule-error-handler-slack";
const extraArgs = { channel: "test", slack: "$res:f/slack_bot/bot_token" };
const setResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/edit_error_handler`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: handlerPath,
extra_args: extraArgs,
muted_on_cancel: false,
muted_on_user_path: false,
}),
},
);
expect(setResp.ok).toBe(true);
await writeFile(
`${tempDir}/wmill.yaml`,
`defaultTs: bun
includes:
- "**"
includeSettings: true`,
"utf-8",
);
const pullResult = await backend.runCLICommand(
["sync", "pull", "--yes"],
tempDir,
);
expect(pullResult.code).toEqual(0);
const settingsYaml = await readFile(`${tempDir}/settings.yaml`, "utf-8");
const localSettings = parse(settingsYaml) as Record<string, unknown>;
const localHandler = localSettings.error_handler as Record<string, unknown>;
expect(localHandler).not.toBeNull();
expect(localHandler.path).toEqual(handlerPath);
expect(localHandler.extra_args).toEqual(extraArgs);
// Always-persist booleans (fix 4b) ensure round-trip stability.
expect(localHandler.muted_on_cancel).toEqual(false);
expect(localHandler.muted_on_user_path).toEqual(false);
// Push unchanged — remote must be byte-identical.
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes"],
tempDir,
);
expect(pushResult.code).toEqual(0);
const settingsResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/workspaces/get_settings`,
);
expect(settingsResp.ok).toBe(true);
const settings = await settingsResp.json();
expect(settings.error_handler).toEqual({
path: handlerPath,
extra_args: extraArgs,
muted_on_cancel: false,
muted_on_user_path: false,
});
});
},
);