diff --git a/ui/package.json b/ui/package.json index a78b13e76..f64c53f1e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,6 +19,7 @@ "favico.js": "0.3.10", "fontfaceobserver": "2.1.0", "lodash.debounce": "4.0.8", + "lodash.merge": "4.6.2", "lodash.throttle": "4.1.1", "lodash.uniqueid": "4.0.1", "mobx": "5.13.0", diff --git a/ui/src/Common/Fetch.js b/ui/src/Common/Fetch.js new file mode 100644 index 000000000..15a1e2e11 --- /dev/null +++ b/ui/src/Common/Fetch.js @@ -0,0 +1,6 @@ +import merge from "lodash.merge"; + +const FetchWithCredentials = async (uri, options) => + await fetch(uri, merge({}, { credentials: "include" }, options)); + +export { FetchWithCredentials }; diff --git a/ui/src/Common/Fetch.test.js b/ui/src/Common/Fetch.test.js new file mode 100644 index 000000000..14483d44c --- /dev/null +++ b/ui/src/Common/Fetch.test.js @@ -0,0 +1,40 @@ +import { FetchWithCredentials } from "./Fetch"; + +beforeEach(() => { + fetch.resetMocks(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("FetchWithCredentials", () => { + it("fetch passes '{credentials: include}' to all requests", async () => { + const request = FetchWithCredentials("http://example.com", {}); + await expect(request).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledWith("http://example.com", { + credentials: "include" + }); + }); + + it("custom keys are merged with defaults", async () => { + const request = FetchWithCredentials("http://example.com", { + foo: "bar" + }); + await expect(request).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledWith("http://example.com", { + credentials: "include", + foo: "bar" + }); + }); + + it("custom credentials are used when passed", async () => { + const request = FetchWithCredentials("http://example.com", { + credentials: "none" + }); + await expect(request).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledWith("http://example.com", { + credentials: "none" + }); + }); +}); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js index 055dbbc58..d22aa16e8 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js @@ -15,6 +15,7 @@ import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch"; import { APIAlertmanagerUpstream } from "Models/API"; import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore"; import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query"; +import { FetchWithCredentials } from "Common/Fetch"; import { Modal } from "Components/Modal"; import { LabelSetList, @@ -136,7 +137,7 @@ const DeleteSilenceModalContent = observer( FormatQuery(StaticLabels.SilenceID, QueryOperators.Equal, silenceID) ]); - this.previewState.fetch = fetch(alertsURI, { credentials: "include" }) + this.previewState.fetch = FetchWithCredentials(alertsURI, {}) .then(result => result.json()) .then(result => { this.previewState.groupsToUniqueLabels(Object.values(result.groups)); @@ -165,9 +166,9 @@ const DeleteSilenceModalContent = observer( ? `${alertmanager.publicURI}/api/v2/silence/${silenceID}` : `${alertmanager.publicURI}/api/v1/silence/${silenceID}`; - this.deleteState.fetch = fetch(uri, { + this.deleteState.fetch = FetchWithCredentials(uri, { method: "DELETE", - credentials: "include" + headers: alertmanager.headers }) .then(result => { if (isOpenAPI) { diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js index 7704ff8b2..f74f47a93 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js @@ -122,7 +122,7 @@ describe("", () => { expect(tree.find("ErrorMessage")).toHaveLength(1); }); - it("[v1] sends a DELETE request after clicking 'Confirm' button ", async () => { + it("[v1] sends a DELETE request after clicking 'Confirm' button", async () => { await VerifyResponse({ status: "success" }); expect(fetch.mock.calls[1][0]).toBe( "http://am.example.com/api/v1/silence/123456789" @@ -130,7 +130,7 @@ describe("", () => { expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" }); }); - it("[v2] sends a DELETE request after clicking 'Confirm' button ", async () => { + it("[v2] sends a DELETE request after clicking 'Confirm' button", async () => { alertmanager.version = "0.16.2"; await VerifyResponse({ status: "success" }); expect(fetch.mock.calls[1][0]).toBe( @@ -139,6 +139,33 @@ describe("", () => { expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" }); }); + it("[v1] sends headers from alertmanager config", async () => { + alertmanager.headers = { Authorization: "Basic ***" }; + await VerifyResponse({ status: "success" }); + expect(fetch.mock.calls[1][0]).toBe( + "http://am.example.com/api/v1/silence/123456789" + ); + expect(fetch.mock.calls[1][1]).toMatchObject({ + credentials: "include", + method: "DELETE", + headers: { Authorization: "Basic ***" } + }); + }); + + it("[v1] sends headers from alertmanager config", async () => { + alertmanager.headers = { Authorization: "Basic ***" }; + alertmanager.version = "0.16.2"; + await VerifyResponse({ status: "success" }); + expect(fetch.mock.calls[1][0]).toBe( + "http://am.example.com/api/v2/silence/123456789" + ); + expect(fetch.mock.calls[1][1]).toMatchObject({ + credentials: "include", + method: "DELETE", + headers: { Authorization: "Basic ***" } + }); + }); + it("'Confirm' button is no-op after successful DELETE", async () => { const tree = await VerifyResponse({ status: "success" }); expect(fetch.mock.calls[1][0]).toBe( diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js index 1270712d6..51630994e 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js @@ -67,6 +67,7 @@ beforeEach(() => { cluster: "default", uri: "file:///mock", publicURI: "http://example.com", + headers: {}, error: "", version: "0.15.0", clusterMembers: ["default"] @@ -203,6 +204,7 @@ describe("", () => { cluster: "default", uri: "file:///mock", publicURI: "http://example.com", + headers: {}, error: "", version: "0.15.0", clusterMembers: ["default"] diff --git a/ui/src/Components/MainModal/Configuration/SortLabelName.js b/ui/src/Components/MainModal/Configuration/SortLabelName.js index 02c44cdf4..096b3f6aa 100644 --- a/ui/src/Components/MainModal/Configuration/SortLabelName.js +++ b/ui/src/Components/MainModal/Configuration/SortLabelName.js @@ -7,6 +7,7 @@ import { observer } from "mobx-react"; import Creatable from "react-select/creatable"; import { StaticLabels } from "Common/Query"; +import { FetchWithCredentials } from "Common/Fetch"; import { FormatBackendURI } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; import { ReactSelectStyles } from "Components/MultiSelect"; @@ -33,9 +34,10 @@ const SortLabelName = observer( }); populateNameSuggestions = action(() => { - this.nameSuggestionsFetch = fetch(FormatBackendURI(`labelNames.json`), { - credentials: "include" - }) + this.nameSuggestionsFetch = FetchWithCredentials( + FormatBackendURI(`labelNames.json`), + {} + ) .then( result => result.json(), err => { diff --git a/ui/src/Components/NavBar/FilterInput/index.js b/ui/src/Components/NavBar/FilterInput/index.js index 7b285880e..0dc7b5329 100644 --- a/ui/src/Components/NavBar/FilterInput/index.js +++ b/ui/src/Components/NavBar/FilterInput/index.js @@ -15,6 +15,7 @@ import { faSearch } from "@fortawesome/free-solid-svg-icons/faSearch"; import { AlertStore, FormatBackendURI } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; import { IsMobile } from "Common/Device"; +import { FetchWithCredentials } from "Common/Fetch"; import { FilterInputLabel } from "Components/Labels/FilterInputLabel"; import { AutosuggestTheme } from "./Constants"; import { History } from "./History"; @@ -72,9 +73,9 @@ const FilterInput = observer( onSuggestionsFetchRequested = debounce( action(({ value }) => { if (value !== "") { - this.inputStore.suggestionsFetch = fetch( + this.inputStore.suggestionsFetch = FetchWithCredentials( FormatBackendURI(`autocomplete.json?term=${value}`), - { credentials: "include" } + {} ) .then( result => result.json(), diff --git a/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js b/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js index 1313dd7ec..cd8b4b442 100644 --- a/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js +++ b/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js @@ -22,6 +22,7 @@ beforeEach(() => { name: "am1", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "ha", @@ -31,6 +32,7 @@ beforeEach(() => { name: "am2", uri: "http://am2.example.com", publicURI: "http://am2.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "ha", @@ -40,6 +42,7 @@ beforeEach(() => { name: "am3", uri: "http://am3.example.com", publicURI: "http://am3.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "am3", diff --git a/ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.js b/ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.js index 4a3176af8..8fc6c6a9e 100644 --- a/ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.js +++ b/ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.js @@ -8,6 +8,7 @@ import { SilenceFormMatcher } from "Models/SilenceForm"; import { MultiSelect } from "Components/MultiSelect"; import { ValidationError } from "Components/MultiSelect/ValidationError"; import { FormatBackendURI } from "Stores/AlertStore"; +import { FetchWithCredentials } from "Common/Fetch"; const LabelNameInput = observer( class LabelNameInput extends MultiSelect { @@ -19,9 +20,10 @@ const LabelNameInput = observer( populateNameSuggestions = action(() => { const { matcher } = this.props; - this.nameSuggestionsFetch = fetch(FormatBackendURI(`labelNames.json`), { - credentials: "include" - }) + this.nameSuggestionsFetch = FetchWithCredentials( + FormatBackendURI(`labelNames.json`), + {} + ) .then( result => result.json(), err => { @@ -43,11 +45,9 @@ const LabelNameInput = observer( populateValueSuggestions = action(() => { const { matcher } = this.props; - this.valueSuggestionsFetch = fetch( + this.valueSuggestionsFetch = FetchWithCredentials( FormatBackendURI(`labelValues.json?name=${matcher.name}`), - { - credentials: "include" - } + {} ) .then( result => result.json(), diff --git a/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js index c68ef230d..8f9d19bc5 100644 --- a/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js +++ b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js @@ -14,6 +14,7 @@ import { FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore"; import { SilenceFormStore } from "Stores/SilenceFormStore"; import { SilenceFormMatcher } from "Models/SilenceForm"; import { TooltipWrapper } from "Components/TooltipWrapper"; +import { FetchWithCredentials } from "Common/Fetch"; import { MatcherToFilter, AlertManagersToFilter } from "../Matchers"; const MatchCounter = observer( @@ -54,7 +55,7 @@ const MatchCounter = observer( const alertsURI = FormatBackendURI("alerts.json?") + FormatAlertsQ(filters); - this.matchedAlerts.fetch = fetch(alertsURI, { credentials: "include" }) + this.matchedAlerts.fetch = FetchWithCredentials(alertsURI, {}) .then(result => { return result.json(); }) diff --git a/ui/src/Components/SilenceModal/SilencePreview/index.js b/ui/src/Components/SilenceModal/SilencePreview/index.js index 41d3c8d2d..2701169e2 100644 --- a/ui/src/Components/SilenceModal/SilencePreview/index.js +++ b/ui/src/Components/SilenceModal/SilencePreview/index.js @@ -15,6 +15,7 @@ import { LabelSetList, GroupListToUniqueLabelsList } from "Components/LabelSetList"; +import { FetchWithCredentials } from "Common/Fetch"; import { MatcherToFilter, AlertManagersToFilter } from "../Matchers"; const FetchError = ({ message }) => ( @@ -67,7 +68,7 @@ const SilencePreview = observer( const alertsURI = FormatBackendURI("alerts.json?") + FormatAlertsQ(filters); - this.matchedAlerts.fetch = fetch(alertsURI, { credentials: "include" }) + this.matchedAlerts.fetch = FetchWithCredentials(alertsURI, {}) .then(result => { return result.json(); }) diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js index 45c322ae7..d16ed4a6e 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js @@ -13,6 +13,7 @@ import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclama import { APISilenceMatcher } from "Models/API"; import { AlertStore } from "Stores/AlertStore"; +import { FetchWithCredentials } from "Common/Fetch"; const SubmitState = Object.freeze({ InProgress: "InProgress", @@ -108,13 +109,13 @@ const SilenceSubmitProgress = observer( ? `${am.publicURI}/api/v2/silences` : `${am.publicURI}/api/v1/silences`; - this.submitState.fetch = fetch(uri, { + this.submitState.fetch = FetchWithCredentials(uri, { method: "POST", body: JSON.stringify(payload), headers: { - "Content-Type": "application/json" - }, - credentials: "include" + "Content-Type": "application/json", + ...am.headers + } }) .then(result => { if (isOpenAPI) { diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js index d04bab3cb..73d7852fb 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js @@ -15,6 +15,7 @@ beforeEach(() => { name: "mockAlertmanager", uri: "file:///mock", publicURI: "http://example.com", + headers: { foo: "bar" }, error: "", version: "0.15.0", cluster: "mockAlertmanager", @@ -68,7 +69,7 @@ describe("", () => { const payload = fetch.mock.calls[0][1]; expect(payload).toMatchObject({ method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", foo: "bar" }, body: JSON.stringify({ matchers: [], startsAt: "now", @@ -93,6 +94,7 @@ describe("", () => { name: "am1", uri: "file:///mock", publicURI: "http://am1.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "ha", @@ -102,6 +104,7 @@ describe("", () => { name: "am2", uri: "file:///mock", publicURI: "http://am2.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "ha", @@ -147,6 +150,7 @@ describe("", () => { name: "am1", uri: "file:///mock", publicURI: "http://am1.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "ha", diff --git a/ui/src/Models/API.js b/ui/src/Models/API.js index 46a452ef7..61710615f 100644 --- a/ui/src/Models/API.js +++ b/ui/src/Models/API.js @@ -70,6 +70,7 @@ const APIAlertmanagerUpstream = PropTypes.exact({ cluster: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, publicURI: PropTypes.string.isRequired, + headers: PropTypes.object.isRequired, error: PropTypes.string.isRequired, version: PropTypes.string.isRequired, clusterMembers: PropTypes.arrayOf(PropTypes.string).isRequired diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index e6025abb5..64ecc7935 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -6,6 +6,8 @@ import equal from "fast-deep-equal"; import qs from "qs"; +import { FetchWithCredentials } from "Common/Fetch"; + const QueryStringEncodeOptions = { encodeValuesOnly: true, // don't encode q[] indices: false // go-gin doesn't support parsing q[0]=foo&q[1]=bar @@ -247,7 +249,7 @@ class AlertStore { `alerts.json?sortOrder=${sortOrder}&sortLabel=${sortLabel}&sortReverse=${sortReverse}&` ) + FormatAPIFilterQuery(this.filters.values.map(f => f.raw)); - return fetch(alertsURI, { credentials: "include" }) + return FetchWithCredentials(alertsURI, {}) .then(result => { this.status.setProcessing(); return result.json(); diff --git a/ui/src/__mocks__/Alerts.js b/ui/src/__mocks__/Alerts.js index e0059eebf..edaf24c93 100644 --- a/ui/src/__mocks__/Alerts.js +++ b/ui/src/__mocks__/Alerts.js @@ -71,6 +71,9 @@ const MockAlertmanager = () => ({ cluster: "default", uri: "http://localhost", publicURI: "http://am.example.com", + headers: { + Authorization: "Basic foo bar" + }, error: "", version: "0.15.0", clusterMembers: ["default"]