230 lines
7 KiB
TypeScript
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);
|
|
});
|
|
});
|