Files
windmill/cli/test/precheck_permissioned_as_apps_unit.test.ts
T
Ruben FiszelandClaude Opus 5 0af7675588 fix: keep an app's deployed policy on wmill push (#11049)
* fix: keep an app's deployed policy on wmill push

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* fix: keep a first push's file-stated app policy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* fix: deploy a file-stated viewer app as viewer, not publisher

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* fix: never put a repo-stated run identity on the wire

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* fix: send the deployed run identity only when the push may claim it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* fix: let a file-stated execution mode win in both directions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* refactor: drop the app-file execution mode helper with no caller

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* feat: warn before a raw-app push takes over the run-as user

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* fix: keep an app's run-as user when a push only deletes one of its files

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* refactor: settle an app's ownership check before any content parsing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* fix: share one rule for which raw-app files a push sends

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* fix: stop tracking raw-app files no push ever sends

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* test: bundle for real instead of stubbing the module for every suite

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* docs: record why a module mock cannot be undone by afterAll

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* fix: keep tracking a runnable whose file shares a bundle-excluded name

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

* test: pin both halves of the backend runnable rule

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X47gNFqf1SsA67zTm71f8c

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:19:02 +02:00

148 lines
5.8 KiB
TypeScript

/**
* The pre-check is what stops a push from silently reassigning an item's run-as
* user. Raw apps were missing from it, so the one kind whose file records no
* policy at all was also the one that changed owner without a word.
*/
import { expect, test } from "bun:test";
import { preCheckPermissionedAs } from "../src/core/permissioned_as.ts";
/** Non-interactive and without the override flag, the pre-check exits rather
* than reassigning silently — so a thrown exit is the signal it fired. */
type Shape = "edited" | "added" | "deleted";
function change(path: string, name: Shape = "edited") {
return { name, path, before: "summary: x\n", content: "summary: x\n" };
}
async function precheck(
changes: ReturnType<typeof change>[],
): Promise<string | undefined> {
const exit = process.exit;
let code: number | undefined;
(process as any).exit = (c?: number) => {
code = c;
throw new Error(`exit:${c}`);
};
const logged: string[] = [];
const err = console.error;
console.error = (...a: unknown[]) => void logged.push(a.join(" "));
try {
await preCheckPermissionedAs(changes, "pusher@corp", false, false, false);
} catch (e) {
if (!String(e).startsWith("Error: exit:")) throw e;
} finally {
(process as any).exit = exit;
console.error = err;
}
return code === undefined ? undefined : logged.join("\n");
}
test("a raw-app push warns the non-deployer it will take over the run-as user", async () => {
const message = await precheck([change("f/test/myapp.raw_app/index.tsx")]);
expect(message).toBeDefined();
expect(message).toContain("f/test/myapp.raw_app");
expect(message).toContain("pusher@corp");
});
// Deleting one file re-pushes the whole app rather than deleting it, so the
// takeover happens there too.
test("deleting one of an app's files warns like editing one", async () => {
const message = await precheck([
change("f/test/myapp.raw_app/gone.tsx", "deleted"),
]);
expect(message).toContain("f/test/myapp.raw_app");
});
// The metadata file going with it means the app itself is created or removed —
// neither takes an owner over.
test("an app arriving or leaving whole is not a takeover", async () => {
const created = await precheck([
change("f/test/new.raw_app/raw_app.yaml", "added"),
change("f/test/new.raw_app/index.tsx", "added"),
]);
const removed = await precheck([
change("f/test/old.raw_app/raw_app.yaml", "deleted"),
change("f/test/old.raw_app/index.tsx", "deleted"),
]);
expect(created).toBeUndefined();
expect(removed).toBeUndefined();
});
// An app carries no owner in its files, so nothing about it depends on their
// content — an empty one redeploys it exactly like any other.
test("an empty file still counts as a change to the app", async () => {
const added = await precheck([
{ name: "added", path: "f/test/myapp.raw_app/blank.ts", content: "" },
]);
const edited = await precheck([
{ name: "edited", path: "f/test/myapp.raw_app/blank.ts", before: "" },
]);
expect(added).toContain("f/test/myapp.raw_app");
expect(edited).toContain("f/test/myapp.raw_app");
});
// `extractFolderPath` normalizes separators but the metadata predicates match a
// literal `/`, so a Windows path must not take a different branch.
test("a Windows path classifies the same as its posix twin", async () => {
const created = await precheck([
change("f\\test\\new.raw_app\\raw_app.yaml", "added"),
change("f\\test\\new.raw_app\\index.tsx", "added"),
]);
const edited = await precheck([
change("f\\test\\myapp.raw_app\\index.tsx"),
]);
expect(created).toBeUndefined();
expect(edited).toContain("f/test/myapp.raw_app");
});
// `collectAppFiles` never sends these, and the sync diff never stops listing
// them (nothing uploads them, so they stay "added" forever) — so warning on one
// would gate every push of a scaffolded app on the override flag.
test("a file the push never sends is not a change to the app", async () => {
const artifacts = await precheck([
change("f/test/myapp.raw_app/AGENTS.md", "added"),
change("f/test/myapp.raw_app/sql_to_apply/a.sql", "added"),
change("f/test/myapp.raw_app/node_modules/dep/index.js", "added"),
change("f/test/myapp.raw_app/recordings/r.json", "added"),
change("f/test/myapp.raw_app/package-lock.json"),
change("f/test/myapp.raw_app/wmill.d.ts"),
// Only the backend folder's *top level* is a runnable; nothing reads deeper,
// so the depth limit is what keeps a `backend/node_modules/` from becoming
// the perpetual diff this predicate exists to remove.
change("f/test/myapp.raw_app/backend/node_modules/dep/index.js", "added"),
]);
// The three channels a push does send through: bundled file, metadata, runnable.
const sent = await precheck([change("f/test/myapp.raw_app/index.tsx")]);
const meta = await precheck([change("f/test/myapp.raw_app/raw_app.yaml")]);
const runnable = await precheck([change("f/test/myapp.raw_app/backend/a.ts")]);
// The runnable channel is not the bundle: the bundle's name exclusions don't
// reach into it, so a runnable file sharing one of those names still deploys.
const namesake = await precheck([
change("f/test/myapp.raw_app/backend/wmill.d.ts"),
]);
expect(artifacts).toBeUndefined();
expect(sent).toContain("f/test/myapp.raw_app");
expect(meta).toContain("f/test/myapp.raw_app");
expect(runnable).toContain("f/test/myapp.raw_app");
expect(namesake).toContain("f/test/myapp.raw_app");
});
test("an app is listed once however many of its files changed", async () => {
const message = await precheck([
change("f/test/myapp.raw_app/index.tsx"),
change("f/test/myapp.raw_app/raw_app.yaml"),
change("f/test/myapp.raw_app/backend/a.ts"),
change("f/test/low.app/app.yaml"),
change("f/test/low.app/inline.ts"),
]);
expect(message).toContain("2 item(s)");
});