mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
95d4c6a94d
* 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>
447 lines
14 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
},
|
|
);
|