Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions web/src/__fixtures__/dummy-project.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
Project,
ProjectRBACConfig,
ProjectRBACPolicy,
ProjectRBACResource,
ProjectRBACRole,
ProjectStaticUser,
} from "pipecd/web/model/project_pb";
import {
Expand All @@ -12,6 +15,22 @@ import {

const [createdAt, updatedAt] = createRandTimes(2);

export const dummyRole: ProjectRBACRole.AsObject = {
name: "dummy-role",
policiesList: [
{
resourcesList: [
{
labelsMap: [["pipecd.dev/project", "dummy-project"]],
type: ProjectRBACResource.ResourceType.ALL,
},
],
actionsList: [ProjectRBACPolicy.Action.ALL],
},
],
isBuiltin: false,
};

export const dummyProject: Project.AsObject = {
id: randomUUID(),
desc: randomWords(8),
Expand Down
9 changes: 5 additions & 4 deletions web/src/components/settings-page/piped/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { FilterValues, PipedFilter } from "./components/piped-filter";
import { PipedTableRow } from "./components/piped-table-row";
import { UpgradePipedDialog } from "./components/upgrade-dialog";
import { TableCellNoWrap } from "../styles";
import { useGetProject } from "~/queries/project/use-get-project";

const filterValue = (
_: AppState,
Expand Down Expand Up @@ -87,7 +88,7 @@ export const SettingsPipedPage: FC = memo(function SettingsPipedPage() {
enabled: true,
});
const dispatch = useAppDispatch();
const projectId = useAppSelector((state) => state.project.id);
const { data: projectDetail } = useGetProject();
const pipeds = useAppSelector<Piped.AsObject[]>((state) =>
selectFilteredPipeds(state, filterValues)
);
Expand All @@ -97,10 +98,10 @@ export const SettingsPipedPage: FC = memo(function SettingsPipedPage() {
}, [dispatch]);

useEffect(() => {
if (projectId) {
dispatch(fetchBreakingChanges({ projectId: projectId }));
if (projectDetail?.id) {
dispatch(fetchBreakingChanges({ projectId: projectDetail.id }));
}
}, [dispatch, projectId]);
}, [dispatch, projectDetail?.id]);

const releasedVersions = useAppSelector<string[]>(
(state) => state.pipeds.releasedVersions
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { screen, render } from "~~/test-utils";
import { GithubSSOForm } from "./index";
import { server } from "~/mocks/server";

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe("GithubSSOForm", () => {
it("renders without crashing", () => {
render(<GithubSSOForm />);
// Check for a heading, label, or unique element in the form
// Adjust the text below to match your component's actual content
expect(
screen.getByRole("heading", { name: /Single Sign-On/i })
).toBeInTheDocument();
expect(
screen.getByText(
"Single sign-on (SSO) allows users to log in to PipeCD by relying on a trusted third party service. Currently, only GitHub is supported."
)
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
Box,
Button,
Dialog,
DialogActions,
Expand All @@ -16,9 +17,6 @@ import { FC, memo, useState } from "react";
import { SSO_DESCRIPTION } from "~/constants/text";
import { UPDATE_SSO_SUCCESS } from "~/constants/toast-text";
import { UI_TEXT_CANCEL, UI_TEXT_SAVE } from "~/constants/ui-text";
import { useAppDispatch, useAppSelector } from "~/hooks/redux";
import { fetchProject, GitHubSSO, updateGitHubSSO } from "~/modules/project";
import { addToast } from "~/modules/toasts";
import {
ProjectDescription,
ProjectTitleWrap,
Expand All @@ -27,6 +25,9 @@ import {
ProjectValuesWrapper,
} from "~/styles/project-setting";
import { ProjectSettingLabeledText } from "../project-setting-labeled-text";
import { useUpdateGithubSso } from "~/queries/project/use-update-github-sso";
import { useToast } from "~/contexts/toast-context";
import { useGetProject } from "~/queries/project/use-get-project";
export interface GitHubSSOFormParams {
clientId: string;
clientSecret: string;
Expand All @@ -42,34 +43,28 @@ const SECTION_TITLE = "Single Sign-On";
const DIALOG_TITLE = `Edit ${SECTION_TITLE}`;

export const GithubSSOForm: FC = memo(function GithubSSOForm() {
const dispatch = useAppDispatch();
const [isEdit, setIsEdit] = useState(false);
const [clientId, setClientID] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [uploadUrl, setUploadUrl] = useState("");
const [sso, sharedSSO] = useAppSelector<
[GitHubSSO | null | undefined, string | null | undefined]
>((state) => [state.project.github, state.project.sharedSSO]);

const { data: projectDetail } = useGetProject();
const { github: sso, sharedSSO: sharedSSO } = projectDetail || {};

const { mutateAsync: updateGithubSso } = useUpdateGithubSso();
const { addToast } = useToast();
const handleClose = (): void => {
setIsEdit(false);
};

const handleSave = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault();
dispatch(
updateGitHubSSO({ clientId, clientSecret, baseUrl, uploadUrl })
).then((result) => {
if (updateGitHubSSO.fulfilled.match(result)) {
dispatch(fetchProject());
dispatch(
addToast({
message: UPDATE_SSO_SUCCESS,
severity: "success",
})
);
}
updateGithubSso({ clientId, clientSecret, baseUrl, uploadUrl }).then(() => {
addToast({
message: UPDATE_SSO_SUCCESS,
severity: "success",
});
});
setIsEdit(false);
};
Expand Down Expand Up @@ -128,51 +123,65 @@ export const GithubSSOForm: FC = memo(function GithubSSOForm() {
<Dialog
open={isEdit}
onClose={handleClose}
TransitionProps={{
onEnter: () => {
setBaseUrl(sso?.baseUrl ?? "");
setUploadUrl(sso?.uploadUrl ?? "");
slotProps={{
transition: {
onEnter: () => {
setBaseUrl(sso?.baseUrl ?? "");
setUploadUrl(sso?.uploadUrl ?? "");
},
},
}}
>
<form onSubmit={handleSave}>
<DialogTitle>{DIALOG_TITLE}</DialogTitle>
<DialogContent>
<TextField
value={clientId}
variant="outlined"
size="small"
label="Client ID"
fullWidth
required
autoFocus
onChange={(e) => setClientID(e.currentTarget.value)}
/>
<TextField
value={clientSecret}
variant="outlined"
size="small"
label="Client Secret"
fullWidth
required
onChange={(e) => setClientSecret(e.currentTarget.value)}
/>
<TextField
value={baseUrl}
variant="outlined"
size="small"
label="Base URL"
fullWidth
onChange={(e) => setBaseUrl(e.currentTarget.value)}
/>
<TextField
value={uploadUrl}
variant="outlined"
size="small"
label="Upload URL"
fullWidth
onChange={(e) => setUploadUrl(e.currentTarget.value)}
/>
<Box
sx={{
display: "grid",
gap: 2,
py: 2,
pt: 1,
minWidth: 300,
maxWidth: "100%",
width: "100%",
}}
>
<TextField
value={clientId}
variant="outlined"
size="small"
label="Client ID"
fullWidth
required
autoFocus
onChange={(e) => setClientID(e.currentTarget.value)}
/>
<TextField
value={clientSecret}
variant="outlined"
size="small"
label="Client Secret"
fullWidth
required
onChange={(e) => setClientSecret(e.currentTarget.value)}
/>
<TextField
value={baseUrl}
variant="outlined"
size="small"
label="Base URL"
fullWidth
onChange={(e) => setBaseUrl(e.currentTarget.value)}
/>
<TextField
value={uploadUrl}
variant="outlined"
size="small"
label="Upload URL"
fullWidth
onChange={(e) => setUploadUrl(e.currentTarget.value)}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>{UI_TEXT_CANCEL}</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { render, screen } from "@testing-library/react";
import { ProjectSettingLabeledText } from "./index";

describe("ProjectSettingLabeledText", () => {
it("renders the label and value", () => {
render(<ProjectSettingLabeledText label="Client ID" value="123456" />);
expect(screen.getByText("Client ID")).toBeInTheDocument();
expect(screen.getByText("123456")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { render, screen, waitFor } from "~~/test-utils";
import userEvent from "@testing-library/user-event";
import { AddRoleDialog } from ".";

describe("AddRoleDialog", () => {
const defaultProps = {
open: true,
onClose: jest.fn(),
onSubmit: jest.fn(),
};

it("renders dialog title and fields", async () => {
render(<AddRoleDialog {...defaultProps} />);
await waitFor(() => {
screen.findByRole("dialog");
});
expect(screen.getByText("Add Role")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: /role/i })).toBeInTheDocument();
expect(
screen.getByRole("textbox", { name: /policies/i })
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument();
});

it("calls onClose when Close button is clicked", async () => {
render(<AddRoleDialog {...defaultProps} />);
await waitFor(() => {
screen.findByRole("dialog");
});
userEvent.click(screen.getByRole("button", { name: /close/i }));
expect(defaultProps.onClose).toHaveBeenCalled();
});

it("calls onSubmit with correct values", async () => {
render(<AddRoleDialog {...defaultProps} />);

const textInput = screen.getByRole("textbox", { name: /role/i });
const policiesInput = screen.getByRole("textbox", { name: /policies/i });

userEvent.type(textInput, "dev-team");
userEvent.type(policiesInput, "resources=application;actions=get");

await waitFor(() => {
expect(screen.getByRole("button", { name: /add/i })).toBeEnabled();
});

userEvent.click(screen.getByRole("button", { name: /add/i }));

await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith({
name: "dev-team",
policies: "resources=application;actions=get",
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,20 @@ import {
} from "@mui/material";
import { useFormik } from "formik";
import { FC } from "react";
import { rbacResourceTypes, rbacActionTypes } from "~/modules/project";
import * as yup from "yup";
import { POLICIES_STRING_REGEX } from "~/constants/project";

export interface AddRoleDialogProps {
open: boolean;
onClose: () => void;
onSubmit: (values: { name: string; policies: string }) => void;
}

// resources=(\*|application|deployment|event|piped|deploymentChain|project|apiKey|insight|,)+;\s*actions=(\*|get|list|create|update|delete|,)+
const validationRgex = new RegExp(
"resources=(" +
rbacResourceTypes()
.map((v) => v.replace(/\*/, "\\*"))
.join("|") +
"|,)+;\\s*actions=(" +
rbacActionTypes()
.map((v) => v.replace(/\*/, "\\*"))
.join("|") +
"|,)+"
);

const validationSchema = yup.object({
name: yup.string().min(1).required(),
policies: yup
.string()
.matches(validationRgex, "Invalid policy format")
.matches(POLICIES_STRING_REGEX, "Invalid policy format")
.required(),
});

Expand Down
Loading