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
16 changes: 16 additions & 0 deletions .changeset/nav-policy-load-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"mobile": patch
---

Clause-②: no
Metadata-protocol: none

The navigation policy is also enforced when a top-frame load starts, so every
top-frame load follows it. A page the policy does not load in the WebView is
stopped and goes where the policy sends it, as a link does: a web page to the
in-app browser, a blocked page nowhere. The WebView then goes back to the
server, to the route the Console was on. If the server's page goes there again
before the Console is ready, the app says it can't show the page, with Try
again and Switch organization.

No native module and no new permission: an over-the-air update is enough.
205 changes: 205 additions & 0 deletions __tests__/components/ConsoleWebView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,22 @@ import { BRIDGE_RESOLVE_GLOBAL, CONNECTION_REMOVED_EVENT } from "~/lib/shell/bri

const mockWebView = {
props: null as any,
/** Each mounted WebView's latest props, in mount order: what its native side calls. */
instances: [] as any[],
mounts: 0,
injectJavaScript: jest.fn(),
reload: jest.fn(),
goBack: jest.fn(),
clearHistory: jest.fn(),
stopLoading: jest.fn(),
};

jest.mock("react-native-webview", () => {
const React = require("react");
const { View } = require("react-native");
const WebView = React.forwardRef(function MockWebView(props: any, ref: any) {
const [index] = React.useState(() => mockWebView.instances.length);
mockWebView.instances[index] = props;
mockWebView.props = props;
React.useEffect(() => {
mockWebView.mounts += 1;
Expand All @@ -34,6 +39,7 @@ jest.mock("react-native-webview", () => {
reload: mockWebView.reload,
goBack: mockWebView.goBack,
clearHistory: mockWebView.clearHistory,
stopLoading: mockWebView.stopLoading,
}));
return <View testID={props.testID} />;
});
Expand Down Expand Up @@ -151,11 +157,13 @@ const lastInjected = (): string => {
beforeEach(() => {
mockIsConnected = true;
mockWebView.props = null;
mockWebView.instances = [];
mockWebView.mounts = 0;
mockWebView.injectJavaScript.mockReset();
mockWebView.reload.mockReset();
mockWebView.goBack.mockReset();
mockWebView.clearHistory.mockReset();
mockWebView.stopLoading.mockReset();
});

describe("ConsoleWebView", () => {
Expand Down Expand Up @@ -880,6 +888,160 @@ describe("ConsoleWebView — saved connections", () => {
});
});

describe("ConsoleWebView — the navigation policy at load start", () => {
const ELSEWHERE = "https://pages.elsewhere.example/form";

function loadEndAt(url: string, loading = false) {
act(() => mockWebView.props.onLoadEnd({ nativeEvent: { url, loading } }));
}

/** The Console, loaded and ready, on `route`. */
async function onConsolePage(
route = "/apps/crm/lead/9",
overrides: Partial<ConsoleWebViewProps> = {}
) {
const rendered = renderConsole(overrides);
loadStart(`${SERVER}/_console/apps/crm`);
loadEndAt(`${SERVER}/_console/apps/crm`);
await consoleReady();
await postEvent("routeChanged", { route });
mockWebView.injectJavaScript.mockReset();
return rendered;
}

afterEach(() => jest.useRealTimers());

it("stops a top-frame load the policy does not keep in the WebView, opens it where the policy sends it, and goes back to the server where the Console was", async () => {
const { props } = await onConsolePage();
loadStart(ELSEWHERE);
expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1);
expect(props.onOpenExternal).toHaveBeenCalledTimes(1);
expect(props.onOpenExternal).toHaveBeenCalledWith(ELSEWHERE);
// A new WebView on the server, at the route the Console was on, behind the cover.
expect(mockWebView.mounts).toBe(2);
expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/apps/crm/lead/9` });
expect(screen.getByText("Loading…")).toBeTruthy();
});

it("judges a load reported only at its end the same way, and never counts that load end as the Console's", async () => {
jest.useFakeTimers();
const { props } = await onConsolePage();
loadEndAt(ELSEWHERE);
expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1);
expect(props.onOpenExternal).toHaveBeenCalledWith(ELSEWHERE);
expect(mockWebView.mounts).toBe(2);
// The new WebView's Console has not loaded: no ready timeout lifts the cover.
act(() => jest.advanceTimersByTime(READY_TIMEOUT_MS * 2));
expect(screen.getByText("Loading…")).toBeTruthy();
});

it("stops a load the policy blocks, opening nothing", async () => {
const { props } = await onConsolePage();
loadStart("data:text/html,page");
expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1);
expect(props.onOpenExternal).not.toHaveBeenCalled();
expect(mockWebView.mounts).toBe(2);
expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/apps/crm/lead/9` });
});

it("keeps the server's own pages: a document, an in-app history change, a route a native screen owns", async () => {
const { props } = await onConsolePage();
// Android reports an in-app history change as a load start: the URL decides.
loadStart(`${SERVER}/_console/apps/crm/lead/10`);
loadStart(`${SERVER}/_console/approvals/7`);
loadStart(`${SERVER}/api/v1/auth/callback/corp?code=abc`);
loadEndAt(`${SERVER}:443/_console/home`);
loadStart("about:blank");
expect(mockWebView.stopLoading).not.toHaveBeenCalled();
expect(props.onOpenExternal).not.toHaveBeenCalled();
expect(props.onOpenNative).not.toHaveBeenCalled();
expect(mockWebView.mounts).toBe(1);
expect(screen.queryByText("Loading…")).toBeNull();
});

it("keeps a trusted host", async () => {
const { props } = await onConsolePage("/apps/crm", { trustedHosts: ["login.partner.example"] });
loadStart("https://login.partner.example/authorize");
loadEndAt("https://login.partner.example/authorize");
expect(mockWebView.stopLoading).not.toHaveBeenCalled();
expect(props.onOpenExternal).not.toHaveBeenCalled();
expect(mockWebView.mounts).toBe(1);
});

it("shows the failure page when the server's page goes there again before a Console is ready", async () => {
const { props } = await onConsolePage();
loadStart(ELSEWHERE);
expect(mockWebView.mounts).toBe(2);
// The new WebView's page goes there again before its Console is ready.
loadStart(`${SERVER}/_console/apps/crm/lead/9`);
loadStart(ELSEWHERE);
expect(mockWebView.stopLoading).toHaveBeenCalledTimes(2);
expect(screen.getByTestId("shell-offline-fallback")).toBeTruthy();
expect(screen.getByText("Can't show this page in the app")).toBeTruthy();
// Opened once, re-created once.
expect(props.onOpenExternal).toHaveBeenCalledTimes(1);
expect(mockWebView.mounts).toBe(2);
// Retry is the user's: one new WebView, which stops at the same page again.
fireEvent.press(screen.getByTestId("shell-retry"));
expect(mockWebView.mounts).toBe(3);
loadStart(ELSEWHERE);
expect(screen.getByTestId("shell-offline-fallback")).toBeTruthy();
expect(props.onOpenExternal).toHaveBeenCalledTimes(1);
});

it("goes back to the server again once a Console has been ready in between", async () => {
const { props } = await onConsolePage();
loadStart(ELSEWHERE);
loadEndAt(`${SERVER}/_console/apps/crm/lead/9`);
await consoleReady();
loadStart(ELSEWHERE);
expect(screen.queryByTestId("shell-offline-fallback")).toBeNull();
expect(props.onOpenExternal).toHaveBeenCalledTimes(2);
expect(mockWebView.mounts).toBe(3);
});

it("drops the reports of the WebView it replaces", async () => {
jest.useFakeTimers();
const { props } = await onConsolePage();
loadStart(ELSEWHERE);
expect(mockWebView.mounts).toBe(2);
const replaced = mockWebView.instances[0];
act(() => {
replaced.onLoadEnd({ nativeEvent: { url: ELSEWHERE, loading: false } });
replaced.onNavigationStateChange({ url: ELSEWHERE, loading: false, canGoBack: true });
});
expect(props.onOpenExternal).toHaveBeenCalledTimes(1);
expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1);
expect(mockWebView.mounts).toBe(2);
expect(screen.queryByTestId("shell-offline-fallback")).toBeNull();
act(() => jest.advanceTimersByTime(READY_TIMEOUT_MS * 2));
expect(screen.getByText("Loading…")).toBeTruthy();
});

it("never judges a report from the WebView a switch replaced against the new server", async () => {
const { props, view } = await onConsolePage();
view.rerender(
<ConsoleWebView {...props} serverUrl="https://erp.example.com" resumeRoute={null} />
);
expect(mockWebView.mounts).toBe(2);
// The WebView left behind was rendered once more, for the new server, before it was replaced.
const replaced = mockWebView.instances[0];
act(() =>
replaced.onLoadEnd({ nativeEvent: { url: `${SERVER}/_console/home`, loading: false } })
);
expect(mockWebView.stopLoading).not.toHaveBeenCalled();
expect(props.onOpenExternal).not.toHaveBeenCalled();
expect(mockWebView.mounts).toBe(2);
// The new server's own pages stay; the one left is another site now.
loadStart("https://erp.example.com/_console/home");
expect(mockWebView.mounts).toBe(2);
loadStart(`${SERVER}/_console/home`);
expect(props.onOpenExternal).toHaveBeenCalledWith(`${SERVER}/_console/home`);
expect(mockWebView.mounts).toBe(3);
expect(mockWebView.props.source).toEqual({ uri: "https://erp.example.com/_console/home" });
});
});

describe("ConsoleWebView — sign-in excursion", () => {
const LOGIN = `${SERVER}/_console/login?redirect=%2Fapps%2Fcrm`;
const IDP = "https://login.idp-corp.example/oauth2/authorize?client_id=acme";
Expand Down Expand Up @@ -975,6 +1137,49 @@ describe("ConsoleWebView — sign-in excursion", () => {
);
});

it("keeps the excursion's pages at load start and load end, and returns by its own rule", async () => {
const { props } = await inExcursion();
loadStart("https://mfa.idp-corp.example/verify");
loadEndAt("https://mfa.idp-corp.example/verify");
loadStart("https://federation.partner.example/saml");
expect(screen.getByTestId("shell-excursion-host")).toHaveTextContent(
"federation.partner.example"
);
expect(mockWebView.stopLoading).not.toHaveBeenCalled();
expect(props.onOpenExternal).not.toHaveBeenCalled();
// Back on the server: the excursion returns, nothing is stopped.
loadStart(CALLBACK);
loadEndAt(`${SERVER}/_console/home`);
expect(screen.queryByTestId("shell-excursion-bar")).toBeNull();
expect(mockWebView.stopLoading).not.toHaveBeenCalled();
expect(mockWebView.mounts).toBe(1);
// Outside the excursion, the provider's page is another site again.
loadStart("https://login.idp-corp.example/session");
expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1);
expect(props.onOpenExternal).toHaveBeenCalledWith("https://login.idp-corp.example/session");
expect(mockWebView.mounts).toBe(2);
});

it("leaves a plain http page during the excursion to the excursion's own end", async () => {
const { props } = await inExcursion();
loadStart("http://login.idp-corp.example/next");
expect(screen.getByTestId("shell-excursion-notice-insecure")).toBeTruthy();
expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/login` });
expect(mockWebView.mounts).toBe(2);
expect(props.onOpenExternal).not.toHaveBeenCalled();
});

it("opens nothing for the provider's page after 取消", async () => {
const { props } = await inExcursion();
fireEvent.press(screen.getByTestId("shell-excursion-cancel"));
expect(mockWebView.mounts).toBe(2);
const replaced = mockWebView.instances[0];
act(() => replaced.onLoadEnd({ nativeEvent: { url: IDP, loading: false } }));
expect(props.onOpenExternal).not.toHaveBeenCalled();
expect(mockWebView.stopLoading).not.toHaveBeenCalled();
expect(mockWebView.mounts).toBe(2);
});

describe("the bridge", () => {
it("answers no call while an excursion page is on screen, whatever origin it names", async () => {
const { props } = await inExcursion();
Expand Down
52 changes: 52 additions & 0 deletions __tests__/lib/shell/navigation-policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
decideNavigation,
decideTopFrameLoad,
isSignInSurface,
type NavigationContext,
} from "~/lib/shell/navigation-policy";
Expand Down Expand Up @@ -96,6 +97,57 @@ describe("decideNavigation", () => {
});
});

describe("decideTopFrameLoad", () => {
it("keeps the server's own pages, a route a native screen owns included", () => {
expect(decideTopFrameLoad("https://acme.example.com/_console/apps/crm", ctx)).toEqual({
kind: "stay",
});
expect(decideTopFrameLoad("https://acme.example.com/_console/approvals/9", ctx)).toEqual({
kind: "stay",
});
expect(decideTopFrameLoad("https://acme.example.com/api/v1/auth/callback/x", ctx)).toEqual({
kind: "stay",
});
expect(decideTopFrameLoad("https://acme.example.com:443/_console/home", ctx)).toEqual({
kind: "stay",
});
});

it("keeps what the policy loads in the top frame: trusted hosts, about:, blob:", () => {
expect(decideTopFrameLoad("https://login.acme-idp.example/authorize", ctx)).toEqual({
kind: "stay",
});
expect(decideTopFrameLoad("about:blank", ctx)).toEqual({ kind: "stay" });
expect(decideTopFrameLoad("blob:https://acme.example.com/abc", ctx)).toEqual({ kind: "stay" });
});

it("stops any other page, to open it where the policy sends it", () => {
expect(decideTopFrameLoad("https://docs.example.org/page", ctx)).toEqual({
kind: "stop",
open: "https://docs.example.org/page",
});
expect(decideTopFrameLoad("http://acme.example.com/_console/home", ctx)).toEqual({
kind: "stop",
open: "http://acme.example.com/_console/home",
});
});

it("stops a page the policy blocks, opening nothing", () => {
expect(decideTopFrameLoad("data:text/html,<b>x</b>", ctx)).toEqual({
kind: "stop",
open: null,
});
expect(decideTopFrameLoad("file:///etc/passwd", ctx)).toEqual({ kind: "stop", open: null });
expect(decideTopFrameLoad("not a url", ctx)).toEqual({ kind: "stop", open: null });
});

it("judges every load as the top frame's, whatever the context says", () => {
expect(
decideTopFrameLoad("https://maps.example.org/embed", { ...ctx, isTopFrame: false })
).toEqual({ kind: "stop", open: "https://maps.example.org/embed" });
});
});

describe("isSignInSurface", () => {
const server = "https://acme.example.com";

Expand Down
Loading
Loading