fix(auth): bind mailbox callbacks to pending popup
This commit is contained in:
@@ -44,6 +44,7 @@ import {
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { emailOAuthCallbackOrigin, emailOAuthMessage, type EmailOAuthAttempt } from "../emailOAuth";
|
||||
|
||||
function parseRawEmail(raw: string): { subject?: string; date?: string; from?: string; to?: string; body: string } {
|
||||
const lines = raw.replace(/\r\n/g, "\n").split("\n");
|
||||
@@ -155,6 +156,16 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
const [manageNote, setManageNote] = useState("");
|
||||
const [manageSaving, setManageSaving] = useState(false);
|
||||
const autoRefreshKeyRef = useRef<string | null>(null);
|
||||
const gmailOAuth = useRef<{ active: boolean; generation: number; starting: boolean; attempt: EmailOAuthAttempt | null }>({ active: true, generation: 0, starting: false, attempt: null });
|
||||
const startupTranslation = useRef(t);
|
||||
|
||||
useEffect(() => { startupTranslation.current = t; }, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const session = gmailOAuth.current;
|
||||
session.active = true;
|
||||
return () => { session.active = false; session.generation += 1; session.starting = false; session.attempt = null; };
|
||||
}, [jobId]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setMessagesLoading(true);
|
||||
@@ -292,8 +303,11 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const data = event.data as { source?: string; status?: string; message?: string };
|
||||
if (data?.source !== "jobtracker-gmail-oauth") return;
|
||||
const session = gmailOAuth.current;
|
||||
if (!session.active || !session.attempt) return;
|
||||
const data = emailOAuthMessage(event, session.attempt);
|
||||
if (!data) return;
|
||||
session.attempt = null;
|
||||
if (data.status === "connected") {
|
||||
toast(data.message || t("googleLinkedSuccess"), "success");
|
||||
void loadGmailStatus();
|
||||
@@ -356,12 +370,24 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
};
|
||||
|
||||
const connectGmail = async () => {
|
||||
const session = gmailOAuth.current;
|
||||
if (!session.active || session.starting) return;
|
||||
if (session.attempt && !session.attempt.popup.closed) { session.attempt.popup.focus(); return; }
|
||||
session.attempt = null;
|
||||
session.starting = true;
|
||||
const generation = session.generation;
|
||||
try {
|
||||
const res = await api.get<{ url: string }>("/gmail/connect-url");
|
||||
if (!session.active || generation !== session.generation) return;
|
||||
const origin = emailOAuthCallbackOrigin(res.data.url, "gmail");
|
||||
if (!origin) { toast(startupTranslation.current("correspondenceStartGmailFailed"), "error"); return; }
|
||||
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
|
||||
if (!popup) toast(t("correspondenceBlockedPopup"), "error");
|
||||
if (!popup) toast(startupTranslation.current("correspondenceBlockedPopup"), "error");
|
||||
else session.attempt = { provider: "gmail", origin, popup };
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("correspondenceStartGmailFailed")), "error");
|
||||
if (session.active && generation === session.generation) toast(getApiErrorMessage(error, startupTranslation.current("correspondenceStartGmailFailed")), "error");
|
||||
} finally {
|
||||
if (generation === session.generation) session.starting = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
@@ -7,6 +7,7 @@ import { api, getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useToast } from "../toast";
|
||||
import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types";
|
||||
import { emailOAuthCallbackOrigin, emailOAuthMessage, type EmailOAuthAttempt, type EmailOAuthProvider } from "../emailOAuth";
|
||||
|
||||
// Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use
|
||||
// the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController /
|
||||
@@ -22,6 +23,16 @@ export default function EmailProviderConnections() {
|
||||
|
||||
const [imapForm, setImapForm] = useState({ host: "", port: 993, useSsl: true, username: "", password: "" });
|
||||
const [imapConnecting, setImapConnecting] = useState(false);
|
||||
const oauth = useRef({ mounted: true, generation: 0, starting: new Set<EmailOAuthProvider>(), attempts: new Map<EmailOAuthProvider, EmailOAuthAttempt>() });
|
||||
const startupTranslation = useRef(t);
|
||||
|
||||
useEffect(() => { startupTranslation.current = t; }, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const session = oauth.current;
|
||||
session.mounted = true;
|
||||
return () => { session.mounted = false; session.generation += 1; session.starting.clear(); session.attempts.clear(); };
|
||||
}, []);
|
||||
|
||||
const loadGmailStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -58,21 +69,18 @@ export default function EmailProviderConnections() {
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const data = event.data as { source?: string; status?: string; message?: string };
|
||||
if (data?.source === "jobtracker-gmail-oauth") {
|
||||
if (!oauth.current.mounted) return;
|
||||
for (const [provider, attempt] of oauth.current.attempts) {
|
||||
const data = emailOAuthMessage(event, attempt);
|
||||
if (!data) continue;
|
||||
oauth.current.attempts.delete(provider);
|
||||
if (data.status === "connected") {
|
||||
toast(data.message || t("emailConnectionsGmailConnected"), "success");
|
||||
void loadGmailStatus();
|
||||
toast(data.message || t(provider === "gmail" ? "emailConnectionsGmailConnected" : "emailConnectionsOutlookConnected"), "success");
|
||||
void (provider === "gmail" ? loadGmailStatus() : loadMicrosoftStatus());
|
||||
} else {
|
||||
toast(data.message || t("emailConnectionsGmailFailed"), "error");
|
||||
}
|
||||
} else if (data?.source === "jobtracker-microsoft-oauth") {
|
||||
if (data.status === "connected") {
|
||||
toast(data.message || t("emailConnectionsOutlookConnected"), "success");
|
||||
void loadMicrosoftStatus();
|
||||
} else {
|
||||
toast(data.message || t("emailConnectionsOutlookFailed"), "error");
|
||||
toast(data.message || t(provider === "gmail" ? "emailConnectionsGmailFailed" : "emailConnectionsOutlookFailed"), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,13 +88,26 @@ export default function EmailProviderConnections() {
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [loadGmailStatus, loadMicrosoftStatus, t, toast]);
|
||||
|
||||
const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => {
|
||||
const connectViaPopup = async (provider: EmailOAuthProvider, connectUrlPath: string, popupName: string, providerLabel: string) => {
|
||||
const session = oauth.current;
|
||||
if (!session.mounted || session.starting.has(provider)) return;
|
||||
const previous = session.attempts.get(provider);
|
||||
if (previous && !previous.popup.closed) { previous.popup.focus(); return; }
|
||||
session.attempts.delete(provider);
|
||||
session.starting.add(provider);
|
||||
const generation = session.generation;
|
||||
try {
|
||||
const res = await api.get<{ url: string }>(connectUrlPath);
|
||||
if (!session.mounted || generation !== session.generation) return;
|
||||
const origin = emailOAuthCallbackOrigin(res.data.url, provider);
|
||||
if (!origin) { toast(startupTranslation.current("emailConnectionsStartFailed", { provider: providerLabel }), "error"); return; }
|
||||
const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes");
|
||||
if (!popup) toast(t("emailConnectionsPopupBlocked"), "error");
|
||||
if (!popup) toast(startupTranslation.current("emailConnectionsPopupBlocked"), "error");
|
||||
else session.attempts.set(provider, { provider, origin, popup });
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("emailConnectionsStartFailed", { provider: providerLabel })), "error");
|
||||
if (session.mounted && generation === session.generation) toast(getApiErrorMessage(error, startupTranslation.current("emailConnectionsStartFailed", { provider: providerLabel })), "error");
|
||||
} finally {
|
||||
if (generation === session.generation) session.starting.delete(provider);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,7 +151,7 @@ export default function EmailProviderConnections() {
|
||||
label="Gmail"
|
||||
connected={Boolean(gmailStatus?.connected)}
|
||||
address={gmailStatus?.gmailAddress ?? null}
|
||||
onConnect={() => void connectViaPopup("/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
|
||||
onConnect={() => void connectViaPopup("gmail", "/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
|
||||
onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")}
|
||||
/>
|
||||
|
||||
@@ -140,7 +161,7 @@ export default function EmailProviderConnections() {
|
||||
label="Outlook / Microsoft 365"
|
||||
connected={Boolean(microsoftStatus?.connected)}
|
||||
address={microsoftStatus?.mailAddress ?? null}
|
||||
onConnect={() => void connectViaPopup("/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
|
||||
onConnect={() => void connectViaPopup("microsoft", "/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
|
||||
onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export type EmailOAuthProvider = "gmail" | "microsoft";
|
||||
export type EmailOAuthAttempt = { provider: EmailOAuthProvider; origin: string; popup: Window };
|
||||
type TerminalMessage = { status: "connected" | "error"; message?: string };
|
||||
|
||||
export function emailOAuthCallbackOrigin(authorizationUrl: unknown, provider: EmailOAuthProvider): string | null {
|
||||
if (typeof authorizationUrl !== "string") return null;
|
||||
try {
|
||||
const authorization = new URL(authorizationUrl);
|
||||
if (!["https:", "http:"].includes(authorization.protocol) || authorization.username || authorization.password) return null;
|
||||
const redirects = authorization.searchParams.getAll("redirect_uri");
|
||||
if (redirects.length !== 1) return null;
|
||||
const callback = new URL(redirects[0]);
|
||||
const path = provider === "gmail" ? "/api/gmail/oauth/callback" : "/api/microsoft-graph/oauth/callback";
|
||||
return ["https:", "http:"].includes(callback.protocol) && !callback.username && !callback.password
|
||||
&& callback.pathname === path && !callback.search && !callback.hash ? callback.origin : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function emailOAuthMessage(event: MessageEvent, attempt: EmailOAuthAttempt): TerminalMessage | null {
|
||||
if (event.origin !== attempt.origin || event.source !== attempt.popup) return null;
|
||||
const data = event.data as { source?: unknown; status?: unknown; message?: unknown } | null;
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)
|
||||
|| data.source !== (attempt.provider === "gmail" ? "jobtracker-gmail-oauth" : "jobtracker-microsoft-oauth")
|
||||
|| (data.status !== "connected" && data.status !== "error")
|
||||
|| (data.message !== undefined && typeof data.message !== "string")) return null;
|
||||
return { status: data.status, message: data.message };
|
||||
}
|
||||
Reference in New Issue
Block a user