Removed Zustand storage, cleanup up auth storage IO interfaces, implementations, and usage.

This commit is contained in:
Bob Dickinson 2026-07-04 21:56:45 -07:00
commit 3c65bdb4c6
6 changed files with 863 additions and 1008 deletions

View file

@ -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();
}
});
});

View file

@ -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);
});

View file

@ -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(() => {});
});
@ -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),

View file

@ -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");

View file

@ -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)
);
},
);

View file

@ -1,17 +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 } 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/oauth-persist.js";
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
import {
writeStoreFile,
@ -53,80 +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 () => {
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/);
});
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(() => {
@ -143,19 +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["https://example.com"].tokens).toEqual({
expect(parsed.servers["https://example.com"].tokens).toEqual({
access_token: "test-token",
token_type: "Bearer",
});
@ -165,67 +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 (should load persisted state)
const storage2 = createFileStorageAdapter({ filePath });
const store2 = createOAuthStore(storage2);
await new Promise((resolve) => setTimeout(resolve, 100));
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);
});
});
@ -255,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);
@ -292,7 +242,7 @@ describe("Storage adapters", () => {
});
});
describe("RemoteStorageAdapter", () => {
describe("Remote OAuth persistence", () => {
let remoteServer: ServerType | null = null;
let tempDir: string | null = null;
@ -320,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",
});
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?.["https://example.com"]?.tokens?.access_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: {
@ -353,7 +299,7 @@ describe("Storage adapters", () => {
});
expect(res.status).toBe(200);
const storeData = await res.json();
expect(storeData.state.servers["https://example.com"].tokens).toEqual({
expect(storeData.servers["https://example.com"].tokens).toEqual({
access_token: "test-token",
token_type: "Bearer",
});
@ -366,46 +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",
});
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?.["https://example.com"]?.tokens?.access_token ===
d?.servers?.["https://example.com"]?.tokens?.access_token ===
"initial-token"
);
});
// Create new store instance (should load persisted state)
const storage2 = createRemoteStorageAdapter({
const storage2 = new RemoteOAuthStorage({
baseUrl,
storeId: "test-store",
authToken,
});
const store2 = createOAuthStore(storage2);
await vi.waitFor(
() => {
const state = store2.getState().getServerState("https://example.com");
if (!state.tokens) throw new Error("Store not yet hydrated");
return state;
},
{ timeout: 2000, interval: 50 },
);
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",
});
@ -418,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: {
@ -443,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: {
@ -465,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);
});
});
});