Files
windmill/cli/test/protection_rules_converter_unit.test.ts
Ruben Fiszel 01bad16c0c feat: add wmill protection-rules pull/push CLI commands (#9240)
* feat: add wmill protection-rules pull/push CLI commands

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

* refactor: use directional keys for protection-rules pull --json diff

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

* fix: address review — exit non-zero on failure, resolve override workspace key

- failure paths in pull/push now exit 1 so CI/scripts detect failed reconciles
- --override writes under the resolved workspace key (findWorkspaceByGitBranch),
  not the raw branch, so gitBranch-mapped entries aren't left inert
- pull --replace clears a shadowing protectionRules override so top-level takes
  effect (was an infinite pull --diff loop)
- push reports applied create/update/delete counts on partial failure and warns
  loudly when an empty list would wipe all backend rules

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

* fix: address review — dry-run pull --diff no longer writes; --promotion coherent

- pull --diff returns before the no-wmill.yaml bootstrap, so a dry run never
  creates/mutates wmill.yaml
- pull --promotion now writes/clears the promotion target's promotionOverrides
  (the same block getEffectiveSettings reads), instead of the current branch's
  regular overrides — read and write are now coherent

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

* refactor: move protection rules to a per-workspace protection-rules.yaml

Replaces the wmill.yaml/SyncOptions integration (top-level + overrides +
promotionOverrides) with a dedicated protection-rules.yaml keyed by workspace
name. This removes the getEffectiveSettings layering that caused the override
shadowing / promotion-coherence / dry-run bugs entirely.

- protection-rules.yaml: { <workspace>: ProtectionRuleEntry[] }, keys must
  match wmill.yaml 'workspaces' (source of truth for backend id/baseUrl/token)
- commands reduced to: pull/push [workspace] | --all, with --dry-run
- per-workspace auth resolved via tryResolveBranchWorkspace + setClient
- push remains a full reconcile (create/update/delete) with delete confirm,
  empty-list wipe warning, partial-failure reporting, non-zero exit on failure
- conf.ts reverted to main; SyncOptions no longer carries protectionRules

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

* fix: address review — honor explicit --base-url/--token in protection-rules

configureClientForWorkspace bypassed the credential precedence other commands
use: explicit --base-url/--token now work for stateless CI (no stored profile
or wmill.yaml baseUrl needed), and an explicit --token overrides a stored
profile's token. The backend workspace id still derives from the wmill.yaml
mapping (feature invariant).

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

* fix: address cubic review — consistent status on partial --all failure

cubic found that pull/push reported success:true while exiting non-zero on
partial --all failures, and that the push command description was missing from
the generated CLI docs.

- pull/push now report success:false + partialFailure:true (and exit 1) when
  any --all workspace fails; success:true only on full success
- .description() calls use single string literals (not + concatenation) so
  system_prompts/generate.py parses them; regenerated CLI docs

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

* fix: address review — --json-output must emit only JSON on stdout

Codex flagged that workspace resolution (tryResolveBranchWorkspace's log.info)
and push's empty-list delete warning print to stdout before the JSON payload,
breaking machine callers. Silence human logs via log.setSilent(true) as the
first action when --json-output is set (before readConfigFile / resolution);
log.error still goes to stderr.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:40:34 +00:00

241 lines
8.0 KiB
TypeScript

/**
* Unit tests for the protection-rules feature: the reconciliation converter,
* the WorkspaceResolver (protection-rules.yaml key -> backend id via
* wmill.yaml), and protection-rules.yaml read/write round-tripping.
*/
import { expect, test, describe } from "bun:test";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { ProtectionRulesConverter } from "../src/commands/protection-rules/converter.ts";
import {
WorkspaceResolver,
readProtectionRulesFile,
writeProtectionRulesFile,
} from "../src/commands/protection-rules/file.ts";
import { ProtectionRuleEntry } from "../src/commands/protection-rules/types.ts";
import { SyncOptions } from "../src/core/conf.ts";
const rule = (
name: string,
rules: ProtectionRuleEntry["rules"],
groups: string[] = [],
users: string[] = [],
): ProtectionRuleEntry => ({
name,
rules,
bypass_groups: groups,
bypass_users: users,
});
describe("normalizeEntry", () => {
test("sorts and dedupes rules, groups, users", () => {
const r = rule(
"prod",
["RestrictDeployToDeployers", "DisableDirectDeployment", "DisableDirectDeployment"],
["g/b", "g/a"],
["u/y", "u/x", "u/x"],
);
const n = ProtectionRulesConverter.normalizeEntry(r);
expect(n.rules).toEqual([
"DisableDirectDeployment",
"RestrictDeployToDeployers",
]);
expect(n.bypass_groups).toEqual(["g/a", "g/b"]);
expect(n.bypass_users).toEqual(["u/x", "u/y"]);
});
test("handles missing arrays", () => {
const n = ProtectionRulesConverter.normalizeEntry({
name: "x",
} as unknown as ProtectionRuleEntry);
expect(n.rules).toEqual([]);
expect(n.bypass_groups).toEqual([]);
expect(n.bypass_users).toEqual([]);
});
});
describe("entriesEqual", () => {
test("equal regardless of array order", () => {
const a = rule("p", ["DisableDirectDeployment", "DisableWorkspaceForking"], ["g/a", "g/b"]);
const b = rule("p", ["DisableWorkspaceForking", "DisableDirectDeployment"], ["g/b", "g/a"]);
expect(ProtectionRulesConverter.entriesEqual(a, b)).toBe(true);
});
test("different rules are not equal", () => {
const a = rule("p", ["DisableDirectDeployment"]);
const b = rule("p", ["DisableWorkspaceForking"]);
expect(ProtectionRulesConverter.entriesEqual(a, b)).toBe(false);
});
test("different bypass users are not equal", () => {
const a = rule("p", ["DisableDirectDeployment"], [], ["u/a"]);
const b = rule("p", ["DisableDirectDeployment"], [], ["u/b"]);
expect(ProtectionRulesConverter.entriesEqual(a, b)).toBe(false);
});
});
describe("fromBackend", () => {
test("strips workspace_id and normalizes", () => {
const out = ProtectionRulesConverter.fromBackend([
{
name: "p",
workspace_id: "ws1",
rules: ["DisableWorkspaceForking", "DisableDirectDeployment"],
bypass_groups: ["g/b", "g/a"],
bypass_users: [],
},
]);
expect(out).toEqual([
{
name: "p",
rules: ["DisableDirectDeployment", "DisableWorkspaceForking"],
bypass_groups: ["g/a", "g/b"],
bypass_users: [],
},
]);
});
});
describe("listsEqual", () => {
test("equal regardless of list order", () => {
const a = [rule("a", ["DisableDirectDeployment"]), rule("b", ["DisableWorkspaceForking"])];
const b = [rule("b", ["DisableWorkspaceForking"]), rule("a", ["DisableDirectDeployment"])];
expect(ProtectionRulesConverter.listsEqual(a, b)).toBe(true);
});
test("undefined equals empty", () => {
expect(ProtectionRulesConverter.listsEqual(undefined, [])).toBe(true);
});
test("different length not equal", () => {
expect(
ProtectionRulesConverter.listsEqual([rule("a", [])], []),
).toBe(false);
});
});
describe("computePlan (full reconcile)", () => {
test("creates rules present locally but not on backend", () => {
const plan = ProtectionRulesConverter.computePlan(
[rule("new", ["DisableDirectDeployment"])],
[],
);
expect(plan.toCreate.map((e) => e.name)).toEqual(["new"]);
expect(plan.toUpdate).toEqual([]);
expect(plan.toDelete).toEqual([]);
});
test("deletes backend rules not present locally", () => {
const plan = ProtectionRulesConverter.computePlan(
[],
[rule("stale", ["DisableDirectDeployment"])],
);
expect(plan.toDelete).toEqual(["stale"]);
expect(plan.toCreate).toEqual([]);
});
test("updates rules whose content changed", () => {
const plan = ProtectionRulesConverter.computePlan(
[rule("p", ["DisableDirectDeployment", "DisableWorkspaceForking"])],
[rule("p", ["DisableDirectDeployment"])],
);
expect(plan.toUpdate.map((e) => e.name)).toEqual(["p"]);
expect(plan.toCreate).toEqual([]);
expect(plan.toDelete).toEqual([]);
});
test("unchanged rules are not in create/update/delete", () => {
const same = [rule("p", ["DisableDirectDeployment"], ["g/a"])];
const plan = ProtectionRulesConverter.computePlan(same, [
rule("p", ["DisableDirectDeployment"], ["g/a"]),
]);
expect(ProtectionRulesConverter.planHasChanges(plan)).toBe(false);
expect(plan.unchanged).toEqual(["p"]);
});
test("mixed plan: create + update + delete + unchanged", () => {
const local = [
rule("keep", ["DisableDirectDeployment"]),
rule("change", ["DisableWorkspaceForking"]),
rule("brand-new", ["RestrictDeployToDeployers"]),
];
const backend = [
rule("keep", ["DisableDirectDeployment"]),
rule("change", ["DisableDirectDeployment"]),
rule("gone", ["DisableDirectDeployment"]),
];
const plan = ProtectionRulesConverter.computePlan(local, backend);
expect(plan.toCreate.map((e) => e.name)).toEqual(["brand-new"]);
expect(plan.toUpdate.map((e) => e.name)).toEqual(["change"]);
expect(plan.toDelete).toEqual(["gone"]);
expect(plan.unchanged).toEqual(["keep"]);
expect(ProtectionRulesConverter.planHasChanges(plan)).toBe(true);
});
test("reordered arrays do not produce spurious updates", () => {
const plan = ProtectionRulesConverter.computePlan(
[rule("p", ["DisableWorkspaceForking", "DisableDirectDeployment"], ["g/b", "g/a"])],
[rule("p", ["DisableDirectDeployment", "DisableWorkspaceForking"], ["g/a", "g/b"])],
);
expect(ProtectionRulesConverter.planHasChanges(plan)).toBe(false);
});
});
describe("WorkspaceResolver", () => {
const config: SyncOptions = {
workspaces: {
prod: { workspaceId: "acme-prod" },
dev: {},
commonSpecificItems: { settings: true },
} as any,
};
const r = WorkspaceResolver.fromConfig(config);
test("knownNames excludes reserved keys", () => {
expect(r.knownNames().sort()).toEqual(["dev", "prod"]);
});
test("backendId uses workspaceId when set, else the key name", () => {
expect(r.backendId("prod")).toBe("acme-prod");
expect(r.backendId("dev")).toBe("dev");
});
test("backendId throws for a key absent from wmill.yaml", () => {
expect(() => r.backendId("ghost")).toThrow(/not defined in wmill\.yaml/);
});
test("has reflects membership", () => {
expect(r.has("prod")).toBe(true);
expect(r.has("ghost")).toBe(false);
});
test("empty config resolves to no workspaces", () => {
expect(WorkspaceResolver.fromConfig({}).knownNames()).toEqual([]);
});
});
describe("protection-rules.yaml read/write", () => {
test("round-trips and sorts workspace keys deterministically", async () => {
const dir = mkdtempSync(join(tmpdir(), "prfile-"));
const path = join(dir, "protection-rules.yaml");
try {
expect(await readProtectionRulesFile(path)).toEqual({});
await writeProtectionRulesFile(path, {
prod: [rule("p", ["DisableDirectDeployment"], ["g/a"])],
dev: [],
});
const back = await readProtectionRulesFile(path);
expect(Object.keys(back)).toEqual(["dev", "prod"]);
expect(back.prod).toEqual([
rule("p", ["DisableDirectDeployment"], ["g/a"]),
]);
expect(back.dev).toEqual([]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});