Skip to content

Commit

Permalink
EVG-19151: Generate unique webhook secrets (evergreen-ci#1733)
Browse files Browse the repository at this point in the history
  • Loading branch information
minnakt authored Mar 31, 2023
1 parent 88e7350 commit a712d5b
Show file tree
Hide file tree
Showing 8 changed files with 222 additions and 139 deletions.
4 changes: 2 additions & 2 deletions src/components/Notifications/form/notification.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { SpruceFormProps } from "components/SpruceForm/types";
import { generateWebhookSecret } from "pages/projectSettings/tabs/NotificationsTab/utils";
import {
SubscriptionMethodOption,
NotificationMethods,
Expand Down Expand Up @@ -99,7 +98,6 @@ export const getNotificationSchema = (
secretInput: {
type: "string" as "string",
title: "Webhook Secret",
default: generateWebhookSecret(),
},
httpHeaders: {
type: "array" as "array",
Expand Down Expand Up @@ -187,6 +185,8 @@ export const getNotificationSchema = (
},
secretInput: {
"ui:readonly": true,
"ui:placeholder":
"The secret will be shown upon saving the subscription.",
"ui:data-cy": "secret-input",
},
httpHeaders: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getGqlPayload } from "./utils";
import { getGqlPayload } from "./getGqlPayload";
import * as utils from "./utils";

describe("getGqlPayload", () => {
it("should correctly format multiple subscriptions", () => {
Expand Down Expand Up @@ -42,8 +43,9 @@ describe("getGqlPayload", () => {
},
]);
});

it("should correctly format webhook subscription", () => {
const payload = getGqlPayload("project_id")(webhookSubscription);
const payload = getGqlPayload("project_id")(webhookSubscriptionWithSecret);
expect(payload).toStrictEqual({
id: "webhook_subscription",
owner_type: "project",
Expand All @@ -68,6 +70,38 @@ describe("getGqlPayload", () => {
});
});

it("should correctly format and generate a secret for webhook subscription", () => {
jest
.spyOn(utils, "generateWebhookSecret")
.mockImplementationOnce(() => "my_generated_secret");

const payload = getGqlPayload("project_id")(
webhookSubscriptionWithoutSecret
);
expect(payload).toStrictEqual({
id: "webhook_subscription",
owner_type: "project",
regex_selectors: [],
resource_type: "TASK",
selectors: [
{ type: "project", data: "project_id" },
{ type: "requester", data: "gitter_request" },
],
subscriber: {
target: "https://fake-website.com",
type: "evergreen-webhook",
webhookSubscriber: {
secret: "my_generated_secret",
url: "https://fake-website.com",
headers: [],
},
jiraIssueSubscriber: undefined,
},
trigger: "outcome",
trigger_data: { requester: "gitter_request" },
});
});

it("should correctly format jira issue subscription", () => {
const payload = getGqlPayload("project_id")(jiraIssueSubscription);
expect(payload).toStrictEqual({
Expand Down Expand Up @@ -155,7 +189,7 @@ const multipleSubscriptions = [
},
];

const webhookSubscription = {
const webhookSubscriptionWithSecret = {
subscriptionData: {
id: "webhook_subscription",
event: {
Expand All @@ -175,6 +209,26 @@ const webhookSubscription = {
},
};

const webhookSubscriptionWithoutSecret = {
subscriptionData: {
id: "webhook_subscription",
event: {
extraFields: {
requester: "gitter_request",
},
eventSelect: "any-task-finishes",
},
notification: {
webhookInput: {
secretInput: "",
urlInput: "https://fake-website.com",
httpHeaders: undefined,
},
notificationSelect: "evergreen-webhook",
},
},
};

const jiraIssueSubscription = {
subscriptionData: {
id: "jira_issue_subscription",
Expand Down
124 changes: 124 additions & 0 deletions src/pages/projectSettings/tabs/NotificationsTab/getGqlPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { projectTriggers, allowedSelectors } from "constants/triggers";
import { SubscriptionInput } from "gql/generated/types";
import { NotificationMethods } from "types/subscription";
import { ExtraField } from "types/triggers";
import { Unpacked } from "types/utils";
import {
FormState,
Notification,
FormExtraFields,
FormRegexSelector,
} from "./types";
import { getTargetForMethod, generateWebhookSecret } from "./utils";

// Converts the form regexSelector into the proper format for GQL payload.
// We need to check if the trigger has regex selectors because it's possible for data
// from other dependencies to persist.
const regexFormToGql = (
hasRegexSelectors: boolean,
regexForm: FormRegexSelector[]
) =>
hasRegexSelectors && regexForm
? regexForm.map((r) => ({
type: r.regexSelect,
data: r.regexInput,
}))
: [];

const webhookFormToGql = (webhookInput: Notification["webhookInput"]) => {
if (!webhookInput) {
return null;
}
return {
url: webhookInput.urlInput,
// Use existing secret if it was already generated, otherwise generate a new secret.
secret: webhookInput.secretInput || generateWebhookSecret(),
headers:
webhookInput.httpHeaders?.map(({ keyInput, valueInput }) => ({
key: keyInput,
value: valueInput,
})) ?? [],
};
};

const jiraFormToGql = (jiraInput: Notification["jiraIssueInput"]) => {
if (!jiraInput) {
return null;
}
return {
project: jiraInput.projectInput,
issueType: jiraInput.issueInput,
};
};

// Converts the form extraFields into the proper format for GQL payload.
// We need to check what extraFields exist for a particular trigger because it's possible
// for data from other dependencies to persist.
const extraFieldsFormToGql = (
extraFieldsToInclude: ExtraField[],
extraFieldsForm: FormExtraFields
) =>
(extraFieldsToInclude || []).reduce((acc, e) => {
if (extraFieldsForm[e.key]) {
acc[e.key] = extraFieldsForm[e.key].toString();
}
return acc;
}, {} as { [key: string]: string });

export const getGqlPayload =
(projectId: string) =>
(subscription: Unpacked<FormState["subscriptions"]>): SubscriptionInput => {
const { subscriptionData } = subscription;
const event = projectTriggers[subscriptionData.event.eventSelect];
const {
resourceType = "",
trigger,
extraFields,
regexSelectors,
} = event || {};

const triggerData = extraFieldsFormToGql(
extraFields,
subscriptionData.event.extraFields
);

const regexData = regexFormToGql(
!!regexSelectors,
subscriptionData.event.regexSelector
);

const method = subscriptionData.notification.notificationSelect;
const subscriber = getTargetForMethod(
method,
subscriptionData?.notification
);

const selectors = Object.entries(triggerData)
.map(([key, value]) => ({
type: key,
data: value.toString(),
}))
.filter(({ type }) => allowedSelectors.has(type));

return {
id: subscriptionData.id,
owner_type: "project",
regex_selectors: regexData,
resource_type: resourceType,
selectors: [{ type: "project", data: projectId }, ...selectors],
subscriber: {
type: method,
target: subscriber,
webhookSubscriber:
method === NotificationMethods.WEBHOOK
? webhookFormToGql(subscriptionData.notification?.webhookInput)
: undefined,
jiraIssueSubscriber:
method === NotificationMethods.JIRA_ISSUE
? jiraFormToGql(subscriptionData.notification?.jiraIssueInput)
: undefined,
},
trigger,
trigger_data: triggerData,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ describe("project data", () => {
notificationSelect: "evergreen-webhook",
webhookInput: {
urlInput: "https://example.com",
secretInput: "",
secretInput: "webhook_secret",
httpHeaders: [
{
keyInput: "Content-Type",
Expand Down Expand Up @@ -184,7 +184,7 @@ describe("project data", () => {
jiraIssueSubscriber: undefined,
webhookSubscriber: {
url: "https://example.com",
secret: "",
secret: "webhook_secret",
headers: [
{
key: "Content-Type",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { NotificationMethods } from "types/subscription";
import { TriggerType } from "types/triggers";
import { string } from "utils";
import { FormToGqlFunction, GqlToFormFunction } from "../types";
import { getGqlPayload } from "./getGqlPayload";
import { FormState } from "./types";
import { getGqlPayload } from "./utils";

type Tab = ProjectSettingsTabRoutes.Notifications;

Expand Down
Loading

0 comments on commit a712d5b

Please sign in to comment.