mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 13:01:40 +00:00
* feat(macos): redesign device-code sign-in * chore(i18n): refresh native string inventory * fix(macos): localize device-code completion action * fix(auth): preserve device-code fallback copy * fix(xai): report browser launch accurately
583 lines
19 KiB
TypeScript
583 lines
19 KiB
TypeScript
// Xai tests cover xai oauth plugin behavior.
|
|
import type { ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
|
|
import {
|
|
createRuntimeEnv,
|
|
createTestWizardPrompter,
|
|
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
import type { OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createXaiDeviceCodeAuthMethod,
|
|
createXaiOAuthAuthMethod,
|
|
fetchXaiOAuthDiscovery,
|
|
isTrustedXaiOAuthEndpoint,
|
|
loginXaiDeviceCode,
|
|
refreshXaiOAuthCredential,
|
|
XAI_OAUTH_CLIENT_ID,
|
|
XAI_OAUTH_DISCOVERY_URL,
|
|
XAI_OAUTH_SCOPE,
|
|
} from "./xai-oauth.js";
|
|
|
|
function jsonResponse(value: unknown, init?: ResponseInit): Response {
|
|
return new Response(JSON.stringify(value), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
...init,
|
|
});
|
|
}
|
|
|
|
function createJwt(payload: Record<string, unknown>): string {
|
|
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
|
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
return `${header}.${body}.signature`;
|
|
}
|
|
|
|
function requireStringBody(init: RequestInit | undefined): string {
|
|
if (typeof init?.body !== "string") {
|
|
throw new Error("expected request body to be a string");
|
|
}
|
|
return init.body;
|
|
}
|
|
|
|
function requestUrl(input: RequestInfo | URL): string {
|
|
if (typeof input === "string") {
|
|
return input;
|
|
}
|
|
if (input instanceof URL) {
|
|
return input.href;
|
|
}
|
|
return input.url;
|
|
}
|
|
|
|
describe("xAI OAuth", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("accepts only trusted xAI OAuth endpoints", () => {
|
|
expect(isTrustedXaiOAuthEndpoint("https://auth.x.ai/oauth2/token")).toBe(true);
|
|
expect(isTrustedXaiOAuthEndpoint("https://accounts.x.ai/oauth2/token")).toBe(true);
|
|
expect(isTrustedXaiOAuthEndpoint("http://auth.x.ai/oauth2/token")).toBe(false);
|
|
expect(isTrustedXaiOAuthEndpoint("https://x.ai.evil.test/oauth2/token")).toBe(false);
|
|
expect(isTrustedXaiOAuthEndpoint("not a url")).toBe(false);
|
|
});
|
|
|
|
it("keeps the public auth method named OAuth while using device code", () => {
|
|
const method = createXaiOAuthAuthMethod();
|
|
|
|
expect(method.id).toBe("oauth");
|
|
expect(method.kind).toBe("oauth");
|
|
expect(method.wizard?.choiceId).toBe("xai-oauth");
|
|
expect(method.wizard?.methodId).toBe("oauth");
|
|
});
|
|
|
|
it("preserves device-code as an explicit auth method alias", () => {
|
|
const method = createXaiDeviceCodeAuthMethod();
|
|
|
|
expect(method.id).toBe("device-code");
|
|
expect(method.kind).toBe("device_code");
|
|
expect(method.wizard?.choiceId).toBe("xai-device-code");
|
|
expect(method.wizard?.methodId).toBe("device-code");
|
|
expect(method.wizard?.assistantVisibility).toBe("manual-only");
|
|
});
|
|
|
|
it("validates discovered endpoints before using them", async () => {
|
|
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
|
|
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
jsonResponse({
|
|
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
|
|
token_endpoint: "https://auth.x.ai/oauth2/token",
|
|
}),
|
|
);
|
|
|
|
await expect(fetchXaiOAuthDiscovery({ fetchImpl })).resolves.toEqual({
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
});
|
|
|
|
const discoveryInit = fetchImpl.mock.calls.at(0)?.[1];
|
|
const discoveryHeaders = new Headers(discoveryInit?.headers ?? {});
|
|
expect(discoveryHeaders.get("user-agent")).toBe("openclaw/2026.3.22");
|
|
vi.unstubAllEnvs();
|
|
|
|
const poisonedFetch = vi.fn<typeof fetch>(async () =>
|
|
jsonResponse({
|
|
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
|
|
token_endpoint: "https://evil.test/oauth2/token",
|
|
}),
|
|
);
|
|
|
|
await expect(fetchXaiOAuthDiscovery({ fetchImpl: poisonedFetch })).rejects.toThrow(
|
|
"untrusted token endpoint",
|
|
);
|
|
});
|
|
|
|
it("refreshes with the cached token endpoint and preserves refresh fallback", async () => {
|
|
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
|
|
const fetchImpl = vi.fn<typeof fetch>(async (_url, init) => {
|
|
expect(init?.method).toBe("POST");
|
|
expect(typeof init?.body).toBe("string");
|
|
const body = requireStringBody(init);
|
|
expect(body).toContain("grant_type=refresh_token");
|
|
expect(body).toContain(`client_id=${encodeURIComponent(XAI_OAUTH_CLIENT_ID)}`);
|
|
expect(body).toContain("refresh_token=refresh-1");
|
|
const headers = new Headers(init?.headers ?? {});
|
|
expect(headers.get("user-agent")).toBe("openclaw/2026.3.22");
|
|
return jsonResponse({
|
|
access_token: "access-2",
|
|
expires_in: 120,
|
|
});
|
|
});
|
|
|
|
const credential = {
|
|
type: "oauth",
|
|
provider: "xai",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: 100,
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
} satisfies OAuthCredential & { tokenEndpoint: string };
|
|
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
|
|
|
|
expect(fetchImpl).toHaveBeenCalledWith("https://auth.x.ai/oauth2/token", expect.any(Object));
|
|
expect(refreshed.access).toBe("access-2");
|
|
expect(refreshed.refresh).toBe("refresh-1");
|
|
expect(refreshed.expires).toBe(121_000);
|
|
});
|
|
|
|
it("rediscovers the current token endpoint for stale xAI OAuth credentials", async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>(async (url, init) => {
|
|
if (requestUrl(url) === XAI_OAUTH_DISCOVERY_URL) {
|
|
expect(init?.method).toBeUndefined();
|
|
return jsonResponse({
|
|
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
|
|
token_endpoint: "https://auth.x.ai/oauth2/token",
|
|
});
|
|
}
|
|
expect(requestUrl(url)).toBe("https://auth.x.ai/oauth2/token");
|
|
expect(init?.method).toBe("POST");
|
|
expect(requireStringBody(init)).toContain("refresh_token=refresh-1");
|
|
return jsonResponse({
|
|
access_token: "access-2",
|
|
refresh_token: "refresh-2",
|
|
expires_in: 120,
|
|
});
|
|
});
|
|
const credential = {
|
|
type: "oauth",
|
|
provider: "xai",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: 100,
|
|
tokenEndpoint: "https://auth.x.ai/oauth/token",
|
|
} satisfies OAuthCredential & { tokenEndpoint: string };
|
|
|
|
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
|
|
|
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
expect(fetchImpl.mock.calls.map(([url]) => requestUrl(url))).toEqual([
|
|
XAI_OAUTH_DISCOVERY_URL,
|
|
"https://auth.x.ai/oauth2/token",
|
|
]);
|
|
expect(refreshed).toMatchObject({
|
|
access: "access-2",
|
|
refresh: "refresh-2",
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
});
|
|
});
|
|
|
|
it("does not reuse the stale xAI OAuth token endpoint when discovery fails", async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>(async (url) => {
|
|
expect(requestUrl(url)).toBe(XAI_OAUTH_DISCOVERY_URL);
|
|
throw new Error("discovery unavailable");
|
|
});
|
|
const credential = {
|
|
type: "oauth",
|
|
provider: "xai",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: 100,
|
|
tokenEndpoint: "https://auth.x.ai/oauth/token",
|
|
} satisfies OAuthCredential & { tokenEndpoint: string };
|
|
|
|
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
|
|
"discovery unavailable",
|
|
);
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("retries transient HTML refresh failures before succeeding", async () => {
|
|
vi.useFakeTimers();
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(
|
|
new Response("<!DOCTYPE html><html><body>Attention Required! Cloudflare</body></html>", {
|
|
status: 403,
|
|
headers: {
|
|
"Content-Type": "text/html",
|
|
"cf-mitigated": "challenge",
|
|
},
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response("<!DOCTYPE html><html><body>Just a moment...</body></html>", {
|
|
status: 403,
|
|
headers: {
|
|
"Content-Type": "text/html",
|
|
},
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
access_token: "access-2",
|
|
expires_in: 120,
|
|
}),
|
|
);
|
|
const credential = {
|
|
type: "oauth",
|
|
provider: "xai",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: 100,
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
} satisfies OAuthCredential & { tokenEndpoint: string };
|
|
|
|
const refresh = refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
const refreshed = await refresh;
|
|
|
|
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
|
expect(refreshed.access).toBe("access-2");
|
|
expect(refreshed.refresh).toBe("refresh-1");
|
|
});
|
|
|
|
it("surfaces xAI Cloudflare refresh failures after retry exhaustion", async () => {
|
|
vi.useFakeTimers();
|
|
const fetchImpl = vi.fn<typeof fetch>(
|
|
async () =>
|
|
new Response(
|
|
"<!DOCTYPE html><html><head><title>Attention Required! | Cloudflare</title></head><body>You are unable to access x.ai</body></html>",
|
|
{
|
|
status: 403,
|
|
headers: {
|
|
"Content-Type": "text/html",
|
|
"cf-mitigated": "challenge",
|
|
},
|
|
},
|
|
),
|
|
);
|
|
const credential = {
|
|
type: "oauth",
|
|
provider: "xai",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: 100,
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
} satisfies OAuthCredential & { tokenEndpoint: string };
|
|
|
|
const refresh = refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
|
|
const expectation = expect(refresh).rejects.toThrow(
|
|
"xAI returned an HTML/Cloudflare challenge",
|
|
);
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
|
|
await expectation;
|
|
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("does not retry terminal xAI OAuth refresh errors", async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
jsonResponse(
|
|
{
|
|
error: "invalid_grant",
|
|
error_description: "Invalid or unknown refresh token",
|
|
},
|
|
{ status: 400 },
|
|
),
|
|
);
|
|
const credential = {
|
|
type: "oauth",
|
|
provider: "xai",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: 100,
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
} satisfies OAuthCredential & { tokenEndpoint: string };
|
|
|
|
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
|
|
"invalid_grant (Invalid or unknown refresh token)",
|
|
);
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not retry refresh-token service failures", async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
jsonResponse(
|
|
{
|
|
error: "server_error",
|
|
error_description: "try again later",
|
|
},
|
|
{ status: 503 },
|
|
),
|
|
);
|
|
const credential = {
|
|
type: "oauth",
|
|
provider: "xai",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: 100,
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
} satisfies OAuthCredential & { tokenEndpoint: string };
|
|
|
|
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
|
|
"server_error (try again later)",
|
|
);
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not retry refresh on transport errors so a rotated refresh token is never resent", async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>(async () => {
|
|
throw new Error("socket hang up");
|
|
});
|
|
const credential = {
|
|
type: "oauth",
|
|
provider: "xai",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: 100,
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
} satisfies OAuthCredential & { tokenEndpoint: string };
|
|
|
|
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
|
|
"socket hang up",
|
|
);
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not coerce partial xAI expires_in values", async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
jsonResponse({
|
|
access_token: "access-2",
|
|
expires_in: "120s",
|
|
}),
|
|
);
|
|
const credential = {
|
|
type: "oauth",
|
|
provider: "xai",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: 100,
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
} satisfies OAuthCredential & { tokenEndpoint: string };
|
|
|
|
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
|
|
|
|
expect(refreshed.expires).toBe(100);
|
|
});
|
|
|
|
it("preserves the cached xAI expiry when token lifetimes overflow safe milliseconds", async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
jsonResponse({
|
|
access_token: createJwt({ exp: Number.MAX_SAFE_INTEGER }),
|
|
expires_in: Number.MAX_SAFE_INTEGER,
|
|
}),
|
|
);
|
|
const credential = {
|
|
type: "oauth",
|
|
provider: "xai",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: 100,
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
} satisfies OAuthCredential & { tokenEndpoint: string };
|
|
|
|
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
|
|
|
|
expect(refreshed.expires).toBe(100);
|
|
});
|
|
|
|
it("ignores unsafe JWT expiry fallbacks from xAI access tokens", async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
jsonResponse({
|
|
access_token: createJwt({ exp: Number.MAX_SAFE_INTEGER }),
|
|
}),
|
|
);
|
|
const credential = {
|
|
type: "oauth",
|
|
provider: "xai",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: 100,
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
} satisfies OAuthCredential & { tokenEndpoint: string };
|
|
|
|
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
|
|
|
|
expect(refreshed.expires).toBe(100);
|
|
});
|
|
|
|
it("logs in with xAI device code without a localhost callback", async () => {
|
|
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
|
|
const progress = {
|
|
update: vi.fn(),
|
|
stop: vi.fn(),
|
|
};
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
|
|
device_authorization_endpoint: "https://auth.x.ai/oauth2/device/code",
|
|
token_endpoint: "https://auth.x.ai/oauth2/token",
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
device_code: "device-code-1",
|
|
user_code: "ABCD-1234",
|
|
verification_uri: "https://accounts.x.ai/oauth2/device",
|
|
verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-1234",
|
|
expires_in: 900,
|
|
interval: 5,
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
access_token: createJwt({ exp: 4, sub: "acct-1" }),
|
|
refresh_token: "refresh-1",
|
|
id_token: createJwt({
|
|
sub: "acct-1",
|
|
email: "dev@example.com",
|
|
name: "Dev User",
|
|
}),
|
|
expires_in: 120,
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchImpl);
|
|
const deviceCode = vi.fn(async () => {});
|
|
const openUrl = vi.fn(async () => {});
|
|
const log = vi.fn();
|
|
const runtime = { ...createRuntimeEnv(), log };
|
|
const ctx: ProviderAuthContext = {
|
|
config: {},
|
|
isRemote: true,
|
|
openUrl,
|
|
prompter: createTestWizardPrompter({
|
|
progress: vi.fn(() => progress),
|
|
deviceCode,
|
|
}),
|
|
runtime,
|
|
oauth: {
|
|
createVpsAwareHandlers: () => {
|
|
throw new Error("unexpected VPS OAuth handler request");
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await loginXaiDeviceCode(ctx);
|
|
|
|
expect(openUrl).toHaveBeenCalledWith("https://accounts.x.ai/oauth2/device?user_code=ABCD-1234");
|
|
expect(deviceCode).toHaveBeenCalledWith({
|
|
title: "xAI OAuth",
|
|
code: "ABCD-1234",
|
|
expiresInMinutes: 15,
|
|
message: "Enter this one-time code on the xAI sign-in page.",
|
|
});
|
|
expect(openUrl.mock.invocationCallOrder[0]).toBeLessThan(
|
|
deviceCode.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER,
|
|
);
|
|
const remoteLog = log.mock.calls[0]?.[0];
|
|
expect(remoteLog).toContain("https://accounts.x.ai/oauth2/device");
|
|
expect(remoteLog).not.toContain("ABCD-1234");
|
|
const deviceRequest = fetchImpl.mock.calls[1]?.[1];
|
|
expect(deviceRequest?.method).toBe("POST");
|
|
const deviceBody = requireStringBody(deviceRequest);
|
|
expect(deviceBody).toContain(`client_id=${encodeURIComponent(XAI_OAUTH_CLIENT_ID)}`);
|
|
expect(deviceBody).toContain(`scope=${encodeURIComponent(XAI_OAUTH_SCOPE)}`);
|
|
|
|
const tokenRequest = fetchImpl.mock.calls[2]?.[1];
|
|
expect(tokenRequest?.method).toBe("POST");
|
|
const tokenBody = requireStringBody(tokenRequest);
|
|
expect(tokenBody).toContain(
|
|
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code",
|
|
);
|
|
expect(tokenBody).toContain("device_code=device-code-1");
|
|
|
|
expect(result.profiles[0]?.credential).toMatchObject({
|
|
type: "oauth",
|
|
provider: "xai",
|
|
refresh: "refresh-1",
|
|
email: "dev@example.com",
|
|
displayName: "Dev User",
|
|
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
|
deviceAuthorizationEndpoint: "https://auth.x.ai/oauth2/device/code",
|
|
issuer: "https://auth.x.ai",
|
|
authFlow: "device-code",
|
|
accountId: "acct-1",
|
|
access: expect.any(String),
|
|
});
|
|
expect(progress.update).toHaveBeenCalledWith("Waiting for xAI device authorization...");
|
|
expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete");
|
|
});
|
|
|
|
it("falls back for unsafe xAI device-code lifetime fields", async () => {
|
|
const progress = {
|
|
update: vi.fn(),
|
|
stop: vi.fn(),
|
|
};
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
|
|
device_authorization_endpoint: "https://auth.x.ai/oauth2/device/code",
|
|
token_endpoint: "https://auth.x.ai/oauth2/token",
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
device_code: "device-code-1",
|
|
user_code: "ABCD-1234",
|
|
verification_uri: "https://accounts.x.ai/oauth2/device",
|
|
expires_in: Number.MAX_SAFE_INTEGER,
|
|
interval: Number.MAX_SAFE_INTEGER,
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
access_token: "access-token",
|
|
refresh_token: "refresh-1",
|
|
expires_in: 120,
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchImpl);
|
|
const note = vi.fn<(message: string, title?: string) => Promise<void>>(async () => {});
|
|
const ctx: ProviderAuthContext = {
|
|
config: {},
|
|
isRemote: true,
|
|
openUrl: vi.fn(async () => {}),
|
|
prompter: createTestWizardPrompter({
|
|
progress: vi.fn(() => progress),
|
|
note,
|
|
}),
|
|
runtime: createRuntimeEnv(),
|
|
oauth: {
|
|
createVpsAwareHandlers: () => {
|
|
throw new Error("unexpected VPS OAuth handler request");
|
|
},
|
|
},
|
|
};
|
|
|
|
await loginXaiDeviceCode(ctx);
|
|
|
|
expect(note).toHaveBeenCalledWith(
|
|
expect.stringContaining("Code expires in 5 minutes."),
|
|
"xAI OAuth",
|
|
);
|
|
expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete");
|
|
});
|
|
});
|