mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 08:02:58 +00:00
`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>
586 lines
19 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|