Skip to content

Commit e759a6b

Browse files
authored
chore: Add token manager and config unit tests MCP-539 (#1287)
1 parent c6dd48a commit e759a6b

2 files changed

Lines changed: 269 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { loadConfig, ConfigurationError } from "./config.js";
3+
import { LOG_LEVELS } from "./common.js";
4+
import type { AppConfig } from "./common.js";
5+
6+
const TEST_CLIENT_ID = "client-id";
7+
const TEST_CLIENT_SECRET = "client-secret";
8+
9+
const CONFIG_ENV_VARS = [
10+
"MDB_MCP_API_CLIENT_ID",
11+
"MDB_MCP_API_CLIENT_SECRET",
12+
"MDB_MCP_API_BASE_URL",
13+
"MDB_MCP_LOG_LEVEL",
14+
"MDB_MCP_TOKEN_TIMEOUT_MS",
15+
] as const;
16+
17+
function stubSA(): void {
18+
vi.stubEnv("MDB_MCP_API_CLIENT_ID", TEST_CLIENT_ID);
19+
vi.stubEnv("MDB_MCP_API_CLIENT_SECRET", TEST_CLIENT_SECRET);
20+
}
21+
22+
function loadConfigExpectConfigurationError(): ConfigurationError {
23+
try {
24+
loadConfig();
25+
expect.fail("expected error");
26+
} catch (e) {
27+
expect(e).toBeInstanceOf(ConfigurationError);
28+
return e as ConfigurationError;
29+
}
30+
}
31+
32+
const DEFAULT_CONFIG: AppConfig = {
33+
clientId: TEST_CLIENT_ID,
34+
clientSecret: TEST_CLIENT_SECRET,
35+
tokenUrl: "https://cloud.mongodb.com/api/oauth/token",
36+
remoteUrl: "https://cloud.mongodb.com/api/private/mcp",
37+
logLevel: "info",
38+
tokenTimeoutMs: 10_000,
39+
};
40+
41+
describe("loadConfig", () => {
42+
beforeEach(() => {
43+
// Prevent shell env vars from being picked up
44+
for (const envVar of CONFIG_ENV_VARS) {
45+
vi.stubEnv(envVar, undefined);
46+
}
47+
});
48+
49+
afterEach(() => {
50+
vi.unstubAllEnvs();
51+
});
52+
53+
describe("valid configurations", () => {
54+
it("basic config", () => {
55+
stubSA();
56+
expect(loadConfig()).toEqual(DEFAULT_CONFIG);
57+
});
58+
59+
it("accepts all valid log levels", () => {
60+
stubSA();
61+
for (const level of LOG_LEVELS) {
62+
vi.stubEnv("MDB_MCP_LOG_LEVEL", level);
63+
expect(loadConfig()).toEqual({ ...DEFAULT_CONFIG, logLevel: level });
64+
}
65+
});
66+
67+
it("accepts MDB_MCP_API_BASE_URL", () => {
68+
stubSA();
69+
vi.stubEnv("MDB_MCP_API_BASE_URL", "https://test.mongodb.com");
70+
expect(loadConfig()).toEqual({
71+
...DEFAULT_CONFIG,
72+
tokenUrl: "https://test.mongodb.com/api/oauth/token",
73+
remoteUrl: "https://test.mongodb.com/api/private/mcp",
74+
});
75+
});
76+
77+
it("accepts valid MDB_MCP_TOKEN_TIMEOUT_MS", () => {
78+
stubSA();
79+
vi.stubEnv("MDB_MCP_TOKEN_TIMEOUT_MS", "5000");
80+
expect(loadConfig()).toEqual({ ...DEFAULT_CONFIG, tokenTimeoutMs: 5000 });
81+
});
82+
});
83+
84+
describe("invalid configurations", () => {
85+
it("throws ConfigurationError when client id and secret are missing", () => {
86+
const error = loadConfigExpectConfigurationError();
87+
expect(error.errors).toContain("MDB_MCP_API_CLIENT_ID is required");
88+
expect(error.errors).toContain("MDB_MCP_API_CLIENT_SECRET is required");
89+
});
90+
91+
it("throws ConfigurationError when client id is missing", () => {
92+
vi.stubEnv("MDB_MCP_API_CLIENT_SECRET", TEST_CLIENT_SECRET);
93+
94+
const error = loadConfigExpectConfigurationError();
95+
expect(error.errors).toContain("MDB_MCP_API_CLIENT_ID is required");
96+
});
97+
98+
it("throws ConfigurationError when client secret is missing", () => {
99+
vi.stubEnv("MDB_MCP_API_CLIENT_ID", TEST_CLIENT_ID);
100+
101+
const error = loadConfigExpectConfigurationError();
102+
expect(error.errors).toContain("MDB_MCP_API_CLIENT_SECRET is required");
103+
});
104+
105+
it("throws ConfigurationError for invalid log levels", () => {
106+
stubSA();
107+
vi.stubEnv("MDB_MCP_LOG_LEVEL", "invalid");
108+
109+
const error = loadConfigExpectConfigurationError();
110+
expect(error.errors[0]).toContain("MDB_MCP_LOG_LEVEL must be one of");
111+
expect(error.errors[0]).toContain("got: invalid");
112+
});
113+
114+
it("throws ConfigurationError for invalid token timeouts", () => {
115+
stubSA();
116+
for (const value of ["abc", "-1", "0"]) {
117+
vi.stubEnv("MDB_MCP_TOKEN_TIMEOUT_MS", value);
118+
119+
const error = loadConfigExpectConfigurationError();
120+
expect(error.errors).toContain(`MDB_MCP_TOKEN_TIMEOUT_MS must be a positive integer, got: ${value}`);
121+
}
122+
});
123+
});
124+
});
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { Mock } from "vitest";
2+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3+
import { TokenManager, TokenError } from "./tokenManager.js";
4+
import type { FetchLike } from "@modelcontextprotocol/client";
5+
6+
const TEST_TOKEN_1 = "token-1";
7+
const TEST_TOKEN_2 = "token-2";
8+
9+
function createManager(fetch: FetchLike): TokenManager {
10+
return new TokenManager("https://test.com/api/oauth/token", "client_id", "client_secret", 10_000, fetch);
11+
}
12+
13+
function mockOkFetch(tokens: string[] = [TEST_TOKEN_1]): Mock {
14+
const mock = vi.fn();
15+
for (const token of tokens) {
16+
mock.mockResolvedValueOnce({
17+
ok: true,
18+
status: 200,
19+
json: () => ({ access_token: token, expires_in: 3600 }),
20+
});
21+
}
22+
return mock;
23+
}
24+
25+
describe("TokenManager", () => {
26+
beforeEach(() => {
27+
vi.useFakeTimers();
28+
});
29+
30+
afterEach(() => {
31+
vi.useRealTimers();
32+
});
33+
34+
describe("token fetching, caching and refreshing", () => {
35+
it("fetches a token on the first call", async () => {
36+
const mockFetch = mockOkFetch();
37+
const manager = createManager(mockFetch);
38+
const token = await manager.getToken();
39+
40+
expect(token).toBe(TEST_TOKEN_1);
41+
expect(mockFetch).toHaveBeenCalledTimes(1);
42+
});
43+
44+
it("returns the cached token on subsequent calls", async () => {
45+
const mockFetch = mockOkFetch();
46+
const manager = createManager(mockFetch);
47+
48+
await manager.getToken();
49+
const token = await manager.getToken();
50+
51+
expect(token).toBe(TEST_TOKEN_1);
52+
expect(mockFetch).toHaveBeenCalledTimes(1);
53+
});
54+
55+
it("refreshes when within 10 minutes to expiration", async () => {
56+
const mockFetch = mockOkFetch([TEST_TOKEN_1, TEST_TOKEN_2]);
57+
const manager = createManager(mockFetch);
58+
59+
const token1 = await manager.getToken();
60+
vi.setSystemTime(Date.now() + 51 * 60 * 1000);
61+
const token2 = await manager.getToken();
62+
63+
expect(token1).toBe(TEST_TOKEN_1);
64+
expect(token2).toBe(TEST_TOKEN_2);
65+
expect(mockFetch).toHaveBeenCalledTimes(2);
66+
});
67+
68+
it("refreshes when the token is expired", async () => {
69+
const mockFetch = mockOkFetch([TEST_TOKEN_1, TEST_TOKEN_2]);
70+
const manager = createManager(mockFetch);
71+
72+
const token1 = await manager.getToken();
73+
vi.setSystemTime(Date.now() + 61 * 60 * 1000);
74+
const token2 = await manager.getToken();
75+
76+
expect(token1).toBe(TEST_TOKEN_1);
77+
expect(token2).toBe(TEST_TOKEN_2);
78+
expect(mockFetch).toHaveBeenCalledTimes(2);
79+
});
80+
81+
it("fetches a new token after invalidating", async () => {
82+
const mockFetch = mockOkFetch([TEST_TOKEN_1, TEST_TOKEN_2]);
83+
const manager = createManager(mockFetch);
84+
85+
const token1 = await manager.getToken();
86+
manager.invalidateToken();
87+
const token2 = await manager.getToken();
88+
89+
expect(token1).toBe(TEST_TOKEN_1);
90+
expect(token2).toBe(TEST_TOKEN_2);
91+
expect(mockFetch).toHaveBeenCalledTimes(2);
92+
});
93+
});
94+
95+
describe("single-flight refresh", () => {
96+
it("concurrent getToken() calls share a single fetch", async () => {
97+
const mockFetch = mockOkFetch();
98+
const manager = createManager(mockFetch);
99+
100+
const [token1, token2] = await Promise.all([manager.getToken(), manager.getToken()]);
101+
102+
expect(token1).toBe(TEST_TOKEN_1);
103+
expect(token2).toBe(TEST_TOKEN_1);
104+
expect(mockFetch).toHaveBeenCalledTimes(1);
105+
});
106+
});
107+
108+
describe("error cases", () => {
109+
it("throws TokenError with the HTTP status code on a non-OK response", async () => {
110+
const mockFetch = vi.fn().mockResolvedValue({
111+
ok: false,
112+
status: 401,
113+
text: () => "Unauthorized",
114+
});
115+
const manager = createManager(mockFetch);
116+
117+
const error = await manager.getToken().catch((e: unknown) => e);
118+
expect(error).toBeInstanceOf(TokenError);
119+
expect((error as TokenError).statusCode).toBe(401);
120+
expect((error as TokenError).message).toContain("Token request failed");
121+
});
122+
123+
it("throws TokenError with a timeout message on TimeoutError", async () => {
124+
const mockFetch = vi.fn().mockRejectedValue(Object.assign(new Error("timeout"), { name: "TimeoutError" }));
125+
const manager = createManager(mockFetch);
126+
127+
const error = await manager.getToken().catch((e: unknown) => e);
128+
expect(error).toBeInstanceOf(TokenError);
129+
expect((error as TokenError).message).toContain("Token request timed out");
130+
});
131+
132+
it("throws TokenError when access_token is missing from the response", async () => {
133+
const mockFetch = vi.fn().mockResolvedValue({
134+
ok: true,
135+
status: 200,
136+
json: () => ({ expires_in: 3600 }),
137+
});
138+
const manager = createManager(mockFetch);
139+
140+
const error = await manager.getToken().catch((e: unknown) => e);
141+
expect(error).toBeInstanceOf(TokenError);
142+
expect((error as TokenError).message).toContain("Token response missing access_token");
143+
});
144+
});
145+
});

0 commit comments

Comments
 (0)