inspector/clients/cli/__tests__/cliOAuth.test.ts
2026-07-02 16:35:31 -07:00

230 lines
7 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from "vitest";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js";
import * as runnerInteractive from "@inspector/core/auth/node/runner-interactive-oauth.js";
import {
handleCliAuthRecoveryRequired,
isStandardOAuthStepUp,
runCliInteractiveOAuth,
withCliAuthRecoveryRetry,
} from "../src/cliOAuth.js";
describe("cliOAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("isStandardOAuthStepUp", () => {
it("returns true for insufficient_scope on non-EMA servers", () => {
expect(
isStandardOAuthStepUp(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{},
),
).toBe(true);
});
it("returns false for EMA servers", () => {
expect(
isStandardOAuthStepUp(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{ enterpriseManaged: true },
),
).toBe(false);
});
});
it("runCliInteractiveOAuth writes success to stderr", async () => {
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
kind: "success",
});
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
const redirectUrlProvider = new MutableRedirectUrlProvider();
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
await runCliInteractiveOAuth(client, redirectUrlProvider, {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
});
expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n");
});
it("runCliInteractiveOAuth throws when scopes remain insufficient", async () => {
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["weather:read"],
};
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
kind: "insufficient_scope",
challenge,
});
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
await expect(
runCliInteractiveOAuth(
client,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{ authChallenge: challenge },
),
).rejects.toThrow(/required scopes were not granted/);
});
it("handleCliAuthRecoveryRequired declines standard step-up when user says no", async () => {
const runSpy = vi
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
.mockResolvedValue({ kind: "success" });
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
const error = new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
);
await expect(
handleCliAuthRecoveryRequired(
client,
error,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{},
async () => false,
),
).rejects.toThrow("Step-up authorization declined.");
expect(runSpy).not.toHaveBeenCalled();
});
it("handleCliAuthRecoveryRequired runs OAuth after step-up confirm", async () => {
const runSpy = vi
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
.mockResolvedValue({ kind: "success" });
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
const authorizationUrl = new URL("https://as.example/authorize");
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["weather:read"],
};
const error = new AuthRecoveryRequiredError(authorizationUrl, challenge);
await handleCliAuthRecoveryRequired(
client,
error,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{},
async () => true,
);
expect(runSpy).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl,
authChallenge: challenge,
}),
);
});
it("handleCliAuthRecoveryRequired skips step-up when storage already satisfies", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
};
const error = new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
{
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
},
);
await handleCliAuthRecoveryRequired(
client,
error,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
);
expect(runSpy).not.toHaveBeenCalled();
});
it("handleCliAuthRecoveryRequired skips OAuth when storage already satisfies reauth", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
};
const error = new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
{ reason: "token_expired" },
);
await handleCliAuthRecoveryRequired(
client,
error,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
);
expect(runSpy).not.toHaveBeenCalled();
});
it("withCliAuthRecoveryRetry reruns the operation after interactive recovery", async () => {
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
kind: "success",
});
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
const fn = vi
.fn()
.mockRejectedValueOnce(
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
reason: "unauthorized",
}),
)
.mockResolvedValueOnce("ok");
const result = await withCliAuthRecoveryRetry(
client,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{ enterpriseManaged: true },
fn,
async () => true,
);
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
});
});