Skip to content

Commit

Permalink
feat(apw): only creator can edit and delete change requests (#3543)
Browse files Browse the repository at this point in the history
  • Loading branch information
mihajlovco committed Oct 23, 2023
1 parent 381f2a1 commit 8ccbf7e
Show file tree
Hide file tree
Showing 6 changed files with 258 additions and 55 deletions.
6 changes: 3 additions & 3 deletions packages/api-apw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ To run tests api-apw tests with targeted storage operations loaded use:
#### DynamoDB

```
yarn test packages/api-apw --keyword=apw:ddb --keyword=apw:base
yarn test packages/api-apw --storage:ddb --keyword=apw:ddb --keyword=apw:base
```

#### Note

> All the tests in `@webiny/api-apw` package are being tested against ddb-only storage operations because
current jest setup doesn't allow usage of more than one storage operations at a time with the help of --keyword flag.
We should revisit these tests once we have the ability to load multiple storage operations in the jest setup.
> current jest setup doesn't allow usage of more than one storage operations at a time with the help of --keyword flag.
> We should revisit these tests once we have the ability to load multiple storage operations in the jest setup.
130 changes: 126 additions & 4 deletions packages/api-apw/__tests__/graphql/access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ import { useGraphQlHandler } from "~tests/utils/useGraphQlHandler";
const model = accessTestModel.contentModel as CmsModel;

describe("access", () => {
const workflowIdentity = {
id: "90962378",
type: "admin",
displayName: "Workflow Name",
email: "worflow-mock@webiny.local"
};

const gqlHandler = useGraphQlHandler({
path: "/graphql",
plugins: [accessTestGroup, accessTestModel],
storageOperationPlugins: []
});

const {
securityIdentity,
reviewer: reviewerGQL,
Expand All @@ -29,10 +42,7 @@ describe("access", () => {
createChangeRequestMutation,
updateChangeRequestMutation,
deleteChangeRequestMutation
} = useGraphQlHandler({
path: "/graphql",
plugins: [accessTestGroup, accessTestModel]
});
} = gqlHandler;

const {
// content entry
Expand Down Expand Up @@ -232,6 +242,7 @@ describe("access", () => {
}
}
});

const changeRequestId = createChangeRequestResponse.data.apw.createChangeRequest.data.id;

const [updateChangeRequestResponse] = await updateChangeRequestMutation({
Expand Down Expand Up @@ -292,6 +303,117 @@ describe("access", () => {
});
});

it("should not able to update the change request, when user is not the owner", async () => {
const createContentReviewResponse = await setupContentReview();
const contentReview = createContentReviewResponse.data.apw.createContentReview.data;
const changeRequestStepId = `${contentReview.id}#${contentReview.steps[0].id}`;

const [createChangeRequestResponse] = await createChangeRequestMutation({
data: {
step: changeRequestStepId,
title: `Requesting change on "${entryTitle}"`,
body: [
{
type: "h1",
text: "Really important!"
}
],
resolved: false,
media: {
src: "cloudfront.net/my-file"
}
}
});

/**
* Login another user, that is not creator of the change request.
*/
const notChangeRequestCreatorHandler = useGraphQlHandler({
path: "/graphql",
identity: workflowIdentity
});
await notChangeRequestCreatorHandler.securityIdentity.login();

/**
* Try to update the same change request with other user
*/
const changeRequestId = createChangeRequestResponse.data.apw.createChangeRequest.data.id;
const [updateChangeRequestResponse] =
await notChangeRequestCreatorHandler.updateChangeRequestMutation({
id: changeRequestId,
data: {
title: `Requesting change on "${entryTitle}" - updated`,
body: [
{
type: "h1",
text: "Really important! - updated"
}
],
resolved: false,
media: {
src: "cloudfront.net/my-file-updated"
}
}
});

expect(updateChangeRequestResponse.data?.apw?.updateChangeRequest).toMatchObject({
data: null,
error: {
message: "A change request can only be updated by its creator.",
code: "ONLY_CREATOR_CAN_UPDATE_CHANGE_REQUEST"
}
});
});

it("should not able to delete the change request, when user is not the owner", async () => {
const createContentReviewResponse = await setupContentReview();
const contentReview = createContentReviewResponse.data.apw.createContentReview.data;
const changeRequestStepId = `${contentReview.id}#${contentReview.steps[0].id}`;

const [createChangeRequestResponse] = await createChangeRequestMutation({
data: {
step: changeRequestStepId,
title: `Requesting change on "${entryTitle}"`,
body: [
{
type: "h1",
text: "Really important!"
}
],
resolved: false,
media: {
src: "cloudfront.net/my-file"
}
}
});

/**
* Login another user, that is not creator of the change request.
*/
const notChangeRequestCreatorHandler = useGraphQlHandler({
path: "/graphql",
identity: workflowIdentity
});
await notChangeRequestCreatorHandler.securityIdentity.login();

/**
* Try to delete the same change request with other user
*/
const changeRequestId = createChangeRequestResponse.data.apw.createChangeRequest.data.id;
const [deleteChangeRequestResponse] =
await notChangeRequestCreatorHandler.deleteChangeRequestMutation({
id: changeRequestId
});

expect(deleteChangeRequestResponse.data?.apw?.deleteChangeRequest).toMatchObject({
data: null,
error: {
message: "A change request can only be deleted by its creator.",
code: "ONLY_CREATOR_CAN_DELETE_CHANGE_REQUEST"
}
});
});

it("should create comment, update it and delete it", async () => {
const createContentReviewResponse = await setupContentReview();
const contentReview = createContentReviewResponse.data.apw.createContentReview.data;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,23 @@ export const createChangeRequestStorageOperations = (
},
async updateChangeRequest(params) {
const model = await getChangeRequestModel();

/**
* We're fetching the existing entry here because we're not accepting "app" field as input,
* but, we still need to retain its value after the "update" operation.
*/
const existingEntry = await getChangeRequest({ id: params.id });

/**
* Only creator can update the change request
*/
if (existingEntry.createdBy.id !== security.getIdentity().id) {
throw new WebinyError(
"A change request can only be updated by its creator.",
"ONLY_CREATOR_CAN_UPDATE_CHANGE_REQUEST"
);
}

const entry = await security.withoutAuthorization(async () => {
return cms.updateEntry(model, params.id, {
...existingEntry,
Expand All @@ -102,9 +113,29 @@ export const createChangeRequestStorageOperations = (
async deleteChangeRequest(params) {
const model = await getChangeRequestModel();

if (!security.getIdentity()) {
return true;
}

/**
* We're fetching the existing entry
*/
const existingEntry = await getChangeRequest({ id: params.id });

/**
* Only creator can delete the change request
*/
if (existingEntry.createdBy.id !== security.getIdentity().id) {
throw new WebinyError(
"A change request can only be deleted by its creator.",
"ONLY_CREATOR_CAN_DELETE_CHANGE_REQUEST"
);
}

await security.withoutAuthorization(async () => {
return cms.deleteEntry(model, params.id);
});

return true;
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import styled from "@emotion/styled";
import React from "react";
import { cx, css } from "emotion";
import { css, cx } from "emotion";
import { ButtonDefault, ButtonIcon } from "@webiny/ui/Button";
import { RichTextEditor } from "@webiny/ui/RichTextEditor";
import { Box, Columns, Stack } from "~/components/Layout";
import { ReactComponent as EditIcon } from "~/assets/icons/edit_24dp.svg";
import { ReactComponent as DeleteIcon } from "~/assets/icons/delete_24dp.svg";
import { ReactComponent as MarkTaskIcon } from "~/assets/icons/task_alt_24dp.svg";
import { useChangeRequest } from "~/hooks/useChangeRequest";
import { useConfirmationDialog } from "@webiny/app-admin";
import { useConfirmationDialog, useDialog } from "@webiny/app-admin";
import { i18n } from "@webiny/app/i18n";
import { DefaultRenderImagePreview } from "./ApwFile";
import { useChangeRequestDialog } from "./useChangeRequestDialog";
import { richTextWrapperStyles, TypographyBody, TypographyTitle } from "../Styled";
import { FileWithOverlay, Media } from "./ChangeRequestMedia";
import { CircularProgress } from "@webiny/ui/Progress";
import { useSecurity } from "@webiny/app-security";

const t = i18n.ns("app-apw/content-reviews/editor/steps/changeRequest");

Expand Down Expand Up @@ -80,7 +81,9 @@ export const ChangeRequest: React.FC<ChangeRequestProps> = props => {
const { id } = props;
const { deleteChangeRequest, changeRequest, markResolved, loading } = useChangeRequest({ id });
const { setOpen, setChangeRequestId } = useChangeRequestDialog();
const { identity } = useSecurity();

const { showDialog } = useDialog();
const { showConfirmation } = useConfirmationDialog({
title: t`Delete change request`,
message: (
Expand All @@ -99,6 +102,10 @@ export const ChangeRequest: React.FC<ChangeRequestProps> = props => {
await markResolved(!resolved);
};

const canEditChangeRequest = (): boolean => {
return changeRequest.createdBy.id === identity?.id;
};

if (!changeRequest) {
return null;
}
Expand Down Expand Up @@ -134,6 +141,15 @@ export const ChangeRequest: React.FC<ChangeRequestProps> = props => {
<ButtonBox paddingY={1}>
<DefaultButton
onClick={() => {
if (!canEditChangeRequest()) {
showDialog(
t`A change request can only be edited by its creator.`,
{
title: t`Edit change request`
}
);
return;
}
setOpen(true);
setChangeRequestId(id);
}}
Expand All @@ -144,11 +160,20 @@ export const ChangeRequest: React.FC<ChangeRequestProps> = props => {
</ButtonBox>
<ButtonBox paddingY={1} border={true}>
<DefaultButton
onClick={() =>
onClick={() => {
if (!canEditChangeRequest()) {
showDialog(
t`A change request can only be deleted by its creator.`,
{
title: t`Delete change request`
}
);
return;
}
showConfirmation(async () => {
await deleteChangeRequest(id);
})
}
});
}}
>
<ButtonIcon icon={<DeleteIcon />} />
Delete
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Box, Columns, Stack } from "~/components/Layout";
import { fromNow } from "~/utils";
import { Avatar } from "~/views/publishingWorkflows/components/ReviewersList";
import { useCommentsList } from "~/hooks/useCommentsList";
import { TypographyBody, TypographySecondary, AuthorName, richTextWrapperStyles } from "../Styled";
import { AuthorName, richTextWrapperStyles, TypographyBody, TypographySecondary } from "../Styled";
import { CommentFile } from "../ChangeRequest/ApwFile";
import { FileWithOverlay } from "../ChangeRequest/ChangeRequestMedia";

Expand Down

0 comments on commit 8ccbf7e

Please sign in to comment.