inspector/clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts
Bob Dickinson 33e88eac9f
CLI: catalog listing, auth flags, and OAuth browser UX (#1782)
* feat(cli): catalog listing, auth flags, and OAuth browser UX

Add servers/list and servers/show, --relogin / --stored-auth-only, browser
OAuth navigation with OSC 8 links, and extract method handlers under
clients/cli/src/handlers. Tracks #1781.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address #1782 review — relogin clear, stored-auth-only, docs

Clear both raw and normalized OAuth keys on --relogin; check the shared
store before bailing under --stored-auth-only mid-session; restore the
tests README link target; document servers/show redaction limits; keep
mcpi groundwork with TODO(#1432) markers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): gate OAuth browser open and harden #1782 review follow-ups

Arm auto-open only for CLI-owned interactive OAuth, honor MCP_AUTO_OPEN_ENABLED,
move --relogin clear after validation, skip keychain on servers/list, and cover
secret redaction after rehydrate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): silence disarmed OAuth URLs and round-3 review nits

Suppress authorize URL print when navigation is disarmed or
--stored-auth-only; document MCP_AUTO_OPEN_ENABLED; redact Cookie/auth
headers; keep servers/show unit tests off the OS keychain.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): cover withArmedAutoOpen for the ≥90 branch gate

CI failed on cliOAuth.ts branch coverage after the auto-open arming
helper landed; exercise the armed path through runCliInteractiveOAuth.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): mid-RPC unauthorized recovery and clear round-4 nits

Mirror connect's UnauthorizedError path in withCliAuthRecoveryRetry, let
MCP_AUTO_OPEN_ENABLED=true force open off-TTY, mock open-url in the
in-process runner, reject --relogin on catalog methods, restore the
__tests__ README inventory, and narrow OAuth helpers to structural client types.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): reconnect after mid-RPC OAuth and finish round-5 nits

Reconnect before retrying after unauthorized recovery, guard non-OAuth
configs, decouple forceAutoOpen, move open-url mock to vitest setupFiles,
and reject --relogin with stored-auth short-circuits.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): fail fast on non-TTY interactive OAuth and round-6 nits

Skip interactive OAuth without a TTY unless MCP_AUTO_OPEN_ENABLED=true;
document --stored-auth-only for CI; redact settings.metadata; reject
--relogin for stdio; clarify AuthRecoveryRequired reconnect comments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): admit interactive OAuth when stdin is a TTY (round-7)

Gate on stdin||stderr so `2>&1 | tee` still works; fold confirm/auto-open/
TTY into CliOAuthConnectOptions; rename authRequiredFailure; document
MCP_AUTO_OPEN_ENABLED=true as non-TTY admit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): fail fast on step-up without stdin (round-8)

Decline [y/N] on readline EOF/close, require a stdin TTY for the default
step-up confirmer (force-open admit is not enough), document stderr-only
browser auto-open vs stdin||stderr flow admit, and drop the alias wrapper.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): allow piped step-up answers; bound silent pipes (round-9)

Drop the hard stdin-TTY gate that broke `echo y | …`. Keep EOF decline and
add a short non-TTY timeout so an open pipe that never writes cannot hang.
Clarify isTTY as admit-only; extract the readline mock factory.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): keep partial piped step-up answers (round-10)

Capture readline `line` before close so `printf y` without a newline is
not treated as decline; timeout is auth_required with a distinct message;
document the 5s bound and post-confirm callback wait; pin the TTY no-timeout arm.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): tighten step-up timeout messaging and TTY test (round-11)

Make the TTY no-timeout assertion use a settled flag (the Promise.race
check was vacuous), say "timed out" instead of "declined" on the pipe
bound, and document that piped answers need a newline or stdin close.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:55:25 -04:00

112 lines
3.9 KiB
TypeScript

import { describe, it, expect, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
getStateFilePath,
resetNodeOAuthStorageCache,
} from "@inspector/core/auth/node/storage-node.js";
import { clearStoredAuthForRelogin } from "../src/clear-stored-auth-for-relogin.js";
describe("clearStoredAuthForRelogin", () => {
let dir: string | undefined;
let prevPath: string | undefined;
afterEach(() => {
if (prevPath === undefined)
delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prevPath;
resetNodeOAuthStorageCache();
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
dir = undefined;
}
});
it("no-ops when serverUrl is missing or blank", async () => {
await clearStoredAuthForRelogin(undefined);
await clearStoredAuthForRelogin(" ");
});
it("clears a URL-keyed entry and tolerates non-URL keys", async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-"));
const file = path.join(dir, "oauth.json");
fs.writeFileSync(
file,
JSON.stringify({
servers: {
"https://example.com/mcp": {
tokens: { access_token: "a", token_type: "Bearer" },
},
"not a url": {
tokens: { access_token: "b", token_type: "Bearer" },
},
},
idpSessions: {},
}),
"utf8",
);
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
resetNodeOAuthStorageCache();
expect(getStateFilePath()).toBe(file);
await clearStoredAuthForRelogin("https://example.com/mcp");
let blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
servers: Record<string, unknown>;
};
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
expect(blob.servers["not a url"]).toBeDefined();
// normalizeServerUrl catch path — clears under the raw key
await clearStoredAuthForRelogin("not a url");
blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
servers: Record<string, unknown>;
};
expect(blob.servers["not a url"]).toBeUndefined();
});
it("clears both raw and URL-normalised keys (bare origin / mixed-case host)", async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-norm-"));
const file = path.join(dir, "oauth.json");
// Runtime storage keys by the transport's raw url string — often without a
// trailing slash / with mixed-case host — while new URL().href normalises.
fs.writeFileSync(
file,
JSON.stringify({
servers: {
"https://example.com": {
tokens: { access_token: "bare", token_type: "Bearer" },
},
"https://example.com/": {
tokens: { access_token: "slash", token_type: "Bearer" },
},
"https://Example.com/mcp": {
tokens: { access_token: "mixed", token_type: "Bearer" },
},
},
idpSessions: {},
}),
"utf8",
);
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
resetNodeOAuthStorageCache();
await clearStoredAuthForRelogin("https://example.com");
let blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
servers: Record<string, unknown>;
};
expect(blob.servers["https://example.com"]).toBeUndefined();
expect(blob.servers["https://example.com/"]).toBeUndefined();
expect(blob.servers["https://Example.com/mcp"]).toBeDefined();
await clearStoredAuthForRelogin("https://Example.com/mcp");
blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
servers: Record<string, unknown>;
};
expect(blob.servers["https://Example.com/mcp"]).toBeUndefined();
// Normalised key (lowercased host) is cleared too when distinct.
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
});
});