@zereight/mcp-gitlab 2.0.36 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Auth Retry Tests
3
+ * Unit tests for headersToPlainObject, isNonReplayableBody, and wrapWithAuthRetry.
4
+ *
5
+ * These are pure-function / DI-based tests — no env vars or external services needed.
6
+ */
7
+ import { describe, test } from "node:test";
8
+ import assert from "node:assert";
9
+ import { Headers, Response } from "node-fetch";
10
+ import { headersToPlainObject, isNonReplayableBody, wrapWithAuthRetry, } from "../auth-retry.js";
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+ function mockFetch(status) {
15
+ return (async () => new Response("", { status }));
16
+ }
17
+ function mockFetchThenRetry() {
18
+ let callCount = 0;
19
+ return (async () => {
20
+ callCount++;
21
+ return new Response("", { status: callCount === 1 ? 401 : 200 });
22
+ });
23
+ }
24
+ function makeConfig(overrides) {
25
+ return {
26
+ isOAuthEnabled: () => true,
27
+ refreshToken: async () => "new-token",
28
+ onTokenRefreshed: () => { },
29
+ buildAuthHeaders: () => ({ Authorization: "Bearer new-token" }),
30
+ ...overrides,
31
+ };
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // headersToPlainObject
35
+ // ---------------------------------------------------------------------------
36
+ describe("headersToPlainObject", () => {
37
+ test("null returns empty object", () => {
38
+ assert.deepStrictEqual(headersToPlainObject(null), {});
39
+ });
40
+ test("undefined returns empty object", () => {
41
+ assert.deepStrictEqual(headersToPlainObject(undefined), {});
42
+ });
43
+ test("plain object passed through", () => {
44
+ const obj = { "Content-Type": "application/json", Accept: "text/html" };
45
+ assert.deepStrictEqual(headersToPlainObject(obj), obj);
46
+ });
47
+ test("Headers instance normalized", () => {
48
+ const h = new Headers();
49
+ h.set("x-custom", "value1");
50
+ h.set("authorization", "Bearer tok");
51
+ const result = headersToPlainObject(h);
52
+ assert.strictEqual(result["x-custom"], "value1");
53
+ assert.strictEqual(result["authorization"], "Bearer tok");
54
+ });
55
+ test("array of tuples normalized", () => {
56
+ const arr = [
57
+ ["x-foo", "bar"],
58
+ ["x-baz", "qux"],
59
+ ];
60
+ assert.deepStrictEqual(headersToPlainObject(arr), {
61
+ "x-foo": "bar",
62
+ "x-baz": "qux",
63
+ });
64
+ });
65
+ });
66
+ // ---------------------------------------------------------------------------
67
+ // isNonReplayableBody
68
+ // ---------------------------------------------------------------------------
69
+ describe("isNonReplayableBody", () => {
70
+ test("null returns false", () => {
71
+ assert.strictEqual(isNonReplayableBody(null), false);
72
+ });
73
+ test("undefined returns false", () => {
74
+ assert.strictEqual(isNonReplayableBody(undefined), false);
75
+ });
76
+ test("empty string returns false", () => {
77
+ assert.strictEqual(isNonReplayableBody(""), false);
78
+ });
79
+ test("plain string returns false", () => {
80
+ assert.strictEqual(isNonReplayableBody("hello"), false);
81
+ });
82
+ test("object with .pipe() returns true (stream-like)", () => {
83
+ assert.strictEqual(isNonReplayableBody({ pipe: () => { } }), true);
84
+ });
85
+ test("object with .read() returns true (stream-like)", () => {
86
+ assert.strictEqual(isNonReplayableBody({ read: () => { } }), true);
87
+ });
88
+ test("object with .getBuffer() and .getBoundary() returns true (FormData-like)", () => {
89
+ assert.strictEqual(isNonReplayableBody({ getBuffer: () => { }, getBoundary: () => { } }), true);
90
+ });
91
+ test("object with only .getBuffer() (no .getBoundary()) returns false", () => {
92
+ assert.strictEqual(isNonReplayableBody({ getBuffer: () => { } }), false);
93
+ });
94
+ });
95
+ // ---------------------------------------------------------------------------
96
+ // wrapWithAuthRetry
97
+ // ---------------------------------------------------------------------------
98
+ describe("wrapWithAuthRetry", () => {
99
+ test("non-401 response passes through unchanged", async () => {
100
+ const wrapped = wrapWithAuthRetry(mockFetch(200), makeConfig());
101
+ const res = await wrapped("http://example.com");
102
+ assert.strictEqual(res.status, 200);
103
+ });
104
+ test("401 when OAuth disabled passes through unchanged", async () => {
105
+ const config = makeConfig({ isOAuthEnabled: () => false });
106
+ const wrapped = wrapWithAuthRetry(mockFetch(401), config);
107
+ const res = await wrapped("http://example.com");
108
+ assert.strictEqual(res.status, 401);
109
+ });
110
+ test("401 with OAuth enabled triggers refresh and retry", async () => {
111
+ let refreshCalled = false;
112
+ let tokenSet = null;
113
+ const config = makeConfig({
114
+ refreshToken: async () => {
115
+ refreshCalled = true;
116
+ return "refreshed-token";
117
+ },
118
+ onTokenRefreshed: (token) => {
119
+ tokenSet = token;
120
+ },
121
+ buildAuthHeaders: () => ({ Authorization: "Bearer refreshed-token" }),
122
+ });
123
+ const base = mockFetchThenRetry();
124
+ const wrapped = wrapWithAuthRetry(base, config);
125
+ const res = await wrapped("http://example.com");
126
+ assert.strictEqual(res.status, 200);
127
+ assert.strictEqual(refreshCalled, true);
128
+ assert.strictEqual(tokenSet, "refreshed-token");
129
+ });
130
+ test("401 with non-replayable body skips retry", async () => {
131
+ let refreshCalled = false;
132
+ const config = makeConfig({
133
+ refreshToken: async () => {
134
+ refreshCalled = true;
135
+ return "tok";
136
+ },
137
+ });
138
+ const wrapped = wrapWithAuthRetry(mockFetch(401), config);
139
+ const res = await wrapped("http://example.com", {
140
+ body: { pipe: () => { } }, // stream-like
141
+ });
142
+ assert.strictEqual(res.status, 401);
143
+ assert.strictEqual(refreshCalled, false);
144
+ });
145
+ test("concurrent 401s only trigger one refresh (stampede test)", async () => {
146
+ let refreshCount = 0;
147
+ let resolveRefresh = () => { };
148
+ const config = makeConfig({
149
+ refreshToken: () => {
150
+ refreshCount++;
151
+ return new Promise((resolve) => {
152
+ resolveRefresh = resolve;
153
+ });
154
+ },
155
+ buildAuthHeaders: () => ({ Authorization: "Bearer stamped" }),
156
+ });
157
+ // Each call returns 401 first, then 200 on retry
158
+ let callCount = 0;
159
+ const base = (async () => {
160
+ callCount++;
161
+ // First two calls are the initial requests (both 401)
162
+ // Next two are the retries (both 200)
163
+ return new Response("", { status: callCount <= 2 ? 401 : 200 });
164
+ });
165
+ const wrapped = wrapWithAuthRetry(base, config);
166
+ // Fire two concurrent requests
167
+ const p1 = wrapped("http://example.com/a");
168
+ const p2 = wrapped("http://example.com/b");
169
+ // Wait a tick for both to hit the refresh path
170
+ await new Promise((r) => setTimeout(r, 10));
171
+ // Resolve the single pending refresh
172
+ resolveRefresh("stamped-token");
173
+ const [r1, r2] = await Promise.all([p1, p2]);
174
+ assert.strictEqual(r1.status, 200);
175
+ assert.strictEqual(r2.status, 200);
176
+ assert.strictEqual(refreshCount, 1, "refresh should be called exactly once");
177
+ });
178
+ test("token refresh failure returns original 401 response", async () => {
179
+ const config = makeConfig({
180
+ refreshToken: async () => {
181
+ throw new Error("refresh exploded");
182
+ },
183
+ });
184
+ const wrapped = wrapWithAuthRetry(mockFetch(401), config);
185
+ const res = await wrapped("http://example.com");
186
+ assert.strictEqual(res.status, 401);
187
+ });
188
+ });
@@ -71,8 +71,8 @@ describe("Search Code Tools", () => {
71
71
  if (mockGitLab)
72
72
  await mockGitLab.stop();
73
73
  });
74
- // ---- 1. search toolset exposes exactly 3 tools ----
75
- describe("search toolset exposes exactly 3 tools", () => {
74
+ // ---- 1. search toolset exposes exactly 4 tools ----
75
+ describe("search toolset exposes exactly 4 tools", () => {
76
76
  let server;
77
77
  let tools;
78
78
  before(async () => {
@@ -83,8 +83,8 @@ describe("Search Code Tools", () => {
83
83
  tools = await getToolNames(`http://${HOST}:${port}/mcp`);
84
84
  });
85
85
  after(() => cleanupServers([server]));
86
- test("returns exactly 3 tools", () => {
87
- assert.strictEqual(tools.length, 3, `Expected 3 tools but got ${tools.length}: ${tools.join(", ")}`);
86
+ test("returns exactly 4 tools", () => {
87
+ assert.strictEqual(tools.length, 4, `Expected 4 tools but got ${tools.length}: ${tools.join(", ")}`);
88
88
  });
89
89
  test("includes search_code", () => {
90
90
  assert.ok(tools.includes("search_code"), "Expected search_code to be present");
@@ -95,6 +95,9 @@ describe("Search Code Tools", () => {
95
95
  test("includes search_group_code", () => {
96
96
  assert.ok(tools.includes("search_group_code"), "Expected search_group_code to be present");
97
97
  });
98
+ test("includes discover_tools", () => {
99
+ assert.ok(tools.includes("discover_tools"), "Expected discover_tools to be present");
100
+ });
98
101
  });
99
102
  // ---- 2. search_code returns blob results ----
100
103
  describe("search_code returns blob results", () => {