Removed Zustand storage, cleanup up auth storage IO interfaces, implementations, and usage.
This commit is contained in:
parent
512f45d357
commit
3c65bdb4c6
1 changed files with 863 additions and 1008 deletions
|
|
@ -1,7 +1,8 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
NodeOAuthStorage,
|
||||
getOAuthStore,
|
||||
getStateFilePath,
|
||||
resetNodeOAuthStorageCache,
|
||||
clearAllOAuthClientState,
|
||||
} from "@inspector/core/auth/node/storage-node.js";
|
||||
import type {
|
||||
|
|
@ -20,44 +21,27 @@ const testStatePath = path.join(
|
|||
`mcp-inspector-oauth-${process.pid}-storage-node.json`,
|
||||
);
|
||||
|
||||
async function resetTestStorage(stateFilePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(stateFilePath);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
resetNodeOAuthStorageCache(stateFilePath);
|
||||
}
|
||||
|
||||
describe("NodeOAuthStorage", () => {
|
||||
let storage: NodeOAuthStorage;
|
||||
const testServerUrl = "http://localhost:3000";
|
||||
const stateFilePath = testStatePath;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up any existing state file
|
||||
try {
|
||||
await fs.unlink(stateFilePath);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
|
||||
// Reset store state by clearing all servers
|
||||
const store = getOAuthStore(testStatePath);
|
||||
const state = store.getState();
|
||||
// Clear all server states
|
||||
Object.keys(state.servers).forEach((url) => {
|
||||
state.clearServerState(url);
|
||||
});
|
||||
|
||||
await resetTestStorage(stateFilePath);
|
||||
storage = new NodeOAuthStorage(testStatePath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up state file after each test
|
||||
try {
|
||||
await fs.unlink(stateFilePath);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
|
||||
// Reset store state
|
||||
const store = getOAuthStore(testStatePath);
|
||||
const state = store.getState();
|
||||
Object.keys(state.servers).forEach((url) => {
|
||||
state.clearServerState(url);
|
||||
});
|
||||
await resetTestStorage(stateFilePath);
|
||||
});
|
||||
|
||||
describe("getClientInformation", () => {
|
||||
|
|
@ -88,11 +72,10 @@ describe("NodeOAuthStorage", () => {
|
|||
client_secret: "preregistered-secret",
|
||||
};
|
||||
|
||||
// Store as preregistered by directly setting it in the store
|
||||
const store = getOAuthStore(testStatePath);
|
||||
store.getState().setServerState(testServerUrl, {
|
||||
preregisteredClientInformation: preregisteredInfo,
|
||||
});
|
||||
await storage.savePreregisteredClientInformation(
|
||||
testServerUrl,
|
||||
preregisteredInfo,
|
||||
);
|
||||
|
||||
const result = await storage.getClientInformation(testServerUrl, true);
|
||||
|
||||
|
|
@ -341,16 +324,16 @@ describe("NodeOAuthStorage", () => {
|
|||
|
||||
it("clearCodeVerifier removes only the PKCE verifier", async () => {
|
||||
await storage.saveCodeVerifier(testServerUrl, "verifier");
|
||||
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
|
||||
expect(await storage.getCodeVerifier(testServerUrl)).toBe("verifier");
|
||||
await storage.clearCodeVerifier(testServerUrl);
|
||||
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getCodeVerifier(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearScope removes only the scope", async () => {
|
||||
await storage.saveScope(testServerUrl, "read write");
|
||||
expect(storage.getScope(testServerUrl)).toBe("read write");
|
||||
expect(await storage.getScope(testServerUrl)).toBe("read write");
|
||||
await storage.clearScope(testServerUrl);
|
||||
expect(storage.getScope(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getScope(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearServerMetadata removes only the cached metadata", async () => {
|
||||
|
|
@ -361,9 +344,9 @@ describe("NodeOAuthStorage", () => {
|
|||
response_types_supported: ["code"],
|
||||
};
|
||||
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
|
||||
expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata);
|
||||
await storage.clearServerMetadata(testServerUrl);
|
||||
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
|
||||
expect(await storage.getServerMetadata(testServerUrl)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -437,83 +420,52 @@ describe("NodeOAuthStorage", () => {
|
|||
expect(result2).toEqual(clientInfo2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Store (Zustand)", () => {
|
||||
const stateFilePath = testStatePath;
|
||||
it("reuses in-memory state for the same file path", async () => {
|
||||
const serverUrl = "http://localhost:3000";
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "shared-memory-token",
|
||||
token_type: "Bearer",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
await fs.unlink(stateFilePath);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
await storage.saveTokens(serverUrl, tokens);
|
||||
|
||||
const otherView = new NodeOAuthStorage(testStatePath);
|
||||
expect(await otherView.getTokens(serverUrl)).toEqual(tokens);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.unlink(stateFilePath);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
it("should create a new store", () => {
|
||||
const store = getOAuthStore(testStatePath);
|
||||
expect(store).toBeDefined();
|
||||
expect(store.getState).toBeDefined();
|
||||
expect(store.setState).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return the same store instance via getOAuthStore", () => {
|
||||
const store1 = getOAuthStore(testStatePath);
|
||||
const store2 = getOAuthStore(testStatePath);
|
||||
expect(store1).toBe(store2);
|
||||
});
|
||||
|
||||
it("should persist state to file", async () => {
|
||||
// Use a unique path so no other test (e.g. "should overwrite..." with second-id) can
|
||||
// write to the same file; Zustand persist is async and can race with shared paths.
|
||||
it("persists state to file on save", async () => {
|
||||
const persistTestPath = path.join(
|
||||
os.tmpdir(),
|
||||
`mcp-inspector-oauth-persist-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
||||
);
|
||||
try {
|
||||
if (process.env.DEBUG_PERSIST_PATH === "1") {
|
||||
console.error("[storage-node.test] state file path:", persistTestPath);
|
||||
}
|
||||
const store = getOAuthStore(persistTestPath);
|
||||
resetNodeOAuthStorageCache(persistTestPath);
|
||||
const fileStorage = new NodeOAuthStorage(persistTestPath);
|
||||
const serverUrl = "http://localhost:3000";
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
client_id: "test-client-id",
|
||||
};
|
||||
|
||||
store.getState().setServerState(serverUrl, {
|
||||
clientInformation: clientInfo,
|
||||
await fileStorage.saveClientInformation(serverUrl, clientInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
|
||||
type StateShape = {
|
||||
state: {
|
||||
servers: Record<
|
||||
string,
|
||||
{ clientInformation?: OAuthClientInformation }
|
||||
>;
|
||||
};
|
||||
servers: Record<string, { clientInformation?: OAuthClientInformation }>;
|
||||
};
|
||||
// Persistence is fire-and-forget; await the write rather than polling.
|
||||
await flushStoreFileWrites(persistTestPath);
|
||||
const parsed = JSON.parse(
|
||||
await fs.readFile(persistTestPath, "utf-8"),
|
||||
) as StateShape;
|
||||
expect(parsed.state.servers[serverUrl]?.clientInformation).toEqual(
|
||||
clientInfo,
|
||||
);
|
||||
expect(parsed.servers[serverUrl]?.clientInformation).toEqual(clientInfo);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(persistTestPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
resetNodeOAuthStorageCache(persistTestPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -572,9 +524,7 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
|||
await storage.saveTokens(testServerUrl, tokens);
|
||||
|
||||
type StateShape = {
|
||||
state: {
|
||||
servers: Record<string, { tokens?: { access_token?: string } }>;
|
||||
};
|
||||
servers: Record<string, { tokens?: { access_token?: string } }>;
|
||||
};
|
||||
// Persistence is fire-and-forget; await the write rather than polling.
|
||||
await flushStoreFileWrites(customPath);
|
||||
|
|
@ -582,7 +532,7 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
|||
await fs.readFile(customPath, "utf-8"),
|
||||
) as StateShape;
|
||||
|
||||
expect(parsed.state.servers[testServerUrl]?.tokens?.access_token).toBe(
|
||||
expect(parsed.servers[testServerUrl]?.tokens?.access_token).toBe(
|
||||
tokens.access_token,
|
||||
);
|
||||
|
||||
|
|
@ -599,33 +549,34 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
|||
});
|
||||
|
||||
it("clearAllOAuthClientState clears every server in the default store", async () => {
|
||||
const defaultStore = getOAuthStore();
|
||||
defaultStore.getState().setServerState("http://server-a.test", {
|
||||
tokens: { access_token: "a", token_type: "Bearer" },
|
||||
resetNodeOAuthStorageCache();
|
||||
const storage = new NodeOAuthStorage();
|
||||
await storage.saveTokens("http://server-a.test", {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
defaultStore.getState().setServerState("http://server-b.test", {
|
||||
tokens: { access_token: "b", token_type: "Bearer" },
|
||||
await storage.saveTokens("http://server-b.test", {
|
||||
access_token: "b",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
clearAllOAuthClientState();
|
||||
const all = defaultStore.getState();
|
||||
expect(all.getServerState("http://server-a.test").tokens).toBeUndefined();
|
||||
expect(all.getServerState("http://server-b.test").tokens).toBeUndefined();
|
||||
await flushStoreFileWrites(getStateFilePath());
|
||||
|
||||
await clearAllOAuthClientState();
|
||||
|
||||
expect(await storage.getTokens("http://server-a.test")).toBeUndefined();
|
||||
expect(await storage.getTokens("http://server-b.test")).toBeUndefined();
|
||||
|
||||
resetNodeOAuthStorageCache();
|
||||
});
|
||||
|
||||
it("clearAllOAuthClientState tolerates a store whose servers map is absent", () => {
|
||||
// Exercises the `state.servers ?? {}` nullish fallback: when the persisted
|
||||
// state lacks a `servers` map, the iteration must default to {} (no throw).
|
||||
const defaultStore = getOAuthStore();
|
||||
const clearServerState = vi.fn();
|
||||
vi.spyOn(defaultStore, "getState").mockReturnValue({
|
||||
servers: undefined,
|
||||
clearServerState,
|
||||
} as unknown as ReturnType<typeof defaultStore.getState>);
|
||||
it("clearAllOAuthClientState tolerates persisted state without a servers map", async () => {
|
||||
const filePath = getStateFilePath();
|
||||
resetNodeOAuthStorageCache();
|
||||
await fs.writeFile(filePath, JSON.stringify({ idpSessions: {} }), "utf-8");
|
||||
|
||||
expect(() => clearAllOAuthClientState()).not.toThrow();
|
||||
expect(clearServerState).not.toHaveBeenCalled();
|
||||
await expect(clearAllOAuthClientState()).resolves.toBeUndefined();
|
||||
|
||||
vi.mocked(defaultStore.getState).mockRestore();
|
||||
resetNodeOAuthStorageCache();
|
||||
});
|
||||
|
||||
it("should isolate state from default store", async () => {
|
||||
|
|
@ -635,12 +586,14 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
|||
);
|
||||
|
||||
try {
|
||||
resetNodeOAuthStorageCache();
|
||||
const defaultStorage = new NodeOAuthStorage();
|
||||
await defaultStorage.saveTokens(testServerUrl, {
|
||||
access_token: "default-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
resetNodeOAuthStorageCache(customPath);
|
||||
const customStorage = new NodeOAuthStorage(customPath);
|
||||
await customStorage.saveTokens(testServerUrl, {
|
||||
access_token: "custom-token",
|
||||
|
|
@ -653,13 +606,15 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
|||
const fromDefault = await defaultStorage.getTokens(testServerUrl);
|
||||
expect(fromDefault?.access_token).toBe("default-token");
|
||||
|
||||
getOAuthStore().getState().clearServerState(testServerUrl);
|
||||
await defaultStorage.clear(testServerUrl);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(customPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
resetNodeOAuthStorageCache(customPath);
|
||||
resetNodeOAuthStorageCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue