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,12 +1,13 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ConfirmProvider } from "./confirm";
import { PromptProvider } from "./prompt";
import { ToastProvider } from "./toast";
import { I18nProvider } from "./i18n/I18nProvider";
import Correspondence from "./components/Correspondence";
import { api } from "./api";
import { translations } from "./i18n/translations";
jest.setTimeout(15000);
@@ -19,31 +20,47 @@ jest.mock("./api", () => ({
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
}));
const mockedApi = api as jest.Mocked<typeof api>;
const originalWindowOpen = window.open;
const gmailCallbackOrigin = "http://localhost:3000";
const gmailAuthorizationUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=test-client&redirect_uri=${encodeURIComponent(`${gmailCallbackOrigin}/api/gmail/oauth/callback`)}&response_type=code&state=opaque-test-state`;
function renderDialog() {
return render(
function dialog(jobId = 42) {
return (
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<Correspondence
jobId={42}
jobId={jobId}
jobContext={{ jobTitle: "Backend Developer", companyName: "Acme", recruiterEmail: "maria@acme.test" }}
/>
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
</ToastProvider>
);
}
function renderDialog() {
return render(dialog());
}
async function oauthMessage(data: unknown, source: Window | null, origin = gmailCallbackOrigin) {
await act(async () => {
window.dispatchEvent(new MessageEvent("message", { data, source, origin }));
});
}
describe("correspondence Gmail import", () => {
let correspondenceMessages: any[];
let gmailConnected: boolean;
beforeEach(() => {
window.localStorage.clear();
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
@@ -52,6 +69,7 @@ describe("correspondence Gmail import", () => {
window.open = jest.fn();
correspondenceMessages = [];
gmailConnected = true;
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === "/jobapplications/choices") {
@@ -97,7 +115,10 @@ describe("correspondence Gmail import", () => {
return Promise.resolve({ data: correspondenceMessages } as any);
}
if (url === "/gmail/status") {
return Promise.resolve({ data: { connected: true, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString(), lastSyncAttemptedAt: new Date().toISOString(), lastSyncMode: "list-messages", lastSyncSource: "custom-query", lastSyncStatus: "error", lastSyncError: "Token refresh failed" } } as any);
return Promise.resolve({ data: { connected: gmailConnected, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString(), lastSyncAttemptedAt: new Date().toISOString(), lastSyncMode: "list-messages", lastSyncSource: "custom-query", lastSyncStatus: "error", lastSyncError: "Token refresh failed" } } as any);
}
if (url === "/gmail/connect-url") {
return Promise.resolve({ data: { url: gmailAuthorizationUrl } } as any);
}
if (url === "/gmail/job-candidates") {
return Promise.resolve({
@@ -265,6 +286,8 @@ describe("correspondence Gmail import", () => {
});
afterEach(() => {
window.open = originalWindowOpen;
window.localStorage.clear();
jest.clearAllMocks();
});
@@ -414,4 +437,152 @@ describe("correspondence Gmail import", () => {
}));
});
});
test("ignores an unsolicited Gmail success without status, candidate reads or feedback", async () => {
gmailConnected = false;
renderDialog();
await screen.findByText(/no messages yet/i);
const initialReads = [...mockedApi.get.mock.calls];
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: "Forged Gmail success" }, window);
expect(mockedApi.get.mock.calls).toEqual(initialReads);
expect(screen.queryByRole("alert", { hidden: true })).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
expect(window.open).not.toHaveBeenCalled();
});
test("accepts only the pending Gmail popup and callback origin, including a just-closed popup, once", async () => {
gmailConnected = false;
const popup = { closed: false, focus: jest.fn() } as unknown as Window;
(window.open as jest.Mock).mockReturnValue(popup);
renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i }));
await waitFor(() => expect(window.open).toHaveBeenCalledWith(gmailAuthorizationUrl, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes"));
const pendingReads = [...mockedApi.get.mock.calls];
const connected = { source: "jobtracker-gmail-oauth", status: "connected" };
await oauthMessage(connected, popup, "https://forged.example.test");
await oauthMessage(connected, window);
await oauthMessage(connected, null);
await oauthMessage({ source: "jobtracker-microsoft-oauth", status: "connected" }, popup);
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "pending" }, popup);
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: 42 }, popup);
await oauthMessage(null, popup);
expect(mockedApi.get.mock.calls).toEqual(pendingReads);
expect(screen.queryByRole("alert", { hidden: true })).not.toBeInTheDocument();
gmailConnected = true;
Object.assign(popup, { closed: true });
await oauthMessage(connected, popup);
expect(await screen.findByText("Google account linked.")).toBeInTheDocument();
await screen.findByLabelText(/search gmail/i);
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/status")).toHaveLength(2);
expect(mockedApi.get).toHaveBeenCalledWith("/gmail/job-candidates", expect.objectContaining({ params: expect.objectContaining({ jobApplicationId: 42 }) }));
const acceptedReads = [...mockedApi.get.mock.calls];
await oauthMessage({ ...connected, message: "Duplicate terminal callback" }, popup);
expect(mockedApi.get.mock.calls).toEqual(acceptedReads);
expect(screen.queryByText("Duplicate terminal callback")).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("keeps a pending Gmail popup across locale changes and reports terminal error in the current locale once", async () => {
gmailConnected = false;
const popup = { closed: false, focus: jest.fn() } as unknown as Window;
(window.open as jest.Mock).mockReturnValue(popup);
renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i }));
await waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
await act(async () => {
window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" }));
});
await screen.findByRole("button", { name: /^koble til gmail$/i });
const pendingReads = [...mockedApi.get.mock.calls];
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "error" }, popup);
expect(await screen.findByText("Google-autentisering mislyktes.")).toBeInTheDocument();
expect(screen.queryByText("Google authentication failed.")).not.toBeInTheDocument();
expect(mockedApi.get.mock.calls).toEqual(pendingReads);
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: "Error callback already consumed" }, popup);
expect(mockedApi.get.mock.calls).toEqual(pendingReads);
expect(screen.queryByText("Error callback already consumed")).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("does not open a Gmail popup when its connect URL resolves after unmount", async () => {
gmailConnected = false;
let resolveConnect!: (value: { data: { url: string } }) => void;
const connect = new Promise<any>((resolve) => { resolveConnect = resolve; });
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === "/gmail/connect-url" ? connect : get(url, config));
const view = renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i }));
expect(mockedApi.get).toHaveBeenCalledWith("/gmail/connect-url");
view.unmount();
const disposedReads = [...mockedApi.get.mock.calls];
await act(async () => { resolveConnect({ data: { url: gmailAuthorizationUrl } }); });
expect(window.open).not.toHaveBeenCalled();
expect(mockedApi.get.mock.calls).toEqual(disposedReads);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("invalidates the pending Gmail callback when the correspondence job changes", async () => {
gmailConnected = false;
const popup = { closed: false, focus: jest.fn() } as unknown as Window;
(window.open as jest.Mock).mockReturnValue(popup);
const view = renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i }));
await waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
view.rerender(dialog(43));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/correspondence/43"));
const currentJobReads = [...mockedApi.get.mock.calls];
await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: "Previous job Gmail callback" }, popup);
expect(mockedApi.get.mock.calls).toEqual(currentJobReads);
expect(screen.queryByText("Previous job Gmail callback")).not.toBeInTheDocument();
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/job-candidates")).toHaveLength(0);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test.each(["blocked", "rejected"] as const)("delayed %s Gmail startup feedback follows a locale change while the connect URL is pending", async outcome => {
gmailConnected = false;
let resolveConnect!: (value: any) => void;
let rejectConnect!: (cause: unknown) => void;
const pending = new Promise<any>((resolve, reject) => { resolveConnect = resolve; rejectConnect = reject; });
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === "/gmail/connect-url" ? pending : get(url, config));
(window.open as jest.Mock).mockReturnValue(null);
renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i }));
expect(mockedApi.get).toHaveBeenCalledWith("/gmail/connect-url");
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
await screen.findByRole("button", { name: /^koble til gmail$/i });
const reads = [...mockedApi.get.mock.calls];
await act(async () => {
if (outcome === "blocked") resolveConnect({ data: { url: gmailAuthorizationUrl } });
else rejectConnect(new Error("offline"));
});
const key = outcome === "blocked" ? "correspondenceBlockedPopup" : "correspondenceStartGmailFailed";
expect(await screen.findByText(translations.nb[key])).toBeVisible();
expect(screen.queryByText(translations.en[key])).not.toBeInTheDocument();
expect(mockedApi.get.mock.calls).toEqual(reads); expect(window.open).toHaveBeenCalledTimes(outcome === "blocked" ? 1 : 0);
expect(mockedApi.post).not.toHaveBeenCalled();
});
});