feat: folder default_permissioned_as rules for ownership defaults on deploy (#8801)
* feat: add folder default_permissioned_as rules for ownership defaults on deploy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unnecessary auth guard on default_permissioned_as — rules are advisory only Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate system prompts with new CLI commands Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address CI review findings — TOCTOU, race condition, email validation, type coercion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add sqlx offline cache for test queries (fixes cargo_test CI) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address remaining review findings — incomplete request bodies, dead code, redundant import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address remaining review findings — full script fields, reactive stores, catch-all validation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: app/schedule/trigger set-permissioned-as fetch remote first to avoid data loss Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: app set-permissioned-as avoid creating redundant app version Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: compact user/group toggle + select for folder default_permissioned_as rules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: collapse default_permissioned_as section by default in folder editor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: include default_permissioned_as in FolderFile CLI type for YAML round-trip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: process folder.meta changes before items in push to apply new rules immediately Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: clone default_permissioned_as on fork/rename + add full lifecycle tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add no-op guarantee test — folder without rules behaves like before Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: rename cliBehavior to syncBehavior — more accurate scope Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
636
cli/test/folder_default_permissioned_as.test.ts
Normal file
636
cli/test/folder_default_permissioned_as.test.ts
Normal file
@@ -0,0 +1,636 @@
|
||||
/**
|
||||
* Integration tests for folder default_permissioned_as — full lifecycle:
|
||||
* 1. Full lifecycle: create folder via CLI, set rules, push, pull, modify, push,
|
||||
* create new item under folder — verifies rules are applied at each step
|
||||
* 2. Fork/merge: parent workspace has folder with rules, fork inherits them,
|
||||
* fork can update rules, deployed back to parent
|
||||
* 3. Backend validation + resolution (minimal API-level checks)
|
||||
* 4. Stale rule rejection
|
||||
*/
|
||||
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { writeFile, mkdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { withTestBackend, type TestBackend } 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 }
|
||||
);
|
||||
}
|
||||
|
||||
async function api(
|
||||
backend: TestBackend,
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
return backend.apiRequest!(path, options);
|
||||
}
|
||||
|
||||
async function createFolderWithRules(
|
||||
backend: any,
|
||||
name: string,
|
||||
rules: Array<{ path_glob: string; permissioned_as: string }>,
|
||||
extraPerms?: Record<string, boolean>,
|
||||
workspaceId?: string
|
||||
): Promise<void> {
|
||||
const ws = workspaceId ?? backend.workspace;
|
||||
const resp = await backend.apiRequest!(`/api/w/${ws}/folders/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
default_permissioned_as: rules,
|
||||
extra_perms: extraPerms ?? {},
|
||||
}),
|
||||
});
|
||||
await resp.text();
|
||||
expect(resp.status).toBeLessThan(300);
|
||||
}
|
||||
|
||||
async function updateFolderRules(
|
||||
backend: any,
|
||||
name: string,
|
||||
rules: Array<{ path_glob: string; permissioned_as: string }>,
|
||||
workspaceId?: string
|
||||
): Promise<Response> {
|
||||
const ws = workspaceId ?? backend.workspace;
|
||||
return backend.apiRequest!(`/api/w/${ws}/folders/update/${name}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ default_permissioned_as: rules }),
|
||||
});
|
||||
}
|
||||
|
||||
async function createScript(
|
||||
backend: any,
|
||||
path: string,
|
||||
workspaceId?: string
|
||||
): Promise<string> {
|
||||
const ws = workspaceId ?? backend.workspace;
|
||||
const resp = await backend.apiRequest!(`/api/w/${ws}/scripts/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
summary: "helper",
|
||||
description: "",
|
||||
content: "export async function main() { return 42; }",
|
||||
language: "deno",
|
||||
schema: { type: "object", properties: {}, required: [] },
|
||||
}),
|
||||
});
|
||||
const body = await resp.text();
|
||||
expect(resp.status).toBe(201);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function createSchedule(
|
||||
backend: any,
|
||||
path: string,
|
||||
scriptPath: string,
|
||||
extra?: Record<string, any>,
|
||||
workspaceId?: string
|
||||
): Promise<Response> {
|
||||
const ws = workspaceId ?? backend.workspace;
|
||||
return backend.apiRequest!(`/api/w/${ws}/schedules/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
schedule: "0 0 */6 * * *",
|
||||
timezone: "UTC",
|
||||
script_path: scriptPath,
|
||||
is_flow: false,
|
||||
enabled: false,
|
||||
...extra,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function getSchedule(
|
||||
backend: any,
|
||||
path: string,
|
||||
workspaceId?: string
|
||||
): Promise<any> {
|
||||
const ws = workspaceId ?? backend.workspace;
|
||||
const resp = await backend.apiRequest!(`/api/w/${ws}/schedules/get/${path}`);
|
||||
expect(resp.status).toBe(200);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function getFolder(
|
||||
backend: any,
|
||||
name: string,
|
||||
workspaceId?: string
|
||||
): Promise<any> {
|
||||
const ws = workspaceId ?? backend.workspace;
|
||||
const resp = await backend.apiRequest!(`/api/w/${ws}/folders/get/${name}`);
|
||||
expect(resp.status).toBe(200);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function runSQL(query: string): Promise<void> {
|
||||
const dbUrl =
|
||||
process.env["DATABASE_URL"] ||
|
||||
"postgres://postgres:changeme@localhost:5432";
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"psql",
|
||||
`${dbUrl}/postgres?sslmode=disable`,
|
||||
"-t",
|
||||
"-c",
|
||||
`SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%' ORDER BY datname DESC LIMIT 1`,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
const dbName = (await new Response(proc.stdout).text()).trim();
|
||||
await proc.exited;
|
||||
if (!dbName) throw new Error("Could not find test database");
|
||||
const sqlProc = Bun.spawn(
|
||||
["psql", `${dbUrl}/${dbName}?sslmode=disable`, "-c", query],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
await sqlProc.exited;
|
||||
}
|
||||
|
||||
async function removeFromSkipTally(workspaceId: string) {
|
||||
await runSQL(
|
||||
`DELETE FROM skip_workspace_diff_tally WHERE workspace_id = '${workspaceId}'`
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteFork(backend: TestBackend, forkId: string) {
|
||||
try {
|
||||
await api(backend, `/api/w/${forkId}/workspaces/delete`, { method: "POST" });
|
||||
} catch {}
|
||||
const esc = forkId.replace(/'/g, "''");
|
||||
await runSQL(`
|
||||
SET session_replication_role = replica;
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT c.table_name FROM information_schema.columns c
|
||||
JOIN information_schema.tables t ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
||||
WHERE c.column_name = 'workspace_id' AND c.table_schema = 'public' AND t.table_type = 'BASE TABLE'
|
||||
GROUP BY c.table_name
|
||||
LOOP EXECUTE format('DELETE FROM %I WHERE workspace_id = ''${esc}''', r.table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
DELETE FROM workspace WHERE id = '${esc}';
|
||||
DELETE FROM workspace_diff WHERE fork_workspace_id = '${esc}';
|
||||
DELETE FROM skip_workspace_diff_tally WHERE workspace_id = '${esc}';
|
||||
SET session_replication_role = DEFAULT;
|
||||
`);
|
||||
}
|
||||
|
||||
describe("folder default_permissioned_as", () => {
|
||||
// ==========================================================================
|
||||
// Test 1: Full CLI lifecycle — create, push, pull, modify, push, verify apply
|
||||
// ==========================================================================
|
||||
test("full CLI lifecycle: push rules → pull back → modify → push → apply on new item", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const id = Date.now();
|
||||
const folderName = `dpa_life_${id}`;
|
||||
|
||||
// Step 1: Seed an empty folder + helper script via API (needed so schedules can reference it)
|
||||
await createFolderWithRules(backend, folderName, []);
|
||||
await createScript(backend, `f/${folderName}/helper`);
|
||||
|
||||
// Step 2: Pull workspace locally
|
||||
await writeFile(
|
||||
join(tempDir, "wmill.yaml"),
|
||||
`defaultTs: bun\nsyncBehavior: v1\nincludes:\n - "f/${folderName}/**"\nincludeSchedules: true\n`,
|
||||
"utf-8"
|
||||
);
|
||||
let r = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir);
|
||||
expect(r.code).toEqual(0);
|
||||
|
||||
// Step 3: Locally edit folder.meta.yaml to add default_permissioned_as rules
|
||||
const metaPath = join(tempDir, "f", folderName, "folder.meta.yaml");
|
||||
const metaContent = await readFile(metaPath, "utf-8");
|
||||
// Backend always emits the field; initially it's an empty array
|
||||
expect(metaContent).toContain("default_permissioned_as: []");
|
||||
|
||||
const newMeta = `display_name: ${folderName}
|
||||
owners:
|
||||
- u/admin
|
||||
extra_perms:
|
||||
u/admin: true
|
||||
summary: null
|
||||
default_permissioned_as:
|
||||
- path_glob: "jobs/critical/**"
|
||||
permissioned_as: "g/all"
|
||||
- path_glob: "jobs/**"
|
||||
permissioned_as: "u/admin"
|
||||
`;
|
||||
await writeFile(metaPath, newMeta, "utf-8");
|
||||
|
||||
// Step 4: Push the folder update AND a brand-new schedule at the same time.
|
||||
// This tests the folder-first ordering: the new schedule should pick up
|
||||
// the new folder rule within the same push batch.
|
||||
const schedPath = join(
|
||||
tempDir,
|
||||
"f",
|
||||
folderName,
|
||||
"new_sched.schedule.yaml"
|
||||
);
|
||||
await writeFile(
|
||||
schedPath,
|
||||
`schedule: "0 0 */6 * * *"
|
||||
timezone: UTC
|
||||
script_path: f/${folderName}/helper
|
||||
is_flow: false
|
||||
enabled: false
|
||||
`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
r = await backend.runCLICommand(["sync", "push", "--yes"], tempDir);
|
||||
expect(r.code).toEqual(0);
|
||||
|
||||
// Verify: folder has the new rules
|
||||
const folder = await getFolder(backend, folderName);
|
||||
expect(folder.default_permissioned_as).toHaveLength(2);
|
||||
expect(folder.default_permissioned_as[0].path_glob).toBe(
|
||||
"jobs/critical/**"
|
||||
);
|
||||
expect(folder.default_permissioned_as[1].path_glob).toBe("jobs/**");
|
||||
|
||||
// Verify: schedule got u/admin (the jobs/** rule) — only works if folder
|
||||
// was pushed FIRST within the same batch
|
||||
const sched = await getSchedule(backend, `f/${folderName}/new_sched`);
|
||||
expect(sched.permissioned_as).toBe("u/admin");
|
||||
|
||||
// Step 5: Pull again — verify the updated rules round-trip through YAML
|
||||
r = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir);
|
||||
expect(r.code).toEqual(0);
|
||||
const pulledMeta = await readFile(metaPath, "utf-8");
|
||||
expect(pulledMeta).toContain("default_permissioned_as:");
|
||||
expect(pulledMeta).toContain("jobs/critical/**");
|
||||
expect(pulledMeta).toContain("g/all");
|
||||
|
||||
// Step 6: Reorder rules locally and push again — verify order changes
|
||||
const reorderedMeta = `display_name: ${folderName}
|
||||
owners:
|
||||
- u/admin
|
||||
extra_perms:
|
||||
u/admin: true
|
||||
summary: null
|
||||
default_permissioned_as:
|
||||
- path_glob: "jobs/**"
|
||||
permissioned_as: "u/admin"
|
||||
- path_glob: "jobs/critical/**"
|
||||
permissioned_as: "g/all"
|
||||
`;
|
||||
await writeFile(metaPath, reorderedMeta, "utf-8");
|
||||
r = await backend.runCLICommand(["sync", "push", "--yes"], tempDir);
|
||||
expect(r.code).toEqual(0);
|
||||
|
||||
const folder2 = await getFolder(backend, folderName);
|
||||
expect(folder2.default_permissioned_as[0].path_glob).toBe("jobs/**");
|
||||
expect(folder2.default_permissioned_as[1].path_glob).toBe(
|
||||
"jobs/critical/**"
|
||||
);
|
||||
|
||||
// Step 7: Now that jobs/** is listed first, a new critical schedule should
|
||||
// ALSO resolve to u/admin (first-match-wins, critical/** is shadowed)
|
||||
const schedPath2 = join(
|
||||
tempDir,
|
||||
"f",
|
||||
folderName,
|
||||
"crit_sched.schedule.yaml"
|
||||
);
|
||||
await mkdir(join(tempDir, "f", folderName, "jobs", "critical"), {
|
||||
recursive: true,
|
||||
});
|
||||
const critYaml = `schedule: "0 0 */6 * * *"
|
||||
timezone: UTC
|
||||
script_path: f/${folderName}/helper
|
||||
is_flow: false
|
||||
enabled: false
|
||||
`;
|
||||
await writeFile(
|
||||
join(
|
||||
tempDir,
|
||||
"f",
|
||||
folderName,
|
||||
"jobs",
|
||||
"critical",
|
||||
"crit_sched.schedule.yaml"
|
||||
),
|
||||
critYaml,
|
||||
"utf-8"
|
||||
);
|
||||
r = await backend.runCLICommand(["sync", "push", "--yes"], tempDir);
|
||||
expect(r.code).toEqual(0);
|
||||
|
||||
const critSched = await getSchedule(
|
||||
backend,
|
||||
`f/${folderName}/jobs/critical/crit_sched`
|
||||
);
|
||||
expect(critSched.permissioned_as).toBe("u/admin"); // shadowed by jobs/**
|
||||
|
||||
// Step 8: Clear rules locally and push — verify rules are cleared on backend
|
||||
const clearedMeta = `display_name: ${folderName}
|
||||
owners:
|
||||
- u/admin
|
||||
extra_perms:
|
||||
u/admin: true
|
||||
summary: null
|
||||
default_permissioned_as: []
|
||||
`;
|
||||
await writeFile(metaPath, clearedMeta, "utf-8");
|
||||
r = await backend.runCLICommand(["sync", "push", "--yes"], tempDir);
|
||||
expect(r.code).toEqual(0);
|
||||
|
||||
const folder3 = await getFolder(backend, folderName);
|
||||
expect(folder3.default_permissioned_as).toHaveLength(0);
|
||||
|
||||
// Step 9: A new schedule after clearing falls back to the acting user
|
||||
await writeFile(
|
||||
join(tempDir, "f", folderName, "after_clear.schedule.yaml"),
|
||||
critYaml,
|
||||
"utf-8"
|
||||
);
|
||||
r = await backend.runCLICommand(["sync", "push", "--yes"], tempDir);
|
||||
expect(r.code).toEqual(0);
|
||||
|
||||
const sched3 = await getSchedule(
|
||||
backend,
|
||||
`f/${folderName}/after_clear`
|
||||
);
|
||||
expect(sched3.permissioned_as).toBe("u/admin"); // admin is the acting user
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Test 2: Fork/merge with default_permissioned_as
|
||||
// ==========================================================================
|
||||
test("fork/merge: rules propagate to fork and deploy back to parent", async () => {
|
||||
await withTestBackend(async (backend, _tempDir) => {
|
||||
const parentWs = backend.workspace;
|
||||
const FORK_ID = "wm-fork-dpa";
|
||||
await deleteFork(backend, FORK_ID);
|
||||
|
||||
const id = Date.now();
|
||||
const folderName = `dpa_fork_${id}`;
|
||||
|
||||
// Step 1: Parent workspace: create folder with rules + helper script
|
||||
await createFolderWithRules(backend, folderName, [
|
||||
{ path_glob: "jobs/**", permissioned_as: "u/admin" },
|
||||
]);
|
||||
await createScript(backend, `f/${folderName}/helper`);
|
||||
|
||||
// Step 2: Create a fork — fork inherits the parent's folder including rules
|
||||
const forkResp = await api(
|
||||
backend,
|
||||
`/api/w/${parentWs}/workspaces/create_fork`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: FORK_ID,
|
||||
name: "DPA Fork",
|
||||
color: "#ff5500",
|
||||
forked_datatables: [],
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!forkResp.ok) {
|
||||
const err = await forkResp.text();
|
||||
throw new Error(`Fork creation failed: ${forkResp.status} ${err}`);
|
||||
}
|
||||
await removeFromSkipTally(FORK_ID);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Step 3: Verify fork inherited the folder rules
|
||||
const forkFolder = await getFolder(backend, folderName, FORK_ID);
|
||||
expect(forkFolder.default_permissioned_as).toHaveLength(1);
|
||||
expect(forkFolder.default_permissioned_as[0].path_glob).toBe("jobs/**");
|
||||
expect(forkFolder.default_permissioned_as[0].permissioned_as).toBe(
|
||||
"u/admin"
|
||||
);
|
||||
|
||||
// Step 4: Create a schedule in the fork at a matching path — rule applies
|
||||
const forkSchedResp = await createSchedule(
|
||||
backend,
|
||||
`f/${folderName}/jobs/fork_sched`,
|
||||
`f/${folderName}/helper`,
|
||||
undefined,
|
||||
FORK_ID
|
||||
);
|
||||
expect(forkSchedResp.status).toBe(200);
|
||||
await forkSchedResp.text();
|
||||
|
||||
const forkSched = await getSchedule(
|
||||
backend,
|
||||
`f/${folderName}/jobs/fork_sched`,
|
||||
FORK_ID
|
||||
);
|
||||
expect(forkSched.permissioned_as).toBe("u/admin");
|
||||
|
||||
// Step 5: Modify the fork's rules (simulating divergence)
|
||||
const updateResp = await updateFolderRules(
|
||||
backend,
|
||||
folderName,
|
||||
[
|
||||
{ path_glob: "jobs/**", permissioned_as: "u/admin" },
|
||||
{ path_glob: "reports/**", permissioned_as: "g/all" },
|
||||
],
|
||||
FORK_ID
|
||||
);
|
||||
expect(updateResp.status).toBe(200);
|
||||
await updateResp.text();
|
||||
|
||||
// Step 6: Deploy the fork's folder rules back to parent via API
|
||||
const forkFolderUpdated = await getFolder(backend, folderName, FORK_ID);
|
||||
const deployResp = await updateFolderRules(
|
||||
backend,
|
||||
folderName,
|
||||
forkFolderUpdated.default_permissioned_as,
|
||||
parentWs
|
||||
);
|
||||
expect(deployResp.status).toBe(200);
|
||||
await deployResp.text();
|
||||
|
||||
// Step 7: Verify parent now has the updated rules
|
||||
const parentFolderAfter = await getFolder(backend, folderName, parentWs);
|
||||
expect(parentFolderAfter.default_permissioned_as).toHaveLength(2);
|
||||
expect(parentFolderAfter.default_permissioned_as[1].path_glob).toBe(
|
||||
"reports/**"
|
||||
);
|
||||
expect(parentFolderAfter.default_permissioned_as[1].permissioned_as).toBe(
|
||||
"g/all"
|
||||
);
|
||||
|
||||
// Step 8: A new schedule in the parent at a reports/ path should now get
|
||||
// the newly-merged g/all rule
|
||||
const parentSchedResp = await createSchedule(
|
||||
backend,
|
||||
`f/${folderName}/reports/parent_sched`,
|
||||
`f/${folderName}/helper`,
|
||||
undefined,
|
||||
parentWs
|
||||
);
|
||||
expect(parentSchedResp.status).toBe(200);
|
||||
await parentSchedResp.text();
|
||||
|
||||
const parentSched = await getSchedule(
|
||||
backend,
|
||||
`f/${folderName}/reports/parent_sched`,
|
||||
parentWs
|
||||
);
|
||||
expect(parentSched.permissioned_as).toBe("g/all");
|
||||
|
||||
await deleteFork(backend, FORK_ID);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Test 3: No-op guarantee — feature is inert when rules are not used
|
||||
// ==========================================================================
|
||||
test("no-op: folder without rules behaves like before for all deploy paths", async () => {
|
||||
await withTestBackend(async (backend, _tempDir) => {
|
||||
const id = Date.now();
|
||||
const folderName = `dpa_noop_${id}`;
|
||||
|
||||
// Create folder with NO rules
|
||||
await createFolderWithRules(backend, folderName, []);
|
||||
await createScript(backend, `f/${folderName}/helper`);
|
||||
|
||||
// Schedule create: should use acting user (admin), not fail or behave differently
|
||||
const schedResp = await createSchedule(
|
||||
backend,
|
||||
`f/${folderName}/noop_sched`,
|
||||
`f/${folderName}/helper`
|
||||
);
|
||||
expect(schedResp.status).toBe(200);
|
||||
await schedResp.text();
|
||||
const sched = await getSchedule(backend, `f/${folderName}/noop_sched`);
|
||||
expect(sched.permissioned_as).toBe("u/admin");
|
||||
expect(sched.email).toBe("admin@windmill.dev");
|
||||
|
||||
// Schedule at a nested path: still acting user
|
||||
const schedResp2 = await createSchedule(
|
||||
backend,
|
||||
`f/${folderName}/jobs/deep/sched`,
|
||||
`f/${folderName}/helper`
|
||||
);
|
||||
expect(schedResp2.status).toBe(200);
|
||||
await schedResp2.text();
|
||||
const sched2 = await getSchedule(
|
||||
backend,
|
||||
`f/${folderName}/jobs/deep/sched`
|
||||
);
|
||||
expect(sched2.permissioned_as).toBe("u/admin");
|
||||
|
||||
// Flow create: on_behalf_of_email stays null (nothing injected)
|
||||
const flowResp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/flows/create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: `f/${folderName}/noop_flow`,
|
||||
summary: "",
|
||||
description: "",
|
||||
value: { modules: [] },
|
||||
schema: { type: "object", properties: {}, required: [] },
|
||||
}),
|
||||
}
|
||||
);
|
||||
expect(flowResp.status).toBe(201);
|
||||
await flowResp.text();
|
||||
const flow = await (
|
||||
await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/flows/get/f/${folderName}/noop_flow`
|
||||
)
|
||||
).json();
|
||||
// no folder rule, no client-sent on_behalf_of_email → should be null/undefined
|
||||
expect(flow.on_behalf_of_email == null).toBe(true);
|
||||
|
||||
// Folder response has the field but it's empty
|
||||
const folder = await getFolder(backend, folderName);
|
||||
expect(folder.default_permissioned_as).toEqual([]);
|
||||
|
||||
// Folder update without touching default_permissioned_as should leave it alone
|
||||
const updateResp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/folders/update/${folderName}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ summary: "updated summary" }),
|
||||
}
|
||||
);
|
||||
expect(updateResp.status).toBe(200);
|
||||
await updateResp.text();
|
||||
const folder2 = await getFolder(backend, folderName);
|
||||
expect(folder2.summary).toBe("updated summary");
|
||||
expect(folder2.default_permissioned_as).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Test 4: Backend validation + stale rule rejection (minimal API-level)
|
||||
// ==========================================================================
|
||||
test("validation and stale rule rejection", async () => {
|
||||
await withTestBackend(async (backend, _tempDir) => {
|
||||
const id = Date.now();
|
||||
const folderName = `dpa_val_${id}`;
|
||||
await createFolderWithRules(backend, folderName, []);
|
||||
|
||||
// Invalid glob
|
||||
const r1 = await updateFolderRules(backend, folderName, [
|
||||
{ path_glob: "[unclosed", permissioned_as: "u/admin" },
|
||||
]);
|
||||
expect(r1.status).toBe(400);
|
||||
const b1 = await r1.text();
|
||||
expect(b1).toContain("not a valid glob");
|
||||
|
||||
// Invalid permissioned_as format
|
||||
const r2 = await updateFolderRules(backend, folderName, [
|
||||
{ path_glob: "**", permissioned_as: "bogus" },
|
||||
]);
|
||||
expect(r2.status).toBe(400);
|
||||
await r2.text();
|
||||
|
||||
// Non-array
|
||||
const r3 = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/folders/update/${folderName}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ default_permissioned_as: "not-an-array" }),
|
||||
}
|
||||
);
|
||||
expect(r3.status).toBe(400);
|
||||
await r3.text();
|
||||
|
||||
// Stale rule: create helper, then set rule with ghost user, then deploy
|
||||
await createScript(backend, `f/${folderName}/helper`);
|
||||
const staleResp = await updateFolderRules(backend, folderName, [
|
||||
{ path_glob: "**", permissioned_as: "u/ghost" },
|
||||
]);
|
||||
expect(staleResp.status).toBe(200);
|
||||
await staleResp.text();
|
||||
|
||||
const schedResp = await createSchedule(
|
||||
backend,
|
||||
`f/${folderName}/should_fail`,
|
||||
`f/${folderName}/helper`
|
||||
);
|
||||
expect(schedResp.status).toBe(400);
|
||||
const body = await schedResp.text();
|
||||
expect(body).toContain("u/ghost");
|
||||
expect(body).toContain("does not exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user