fix(auth): bind mailbox callbacks to pending popup

This commit is contained in:
cesnimda
2026-10-17 10:27:45 +02:00
parent c5176e3251
commit dac29278db
19 changed files with 1403 additions and 38 deletions
@@ -1,11 +1,12 @@
import React from "react";
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ToastProvider } from "./toast";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import EmailProviderConnections from "./components/EmailProviderConnections";
import { translations } from "./i18n/translations";
jest.mock("./api", () => ({
api: {
@@ -19,6 +20,36 @@ jest.mock("./api", () => ({
const mockedApi = api as jest.Mocked<typeof api>;
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (cause: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
const oauthProviders = [
{ name: "Gmail", path: "/gmail", source: "jobtracker-gmail-oauth", popupName: "jobtracker-gmail-connect", auth: "https://accounts.google.com/o/oauth2/v2/auth", failed: "emailConnectionsGmailFailed" },
{ name: "Outlook / Microsoft 365", path: "/microsoft-graph", source: "jobtracker-microsoft-oauth", popupName: "jobtracker-microsoft-connect", auth: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", failed: "emailConnectionsOutlookFailed" },
] as const;
function authUrl(provider: typeof oauthProviders[number], callback = `http://localhost:3000/api${provider.path}/oauth/callback`) {
return `${provider.auth}?client_id=fixture&state=server-owned-state&redirect_uri=${encodeURIComponent(callback)}`;
}
function popupWindow() { return { closed: false, focus: jest.fn() } as unknown as Window; }
function connectButton(name: string) { return within(screen.getByText(name, { exact: true }).parentElement!.parentElement!).getByRole("button", { name: "Connect" }); }
function statusReads() { return mockedApi.get.mock.calls.filter(([url]) => String(url).endsWith("/status")); }
function message(data: unknown, source: Window | null, origin = "http://localhost:3000") {
act(() => { window.dispatchEvent(new MessageEvent("message", { data, source, origin })); });
}
function disconnectedProviderReads() {
mockedApi.get.mockImplementation((url: string) => {
const provider = oauthProviders.find(value => url === `${value.path}/connect-url`);
return Promise.resolve({ data: provider ? { url: authUrl(provider) } : { connected: false } } as any);
});
}
function renderComponent() {
return render(
<ToastProvider>
@@ -30,9 +61,12 @@ function renderComponent() {
describe("EmailProviderConnections", () => {
beforeEach(() => {
jest.clearAllMocks();
mockedApi.get.mockReset();
window.localStorage.clear();
});
afterEach(() => jest.restoreAllMocks());
it("renders connection controls in Norwegian Bokmål", async () => {
window.localStorage.setItem("uiLanguage", "nb");
mockedApi.get.mockResolvedValue({ data: { connected: false } } as any);
@@ -82,4 +116,109 @@ describe("EmailProviderConnections", () => {
password: "secret",
})));
});
it("[P7-09 OAuth] unsolicited success and error messages cannot announce or refresh any connection", async () => {
disconnectedProviderReads(); renderComponent(); await screen.findByLabelText("IMAP host");
await waitFor(() => expect(statusReads()).toHaveLength(3));
const foreign = popupWindow();
message({ source: "jobtracker-gmail-oauth", status: "connected", message: "Forged Gmail success" }, foreign);
message({ source: "jobtracker-microsoft-oauth", status: "error", message: "Forged Outlook failure" }, foreign);
expect(screen.queryByText("Forged Gmail success")).not.toBeInTheDocument();
expect(screen.queryByText("Forged Outlook failure")).not.toBeInTheDocument();
expect(statusReads()).toHaveLength(3);
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});
it.each(oauthProviders)("[P7-09 OAuth] $name accepts only its captured popup and canonical callback origin once", async provider => {
disconnectedProviderReads(); const popup = popupWindow(); const other = popupWindow();
const opened = jest.spyOn(window, "open").mockReturnValue(popup);
renderComponent(); await screen.findByLabelText("IMAP host");
fireEvent.click(connectButton(provider.name));
await waitFor(() => expect(opened).toHaveBeenCalledWith(authUrl(provider), provider.popupName, "width=620,height=760,resizable=yes,scrollbars=yes"));
const valid = { source: provider.source, status: "connected", message: "Verified mailbox callback" };
for (const [data, sender, origin] of [
[valid, popup, "https://attacker.example.test"], [valid, popup, "http://localhost:5202"],
[valid, other, "http://localhost:3000"], [valid, null, "http://localhost:3000"],
[{ ...valid, source: provider.source === "jobtracker-gmail-oauth" ? "jobtracker-microsoft-oauth" : "jobtracker-gmail-oauth" }, popup, "http://localhost:3000"],
[{ ...valid, status: "unexpected" }, popup, "http://localhost:3000"],
[{ ...valid, message: { unsafe: "not text" } }, popup, "http://localhost:3000"],
[null, popup, "http://localhost:3000"], [[valid], popup, "http://localhost:3000"],
] as Array<[unknown, Window | null, string]>) {
message(data, sender, origin);
expect(screen.queryByText(valid.message)).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3);
}
expect(screen.queryByText(valid.message)).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3);
Object.defineProperty(popup, "closed", { value: true });
message(valid, popup); message(valid, popup);
expect(await screen.findByText(valid.message)).toBeVisible();
await waitFor(() => expect(statusReads()).toHaveLength(4));
expect(statusReads().at(-1)?.[0]).toBe(`${provider.path}/status`);
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});
it("[P7-09 OAuth] a pending popup survives a locale change and valid error uses current fallback without refreshing", async () => {
disconnectedProviderReads(); const provider = oauthProviders[1]; const popup = popupWindow();
jest.spyOn(window, "open").mockReturnValue(popup); renderComponent(); await screen.findByLabelText("IMAP host");
fireEvent.click(connectButton(provider.name)); await waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
message({ source: provider.source, status: "error" }, popup);
expect(await screen.findByText(translations.nb.emailConnectionsOutlookFailed)).toBeVisible();
message({ source: provider.source, status: "connected", message: "Replayed terminal callback" }, popup);
expect(screen.queryByText("Replayed terminal callback")).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3);
});
it("[P7-09 OAuth] duplicate starts reserve one named popup until it is closed", async () => {
disconnectedProviderReads(); const pending = deferred<any>();
mockedApi.get.mockImplementation((url: string) => url === "/gmail/connect-url" ? pending.promise : Promise.resolve({ data: { connected: false } } as any));
const popup = popupWindow(); jest.spyOn(window, "open").mockReturnValue(popup);
renderComponent(); await screen.findByLabelText("IMAP host"); const button = connectButton("Gmail");
act(() => { fireEvent.click(button); fireEvent.click(button); });
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(1);
await act(async () => pending.resolve({ data: { url: authUrl(oauthProviders[0]) } }));
fireEvent.click(button); expect(window.open).toHaveBeenCalledTimes(1);
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(1);
expect(popup.focus).toHaveBeenCalledTimes(1);
});
it("[P7-09 OAuth] blocked popups and late disposed connect responses cannot establish a callback sender", async () => {
disconnectedProviderReads(); const opened = jest.spyOn(window, "open").mockReturnValue(null);
const first = renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.click(connectButton("Gmail"));
expect(await screen.findByText(translations.en.emailConnectionsPopupBlocked)).toBeVisible();
message({ source: "jobtracker-gmail-oauth", status: "connected", message: "Blocked popup forged success" }, popupWindow());
expect(screen.queryByText("Blocked popup forged success")).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3);
first.unmount();
const pending = deferred<any>(); mockedApi.get.mockImplementation((url: string) => url === "/gmail/connect-url" ? pending.promise : Promise.resolve({ data: { connected: false } } as any));
const second = renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.click(connectButton("Gmail")); second.unmount();
await act(async () => pending.resolve({ data: { url: authUrl(oauthProviders[0]) } }));
expect(opened).toHaveBeenCalledTimes(1);
});
it("[P7-09 OAuth] malformed or non-provider callback targets never launch a popup", async () => {
disconnectedProviderReads(); const opened = jest.spyOn(window, "open").mockReturnValue(popupWindow());
const targets = ["javascript:alert(1)", "https://user:password@jobs.example.test/api/gmail/oauth/callback", "https://jobs.example.test/api/microsoft-graph/oauth/callback", "https://jobs.example.test/wrong-callback", "not a URL"];
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/gmail/connect-url" ? { url: authUrl(oauthProviders[0], targets.shift()!) } : { connected: false } } as any));
renderComponent(); await screen.findByLabelText("IMAP host");
for (let index = 0; index < 5; index += 1) { fireEvent.click(connectButton("Gmail")); await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(index + 1)); await act(async () => {}); }
expect(opened).not.toHaveBeenCalled(); expect(statusReads()).toHaveLength(3);
});
it.each(["blocked", "rejected"] as const)("[P7-09 OAuth] delayed %s startup feedback uses the language selected while its connect request was pending", async outcome => {
const pending = deferred<any>();
mockedApi.get.mockImplementation((url: string) => url === "/gmail/connect-url" ? pending.promise : Promise.resolve({ data: { connected: false } } as any));
jest.spyOn(window, "open").mockReturnValue(null);
renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.click(connectButton("Gmail"));
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(1);
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
await screen.findByLabelText("IMAP-vert");
await act(async () => {
if (outcome === "blocked") pending.resolve({ data: { url: authUrl(oauthProviders[0]) } });
else pending.reject(new Error("offline"));
});
const expected = outcome === "blocked" ? translations.nb.emailConnectionsPopupBlocked : translations.nb.emailConnectionsStartFailed.replace("{provider}", "Gmail");
expect(await screen.findByText(expected)).toBeVisible();
const previous = outcome === "blocked" ? translations.en.emailConnectionsPopupBlocked : translations.en.emailConnectionsStartFailed.replace("{provider}", "Gmail");
expect(screen.queryByText(previous)).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3);
expect(window.open).toHaveBeenCalledTimes(outcome === "blocked" ? 1 : 0);
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});
});