Merge remote-tracking branch 'origin/v2/main' into v2/fix/2172-oidc-discovery-compat
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # README.md # core/mcp/inspectorClient.ts # test-servers/src/test-server-oauth.ts
This commit is contained in:
commit
2eeab05d00
2 changed files with 8524 additions and 1598 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
|
@ -8,6 +8,8 @@ import {
|
|||
} from "@inspector/core/auth/node/storage-node.js";
|
||||
import { clearStoredAuthForRelogin } from "../src/clear-stored-auth-for-relogin.js";
|
||||
|
||||
const AS_ISSUER = "https://as.example.com";
|
||||
|
||||
describe("clearStoredAuthForRelogin", () => {
|
||||
let dir: string | undefined;
|
||||
let prevPath: string | undefined;
|
||||
|
|
@ -66,6 +68,386 @@ describe("clearStoredAuthForRelogin", () => {
|
|||
expect(blob.servers["not a url"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// #2144 — RFC 7009. The request is *built* before the delete (from the token,
|
||||
// the client id and the cached metadata it removes) and *sent* after it, so
|
||||
// the local delete never waits on the network.
|
||||
describe("token revocation", () => {
|
||||
function seed(over: Record<string, unknown> = {}): string {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-revoke-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
// Issuer-bound (SEP-2352), matching the cached metadata below: an
|
||||
// unkeyed grant records no authorization server and is refused, so
|
||||
// seeding one would make these cases assert that instead.
|
||||
"https://example.com/mcp": {
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
[AS_ISSUER]: {
|
||||
tokens: {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r",
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: {
|
||||
issuer: AS_ISSUER,
|
||||
authorization_endpoint: `${AS_ISSUER}/authorize`,
|
||||
token_endpoint: `${AS_ISSUER}/token`,
|
||||
revocation_endpoint: `${AS_ISSUER}/revoke`,
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
...over,
|
||||
},
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Seed both key spellings of one server, each with its own refresh token. */
|
||||
function seedBothSpellings(
|
||||
normalizedToken: string,
|
||||
rawToken: string,
|
||||
): void {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-two-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
const metadata = {
|
||||
issuer: AS_ISSUER,
|
||||
authorization_endpoint: `${AS_ISSUER}/authorize`,
|
||||
token_endpoint: `${AS_ISSUER}/token`,
|
||||
revocation_endpoint: `${AS_ISSUER}/revoke`,
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
const entry = (refresh: string) => ({
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
[AS_ISSUER]: {
|
||||
tokens: {
|
||||
access_token: `a-${refresh}`,
|
||||
token_type: "Bearer",
|
||||
refresh_token: refresh,
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: metadata,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
"https://example.com": entry(rawToken),
|
||||
"https://example.com/": entry(normalizedToken),
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
}
|
||||
|
||||
it("revokes the stored grant, having snapshotted it before the delete", async () => {
|
||||
const file = seed();
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
try {
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com/mcp"),
|
||||
).resolves.toMatchObject({
|
||||
status: "revoked",
|
||||
tokenTypeHint: "refresh_token",
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(String(url)).toBe("https://as.example.com/revoke");
|
||||
expect(new URLSearchParams(String(init?.body)).get("token")).toBe("r");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
const blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Both spellings resolve to the same stored entry, so this is one grant and
|
||||
// must produce one request. It also proves the walk does not stop at the
|
||||
// first empty key: the raw spelling holds nothing.
|
||||
it("sends one request when both key spellings name the same grant", async () => {
|
||||
seed();
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
try {
|
||||
// Normalises to the stored `https://example.com/mcp`.
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://Example.com/mcp"),
|
||||
).resolves.toMatchObject({ status: "revoked" });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Both keys are deleted, so both must be revoked: a stale entry under the
|
||||
// other spelling is still a live grant at the authorization server, and
|
||||
// dropping it locally without revoking is the exact leak this closes.
|
||||
// The *reported* outcome is the normalised key's, matching the precedence
|
||||
// `findStoredServerState` reads with — that is the grant a connect uses.
|
||||
it("revokes a stale entry under the other spelling too", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-both-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
const metadata = {
|
||||
issuer: AS_ISSUER,
|
||||
authorization_endpoint: `${AS_ISSUER}/authorize`,
|
||||
token_endpoint: `${AS_ISSUER}/token`,
|
||||
revocation_endpoint: `${AS_ISSUER}/revoke`,
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
// The raw spelling the transport keyed by, holding a STALE grant.
|
||||
"https://example.com": {
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
[AS_ISSUER]: {
|
||||
tokens: {
|
||||
access_token: "stale-a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "stale-r",
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: metadata,
|
||||
},
|
||||
// The normalised spelling, holding the CURRENT grant.
|
||||
"https://example.com/": {
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
[AS_ISSUER]: {
|
||||
tokens: {
|
||||
access_token: "live-a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "live-r",
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: metadata,
|
||||
},
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
try {
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com"),
|
||||
).resolves.toMatchObject({ status: "revoked" });
|
||||
const tokens = fetchSpy.mock.calls.map((call) =>
|
||||
new URLSearchParams(String(call[1]?.body)).get("token"),
|
||||
);
|
||||
// Normalised first, then the stale raw one — neither is left behind.
|
||||
expect(tokens).toEqual(["live-r", "stale-r"]);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
|
||||
const 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();
|
||||
});
|
||||
|
||||
it("deletes the local entry even when the request fails", async () => {
|
||||
const file = seed();
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockRejectedValue(new Error("unreachable"));
|
||||
try {
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com/mcp"),
|
||||
).resolves.toMatchObject({ status: "failed" });
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
const blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips the request when revocation is turned off", async () => {
|
||||
seed();
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
try {
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com/mcp", {
|
||||
revoke: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Keys are revoked from independently. Deduplicating them could only be
|
||||
// done by pre-reading one token, which would skip the second key's OTHER
|
||||
// issuer-bound grants along with it; a duplicate RFC 7009 request is
|
||||
// harmless (§2.2 makes an unknown token a success), a missed one is not.
|
||||
it("revokes from both keys even when they hold the same token", async () => {
|
||||
seedBothSpellings("same-r", "same-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(null, { status: 500 }));
|
||||
try {
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com"),
|
||||
).resolves.toMatchObject({ status: "failed" });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// A grant still live at the authorization server is what the user needs to
|
||||
// hear about; reporting the earlier success would print no warning at all.
|
||||
// One budget across both keys, so a first request that outlives it fails
|
||||
// rather than each key getting a fresh five seconds.
|
||||
it("bounds both keys with one shared budget", async () => {
|
||||
seedBothSpellings("live-r", "stale-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async () => {
|
||||
// Outlives the injected budget, so the second key is never attempted.
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
try {
|
||||
const outcome = await clearStoredAuthForRelogin("https://example.com", {
|
||||
budgetMs: 20,
|
||||
});
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome?.status === "failed" ? outcome.detail : "").toContain(
|
||||
"timed out after 20ms",
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// A plan reached with no budget left is reported rather than firing a
|
||||
// request it would immediately abandon — and that report must outrank an
|
||||
// earlier success, since the unattempted key's grant may still be live.
|
||||
it("reports exhaustion for a key it never attempted", async () => {
|
||||
seedBothSpellings("live-r", "stale-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
try {
|
||||
const outcome = await clearStoredAuthForRelogin("https://example.com", {
|
||||
budgetMs: 0,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome?.status === "failed" ? outcome.detail : "").toContain(
|
||||
"budget was exhausted",
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a later failure over an earlier success", async () => {
|
||||
seedBothSpellings("live-r", "stale-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
||||
.mockResolvedValue(new Response(null, { status: 500 }));
|
||||
try {
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com"),
|
||||
).resolves.toMatchObject({ status: "failed" });
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// `getTokens` parses through `OAuthTokensSchema`, so a persisted token that
|
||||
// no longer validates rejects. That must not abandon the local delete
|
||||
// `--relogin` promises — over a grant that could not have been revoked.
|
||||
it("still clears when the stored token cannot be parsed", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-bad-token-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
"https://example.com/mcp": {
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
// No `token_type` — fails OAuthTokensSchema.
|
||||
[AS_ISSUER]: { tokens: { access_token: 42 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com/mcp"),
|
||||
).resolves.toMatchObject({ status: "failed" });
|
||||
|
||||
const blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports no_tokens when the store holds nothing for the server", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-empty-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ servers: {}, idpSessions: {} }),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com/mcp"),
|
||||
).resolves.toEqual({ status: "skipped", reason: "no_tokens" });
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
|
|
|||
227
clients/cli/__tests__/relogin-revocation.test.ts
Normal file
227
clients/cli/__tests__/relogin-revocation.test.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* `--relogin` token revocation, driven through `runCli` (#2144).
|
||||
*
|
||||
* The helper's own tests cover `clearStoredAuthForRelogin({ revoke })`. What
|
||||
* they cannot cover is the wiring above it: that Commander maps `--no-revoke`,
|
||||
* that the per-server `oauth.revokeOnClear` reaches the call, and that a failed
|
||||
* revocation is reported without changing what `--relogin` does. A regression
|
||||
* in any of those turns revocation silently back on or off, which is precisely
|
||||
* the class of bug the opt-out exists to make controllable.
|
||||
*
|
||||
* Every case here fails to connect on purpose — the fetch double refuses
|
||||
* everything but the revocation endpoint. That is fine: revocation runs before
|
||||
* the connect, so the assertions are about the requests made and the warnings
|
||||
* printed, not about the exit code.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-node.js";
|
||||
import { runCli } from "./helpers/cli-runner.js";
|
||||
|
||||
const SERVER_URL = "https://example.com/mcp";
|
||||
const AS_ISSUER = "https://as.example.com";
|
||||
const REVOKE_URL = `${AS_ISSUER}/revoke`;
|
||||
|
||||
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;
|
||||
prevPath = undefined;
|
||||
resetNodeOAuthStorageCache();
|
||||
vi.restoreAllMocks();
|
||||
if (dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
dir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/** Seed an OAuth store holding a revocable grant for {@link SERVER_URL}. */
|
||||
function seedStore(): void {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-revoke-e2e-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
// Issuer-bound (SEP-2352) and matching the cached metadata: an unkeyed
|
||||
// grant records no authorization server and is deliberately refused.
|
||||
[SERVER_URL]: {
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
[AS_ISSUER]: {
|
||||
tokens: {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r",
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: {
|
||||
issuer: AS_ISSUER,
|
||||
authorization_endpoint: `${AS_ISSUER}/authorize`,
|
||||
token_endpoint: `${AS_ISSUER}/token`,
|
||||
revocation_endpoint: REVOKE_URL,
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
},
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer the revocation endpoint with `status` and refuse everything else, so
|
||||
* the run stops at the connect instead of reaching the network.
|
||||
*/
|
||||
function stubFetch(status = 200): ReturnType<typeof vi.spyOn> {
|
||||
return vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
|
||||
if (String(input instanceof Request ? input.url : input) === REVOKE_URL) {
|
||||
return Promise.resolve(new Response(null, { status }));
|
||||
}
|
||||
return Promise.reject(new Error("network blocked in test"));
|
||||
}) as ReturnType<typeof vi.spyOn>;
|
||||
}
|
||||
|
||||
function revocationCalls(spy: { mock: { calls: unknown[][] } }): unknown[][] {
|
||||
return spy.mock.calls.filter(
|
||||
(call) => String(call[0] as string) === REVOKE_URL,
|
||||
);
|
||||
}
|
||||
|
||||
/** A catalog holding one HTTP server, optionally opting out of revocation. */
|
||||
function writeCatalog(revokeOnClear?: boolean): string {
|
||||
const catalogDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "cli-relogin-catalog-"),
|
||||
);
|
||||
const catalogPath = path.join(catalogDir, "mcp.json");
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
remote: {
|
||||
type: "streamable-http",
|
||||
url: SERVER_URL,
|
||||
...(revokeOnClear !== undefined && { oauth: { revokeOnClear } }),
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return catalogPath;
|
||||
}
|
||||
|
||||
describe("--relogin token revocation", () => {
|
||||
it("revokes the stored grant by default", async () => {
|
||||
seedStore();
|
||||
const fetchSpy = stubFetch();
|
||||
|
||||
await runCli([
|
||||
"--relogin",
|
||||
"--server-url",
|
||||
SERVER_URL,
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expect(revocationCalls(fetchSpy)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Commander turns `--no-revoke` into `options.revoke === false`; nothing else
|
||||
// proves that mapping survives a refactor of the flag.
|
||||
it("--no-revoke skips the request", async () => {
|
||||
seedStore();
|
||||
const fetchSpy = stubFetch();
|
||||
|
||||
await runCli([
|
||||
"--relogin",
|
||||
"--no-revoke",
|
||||
"--server-url",
|
||||
SERVER_URL,
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expect(revocationCalls(fetchSpy)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("honors the per-server oauth.revokeOnClear opt-out", async () => {
|
||||
seedStore();
|
||||
const catalogPath = writeCatalog(false);
|
||||
const fetchSpy = stubFetch();
|
||||
try {
|
||||
await runCli([
|
||||
"--catalog",
|
||||
catalogPath,
|
||||
"--server",
|
||||
"remote",
|
||||
"--relogin",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
expect(revocationCalls(fetchSpy)).toHaveLength(0);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(catalogPath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("revokes for a catalog server that did not opt out", async () => {
|
||||
seedStore();
|
||||
const catalogPath = writeCatalog();
|
||||
const fetchSpy = stubFetch();
|
||||
try {
|
||||
await runCli([
|
||||
"--catalog",
|
||||
catalogPath,
|
||||
"--server",
|
||||
"remote",
|
||||
"--relogin",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
expect(revocationCalls(fetchSpy)).toHaveLength(1);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(catalogPath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Inert-but-accepted flags are rejected in this parser (see the `--strict`
|
||||
// rationale in `cli.ts`). On its own `--no-revoke` reads as "this run will
|
||||
// not revoke anything", which is true only because nothing was being cleared.
|
||||
it("rejects --no-revoke without --relogin", async () => {
|
||||
const result = await runCli([
|
||||
"--no-revoke",
|
||||
"--server-url",
|
||||
SERVER_URL,
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
expect(result.stderr).toMatch(/--no-revoke requires --relogin/);
|
||||
});
|
||||
|
||||
// A failed revocation must be visible — the grant is still live at the
|
||||
// authorization server — without turning `--relogin` into a failure, which
|
||||
// is a local delete the user still gets.
|
||||
it("warns on stderr when the authorization server refuses", async () => {
|
||||
seedStore();
|
||||
stubFetch(500);
|
||||
|
||||
const result = await runCli([
|
||||
"--relogin",
|
||||
"--server-url",
|
||||
SERVER_URL,
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expect(result.stderr).toMatch(/could not revoke the OAuth grant/i);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue