diff --git a/extensions/minimax/oauth.test.ts b/extensions/minimax/oauth.test.ts index 1ad24f37eb4..a4116c4888b 100644 --- a/extensions/minimax/oauth.test.ts +++ b/extensions/minimax/oauth.test.ts @@ -1,7 +1,10 @@ +import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; import { loginMiniMaxPortalOAuth, normalizeOAuthExpires } from "./oauth.js"; afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -54,4 +57,152 @@ describe("loginMiniMaxPortalOAuth", () => { ).rejects.toThrow("invalid expired_in"); expect(note).not.toHaveBeenCalled(); }); + + it("caps oversized authorization poll intervals before scheduling", async () => { + vi.useFakeTimers(); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + let callCount = 0; + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + callCount += 1; + const body = + init?.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(typeof init?.body === "string" ? init.body : ""); + if (callCount === 1) { + return new Response( + JSON.stringify({ + user_code: "CODE", + verification_uri: "https://example.com/device", + expired_in: Date.now() + MAX_TIMER_TIMEOUT_MS + 10_000, + interval: Number.MAX_SAFE_INTEGER, + state: body.get("state"), + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response( + JSON.stringify( + callCount === 2 + ? { status: "pending" } + : { + status: "success", + access_token: "access", + refresh_token: "refresh", + expired_in: 3600, + }, + ), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = loginMiniMaxPortalOAuth({ + openUrl: vi.fn(async () => undefined), + note: vi.fn(async () => undefined), + progress: { update: vi.fn(), stop: vi.fn() }, + }); + + await vi.waitFor(() => { + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS); + }); + + await vi.advanceTimersByTimeAsync(MAX_TIMER_TIMEOUT_MS); + await expect(result).resolves.toMatchObject({ access: "access", refresh: "refresh" }); + }); + + it("does not sleep past the authorization expiry deadline", async () => { + vi.useFakeTimers(); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + let callCount = 0; + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + callCount += 1; + const body = + init?.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(typeof init?.body === "string" ? init.body : ""); + if (callCount === 1) { + return new Response( + JSON.stringify({ + user_code: "CODE", + verification_uri: "https://example.com/device", + expired_in: Date.now() + 10_000, + interval: Number.MAX_SAFE_INTEGER, + state: body.get("state"), + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response(JSON.stringify({ status: "pending" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = loginMiniMaxPortalOAuth({ + openUrl: vi.fn(async () => undefined), + note: vi.fn(async () => undefined), + progress: { update: vi.fn(), stop: vi.fn() }, + }); + + await vi.waitFor(() => { + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10_000); + }); + + const rejection = expect(result).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(10_000); + await rejection; + }); + + it("keeps the default poll delay for zero authorization intervals", async () => { + vi.useFakeTimers(); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + let callCount = 0; + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + callCount += 1; + const body = + init?.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(typeof init?.body === "string" ? init.body : ""); + if (callCount === 1) { + return new Response( + JSON.stringify({ + user_code: "CODE", + verification_uri: "https://example.com/device", + expired_in: Date.now() + 10_000, + interval: 0, + state: body.get("state"), + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response( + JSON.stringify( + callCount === 2 + ? { status: "pending" } + : { + status: "success", + access_token: "access", + refresh_token: "refresh", + expired_in: 3600, + }, + ), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = loginMiniMaxPortalOAuth({ + openUrl: vi.fn(async () => undefined), + note: vi.fn(async () => undefined), + progress: { update: vi.fn(), stop: vi.fn() }, + }); + + await vi.waitFor(() => { + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2_000); + }); + + await vi.advanceTimersByTimeAsync(2_000); + await expect(result).resolves.toMatchObject({ access: "access", refresh: "refresh" }); + }); }); diff --git a/extensions/minimax/oauth.ts b/extensions/minimax/oauth.ts index a51fea7d8f1..7870b11bc44 100644 --- a/extensions/minimax/oauth.ts +++ b/extensions/minimax/oauth.ts @@ -3,6 +3,7 @@ import { MAX_DATE_TIMESTAMP_MS, asSafeIntegerInRange, resolveExpiresAtMsFromDurationOrEpoch, + resolvePositiveTimerTimeoutMs, } from "openclaw/plugin-sdk/number-runtime"; import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth"; import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env"; @@ -259,7 +260,7 @@ export async function loginMiniMaxPortalOAuth(params: { // Fall back to manual copy/paste if browser open fails. } - let pollIntervalMs = oauth.interval ? oauth.interval : 2000; + let pollIntervalMs = resolvePositiveTimerTimeoutMs(oauth.interval, 2000); // The authorization endpoint returns an absolute millisecond deadline. const expireTimeMs = oauth.expired_in; @@ -279,7 +280,11 @@ export async function loginMiniMaxPortalOAuth(params: { throw new Error(result.message); } - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + const remainingMs = Math.max(0, expireTimeMs - Date.now()); + if (remainingMs <= 0) { + break; + } + await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remainingMs))); pollIntervalMs = Math.max(pollIntervalMs, 2000); }