- `revokeStoredKeys`' pre-read of `getTokens` sat outside the best-effort catch. `getTokens` parses through `OAuthTokensSchema`, so a persisted token that no longer validates rejected and abandoned the local delete `--relogin` promises — over a grant that could not have been revoked anyway. Caught and retained as a failed outcome; the clear proceeds. - The multi-key bookkeeping was wrong twice. A token is now marked spent only on a `revoked` outcome, so a failed or unsupported attempt no longer skips a duplicate entry under the other key that may hold the credentials or metadata that would have worked. And a failure now outranks an earlier success for reporting, so a stale grant left live cannot be hidden behind the first key's 200. - `--no-revoke` is now rejected without `--relogin`, ahead of the short-circuit returns, matching the parser's existing stance on accepted-but-inert flags (`--strict`). On its own it reads as "this run will not revoke anything", true only because nothing was being cleared. - Extracted the App draft merge as `utils/serverWithDraftSettings`, so the rule has a test. The hook tests receive whatever they are handed and cannot tell a draft from a persisted entry, and `App.test.tsx`'s settings harness connects a stdio server where the OAuth section never renders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
67 lines
2.2 KiB
TypeScript
67 lines
2.2 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import type {
|
|
InspectorServerSettings,
|
|
ServerEntry,
|
|
} from "@inspector/core/mcp/types.js";
|
|
import { serverWithDraftSettings } from "./serverWithDraftSettings";
|
|
|
|
const settings = (
|
|
over: Partial<InspectorServerSettings> = {},
|
|
): InspectorServerSettings => ({
|
|
headers: [],
|
|
env: [],
|
|
metadata: {},
|
|
connectionTimeout: 0,
|
|
requestTimeout: 0,
|
|
taskTtl: 60000,
|
|
maxFetchRequests: 1000,
|
|
roots: [],
|
|
...over,
|
|
});
|
|
|
|
const entry = (over: Partial<ServerEntry> = {}): ServerEntry =>
|
|
({
|
|
id: "a",
|
|
name: "Server A",
|
|
config: { type: "streamable-http", url: "https://mcp.example/mcp" },
|
|
connection: { status: "disconnected" },
|
|
...over,
|
|
}) as ServerEntry;
|
|
|
|
describe("serverWithDraftSettings", () => {
|
|
// The case the helper exists for (#2144): an action in the settings modal
|
|
// must read the value on screen, not the one the debounced save has written.
|
|
it("prefers an unsaved draft over the persisted settings", () => {
|
|
const result = serverWithDraftSettings(
|
|
entry({ settings: settings({ oauthRevokeOnClear: undefined }) }),
|
|
settings({ oauthRevokeOnClear: false }),
|
|
);
|
|
expect(result.settings?.oauthRevokeOnClear).toBe(false);
|
|
});
|
|
|
|
// The other direction matters just as much: re-checking the box before the
|
|
// save lands must not leave the clear skipping revocation.
|
|
it("prefers a draft that turns the setting back on", () => {
|
|
const result = serverWithDraftSettings(
|
|
entry({ settings: settings({ oauthRevokeOnClear: false }) }),
|
|
settings({ oauthRevokeOnClear: undefined }),
|
|
);
|
|
expect(result.settings?.oauthRevokeOnClear).toBeUndefined();
|
|
});
|
|
|
|
it("returns the entry untouched when there is no draft", () => {
|
|
const original = entry({
|
|
settings: settings({ oauthRevokeOnClear: false }),
|
|
});
|
|
expect(serverWithDraftSettings(original, undefined)).toBe(original);
|
|
});
|
|
|
|
it("keeps the entry's identity and config", () => {
|
|
const original = entry({ settings: settings() });
|
|
const result = serverWithDraftSettings(original, settings({ taskTtl: 1 }));
|
|
expect(result.id).toBe(original.id);
|
|
expect(result.name).toBe(original.name);
|
|
expect(result.config).toBe(original.config);
|
|
expect(result).not.toBe(original);
|
|
});
|
|
});
|