Merge pull request #1285 from prymitive/auth-reload

feat(ui): try to detect if API requests are intercepted by an auth proxy
This commit is contained in:
Łukasz Mierzwa
2019-12-21 18:58:48 +00:00
committed by GitHub
14 changed files with 232 additions and 20 deletions

View File

@@ -8,17 +8,29 @@ const CommonOptions = {
};
const FetchRetryConfig = {
retries: 5,
minTimeout: 1000,
retries: 10,
minTimeout: 2000,
maxTimeout: 5000
};
const FetchGet = async (uri, options, retryOptions) =>
await promiseRetry(
(retry, number) =>
fetch(uri, merge({}, { method: "GET" }, CommonOptions, options)).catch(
retry
),
fetch(
uri,
merge(
{},
{
method: "GET",
mode:
number <= Math.round(FetchRetryConfig.retries * 0.8)
? "cors"
: "no-cors"
},
CommonOptions,
options
)
).catch(retry),
FetchRetryConfig
);

View File

@@ -18,7 +18,7 @@ describe("Fetch", () => {
};
const methodOptions = {
FetchGet: { method: "GET" },
FetchGet: { method: "GET", mode: "cors" },
FetchPost: { method: "POST" },
FetchDelete: { method: "DELETE" }
};
@@ -59,4 +59,24 @@ describe("Fetch", () => {
);
});
}
it("FetchGet switches to no-cors after 80% failures", async () => {
fetch.mockReject(new Error("Fetch error"));
const request = FetchGet("http://example.com", {});
await expect(request).rejects.toThrow("Fetch error");
expect(fetch).toHaveBeenCalledTimes(11);
expect(fetch).toHaveBeenCalledWith("http://example.com", {
method: "GET",
credentials: "include",
mode: "no-cors",
redirect: "follow"
});
for (let i = 0; i <= 10; i++) {
expect(fetch.mock.calls[i][1]).toMatchObject({
mode: i < 8 ? "cors" : "no-cors"
});
}
});
});

View File

@@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ReloadNeeded /> matches snapshot 1`] = `
"
<h1 class=\\"display-1 text-placeholder screen-center\\">
<div class=\\"container-fluid text-center\\">
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"exclamation-circle\\"
class=\\"svg-inline--fa fa-exclamation-circle fa-w-16 screen-center-icon-big text-danger mb-4\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z\\"
>
</path>
</svg>
<p class=\\"lead text-white bg-secondary p-3 rounded text-wrap text-break\\">
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"spinner\\"
class=\\"svg-inline--fa fa-spinner fa-w-16 fa-spin mr-2\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M304 48c0 26.51-21.49 48-48 48s-48-21.49-48-48 21.49-48 48-48 48 21.49 48 48zm-48 368c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zm208-208c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zM96 256c0-26.51-21.49-48-48-48S0 229.49 0 256s21.49 48 48 48 48-21.49 48-48zm12.922 99.078c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48c0-26.509-21.491-48-48-48zm294.156 0c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48c0-26.509-21.49-48-48-48zM108.922 60.922c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.491-48-48-48z\\"
>
</path>
</svg>
All API connection attempts failed. This migth be caused by authentication middleware, will try to reload.
</p>
</div>
</h1>
"
`;

View File

@@ -0,0 +1,48 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
import { CenteredMessage } from "Components/CenteredMessage";
class ReloadNeeded extends Component {
static propTypes = {
reloadAfter: PropTypes.number.isRequired
};
reloadApp = () => {
window.location.reload();
};
componentDidMount() {
const { reloadAfter } = this.props;
this.timer = setTimeout(this.reloadApp, reloadAfter);
}
componentWillUnmount() {
clearTimeout(this.timer);
this.timer = null;
}
render() {
return (
<CenteredMessage>
<div className="container-fluid text-center">
<FontAwesomeIcon
icon={faExclamationCircle}
className="screen-center-icon-big text-danger mb-4"
/>
<p className="lead text-white bg-secondary p-3 rounded text-wrap text-break">
<FontAwesomeIcon className="mr-2" icon={faSpinner} spin />
All API connection attempts failed. This migth be caused by
authentication middleware, will try to reload.
</p>
</div>
</CenteredMessage>
);
}
}
export { ReloadNeeded };

View File

@@ -0,0 +1,43 @@
import React from "react";
import { mount, shallow } from "enzyme";
import toDiffableHtml from "diffable-html";
import { ReloadNeeded } from ".";
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllTimers();
});
afterEach(() => {
jest.clearAllTimers();
jest.restoreAllMocks();
});
describe("<ReloadNeeded />", () => {
it("matches snapshot", () => {
const tree = shallow(
<ReloadNeeded newVersion="1.2.3" reloadAfter={100000000} />
);
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
it("calls window.location.reload after timer is done", () => {
const reloadSpy = jest
.spyOn(global.window.location, "reload")
.mockImplementation(() => {});
mount(<ReloadNeeded reloadAfter={100000000} />);
jest.runOnlyPendingTimers();
expect(reloadSpy).toBeCalled();
});
it("timer is cleared on unmount", () => {
const tree = mount(<ReloadNeeded reloadAfter={100000000} />);
const instance = tree.instance();
instance.componentWillUnmount();
expect(instance.timer).toBeNull();
});
});

View File

@@ -25,7 +25,7 @@ exports[`<UpgradeNeeded /> matches snapshot 1`] = `
focusable=\\"false\\"
data-prefix=\\"fas\\"
data-icon=\\"spinner\\"
class=\\"svg-inline--fa fa-spinner fa-w-16 fa-spin mr-1\\"
class=\\"svg-inline--fa fa-spinner fa-w-16 fa-spin mr-2\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"

View File

@@ -41,7 +41,7 @@ class UpgradeNeeded extends Component {
/>
</div>
<p className="lead text-muted">
<FontAwesomeIcon className="mr-1" icon={faSpinner} spin />
<FontAwesomeIcon className="mr-2" icon={faSpinner} spin />
Upgrading to a new version: {newVersion}
</p>
</div>

View File

@@ -10,6 +10,7 @@ import { AlertGrid } from "./AlertGrid";
import { FatalError } from "./FatalError";
import { UpstreamError } from "./UpstreamError";
import { UpgradeNeeded } from "./UpgradeNeeded";
import { ReloadNeeded } from "./ReloadNeeded";
import { EmptyGrid } from "./EmptyGrid";
const Grid = observer(
@@ -23,10 +24,6 @@ const Grid = observer(
render() {
const { alertStore, settingsStore, silenceFormStore } = this.props;
if (alertStore.status.error) {
return <FatalError message={alertStore.status.error} />;
}
if (alertStore.info.upgradeNeeded) {
return (
<UpgradeNeeded
@@ -36,6 +33,14 @@ const Grid = observer(
);
}
if (alertStore.info.reloadNeeded) {
return <ReloadNeeded reloadAfter={4000} />;
}
if (alertStore.status.error) {
return <FatalError message={alertStore.status.error} />;
}
if (
alertStore.data.upstreams.counters &&
alertStore.data.upstreams.counters.total === 1 &&

View File

@@ -8,6 +8,7 @@ import { Settings } from "Stores/Settings";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { FatalError } from "./FatalError";
import { UpgradeNeeded } from "./UpgradeNeeded";
import { ReloadNeeded } from "./ReloadNeeded";
import { EmptyGrid } from "./EmptyGrid";
import { Grid } from ".";
import { InternalError } from "../../ErrorBoundary";
@@ -32,6 +33,9 @@ storiesOf("Grid", module)
.add("UpgradeNeeded", () => {
return <UpgradeNeeded newVersion="1.2.3" reloadAfter={100000000} />;
})
.add("ReloadNeeded", () => {
return <ReloadNeeded reloadAfter={100000000} />;
})
.add("EmptyGrid", () => {
return (
<div className="text-center">

View File

@@ -94,6 +94,12 @@ describe("<Grid />", () => {
expect(tree.text()).toBe("<UpgradeNeeded />");
});
it("renders ReloadNeeded when alertStore.info.reloadNeeded=true", () => {
alertStore.info.reloadNeeded = true;
const tree = ShallowGrid();
expect(tree.text()).toBe("<ReloadNeeded />");
});
it("renders AlertGrid before any fetch finished when totalAlerts is 0", () => {
alertStore.info.version = "unknown";
alertStore.info.totalAlerts = 0;

View File

@@ -203,7 +203,7 @@ describe("<FilterInput Autosuggest />", () => {
tree.find("input").simulate("change", { target: { value: "bar" } });
await WaitForFetch(tree);
expect(fetch.mock.calls).toHaveLength(6);
expect(fetch.mock.calls).toHaveLength(11);
expect(fetch.mock.calls[0]).toContain("./autocomplete.json?term=bar");
expect(instance.inputStore.suggestions).toHaveLength(0);
});

View File

@@ -90,6 +90,7 @@ describe("<SilencePreview />", () => {
{
method: "GET",
credentials: "include",
mode: "cors",
redirect: "follow"
}
);
@@ -108,6 +109,7 @@ describe("<SilencePreview />", () => {
{
method: "GET",
credentials: "include",
mode: "cors",
redirect: "follow"
}
);

View File

@@ -174,9 +174,15 @@ class AlertStore {
{
totalAlerts: 0,
version: "unknown",
upgradeNeeded: false
upgradeNeeded: false,
reloadNeeded: false,
setReloadNeeded() {
this.reloadNeeded = true;
}
},
{
setReloadNeeded: action
},
{},
{ name: "API response info" }
);
@@ -270,6 +276,13 @@ class AlertStore {
return FetchGet(alertsURI, {})
.then(result => {
// we're sending requests with mode=cors so the response should also be type=cors
// after a few failures in the retry loop we will switch to no-cors
// if that request comes back as type=opaque then we might be getting
// redirected by an auth proxy
if (result.type === "opaque") {
this.info.setReloadNeeded();
}
this.status.setProcessing();
return result.json();
})

View File

@@ -375,7 +375,7 @@ describe("AlertStore.fetch", () => {
const store = new AlertStore([]);
await expect(store.fetch()).resolves.toHaveProperty("error");
expect(global.fetch).toHaveBeenCalledTimes(6);
expect(global.fetch).toHaveBeenCalledTimes(11);
expect(store.status.value).toEqual(AlertStoreStatuses.Failure);
expect(store.info.version).toBe("unknown");
// there should be a trace of the error
@@ -388,7 +388,7 @@ describe("AlertStore.fetch", () => {
fetch.mockReject(new Error("Fetch error"));
await expect(store.fetch()).resolves.toHaveProperty("error");
expect(global.fetch).toHaveBeenCalledTimes(6);
expect(global.fetch).toHaveBeenCalledTimes(11);
});
it("fetch() retry counter is reset after successful fetch", async () => {
@@ -397,16 +397,34 @@ describe("AlertStore.fetch", () => {
fetch.mockReject(new Error("Fetch error"));
await expect(store.fetch()).resolves.toHaveProperty("error");
expect(global.fetch).toHaveBeenCalledTimes(6);
expect(global.fetch).toHaveBeenCalledTimes(11);
const response = EmptyAPIResponse();
fetch.mockResponse(JSON.stringify(response));
await expect(store.fetch()).resolves.toBeUndefined();
expect(global.fetch).toHaveBeenCalledTimes(7);
expect(global.fetch).toHaveBeenCalledTimes(12);
fetch.mockReject(new Error("Fetch error"));
await expect(store.fetch()).resolves.toHaveProperty("error");
expect(global.fetch).toHaveBeenCalledTimes(13);
expect(global.fetch).toHaveBeenCalledTimes(23);
});
it("fetch() reloads the page after if auth middleware is detected", async () => {
jest.spyOn(console, "trace").mockImplementation(() => {});
const store = new AlertStore(["label=value"]);
jest.spyOn(global, "fetch").mockImplementation(async () =>
Promise.resolve({
type: "opaque",
body: "auth needed",
json: jest.fn(() => EmptyAPIResponse())
})
);
await expect(store.fetch()).resolves.toBeUndefined();
expect(store.info.reloadNeeded).toBe(true);
});
it("unapplied filters are marked as applied on fetch error", async () => {