This commit is contained in:
Stefan Pejcic
2024-11-07 19:03:37 +01:00
parent c6df945ed5
commit 09f9f9502d
2472 changed files with 620417 additions and 0 deletions

View File

@@ -0,0 +1,445 @@
import React from "react";
import { act } from "react-dom/test-utils";
import {
mockLegacyRouterProvider,
mockRouterProvider,
render,
TestWrapper,
waitFor,
} from "@test";
import * as UseCanHook from "../../hooks/accessControl/useCan";
import { CanAccess } from ".";
describe("CanAccess Component", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should render children", async () => {
const onUnauthorized = jest.fn();
const { container, findByText } = render(
<CanAccess
action="list"
resource="posts"
onUnauthorized={(args) => onUnauthorized(args)}
>
Accessible
</CanAccess>,
{
wrapper: TestWrapper({
accessControlProvider: {
can: async ({ resource, action }) => {
if (action === "list" && resource === "posts") {
return {
can: true,
};
}
return { can: false };
},
},
}),
},
);
expect(container).toBeTruthy();
await findByText("Accessible");
await waitFor(() => {
expect(onUnauthorized).not.toHaveBeenCalled();
});
});
it("should not render children and call onUnauthorized", async () => {
const onUnauthorized = jest.fn();
const { container, queryByText } = render(
<CanAccess
action="list"
resource="posts"
onUnauthorized={(args) => onUnauthorized(args)}
>
Accessible
</CanAccess>,
{
wrapper: TestWrapper({
accessControlProvider: {
can: async () => ({
can: false,
reason: "test",
}),
},
}),
},
);
await act(async () => {
expect(container).toBeTruthy();
expect(queryByText("Accessible")).not.toBeInTheDocument();
});
await waitFor(() => {
expect(onUnauthorized).toHaveBeenCalledTimes(1);
expect(onUnauthorized).toHaveBeenCalledWith({
resource: "posts",
action: "list",
reason: "test",
params: {
id: undefined,
resource: expect.objectContaining({
name: "posts",
}),
},
});
});
});
it("should successfully pass the own attirbute to its children", async () => {
const { container, findByText } = render(
<CanAccess action="list" resource="posts" data-id="refine">
<p>Accessible</p>
</CanAccess>,
{
wrapper: TestWrapper({
accessControlProvider: {
can: async () => ({
can: true,
}),
},
}),
},
);
expect(container).toBeTruthy();
const el = await findByText("Accessible");
expect(el.closest("p")?.getAttribute("data-id"));
});
it("should fallback successfully render when not accessible", async () => {
const { container, queryByText, findByText } = render(
<CanAccess action="list" resource="posts" fallback={<p>Access Denied</p>}>
<p>Accessible</p>
</CanAccess>,
{
wrapper: TestWrapper({
accessControlProvider: {
can: async () => ({ can: false }),
},
}),
},
);
expect(container).toBeTruthy();
expect(queryByText("Accessible")).not.toBeInTheDocument();
await findByText("Access Denied");
});
describe("when no prop is passed", () => {
it("should work", async () => {
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
const { container, queryByText, findByText } = render(
<CanAccess fallback={<p>Access Denied</p>}>
<p>Accessible</p>
</CanAccess>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", list: "/posts" }],
routerProvider: mockRouterProvider({
resource: { name: "posts", list: "/posts" },
action: "list",
id: undefined,
}),
accessControlProvider: {
can: async () => {
return { can: false };
},
},
}),
},
);
expect(container).toBeTruthy();
expect(useCanSpy).toHaveBeenCalledWith(
expect.objectContaining({
resource: "posts",
action: "list",
params: {
id: undefined,
resource: expect.objectContaining({
list: "/posts",
name: "posts",
}),
},
}),
);
expect(queryByText("Accessible")).not.toBeInTheDocument();
await findByText("Access Denied");
});
test("when fallback is empty", async () => {
const { container } = render(
<CanAccess action="list" resource="posts">
Accessible
</CanAccess>,
{
wrapper: TestWrapper({
accessControlProvider: {
can: async () => {
return { can: false };
},
},
}),
},
);
expect(container.nodeValue).toStrictEqual(null);
});
describe("When props not passed", () => {
describe("When new router", () => {
describe("when resource is an object", () => {
it("should deny access", async () => {
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
const { container, queryByText, findByText } = render(
<CanAccess fallback={<p>Access Denied</p>}>
<p>Accessible</p>
</CanAccess>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", list: "/posts" }],
routerProvider: mockRouterProvider({
resource: { name: "posts", list: "/posts" },
action: "list",
id: undefined,
}),
accessControlProvider: {
can: async () => {
return { can: false };
},
},
}),
},
);
expect(container).toBeTruthy();
expect(useCanSpy).toHaveBeenCalledWith(
expect.objectContaining({
resource: "posts",
action: "list",
params: expect.objectContaining({
id: undefined,
resource: expect.objectContaining({
name: "posts",
list: "/posts",
}),
}),
}),
);
expect(queryByText("Accessible")).not.toBeInTheDocument();
await findByText("Access Denied");
});
});
describe("when resource is a string", () => {
describe("when pick resource is object", () => {
it("should deny access", async () => {
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
const { container, queryByText, findByText } = render(
<CanAccess fallback={<p>Access Denied</p>}>
<p>Accessible</p>
</CanAccess>,
{
wrapper: TestWrapper({
resources: [
{ name: "posts", list: "/posts", identifier: "posts" },
],
routerProvider: mockRouterProvider({
action: "list",
id: undefined,
resource: {
name: "posts",
list: "/posts",
identifier: "posts",
},
}),
accessControlProvider: {
can: async () => {
return { can: false };
},
},
}),
},
);
expect(container).toBeTruthy();
expect(useCanSpy).toHaveBeenCalledWith({
resource: "posts",
action: "list",
params: expect.objectContaining({
id: undefined,
resource: expect.objectContaining({
name: "posts",
list: "/posts",
}),
}),
queryOptions: undefined,
});
expect(queryByText("Accessible")).not.toBeInTheDocument();
await findByText("Access Denied");
});
});
describe("when pick resource is undefined", () => {
it("should work without resource", async () => {
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
const { container, queryByText, findByText } = render(
<CanAccess fallback={<p>Access Denied</p>}>
<p>Accessible</p>
</CanAccess>,
{
wrapper: TestWrapper({
routerProvider: mockRouterProvider({
id: undefined,
action: "list",
resource: undefined,
}),
accessControlProvider: {
can: async () => {
return { can: false };
},
},
}),
},
);
expect(container).toBeTruthy();
expect(useCanSpy).toHaveBeenCalledWith({
resource: undefined,
action: "list",
params: expect.objectContaining({
id: undefined,
resource: undefined,
}),
queryOptions: undefined,
});
expect(queryByText("Accessible")).not.toBeInTheDocument();
await findByText("Access Denied");
});
});
});
});
describe("when legacy router", () => {
it("should deny access", async () => {
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
const { container, queryByText, findByText } = render(
<CanAccess fallback={<p>Access Denied</p>}>
<p>Accessible</p>
</CanAccess>,
{
wrapper: TestWrapper({
legacyRouterProvider: {
...mockLegacyRouterProvider(),
useParams: () =>
({
resource: "posts",
id: undefined,
action: "list",
}) as any,
},
accessControlProvider: {
can: async () => {
return { can: false };
},
},
}),
},
);
expect(container).toBeTruthy();
expect(useCanSpy).toHaveBeenCalledWith({
resource: "posts",
action: "list",
params: expect.objectContaining({
id: undefined,
resource: expect.objectContaining({
name: "posts",
}),
}),
queryOptions: undefined,
});
expect(queryByText("Accessible")).not.toBeInTheDocument();
await findByText("Access Denied");
});
});
});
});
it("should respect queryOptions from component prop", async () => {
const onUnauthorized = jest.fn();
const { container, queryByText } = render(
<CanAccess
action="list"
resource="posts"
queryOptions={{ cacheTime: 10000 }}
onUnauthorized={(args) => onUnauthorized(args)}
>
Accessible
</CanAccess>,
{
wrapper: TestWrapper({
accessControlProvider: {
can: async () => ({
can: true,
}),
},
}),
},
);
expect(container).toBeTruthy();
await waitFor(() => {
expect(queryByText("Accessible")).toBeInTheDocument();
});
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
await waitFor(() => {
expect(useCanSpy).toHaveBeenCalledWith(
expect.objectContaining({
queryOptions: expect.objectContaining({
cacheTime: 10000,
}),
}),
);
});
});
});

View File

@@ -0,0 +1,118 @@
import React, { useEffect } from "react";
import type { UseQueryOptions } from "@tanstack/react-query";
import { useCan, useResourceParams } from "@hooks";
import type { CanReturnType } from "../../contexts/accessControl/types";
import type { BaseKey } from "../../contexts/data/types";
import type { IResourceItem, ITreeMenu } from "../../contexts/resource/types";
type CanParams = {
resource?: IResourceItem & { children?: ITreeMenu[] };
id?: BaseKey;
[key: string]: any;
};
type OnUnauthorizedProps = {
resource?: string;
reason?: string;
action: string;
params: CanParams;
};
type CanAccessBaseProps = {
/**
* Resource name for API data interactions
*/
resource?: string;
/**
* Intended action on resource
*/
action: string;
/**
* Parameters associated with the resource
* @type { resource?: [IResourceItem](https://refine.dev/docs/api-reference/core/interfaceReferences/#canparams), id?: [BaseKey](https://refine.dev/docs/api-reference/core/interfaceReferences/#basekey), [key: string]: any }
*/
params?: CanParams;
/**
* Content to show if access control returns `false`
*/
fallback?: React.ReactNode;
/**
* Callback function to be called if access control returns `can: false`
*/
onUnauthorized?: (props: OnUnauthorizedProps) => void;
children: React.ReactNode;
queryOptions?: UseQueryOptions<CanReturnType>;
};
type CanAccessWithoutParamsProps = {
[key in Exclude<
keyof CanAccessBaseProps,
"fallback" | "children"
>]?: undefined;
} & {
[key in "fallback" | "children"]?: CanAccessBaseProps[key];
};
export type CanAccessProps = CanAccessBaseProps | CanAccessWithoutParamsProps;
export const CanAccess: React.FC<CanAccessProps> = ({
resource: resourceFromProp,
action: actionFromProp,
params: paramsFromProp,
fallback,
onUnauthorized,
children,
queryOptions: componentQueryOptions,
...rest
}) => {
const {
id,
resource,
action: fallbackAction = "",
} = useResourceParams({
resource: resourceFromProp,
id: paramsFromProp?.id,
});
const action = actionFromProp ?? fallbackAction;
const params = paramsFromProp ?? {
id,
resource,
};
const { data } = useCan({
resource: resource?.name,
action,
params,
queryOptions: componentQueryOptions,
});
useEffect(() => {
if (onUnauthorized && data?.can === false) {
onUnauthorized({
resource: resource?.name,
action,
reason: data?.reason,
params,
});
}
}, [data?.can]);
if (data?.can) {
if (React.isValidElement(children)) {
const Children = React.cloneElement(children, rest);
return Children;
}
return <>{children}</>;
}
if (data?.can === false) {
return <>{fallback ?? null}</>;
}
return null;
};