Files
windmill/cli/test/variable_resource_push.test.ts
T
AlexRV12andClaude Opus 5 67da34668f feat(cli): sync variable expiry and the workspace expiration handler
`wmill sync` carries `value_expires_at` on variables in both directions, and
pushes the workspace handler alongside the error and success handlers in the
grouped settings format. Dropping the key from a variable's yaml clears the
remote expiry, so the file stays canonical rather than accumulating fields that
can only ever be added.

The handler follows the same rule, with the caveat recorded where the type is
declared: an omitted handler only reaches the clear when some other setting
differs, because the push short-circuits on a comparison that walks the local
object's keys and so never sees a missing one. That gate is shared with the two
existing handlers and is left as it is here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:15:23 +02:00

586 lines
19 KiB
TypeScript

/**
* Integration tests for variable and resource CLI commands.
* Tests list and push operations via CLI and direct API.
*/
import { expect, test, describe } from "bun:test";
import { writeFile, mkdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { withTestBackend } from "./test_backend.ts";
import { addWorkspace } from "../workspace.ts";
async function setupWorkspaceProfile(backend: any): Promise<void> {
await addWorkspace(
{
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "localhost_test",
token: backend.token,
},
{ force: true, configDir: backend.testConfigDir }
);
}
// =============================================================================
// Variable Tests
// =============================================================================
describe("variable", () => {
test("list returns seeded variables", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(["variable"], tempDir);
expect(result.code).toEqual(0);
// seedTestData creates f/test/my_variable
expect(result.stdout).toContain("f/test/my_variable");
});
});
test("push creates a new variable via sync push", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
// Create wmill.yaml
await writeFile(
join(tempDir, "wmill.yaml"),
`defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`,
"utf-8"
);
// Create variable file
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const varPath = `f/test/test_var_${uniqueId}.variable.yaml`;
await writeFile(
join(tempDir, varPath),
`value: "hello_from_test_${uniqueId}"\nis_secret: false\ndescription: "Test variable created by integration test"\n`,
"utf-8"
);
// Push with sync push targeting just our variable
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes", "--includes", `f/test/test_var_${uniqueId}**`],
tempDir
);
expect(pushResult.code).toEqual(0);
// Verify via API that the variable was created
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/f/test/test_var_${uniqueId}`
);
expect(apiResp.status).toEqual(200);
const varData = await apiResp.json();
expect(varData.path).toBe(`f/test/test_var_${uniqueId}`);
expect(varData.is_secret).toBe(false);
});
});
test("push updates an existing variable", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
// Create variable via API first
const createResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: `f/test/update_var_${uniqueId}`,
value: "original_value",
is_secret: false,
description: "Original description",
}),
}
);
expect(createResp.status).toBeLessThan(300);
await createResp.text();
// Create wmill.yaml and updated variable file
await writeFile(
join(tempDir, "wmill.yaml"),
`defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`,
"utf-8"
);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
await writeFile(
join(tempDir, `f/test/update_var_${uniqueId}.variable.yaml`),
`value: "updated_value"\nis_secret: false\ndescription: "Updated description"\n`,
"utf-8"
);
// Push the update
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes", "--includes", `f/test/update_var_${uniqueId}**`],
tempDir
);
expect(pushResult.code).toEqual(0);
// Verify the update via API
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/f/test/update_var_${uniqueId}`
);
expect(apiResp.status).toEqual(200);
const varData = await apiResp.json();
expect(varData.description).toBe("Updated description");
});
});
test("push clears value_expires_at when the key is dropped from the file", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
const path = `f/test/expiry_var_${uniqueId}`;
const createResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path,
value: "original_value",
is_secret: false,
description: "Expiring",
value_expires_at: "2027-03-15T08:00:00Z",
}),
}
);
expect(createResp.status).toBeLessThan(300);
await createResp.text();
await writeFile(
join(tempDir, "wmill.yaml"),
`defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`,
"utf-8"
);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
// No value_expires_at: the server reads an absent field as "leave the stored
// date alone", so the clear only lands if the CLI sends an explicit null.
await writeFile(
join(tempDir, `${path}.variable.yaml`),
`value: "original_value"\nis_secret: false\ndescription: "Expiring"\n`,
"utf-8"
);
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes", "--includes", `${path}**`],
tempDir
);
expect(pushResult.code).toEqual(0);
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${path}`
);
expect(apiResp.status).toEqual(200);
const varData = await apiResp.json();
expect(varData.value_expires_at ?? null).toBeNull();
// A clear that does not apply leaves the same diff pending forever, so the
// push reporting success is not on its own evidence that it landed.
const secondPush = await backend.runCLICommand(
["sync", "push", "--yes", "--includes", `${path}**`],
tempDir
);
expect(secondPush.code).toEqual(0);
expect(secondPush.stdout).toContain("0 changes to apply");
});
});
test("add creates secret by default and preserves fields on update", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
const varPath = `f/test/add_var_${uniqueId}`;
// Create: secret by default, --description sets the description
const createResult = await backend.runCLICommand(
["variable", "add", "v1", varPath, "--description", "first desc"],
tempDir
);
expect(createResult.code).toEqual(0);
let apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${varPath}`
);
expect(apiResp.status).toEqual(200);
let varData = await apiResp.json();
expect(varData.is_secret).toBe(true);
expect(varData.description).toBe("first desc");
// Update with --yes only: no prompt, is_secret and description preserved
const updateResult = await backend.runCLICommand(
["variable", "add", "v2", varPath, "--yes"],
tempDir
);
expect(updateResult.code).toEqual(0);
apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${varPath}`
);
expect(apiResp.status).toEqual(200);
varData = await apiResp.json();
expect(varData.is_secret).toBe(true);
expect(varData.description).toBe("first desc");
expect(varData.value).toBe("v2");
// Update with --no-secret: flips to non-secret
const noSecretResult = await backend.runCLICommand(
["variable", "add", "v3", varPath, "--yes", "--no-secret"],
tempDir
);
expect(noSecretResult.code).toEqual(0);
apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${varPath}`
);
expect(apiResp.status).toEqual(200);
varData = await apiResp.json();
expect(varData.is_secret).toBe(false);
expect(varData.value).toBe("v3");
// Update the non-secret variable with no secret flags: is_secret must
// stay false (preserved, not re-defaulted to secret)
const preserveResult = await backend.runCLICommand(
["variable", "add", "v4", varPath, "--yes"],
tempDir
);
expect(preserveResult.code).toEqual(0);
apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${varPath}`
);
expect(apiResp.status).toEqual(200);
varData = await apiResp.json();
expect(varData.is_secret).toBe(false);
expect(varData.value).toBe("v4");
// Explicit --secret flips it back
const secretResult = await backend.runCLICommand(
["variable", "add", "v5", varPath, "--yes", "--secret"],
tempDir
);
expect(secretResult.code).toEqual(0);
apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${varPath}`
);
expect(apiResp.status).toEqual(200);
varData = await apiResp.json();
expect(varData.is_secret).toBe(true);
expect(varData.value).toBe("v5");
});
});
test("push encrypts a plaintext secret value (no --plain-secrets) and round-trips", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
const varPath = `f/test/sec_push_${uniqueId}`;
// Existing secret variable (server-encrypted).
const createResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: varPath,
value: "original-secret",
is_secret: true,
description: "",
}),
}
);
expect(createResp.status).toBeLessThan(300);
await createResp.text();
// A hand-authored spec file: plaintext value, is_secret: true. Pushing it
// without --plain-secrets must encrypt the value server-side, not store the
// plaintext verbatim as ciphertext (which would make every read fail).
const specPath = join(tempDir, "v.yaml");
await writeFile(
specPath,
`value: |\n some: plaintext\nis_secret: true\ndescription: ""\n`,
"utf-8"
);
const pushResult = await backend.runCLICommand(
["variable", "push", specPath, varPath],
tempDir
);
expect(pushResult.code).toEqual(0);
// The value must decrypt cleanly to the pushed plaintext.
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${varPath}?decrypt_secret=true`
);
expect(apiResp.status).toEqual(200);
const varData = await apiResp.json();
expect(varData.is_secret).toBe(true);
expect(varData.value).toBe("some: plaintext\n");
});
});
test("push flips is_secret from true to false", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
const varPath = `f/test/sec_down_${uniqueId}`;
const createResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: varPath,
value: "original-secret",
is_secret: true,
description: "",
}),
}
);
expect(createResp.status).toBeLessThan(300);
await createResp.text();
const specPath = join(tempDir, "v_down.yaml");
await writeFile(
specPath,
`value: "now-public"\nis_secret: false\ndescription: ""\n`,
"utf-8"
);
const pushResult = await backend.runCLICommand(
["variable", "push", specPath, varPath],
tempDir
);
expect(pushResult.code).toEqual(0);
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${varPath}?decrypt_secret=true`
);
expect(apiResp.status).toEqual(200);
const varData = await apiResp.json();
expect(varData.is_secret).toBe(false);
expect(varData.value).toBe("now-public");
});
});
test("pull retrieves variables into local files", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
// Create a variable via API
const createResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: `f/test/pull_var_${uniqueId}`,
value: "pull_test_value",
is_secret: false,
description: "Variable for pull test",
}),
}
);
expect(createResp.status).toBeLessThan(300);
await createResp.text();
// Create wmill.yaml
await writeFile(
join(tempDir, "wmill.yaml"),
`defaultTs: bun\nincludes:\n - "f/test/pull_var_${uniqueId}**"\nexcludes: []\n`,
"utf-8"
);
// Pull
const pullResult = await backend.runCLICommand(
["sync", "pull", "--yes"],
tempDir
);
expect(pullResult.code).toEqual(0);
// Check the file was created
const content = await readFile(
join(tempDir, `f/test/pull_var_${uniqueId}.variable.yaml`), "utf-8"
);
expect(content).toContain("pull_test_value");
expect(content).toContain("is_secret: false");
});
});
});
// =============================================================================
// Resource Tests
// =============================================================================
describe("resource", () => {
test("list returns seeded resources", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(["resource"], tempDir);
expect(result.code).toEqual(0);
// seedTestData creates f/test/my_resource
expect(result.stdout).toContain("f/test/my_resource");
});
});
test("push creates a new resource via sync push", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
// Create wmill.yaml
await writeFile(
join(tempDir, "wmill.yaml"),
`defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`,
"utf-8"
);
// Create resource file
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const resPath = `f/test/test_res_${uniqueId}.resource.yaml`;
await writeFile(
join(tempDir, resPath),
`resource_type: "any"\nvalue:\n host: "localhost"\n port: 3000\ndescription: "Test resource"\n`,
"utf-8"
);
// Push
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes", "--includes", `f/test/test_res_${uniqueId}**`],
tempDir
);
expect(pushResult.code).toEqual(0);
// Verify via API
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/resources/get/f/test/test_res_${uniqueId}`
);
expect(apiResp.status).toEqual(200);
const resData = await apiResp.json();
expect(resData.path).toBe(`f/test/test_res_${uniqueId}`);
expect(resData.resource_type).toBe("any");
expect(resData.value.host).toBe("localhost");
});
});
test("push updates an existing resource", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
// Create resource via API
const createResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/resources/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: `f/test/update_res_${uniqueId}`,
resource_type: "any",
value: { host: "old_host" },
description: "Original",
}),
}
);
expect(createResp.status).toBeLessThan(300);
await createResp.text();
// Create wmill.yaml and updated resource file
await writeFile(
join(tempDir, "wmill.yaml"),
`defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`,
"utf-8"
);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
await writeFile(
join(tempDir, `f/test/update_res_${uniqueId}.resource.yaml`),
`resource_type: "any"\nvalue:\n host: "new_host"\n port: 9999\ndescription: "Updated"\n`,
"utf-8"
);
// Push the update
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes", "--includes", `f/test/update_res_${uniqueId}**`],
tempDir
);
expect(pushResult.code).toEqual(0);
// Verify update
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/resources/get/f/test/update_res_${uniqueId}`
);
expect(apiResp.status).toEqual(200);
const resData = await apiResp.json();
expect(resData.value.host).toBe("new_host");
expect(resData.value.port).toBe(9999);
});
});
test("pull retrieves resources into local files", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
// Create resource via API
const createResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/resources/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: `f/test/pull_res_${uniqueId}`,
resource_type: "any",
value: { key: "pull_test" },
}),
}
);
expect(createResp.status).toBeLessThan(300);
await createResp.text();
// Create wmill.yaml
await writeFile(
join(tempDir, "wmill.yaml"),
`defaultTs: bun\nincludes:\n - "f/test/pull_res_${uniqueId}**"\nexcludes: []\nskipVariables: true\n`,
"utf-8"
);
// Pull
const pullResult = await backend.runCLICommand(
["sync", "pull", "--yes"],
tempDir
);
expect(pullResult.code).toEqual(0);
// Check the resource file was created
const content = await readFile(
join(tempDir, `f/test/pull_res_${uniqueId}.resource.yaml`), "utf-8"
);
expect(content).toContain("pull_test");
});
});
});