deer-flow/frontend/tests/unit/core/auth/permissions.test.ts
hataa 71087f2f8e
feat(authz): gate composer send on runs:create (Phase 4 follow-up, #4063) (#5528)
* feat(authz): gate composer send on runs:create (Phase 4 follow-up, #4063)

Mirrors #5294's stop gating on the send side: both chat routes pass
canCreateRuns (from PERMISSIONS.RUNS_CREATE, lockstep with the backend
enum) into the shared composer. The gate sits at the top of
submitThreadMessage — the single choke point every composer entry
(submit button, Enter, goal-set-triggered run) funnels through — and
denies with a toast plus a rejected promise so PromptInput keeps the
text. The idle submit button is disabled and explains the boundary via
conditionally-spread aria-label/title (startTurnUnavailable, en/zh);
while streaming the button stays the runs:cancel stop affordance.

Also removes the unreachable kind === "stop" branch in handleSubmit
(flagged during #5294's review): the Enter path early-returns with the
streaming toast before the classifier runs.

* fix(frontend): reject denied goal starts before saving state

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-20 17:45:03 +08:00

81 lines
2.6 KiB
TypeScript

import { describe, expect, it } from "@rstest/core";
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
import { userSchema } from "@/core/auth/types";
describe("hasPermission", () => {
it("grants a permission present in the resolved list", () => {
expect(
hasPermission(
{ permissions: ["threads:read", "threads:delete"] },
PERMISSIONS.THREADS_DELETE,
),
).toBe(true);
});
it("denies a permission absent from the resolved list", () => {
expect(
hasPermission(
{ permissions: ["threads:read"] },
PERMISSIONS.THREADS_DELETE,
),
).toBe(false);
});
it("treats an absent permissions field as permissive (pre-Phase-4 backend)", () => {
// A mixed old-backend/new-frontend deploy must not hide actions the
// caller can still perform — the Gateway route guards stay the
// enforcement point, the UI field is advisory only.
expect(hasPermission({}, PERMISSIONS.RUNS_CANCEL)).toBe(true);
});
it("treats a null permissions field as permissive", () => {
// Credential-creation responses (register/initialize) serialize null;
// they never advertise an empty grant set.
expect(hasPermission({ permissions: null }, PERMISSIONS.RUNS_CANCEL)).toBe(
true,
);
});
it("treats a not-yet-loaded user as permissive", () => {
expect(hasPermission(null, PERMISSIONS.THREADS_DELETE)).toBe(true);
expect(hasPermission(undefined, PERMISSIONS.THREADS_DELETE)).toBe(true);
});
it("maps the runs:create composer gate onto the /me permission list", () => {
expect(PERMISSIONS.RUNS_CREATE).toBe("runs:create");
expect(
hasPermission({ permissions: ["runs:create"] }, PERMISSIONS.RUNS_CREATE),
).toBe(true);
expect(
hasPermission({ permissions: ["runs:read"] }, PERMISSIONS.RUNS_CREATE),
).toBe(false);
});
});
describe("userSchema permissions field", () => {
const baseUser = {
id: "user-1",
email: "user@example.test",
system_role: "user" as const,
};
it("parses a /me payload that carries effective permissions", () => {
const parsed = userSchema.parse({
...baseUser,
permissions: ["threads:read", "runs:cancel"],
});
expect(parsed.permissions).toEqual(["threads:read", "runs:cancel"]);
});
it("parses a /me payload from a pre-Phase-4 backend (field absent)", () => {
const parsed = userSchema.parse(baseUser);
expect(parsed.permissions).toBeUndefined();
});
it("parses a credential-creation payload that carries null", () => {
const parsed = userSchema.parse({ ...baseUser, permissions: null });
expect(parsed.permissions).toBeNull();
});
});