Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts: # clients/cli/package-lock.json # clients/cli/package.json # clients/web/src/App.tsx # clients/web/src/lib/environmentFactory.test.ts # clients/web/src/lib/environmentFactory.ts # clients/web/src/lib/remoteOAuthStorage.test.ts # clients/web/src/lib/remoteOAuthStorage.ts # clients/web/src/test/core/auth/connection-state.test.ts # clients/web/src/test/core/auth/providers.test.ts # clients/web/src/test/core/auth/storage-browser.test.ts # clients/web/src/test/core/auth/storage-remote.test.ts # clients/web/src/test/core/mcp/oauthManager.test.ts # clients/web/src/test/integration/auth/node/storage.test.ts # clients/web/src/test/integration/storage/adapters.test.ts # clients/web/src/utils/clearServerOAuthState.test.ts # clients/web/src/utils/clearServerOAuthState.ts # core/auth/connection-state.ts # core/auth/oauth-storage.ts # core/auth/providers.ts # core/auth/storage.ts # core/auth/store.ts # core/storage/adapters/remote-storage.ts
This commit is contained in:
commit
32a3610737
8 changed files with 5046 additions and 3271 deletions
|
|
@ -1,10 +1,10 @@
|
|||
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 { normalizeServerUrl } from "@inspector/core/auth/store.js";
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
|
|
@ -21,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", () => {
|
||||
|
|
@ -89,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);
|
||||
|
||||
|
|
@ -127,10 +109,10 @@ describe("NodeOAuthStorage", () => {
|
|||
client_id: "second-id",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, firstInfo, {
|
||||
await storage.saveClientInformation(testServerUrl, firstInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
storage.saveClientInformation(testServerUrl, secondInfo, {
|
||||
await storage.saveClientInformation(testServerUrl, secondInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
|
@ -311,7 +293,7 @@ describe("NodeOAuthStorage", () => {
|
|||
expect(await storage.getClientInformation(testServerUrl)).toEqual({
|
||||
client_id: "dyn",
|
||||
});
|
||||
storage.clearClientInformation(testServerUrl);
|
||||
await storage.clearClientInformation(testServerUrl);
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -322,7 +304,7 @@ describe("NodeOAuthStorage", () => {
|
|||
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
|
||||
client_id: "pre",
|
||||
});
|
||||
storage.clearClientInformation(testServerUrl, true);
|
||||
await storage.clearClientInformation(testServerUrl, true);
|
||||
expect(
|
||||
await storage.getClientInformation(testServerUrl, true),
|
||||
).toBeUndefined();
|
||||
|
|
@ -336,22 +318,22 @@ describe("NodeOAuthStorage", () => {
|
|||
token_type: "Bearer",
|
||||
});
|
||||
expect(await storage.getTokens(testServerUrl)).toBeDefined();
|
||||
storage.clearTokens(testServerUrl);
|
||||
await storage.clearTokens(testServerUrl);
|
||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearCodeVerifier removes only the PKCE verifier", async () => {
|
||||
await storage.saveCodeVerifier(testServerUrl, "verifier");
|
||||
expect(await storage.getCodeVerifier(testServerUrl)).toBe("verifier");
|
||||
storage.clearCodeVerifier(testServerUrl);
|
||||
await storage.clearCodeVerifier(testServerUrl);
|
||||
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");
|
||||
storage.clearScope(testServerUrl);
|
||||
expect(storage.getScope(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getScope(testServerUrl)).toBe("read write");
|
||||
await storage.clearScope(testServerUrl);
|
||||
expect(await storage.getScope(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearServerMetadata removes only the cached metadata", async () => {
|
||||
|
|
@ -363,7 +345,7 @@ describe("NodeOAuthStorage", () => {
|
|||
};
|
||||
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||
expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata);
|
||||
storage.clearServerMetadata(testServerUrl);
|
||||
await storage.clearServerMetadata(testServerUrl);
|
||||
expect(await storage.getServerMetadata(testServerUrl)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -383,7 +365,7 @@ describe("NodeOAuthStorage", () => {
|
|||
});
|
||||
await storage.saveTokens(testServerUrl, tokens);
|
||||
|
||||
storage.clear(testServerUrl);
|
||||
await storage.clear(testServerUrl);
|
||||
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||
|
|
@ -402,7 +384,7 @@ describe("NodeOAuthStorage", () => {
|
|||
registrationKind: "dcr",
|
||||
});
|
||||
|
||||
storage.clear(testServerUrl);
|
||||
await storage.clear(testServerUrl);
|
||||
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
const otherResult = await storage.getClientInformation(otherServerUrl);
|
||||
|
|
@ -425,10 +407,10 @@ describe("NodeOAuthStorage", () => {
|
|||
client_id: "client-2",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(server1Url, clientInfo1, {
|
||||
await storage.saveClientInformation(server1Url, clientInfo1, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
storage.saveClientInformation(server2Url, clientInfo2, {
|
||||
await storage.saveClientInformation(server2Url, clientInfo2, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
|
||||
|
|
@ -438,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[normalizeServerUrl(serverUrl)]?.clientInformation,
|
||||
).toEqual(clientInfo);
|
||||
expect(parsed.servers[serverUrl]?.clientInformation).toEqual(clientInfo);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(persistTestPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
resetNodeOAuthStorageCache(persistTestPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -548,7 +499,7 @@ describe("NodeOAuthStorage idpSessions (EMA)", () => {
|
|||
expect(session?.idToken).toBe("eyJ.id.token");
|
||||
expect(session?.refreshToken).toBe("rt-1");
|
||||
|
||||
storage.clearIdpSession(issuer);
|
||||
await storage.clearIdpSession(issuer);
|
||||
await flushStoreFileWrites(testStatePath);
|
||||
expect(await storage.getIdpSession(issuer)).toBeUndefined();
|
||||
});
|
||||
|
|
@ -573,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);
|
||||
|
|
@ -583,10 +532,9 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
|||
await fs.readFile(customPath, "utf-8"),
|
||||
) as StateShape;
|
||||
|
||||
expect(
|
||||
parsed.state.servers[normalizeServerUrl(testServerUrl)]?.tokens
|
||||
?.access_token,
|
||||
).toBe(tokens.access_token);
|
||||
expect(parsed.servers[testServerUrl]?.tokens?.access_token).toBe(
|
||||
tokens.access_token,
|
||||
);
|
||||
|
||||
const stored = await storage.getTokens(testServerUrl);
|
||||
expect(stored?.access_token).toBe(tokens.access_token);
|
||||
|
|
@ -601,33 +549,46 @@ 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("clearAllOAuthClientState is a no-op when no store file exists", async () => {
|
||||
// Exercises the null-snapshot branch: read() returns null, so
|
||||
// `snapshot?.servers ?? {}` yields no urls and nothing is cleared.
|
||||
const filePath = getStateFilePath();
|
||||
resetNodeOAuthStorageCache();
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
|
||||
await expect(clearAllOAuthClientState()).resolves.toBeUndefined();
|
||||
|
||||
resetNodeOAuthStorageCache();
|
||||
});
|
||||
|
||||
it("should isolate state from default store", async () => {
|
||||
|
|
@ -637,20 +598,14 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
|||
);
|
||||
|
||||
try {
|
||||
// The store is created with skipHydration: true; OAuthStorageBase
|
||||
// normally drives rehydrate(), but this test writes via the raw store
|
||||
// so hydrate it explicitly first (otherwise the later `new
|
||||
// NodeOAuthStorage()` wrapper would drive the first rehydrate and clobber
|
||||
// these unpersisted in-memory writes).
|
||||
const defaultStore = getOAuthStore();
|
||||
await defaultStore.persist.rehydrate();
|
||||
defaultStore.getState().setServerState(testServerUrl, {
|
||||
tokens: {
|
||||
access_token: "default-token",
|
||||
token_type: "Bearer",
|
||||
},
|
||||
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",
|
||||
|
|
@ -660,17 +615,18 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
|||
const fromCustom = await customStorage.getTokens(testServerUrl);
|
||||
expect(fromCustom?.access_token).toBe("custom-token");
|
||||
|
||||
const defaultStorage = new NodeOAuthStorage();
|
||||
const fromDefault = await defaultStorage.getTokens(testServerUrl);
|
||||
expect(fromDefault?.access_token).toBe("default-token");
|
||||
|
||||
defaultStore.getState().clearServerState(testServerUrl);
|
||||
await defaultStorage.clear(testServerUrl);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(customPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
resetNodeOAuthStorageCache(customPath);
|
||||
resetNodeOAuthStorageCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ describe("InspectorClient EMA E2E", () => {
|
|||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
clearAllOAuthClientState();
|
||||
await clearAllOAuthClientState();
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
const keys = await createEmaMockKeyMaterial();
|
||||
|
|
@ -199,14 +199,12 @@ describe("InspectorClient EMA E2E", () => {
|
|||
await flushStoreFileWrites(oauthTestStatePath);
|
||||
|
||||
const raw = JSON.parse(await fs.readFile(oauthTestStatePath, "utf-8")) as {
|
||||
state: {
|
||||
servers: Record<
|
||||
string,
|
||||
{ tokens?: { access_token?: string }; enterpriseManaged?: boolean }
|
||||
>;
|
||||
};
|
||||
servers: Record<
|
||||
string,
|
||||
{ tokens?: { access_token?: string }; enterpriseManaged?: boolean }
|
||||
>;
|
||||
};
|
||||
const entry = raw.state.servers[mcpUrl];
|
||||
const entry = raw.servers[mcpUrl];
|
||||
expect(entry?.tokens?.access_token).toBeDefined();
|
||||
expect(entry?.enterpriseManaged).toBe(true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -91,9 +91,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
clearOAuthTestData();
|
||||
clearAllOAuthClientState();
|
||||
await clearAllOAuthClientState();
|
||||
// Capture console.log output instead of printing to stdout during tests
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
|
@ -630,7 +630,7 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
await client.disconnect();
|
||||
client.clearOAuthTokens();
|
||||
await client.clearOAuthTokens();
|
||||
|
||||
const authUrlSecond = await client.authenticate();
|
||||
if (!authUrlSecond) throw new Error("Expected authorization URL");
|
||||
|
|
@ -995,7 +995,7 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
expect(tokens?.access_token).toBeDefined();
|
||||
expect(await client.isOAuthAuthorized()).toBe(true);
|
||||
|
||||
client.clearOAuthTokens();
|
||||
await client.clearOAuthTokens();
|
||||
expect(await client.isOAuthAuthorized()).toBe(false);
|
||||
expect(await client.getOAuthTokens()).toBeUndefined();
|
||||
});
|
||||
|
|
@ -1072,16 +1072,14 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
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);
|
||||
const parsed = JSON.parse(
|
||||
await fs.readFile(customPath, "utf-8"),
|
||||
) as StateShape;
|
||||
const servers = parsed.state?.servers ?? {};
|
||||
const servers = parsed.servers ?? {};
|
||||
expect(Object.keys(servers).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
Object.values(servers).some((s) => !!s?.tokens?.access_token),
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
|
|||
expect(recovery?.authChallenge.reason).toBe("insufficient_scope");
|
||||
|
||||
const mcpServerUrl = `${serverUrl}/mcp`;
|
||||
const scopeBeforeStepUp = oauthConfig.storage.getScope(mcpServerUrl);
|
||||
const scopeBeforeStepUp = await oauthConfig.storage.getScope(mcpServerUrl);
|
||||
expect(scopeBeforeStepUp).toContain("mcp");
|
||||
expect(scopeBeforeStepUp).not.toContain("weather:read");
|
||||
|
||||
|
|
@ -413,7 +413,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
|
|||
|
||||
expect(callbackClient.getStatus()).toBe("connected");
|
||||
|
||||
const scopeAfterStepUp = oauthConfig.storage.getScope(mcpServerUrl);
|
||||
const scopeAfterStepUp = await oauthConfig.storage.getScope(mcpServerUrl);
|
||||
expect(scopeAfterStepUp).toContain("weather:read");
|
||||
expect(scopeAfterStepUp).toContain("mcp");
|
||||
|
||||
|
|
|
|||
|
|
@ -352,18 +352,11 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
|
|||
remoteAuthToken!,
|
||||
(body) => {
|
||||
const b = body as {
|
||||
state?: {
|
||||
servers?: Record<
|
||||
string,
|
||||
{ tokens?: { access_token?: string } }
|
||||
>;
|
||||
};
|
||||
servers?: Record<string, { tokens?: { access_token?: string } }>;
|
||||
};
|
||||
return !!(
|
||||
b?.state?.servers &&
|
||||
Object.values(b.state.servers).some(
|
||||
(s) => s?.tokens?.access_token,
|
||||
)
|
||||
b?.servers &&
|
||||
Object.values(b.servers).some((s) => s?.tokens?.access_token)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -173,8 +173,8 @@ describe("InspectorClient OAuth", () => {
|
|||
expect(tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should clear OAuth tokens", () => {
|
||||
client.clearOAuthTokens();
|
||||
it("should clear OAuth tokens", async () => {
|
||||
await client.clearOAuthTokens();
|
||||
// Should not throw
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4959,7 +4959,7 @@ describe("InspectorClient", () => {
|
|||
expect(c.getOAuthFlowState()).toBeUndefined();
|
||||
await expect(c.getOAuthState()).resolves.toBeUndefined();
|
||||
// clearOAuthTokens is a no-op when there is no manager
|
||||
expect(() => c.clearOAuthTokens()).not.toThrow();
|
||||
await expect(c.clearOAuthTokens()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("setOAuthConfig throws when oauthManager is unset", () => {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
/**
|
||||
* Tests for storage adapters (file, remote).
|
||||
* Tests for OAuth persistence (file + remote) and store-io flush helpers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { waitForRemoteStore } from "@modelcontextprotocol/inspector-test-server";
|
||||
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { serve } from "@hono/node-server";
|
||||
import type { ServerType } from "@hono/node-server";
|
||||
import { createFileStorageAdapter } from "@inspector/core/storage/adapters/file-storage.js";
|
||||
import { createRemoteStorageAdapter } from "@inspector/core/storage/adapters/remote-storage.js";
|
||||
import {
|
||||
createOAuthStore,
|
||||
normalizeServerUrl,
|
||||
} from "@inspector/core/auth/store.js";
|
||||
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
|
||||
import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js";
|
||||
import { OAuthMemoryStore } from "@inspector/core/auth/store.js";
|
||||
import { createFileOAuthPersistBackend } from "@inspector/core/auth/node/oauth-persist-file.js";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||
import {
|
||||
writeStoreFile,
|
||||
|
|
@ -56,113 +54,8 @@ async function startRemoteServer(
|
|||
});
|
||||
}
|
||||
|
||||
describe("Storage adapters", () => {
|
||||
describe("createRemoteStorageAdapter (unit, mocked fetch)", () => {
|
||||
function makeAdapter(fetchFn: typeof fetch, authToken?: string) {
|
||||
return createRemoteStorageAdapter({
|
||||
baseUrl: "http://remote.example/",
|
||||
storeId: "test",
|
||||
fetchFn,
|
||||
authToken,
|
||||
})!;
|
||||
}
|
||||
|
||||
it("getItem returns null on 404 and parses stored blob otherwise", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ state: { hi: 1 }, version: 0 })),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("{}"));
|
||||
|
||||
const adapter = makeAdapter(fetchFn as typeof fetch);
|
||||
expect(await adapter.getItem("anything")).toBeNull();
|
||||
const blob = await adapter.getItem("anything");
|
||||
expect(blob).toBeTruthy();
|
||||
// Empty object response → treated as "not initialized" → null.
|
||||
expect(await adapter.getItem("anything")).toBeNull();
|
||||
});
|
||||
|
||||
it("getItem throws on non-404 error responses", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response("boom", { status: 500 }));
|
||||
const adapter = makeAdapter(fetchFn as typeof fetch);
|
||||
await expect(adapter.getItem("x")).rejects.toThrow(/500/);
|
||||
});
|
||||
|
||||
it("setItem throws when the POST fails", async () => {
|
||||
// The adapter logs the swallowed-persist failure via console.error before
|
||||
// rethrowing; suppress the expected noise.
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response("nope", { status: 500 }));
|
||||
const adapter = makeAdapter(fetchFn as typeof fetch);
|
||||
await expect(
|
||||
adapter.setItem("name", { state: { a: 1 }, version: 0 }),
|
||||
).rejects.toThrow(/500/);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("setItem sends keepalive so the POST survives an immediate redirect", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response("{}"));
|
||||
const adapter = makeAdapter(fetchFn as typeof fetch);
|
||||
await adapter.setItem("name", { state: { a: 1 }, version: 0 });
|
||||
expect(fetchFn.mock.calls[0]?.[1]?.keepalive).toBe(true);
|
||||
});
|
||||
|
||||
it("setItem rethrows a network-level fetch rejection after logging it", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockRejectedValueOnce(new Error("network down"));
|
||||
const adapter = makeAdapter(fetchFn as typeof fetch);
|
||||
await expect(
|
||||
adapter.setItem("name", { state: { a: 1 }, version: 0 }),
|
||||
).rejects.toThrow(/network down/);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("removeItem tolerates 404 but rethrows on other failures", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response("boom", { status: 500 }));
|
||||
const adapter = makeAdapter(fetchFn as typeof fetch);
|
||||
await expect(adapter.removeItem("x")).resolves.toBeUndefined();
|
||||
await expect(adapter.removeItem("x")).resolves.toBeUndefined();
|
||||
await expect(adapter.removeItem("x")).rejects.toThrow(/500/);
|
||||
});
|
||||
|
||||
it("sends the x-mcp-remote-auth header on all three verbs when authToken is set", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(new Response("{}"));
|
||||
const adapter = makeAdapter(fetchFn as typeof fetch, "secret");
|
||||
await adapter.getItem("x");
|
||||
await adapter.setItem("x", { state: {}, version: 0 });
|
||||
await adapter.removeItem("x");
|
||||
for (const call of fetchFn.mock.calls) {
|
||||
const headers = call[1]?.headers as Record<string, string> | undefined;
|
||||
expect(headers?.["x-mcp-remote-auth"]).toBe("Bearer secret");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileStorageAdapter", () => {
|
||||
describe("OAuth persistence", () => {
|
||||
describe("OAuth file persistence", () => {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -179,21 +72,17 @@ describe("Storage adapters", () => {
|
|||
it("creates store and persists state", async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-"));
|
||||
const filePath = join(tempDir!, "test-store.json");
|
||||
const storage = createFileStorageAdapter({ filePath });
|
||||
const store = createOAuthStore(storage);
|
||||
const storage = new NodeOAuthStorage(filePath);
|
||||
|
||||
// Set some state
|
||||
store.getState().setServerState("https://example.com", {
|
||||
tokens: { access_token: "test-token", token_type: "Bearer" },
|
||||
await storage.saveTokens("https://example.com", {
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
// Persistence is fire-and-forget; await the write rather than polling.
|
||||
await flushStoreFileWrites(filePath);
|
||||
const fileContent = readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(fileContent);
|
||||
expect(
|
||||
parsed.state.servers[normalizeServerUrl("https://example.com")].tokens,
|
||||
).toEqual({
|
||||
expect(parsed.servers["https://example.com"].tokens).toEqual({
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
|
@ -203,69 +92,91 @@ describe("Storage adapters", () => {
|
|||
tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-"));
|
||||
const filePath = join(tempDir!, "test-store.json");
|
||||
|
||||
// Create initial store and persist
|
||||
const storage1 = createFileStorageAdapter({ filePath });
|
||||
const store1 = createOAuthStore(storage1);
|
||||
store1.getState().setServerState("https://example.com", {
|
||||
tokens: { access_token: "initial-token", token_type: "Bearer" },
|
||||
const storage1 = new NodeOAuthStorage(filePath);
|
||||
await storage1.saveTokens("https://example.com", {
|
||||
access_token: "initial-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
await flushStoreFileWrites(filePath);
|
||||
|
||||
// Create new store instance and load persisted state. The store is
|
||||
// created with skipHydration: true, so drive the single rehydrate
|
||||
// explicitly (OAuthStorageBase does this in production).
|
||||
const storage2 = createFileStorageAdapter({ filePath });
|
||||
const store2 = createOAuthStore(storage2);
|
||||
await store2.persist.rehydrate();
|
||||
|
||||
const state = store2.getState().getServerState("https://example.com");
|
||||
const backend = createFileOAuthPersistBackend({ filePath });
|
||||
const snapshot = await backend.read();
|
||||
const freshMemory = new OAuthMemoryStore(snapshot ?? undefined);
|
||||
const state = freshMemory
|
||||
.getState()
|
||||
.getServerState("https://example.com");
|
||||
expect(state.tokens).toEqual({
|
||||
access_token: "initial-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads legacy persist envelope and rewrites as plain JSON on save", async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-"));
|
||||
const filePath = join(tempDir!, "test-store.json");
|
||||
await writeStoreFile(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
state: {
|
||||
servers: {
|
||||
"https://example.com": {
|
||||
tokens: { access_token: "legacy", token_type: "Bearer" },
|
||||
},
|
||||
},
|
||||
idpSessions: {},
|
||||
},
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const storage = new NodeOAuthStorage(filePath);
|
||||
expect(await storage.getTokens("https://example.com")).toEqual({
|
||||
access_token: "legacy",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
await storage.saveScope("https://example.com", "read");
|
||||
await flushStoreFileWrites(filePath);
|
||||
|
||||
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
expect(parsed.servers["https://example.com"].scope).toBe("read");
|
||||
expect(parsed.version).toBeUndefined();
|
||||
expect(parsed.state).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles empty state after clear", async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-"));
|
||||
const filePath = join(tempDir!, "test-store.json");
|
||||
const storage = createFileStorageAdapter({ filePath });
|
||||
const store = createOAuthStore(storage);
|
||||
const storage = new NodeOAuthStorage(filePath);
|
||||
|
||||
// Set state and persist
|
||||
store.getState().setServerState("https://example.com", {
|
||||
tokens: { access_token: "test-token", token_type: "Bearer" },
|
||||
await storage.saveTokens("https://example.com", {
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
await flushStoreFileWrites(filePath);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
|
||||
// Clear all servers (this will persist empty state)
|
||||
const state = store.getState();
|
||||
const urls = Object.keys(state.servers);
|
||||
for (const url of urls) {
|
||||
state.clearServerState(url);
|
||||
}
|
||||
await storage.clear("https://example.com");
|
||||
await flushStoreFileWrites(filePath);
|
||||
|
||||
// Verify file still exists but with empty servers
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
const fileContent = readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(fileContent);
|
||||
expect(Object.keys(parsed.state.servers).length).toBe(0);
|
||||
expect(Object.keys(parsed.servers).length).toBe(0);
|
||||
});
|
||||
|
||||
it("removeItem deletes the underlying file", async () => {
|
||||
it("remove deletes the underlying file", async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-"));
|
||||
const filePath = join(tempDir!, "test-store.json");
|
||||
const storage = createFileStorageAdapter({ filePath });
|
||||
// createJSONStorage returns a PersistStorage<S> wrapper that delegates to
|
||||
// our adapter's removeItem callback (line 25 of file-storage.ts).
|
||||
const store = createOAuthStore(storage);
|
||||
store.getState().setServerState("https://example.com", {
|
||||
tokens: { access_token: "t", token_type: "Bearer" },
|
||||
const storage = new NodeOAuthStorage(filePath);
|
||||
await storage.saveTokens("https://example.com", {
|
||||
access_token: "t",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
await flushStoreFileWrites(filePath);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
await storage!.removeItem("inspector-oauth-store");
|
||||
const backend = createFileOAuthPersistBackend({ filePath });
|
||||
await backend.remove!();
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -295,7 +206,6 @@ describe("Storage adapters", () => {
|
|||
tempDir = mkdtempSync(join(tmpdir(), "inspector-flush-test-"));
|
||||
const filePath = join(tempDir, "store.json");
|
||||
|
||||
// Kick off the write without awaiting it, then flush.
|
||||
const write = writeStoreFile(filePath, '{"hello":"world"}');
|
||||
await flushStoreFileWrites(filePath);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
|
|
@ -332,7 +242,7 @@ describe("Storage adapters", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("RemoteStorageAdapter", () => {
|
||||
describe("Remote OAuth persistence", () => {
|
||||
let remoteServer: ServerType | null = null;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
|
|
@ -360,31 +270,27 @@ describe("Storage adapters", () => {
|
|||
});
|
||||
remoteServer = server;
|
||||
|
||||
const storage = createRemoteStorageAdapter({
|
||||
const storage = new RemoteOAuthStorage({
|
||||
baseUrl,
|
||||
storeId: "test-store",
|
||||
authToken,
|
||||
});
|
||||
const store = createOAuthStore(storage);
|
||||
|
||||
// Set some state
|
||||
store.getState().setServerState("https://example.com", {
|
||||
tokens: { access_token: "test-token", token_type: "Bearer" },
|
||||
await storage.saveTokens("https://example.com", {
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
const exampleKey = normalizeServerUrl("https://example.com");
|
||||
await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => {
|
||||
const d = body as {
|
||||
state?: {
|
||||
servers?: Record<string, { tokens?: { access_token?: string } }>;
|
||||
};
|
||||
servers?: Record<string, { tokens?: { access_token?: string } }>;
|
||||
};
|
||||
return (
|
||||
d?.state?.servers?.[exampleKey]?.tokens?.access_token === "test-token"
|
||||
d?.servers?.["https://example.com"]?.tokens?.access_token ===
|
||||
"test-token"
|
||||
);
|
||||
});
|
||||
|
||||
// Verify via API
|
||||
const res = await fetch(`${baseUrl}/api/storage/test-store`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
|
|
@ -393,7 +299,7 @@ describe("Storage adapters", () => {
|
|||
});
|
||||
expect(res.status).toBe(200);
|
||||
const storeData = await res.json();
|
||||
expect(storeData.state.servers[exampleKey].tokens).toEqual({
|
||||
expect(storeData.servers["https://example.com"].tokens).toEqual({
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
|
@ -406,42 +312,32 @@ describe("Storage adapters", () => {
|
|||
});
|
||||
remoteServer = server;
|
||||
|
||||
// Create initial store and persist
|
||||
const storage1 = createRemoteStorageAdapter({
|
||||
const storage1 = new RemoteOAuthStorage({
|
||||
baseUrl,
|
||||
storeId: "test-store",
|
||||
authToken,
|
||||
});
|
||||
const store1 = createOAuthStore(storage1);
|
||||
store1.getState().setServerState("https://example.com", {
|
||||
tokens: { access_token: "initial-token", token_type: "Bearer" },
|
||||
await storage1.saveTokens("https://example.com", {
|
||||
access_token: "initial-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
const exampleKey = normalizeServerUrl("https://example.com");
|
||||
await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => {
|
||||
const d = body as {
|
||||
state?: {
|
||||
servers?: Record<string, { tokens?: { access_token?: string } }>;
|
||||
};
|
||||
servers?: Record<string, { tokens?: { access_token?: string } }>;
|
||||
};
|
||||
return (
|
||||
d?.state?.servers?.[exampleKey]?.tokens?.access_token ===
|
||||
d?.servers?.["https://example.com"]?.tokens?.access_token ===
|
||||
"initial-token"
|
||||
);
|
||||
});
|
||||
|
||||
// Create new store instance and load persisted state. The store is
|
||||
// created with skipHydration: true, so drive the single rehydrate
|
||||
// explicitly (OAuthStorageBase does this in production).
|
||||
const storage2 = createRemoteStorageAdapter({
|
||||
const storage2 = new RemoteOAuthStorage({
|
||||
baseUrl,
|
||||
storeId: "test-store",
|
||||
authToken,
|
||||
});
|
||||
const store2 = createOAuthStore(storage2);
|
||||
await store2.persist.rehydrate();
|
||||
|
||||
const state = store2.getState().getServerState("https://example.com");
|
||||
expect(state.tokens).toEqual({
|
||||
expect(await storage2.getTokens("https://example.com")).toEqual({
|
||||
access_token: "initial-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
|
@ -454,23 +350,21 @@ describe("Storage adapters", () => {
|
|||
});
|
||||
remoteServer = server;
|
||||
|
||||
const storage = createRemoteStorageAdapter({
|
||||
const storage = new RemoteOAuthStorage({
|
||||
baseUrl,
|
||||
storeId: "test-store",
|
||||
authToken,
|
||||
});
|
||||
const store = createOAuthStore(storage);
|
||||
|
||||
// Set state and persist
|
||||
store.getState().setServerState("https://example.com", {
|
||||
tokens: { access_token: "test-token", token_type: "Bearer" },
|
||||
await storage.saveTokens("https://example.com", {
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => {
|
||||
const d = body as { state?: { servers?: Record<string, unknown> } };
|
||||
return !!d?.state?.servers && Object.keys(d.state.servers).length > 0;
|
||||
const d = body as { servers?: Record<string, unknown> };
|
||||
return !!d?.servers && Object.keys(d.servers).length > 0;
|
||||
});
|
||||
|
||||
// Verify it exists
|
||||
let res = await fetch(`${baseUrl}/api/storage/test-store`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
|
|
@ -479,20 +373,14 @@ describe("Storage adapters", () => {
|
|||
});
|
||||
expect(res.status).toBe(200);
|
||||
const storeData = await res.json();
|
||||
expect(Object.keys(storeData.state.servers).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(storeData.servers).length).toBeGreaterThan(0);
|
||||
|
||||
// Clear all servers (this will persist empty state)
|
||||
const state = store.getState();
|
||||
const urls = Object.keys(state.servers);
|
||||
for (const url of urls) {
|
||||
state.clearServerState(url);
|
||||
}
|
||||
await storage.clear("https://example.com");
|
||||
await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => {
|
||||
const d = body as { state?: { servers?: Record<string, unknown> } };
|
||||
return !d?.state?.servers || Object.keys(d.state.servers).length === 0;
|
||||
const d = body as { servers?: Record<string, unknown> };
|
||||
return !d?.servers || Object.keys(d.servers).length === 0;
|
||||
});
|
||||
|
||||
// Verify it's empty
|
||||
res = await fetch(`${baseUrl}/api/storage/test-store`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
|
|
@ -501,7 +389,7 @@ describe("Storage adapters", () => {
|
|||
});
|
||||
expect(res.status).toBe(200);
|
||||
const emptyStore = await res.json();
|
||||
expect(Object.keys(emptyStore.state.servers).length).toBe(0);
|
||||
expect(Object.keys(emptyStore.servers).length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue