@mastra/server-adapters-test-suite 0.1.3-alpha.2 → 0.1.4-alpha.0
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.
- package/CHANGELOG.md +37 -0
- package/dist/body-limit-test-suite.d.ts +5 -0
- package/dist/body-limit-test-suite.d.ts.map +1 -1
- package/dist/custom-route-fga-test-suite.d.ts +23 -0
- package/dist/custom-route-fga-test-suite.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +851 -106
- package/dist/index.js.map +1 -1
- package/dist/mcp-transport-test-suite.d.ts.map +1 -1
- package/dist/route-adapter-test-suite.d.ts.map +1 -1
- package/dist/route-test-utils.d.ts.map +1 -1
- package/dist/test-helpers.d.ts.map +1 -1
- package/package.json +8 -8
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ErrorCategory, ErrorDomain, MastraError } from "@mastra/core/error";
|
|
2
|
+
import { registerApiRoute } from "@mastra/core/server";
|
|
2
3
|
import { handleError } from "@mastra/server/handlers/error";
|
|
3
4
|
import { HTTPException, SERVER_ROUTES, WorkflowRegistry, createRoute } from "@mastra/server/server-adapter";
|
|
4
5
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -23,8 +24,9 @@ import { MastraVector } from "@mastra/core/vector";
|
|
|
23
24
|
import { CompositeVoice } from "@mastra/core/voice";
|
|
24
25
|
import { createStep, createWorkflow } from "@mastra/core/workflows";
|
|
25
26
|
import { LocalFilesystem, Workspace } from "@mastra/core/workspace";
|
|
26
|
-
import { MCPClient, MCPServer } from "@mastra/mcp";
|
|
27
|
+
import { MCPClient, MCPServer, defineEvent } from "@mastra/mcp";
|
|
27
28
|
import { Mastra as Mastra$1 } from "@mastra/core/mastra";
|
|
29
|
+
import http from "http";
|
|
28
30
|
import { MCPServerBase } from "@mastra/core/mcp";
|
|
29
31
|
import { RequestContext } from "@mastra/core/request-context";
|
|
30
32
|
import { standardSchemaToJSONSchema } from "@mastra/core/schema";
|
|
@@ -59,6 +61,8 @@ function getRouteSpecificSchemaDefaults(route) {
|
|
|
59
61
|
predicateScope: "trace",
|
|
60
62
|
path: "entityName"
|
|
61
63
|
} };
|
|
64
|
+
if (route.path === "/agent-controller/:controllerId/sessions" && route.method === "POST") return { body: { resourceId: "test-resource" } };
|
|
65
|
+
if (route.path === "/mcp/:serverId/events/:eventName/emit") return { body: { data: { id: "test-ticket" } } };
|
|
62
66
|
if (route.path === "/datasets/:datasetId/compare") return { body: {
|
|
63
67
|
experimentIds: ["test-experiment-a", "test-experiment-b"],
|
|
64
68
|
baselineId: "test-experiment-a"
|
|
@@ -136,8 +140,10 @@ function generateValidDataFromSchema(schema, fieldName) {
|
|
|
136
140
|
def = getZodDef(schema);
|
|
137
141
|
}
|
|
138
142
|
if (typeName === "ZodOptional" || typeName === "ZodNullable") return generateValidDataFromSchema(def.innerType, fieldName);
|
|
139
|
-
if (typeName === "ZodDefault")
|
|
140
|
-
|
|
143
|
+
if (typeName === "ZodDefault") {
|
|
144
|
+
if ("_zod" in schema) return def.defaultValue;
|
|
145
|
+
else return def.defaultValue();
|
|
146
|
+
}
|
|
141
147
|
if (typeName === "ZodString") {
|
|
142
148
|
if ((def.checks ?? []).some((check) => {
|
|
143
149
|
const checkDef = check._zod?.def ?? check.def ?? check;
|
|
@@ -235,13 +241,19 @@ function generateValidDataFromSchema(schema, fieldName) {
|
|
|
235
241
|
if (typeName === "ZodUndefined") return void 0;
|
|
236
242
|
if (typeName === "ZodDate") return /* @__PURE__ */ new Date();
|
|
237
243
|
if (typeName === "ZodBigInt") return BigInt(0);
|
|
238
|
-
if (typeName === "ZodLiteral")
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
244
|
+
if (typeName === "ZodLiteral") {
|
|
245
|
+
if ("_zod" in schema) return def.values?.[0];
|
|
246
|
+
else return def.value;
|
|
247
|
+
}
|
|
248
|
+
if (typeName === "ZodEnum") {
|
|
249
|
+
if ("_zod" in schema) return Object.values(def.entries)[0];
|
|
250
|
+
else return def.values[0];
|
|
251
|
+
}
|
|
242
252
|
if (typeName === "ZodNativeEnum") return Object.values(def.values)[0];
|
|
243
|
-
if (typeName === "ZodArray")
|
|
244
|
-
|
|
253
|
+
if (typeName === "ZodArray") {
|
|
254
|
+
if ("_zod" in schema) return [generateValidDataFromSchema(def.element, fieldName)];
|
|
255
|
+
else return [generateValidDataFromSchema(def.type, fieldName)];
|
|
256
|
+
}
|
|
245
257
|
if (typeName === "ZodObject") {
|
|
246
258
|
const shape = typeof def.shape === "function" ? def.shape() : def.shape;
|
|
247
259
|
const obj = {};
|
|
@@ -309,6 +321,7 @@ function getDefaultValidPathParams(route) {
|
|
|
309
321
|
if (route.path.includes(":controllerId")) params.controllerId = "test-controller";
|
|
310
322
|
if (route.path.includes(":responseId")) params.responseId = "test-response";
|
|
311
323
|
if (route.path.includes(":resourceId")) params.resourceId = "test-resource";
|
|
324
|
+
if (route.path.includes(":sessionId")) params.sessionId = "test-session";
|
|
312
325
|
if (route.path.includes(":modelConfigId")) params.modelConfigId = "id1";
|
|
313
326
|
if (route.path.includes(":scorerId") && route.path.includes("/stored/scorers/")) params.scorerId = "test-stored-scorer";
|
|
314
327
|
else if (route.path.includes(":scorerId")) params.scorerId = "test-scorer";
|
|
@@ -333,6 +346,7 @@ function getDefaultValidPathParams(route) {
|
|
|
333
346
|
if (route.path.includes(":id") && route.path.includes("/mcp/v0/servers/")) params.id = "test-server-1";
|
|
334
347
|
if (route.path.includes(":serverId")) params.serverId = "test-server-1";
|
|
335
348
|
if (route.path.includes(":toolId") && route.path.includes("/mcp/")) params.toolId = "getWeather";
|
|
349
|
+
if (route.path.includes(":eventName")) params.eventName = "ticket.created";
|
|
336
350
|
if (route.path.includes(":workspaceId")) params.workspaceId = "test-workspace";
|
|
337
351
|
if (route.path.includes(":skillName")) params.skillName = "test-skill";
|
|
338
352
|
if (route.path.includes(":referencePath")) params.referencePath = "test-reference.md";
|
|
@@ -432,7 +446,8 @@ function createRouteTestSuite(config) {
|
|
|
432
446
|
});
|
|
433
447
|
});
|
|
434
448
|
routes.forEach((route) => {
|
|
435
|
-
|
|
449
|
+
const routeKey = `${route.method} ${route.path}`;
|
|
450
|
+
describe(routeKey, () => {
|
|
436
451
|
if (route.deprecated) {
|
|
437
452
|
it("should be marked as deprecated", () => {
|
|
438
453
|
expect(route.deprecated).toBe(true);
|
|
@@ -784,9 +799,7 @@ async function createDefaultTestContext() {
|
|
|
784
799
|
case "multiply":
|
|
785
800
|
result = a * b;
|
|
786
801
|
break;
|
|
787
|
-
case "divide":
|
|
788
|
-
result = b !== 0 ? a / b : Infinity;
|
|
789
|
-
break;
|
|
802
|
+
case "divide": result = b !== 0 ? a / b : Infinity;
|
|
790
803
|
}
|
|
791
804
|
return { result };
|
|
792
805
|
}
|
|
@@ -811,7 +824,11 @@ async function createDefaultTestContext() {
|
|
|
811
824
|
appResources: { "ui://test/app": {
|
|
812
825
|
name: "Test App",
|
|
813
826
|
html: "<html><body>Test</body></html>"
|
|
814
|
-
} }
|
|
827
|
+
} },
|
|
828
|
+
events: { "ticket.created": defineEvent({
|
|
829
|
+
description: "A ticket was created",
|
|
830
|
+
payloadSchema: z$3.object({ id: z$3.string() })
|
|
831
|
+
}) }
|
|
815
832
|
});
|
|
816
833
|
const mcpServer2 = new MCPServer({
|
|
817
834
|
name: "Test Server 2",
|
|
@@ -956,7 +973,8 @@ async function createDefaultTestContext() {
|
|
|
956
973
|
"trace-aggregate",
|
|
957
974
|
"thread-query",
|
|
958
975
|
"thread-query-rows",
|
|
959
|
-
"span-query"
|
|
976
|
+
"span-query",
|
|
977
|
+
"span-aggregate"
|
|
960
978
|
]);
|
|
961
979
|
vi.spyOn(observability, "queryTraces").mockResolvedValue({
|
|
962
980
|
traces: [],
|
|
@@ -982,6 +1000,10 @@ async function createDefaultTestContext() {
|
|
|
982
1000
|
spans: [],
|
|
983
1001
|
page: { next: null }
|
|
984
1002
|
});
|
|
1003
|
+
vi.spyOn(observability, "aggregateSpans").mockResolvedValue({
|
|
1004
|
+
rows: [],
|
|
1005
|
+
truncated: false
|
|
1006
|
+
});
|
|
985
1007
|
await observability.createSpan({ span: {
|
|
986
1008
|
spanId: "test-span",
|
|
987
1009
|
traceId: "test-trace",
|
|
@@ -1338,9 +1360,10 @@ Follow these instructions for the test skill.
|
|
|
1338
1360
|
installedAt: (/* @__PURE__ */ new Date("2024-01-01T00:00:00.000Z")).toISOString()
|
|
1339
1361
|
};
|
|
1340
1362
|
fs.writeFileSync(path.join(agentSkillsDir, ".meta.json"), JSON.stringify(metaJson, null, 2));
|
|
1363
|
+
const filesystem = new LocalFilesystem({ basePath: tempDir });
|
|
1341
1364
|
const workspace = new Workspace({
|
|
1342
1365
|
id: "test-workspace",
|
|
1343
|
-
filesystem
|
|
1366
|
+
filesystem,
|
|
1344
1367
|
skills: ["skills"],
|
|
1345
1368
|
bm25: true
|
|
1346
1369
|
});
|
|
@@ -1364,7 +1387,8 @@ function createTestTool(overrides = {}) {
|
|
|
1364
1387
|
* Following the pattern from handler tests - uses actual MockMemory implementation
|
|
1365
1388
|
*/
|
|
1366
1389
|
function createMockMemory() {
|
|
1367
|
-
const
|
|
1390
|
+
const storage = new InMemoryStore();
|
|
1391
|
+
const mockMemory = new MockMemory({ storage });
|
|
1368
1392
|
mockMemory.__registerMastra = vi.fn();
|
|
1369
1393
|
return mockMemory;
|
|
1370
1394
|
}
|
|
@@ -1833,7 +1857,8 @@ function createRouteAdapterTestSuite(config) {
|
|
|
1833
1857
|
expect(response.status).toBe(200);
|
|
1834
1858
|
const data = response.data;
|
|
1835
1859
|
expect(data.filter).toEqual({ status: "active" });
|
|
1836
|
-
|
|
1860
|
+
const tags = Array.isArray(data.tag) ? data.tag : [data.tag];
|
|
1861
|
+
expect(tags).toContain("b");
|
|
1837
1862
|
expect(data.keys).not.toContain("__proto__");
|
|
1838
1863
|
expect(data.keys).not.toContain("constructor");
|
|
1839
1864
|
expect(data.keys).not.toContain("prototype");
|
|
@@ -2104,7 +2129,8 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2104
2129
|
Object.entries(deprecatedByCategory).forEach(([category, routes]) => {
|
|
2105
2130
|
describe(category, () => {
|
|
2106
2131
|
routes.forEach((route) => {
|
|
2107
|
-
|
|
2132
|
+
const testName = `${route.method} ${route.path}`;
|
|
2133
|
+
describe(testName, () => {
|
|
2108
2134
|
it("should be marked as deprecated", () => {
|
|
2109
2135
|
expect(route.deprecated).toBe(true);
|
|
2110
2136
|
expect(route.openapi?.deprecated).toBe(true);
|
|
@@ -2143,6 +2169,7 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2143
2169
|
"/editor/builder/registries/:registryId/install",
|
|
2144
2170
|
"/background-tasks/stream",
|
|
2145
2171
|
"/agents/:agentId/observe",
|
|
2172
|
+
"/mcp/:serverId/events/:eventName/stream",
|
|
2146
2173
|
"/agents/:agentId/recover",
|
|
2147
2174
|
"/agents/:agentId/plans/file",
|
|
2148
2175
|
"/tool-providers/:providerId/connections/:connectionId",
|
|
@@ -2155,7 +2182,9 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2155
2182
|
];
|
|
2156
2183
|
const excludedPrefixes = ["/datasets", "/agent-controller"];
|
|
2157
2184
|
const isExcluded = (r) => r.deprecated || r.responseType === "mcp-http" || r.responseType === "mcp-sse" || authRoutesRequiringProviders.includes(r.path) || routesRequiringExternalDeps.includes(r.path) || excludedPrefixes.some((prefix) => r.path.startsWith(prefix));
|
|
2158
|
-
const
|
|
2185
|
+
const activeRoutes = SERVER_ROUTES.filter((r) => !isExcluded(r));
|
|
2186
|
+
const devOnlyRoutes = ["/mcp/:serverId/events/:eventName/emit"];
|
|
2187
|
+
const routesByCategory = activeRoutes.reduce((acc, route) => {
|
|
2159
2188
|
const category = route.path.split("/")[1] || "root";
|
|
2160
2189
|
if (!acc[category]) acc[category] = [];
|
|
2161
2190
|
acc[category].push(route);
|
|
@@ -2164,7 +2193,16 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2164
2193
|
Object.entries(routesByCategory).forEach(([category, routes]) => {
|
|
2165
2194
|
describe(category, () => {
|
|
2166
2195
|
routes.forEach((route) => {
|
|
2167
|
-
|
|
2196
|
+
const testName = `${route.method} ${route.path}`;
|
|
2197
|
+
describe(testName, () => {
|
|
2198
|
+
if (devOnlyRoutes.includes(route.path)) {
|
|
2199
|
+
beforeEach(() => {
|
|
2200
|
+
vi.stubEnv("MASTRA_DEV", "true");
|
|
2201
|
+
});
|
|
2202
|
+
afterEach(() => {
|
|
2203
|
+
vi.unstubAllEnvs();
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2168
2206
|
it("should execute with valid request", async () => {
|
|
2169
2207
|
const request = buildRouteRequest(route);
|
|
2170
2208
|
const httpRequest = {
|
|
@@ -2199,7 +2237,8 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2199
2237
|
query: request.query,
|
|
2200
2238
|
body: request.body
|
|
2201
2239
|
};
|
|
2202
|
-
|
|
2240
|
+
const response = await executeHttpRequest(app, httpRequest);
|
|
2241
|
+
expect(response.status).toBe(404);
|
|
2203
2242
|
});
|
|
2204
2243
|
if (route.path.includes(":workflowId")) it("should return 404 when workflow not found", async () => {
|
|
2205
2244
|
const request = buildRouteRequest(route, { pathParams: { workflowId: "non-existent-workflow" } });
|
|
@@ -2209,7 +2248,8 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2209
2248
|
query: request.query,
|
|
2210
2249
|
body: request.body
|
|
2211
2250
|
};
|
|
2212
|
-
|
|
2251
|
+
const response = await executeHttpRequest(app, httpRequest);
|
|
2252
|
+
expect(response.status).toBe(404);
|
|
2213
2253
|
});
|
|
2214
2254
|
if (route.path.includes(":backgroundTaskId")) it("should return 404 when background task not found", async () => {
|
|
2215
2255
|
const request = buildRouteRequest(route, { pathParams: { backgroundTaskId: "non-existent-background-task" } });
|
|
@@ -2219,7 +2259,8 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2219
2259
|
query: request.query,
|
|
2220
2260
|
body: request.body
|
|
2221
2261
|
};
|
|
2222
|
-
|
|
2262
|
+
const response = await executeHttpRequest(app, httpRequest);
|
|
2263
|
+
expect(response.status).toBe(404);
|
|
2223
2264
|
});
|
|
2224
2265
|
if (route.path.includes(":serverId")) it("should return 404 when MCP server not found (via :serverId)", async () => {
|
|
2225
2266
|
const request = buildRouteRequest(route, { pathParams: { serverId: "non-existent-server" } });
|
|
@@ -2229,7 +2270,8 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2229
2270
|
query: request.query,
|
|
2230
2271
|
body: request.body
|
|
2231
2272
|
};
|
|
2232
|
-
|
|
2273
|
+
const response = await executeHttpRequest(app, httpRequest);
|
|
2274
|
+
expect(response.status).toBe(404);
|
|
2233
2275
|
});
|
|
2234
2276
|
if (route.path.includes("/mcp/v0/servers/:id")) it("should return 404 when MCP server not found (via :id)", async () => {
|
|
2235
2277
|
const request = buildRouteRequest(route, { pathParams: { id: "non-existent-server" } });
|
|
@@ -2239,7 +2281,8 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2239
2281
|
query: request.query,
|
|
2240
2282
|
body: request.body
|
|
2241
2283
|
};
|
|
2242
|
-
|
|
2284
|
+
const response = await executeHttpRequest(app, httpRequest);
|
|
2285
|
+
expect(response.status).toBe(404);
|
|
2243
2286
|
});
|
|
2244
2287
|
if (route.path.includes(":processorId")) it("should return 404 when processor not found", async () => {
|
|
2245
2288
|
const request = buildRouteRequest(route, { pathParams: { processorId: "non-existent-processor" } });
|
|
@@ -2249,7 +2292,8 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2249
2292
|
query: request.query,
|
|
2250
2293
|
body: request.body
|
|
2251
2294
|
};
|
|
2252
|
-
|
|
2295
|
+
const response = await executeHttpRequest(app, httpRequest);
|
|
2296
|
+
expect(response.status).toBe(404);
|
|
2253
2297
|
});
|
|
2254
2298
|
if (route.responseType === "stream") it("should be consumable via stream reader", async () => {
|
|
2255
2299
|
const request = buildRouteRequest(route);
|
|
@@ -2264,9 +2308,13 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2264
2308
|
expect(response.stream).toBeDefined();
|
|
2265
2309
|
if (typeof response.stream.getReader === "function") {
|
|
2266
2310
|
const reader = response.stream.getReader();
|
|
2267
|
-
|
|
2311
|
+
const firstChunk = await reader.read();
|
|
2312
|
+
expect(firstChunk).toBeDefined();
|
|
2268
2313
|
reader.releaseLock();
|
|
2269
|
-
} else if (typeof response.stream[Symbol.asyncIterator] === "function")
|
|
2314
|
+
} else if (typeof response.stream[Symbol.asyncIterator] === "function") {
|
|
2315
|
+
const firstChunk = await response.stream[Symbol.asyncIterator]().next();
|
|
2316
|
+
expect(firstChunk).toBeDefined();
|
|
2317
|
+
}
|
|
2270
2318
|
});
|
|
2271
2319
|
if (route.queryParamSchema || route.bodySchema) it("should return 400 when schema validation fails", async () => {
|
|
2272
2320
|
const request = buildRouteRequest(route);
|
|
@@ -2323,7 +2371,8 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2323
2371
|
}
|
|
2324
2372
|
}
|
|
2325
2373
|
};
|
|
2326
|
-
|
|
2374
|
+
const response = await executeHttpRequest(app, httpRequest);
|
|
2375
|
+
expect(response.status).toBeLessThan(500);
|
|
2327
2376
|
});
|
|
2328
2377
|
const bodySchema = route.bodySchema;
|
|
2329
2378
|
if (["POST", "PUT"].includes(route.method) && bodySchema) it("should spread body fields to handler params", async () => {
|
|
@@ -2384,7 +2433,8 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2384
2433
|
expect(response.type).toBe("json");
|
|
2385
2434
|
const errorData = response.data;
|
|
2386
2435
|
expect(errorData).toBeDefined();
|
|
2387
|
-
|
|
2436
|
+
const hasErrorField = errorData.error !== void 0 || errorData.message !== void 0 || errorData.details !== void 0 || errorData.statusCode !== void 0;
|
|
2437
|
+
expect(hasErrorField).toBe(true);
|
|
2388
2438
|
});
|
|
2389
2439
|
});
|
|
2390
2440
|
SERVER_ROUTES.filter((r) => r.method === "POST" && r.bodySchema && !isExcluded(r)).forEach((route) => {
|
|
@@ -2412,6 +2462,83 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2412
2462
|
});
|
|
2413
2463
|
});
|
|
2414
2464
|
});
|
|
2465
|
+
describe("Studio-only RBAC", () => {
|
|
2466
|
+
const rolePermissions = {
|
|
2467
|
+
viewer: ["agents:read"],
|
|
2468
|
+
member: ["agents:read", "tools:execute"]
|
|
2469
|
+
};
|
|
2470
|
+
const studioAuth = {
|
|
2471
|
+
authenticateToken: async (token) => rolePermissions[token] ? {
|
|
2472
|
+
id: `user_${token}`,
|
|
2473
|
+
role: token
|
|
2474
|
+
} : null,
|
|
2475
|
+
authorize: async () => true
|
|
2476
|
+
};
|
|
2477
|
+
const studioRbac = {
|
|
2478
|
+
getPermissions: async (user) => rolePermissions[user.role] ?? [],
|
|
2479
|
+
getRoles: async (user) => [user.role]
|
|
2480
|
+
};
|
|
2481
|
+
async function setupStudioRbac(options) {
|
|
2482
|
+
const ctx = await createDefaultTestContext();
|
|
2483
|
+
const originalGetServer = ctx.mastra.getServer.bind(ctx.mastra);
|
|
2484
|
+
const customRoute = registerApiRoute("/studio-rbac/custom", {
|
|
2485
|
+
method: "GET",
|
|
2486
|
+
requiresPermission: "tools:execute",
|
|
2487
|
+
handler: async (c) => c.json({ ok: true })
|
|
2488
|
+
});
|
|
2489
|
+
ctx.mastra.getServer = () => ({
|
|
2490
|
+
...originalGetServer(),
|
|
2491
|
+
apiRoutes: [customRoute],
|
|
2492
|
+
...options.withServerAuth ? { auth: studioAuth } : {}
|
|
2493
|
+
});
|
|
2494
|
+
ctx.mastra.getStudio = () => ({
|
|
2495
|
+
auth: studioAuth,
|
|
2496
|
+
rbac: studioRbac
|
|
2497
|
+
});
|
|
2498
|
+
ctx.customRouteAuthConfig = /* @__PURE__ */ new Map([["GET:/studio-rbac/custom", true]]);
|
|
2499
|
+
const route = {
|
|
2500
|
+
method: "GET",
|
|
2501
|
+
path: "/test/studio-rbac",
|
|
2502
|
+
responseType: "json",
|
|
2503
|
+
requiresPermission: "tools:execute",
|
|
2504
|
+
handler: async () => ({ ok: true })
|
|
2505
|
+
};
|
|
2506
|
+
const mutableServerRoutes = SERVER_ROUTES;
|
|
2507
|
+
const originalLength = mutableServerRoutes.length;
|
|
2508
|
+
mutableServerRoutes.push(route);
|
|
2509
|
+
try {
|
|
2510
|
+
return (await setupAdapter(ctx)).app;
|
|
2511
|
+
} finally {
|
|
2512
|
+
mutableServerRoutes.splice(originalLength);
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
const studioRequest = (path, role) => ({
|
|
2516
|
+
method: "GET",
|
|
2517
|
+
path,
|
|
2518
|
+
headers: {
|
|
2519
|
+
Authorization: `Bearer ${role}`,
|
|
2520
|
+
"x-mastra-client-type": "studio"
|
|
2521
|
+
}
|
|
2522
|
+
});
|
|
2523
|
+
describe.each([{
|
|
2524
|
+
label: "studio auth only",
|
|
2525
|
+
withServerAuth: false
|
|
2526
|
+
}, {
|
|
2527
|
+
label: "studio auth + server auth without server RBAC",
|
|
2528
|
+
withServerAuth: true
|
|
2529
|
+
}])("$label", ({ withServerAuth }) => {
|
|
2530
|
+
it.each(["/api/test/studio-rbac", "/studio-rbac/custom"])("denies a studio user without the required permission on %s", async (path) => {
|
|
2531
|
+
const studioApp = await setupStudioRbac({ withServerAuth });
|
|
2532
|
+
const response = await executeHttpRequest(studioApp, studioRequest(path, "viewer"));
|
|
2533
|
+
expect(response.status).toBe(403);
|
|
2534
|
+
});
|
|
2535
|
+
it.each(["/api/test/studio-rbac", "/studio-rbac/custom"])("allows a studio user with the required permission on %s", async (path) => {
|
|
2536
|
+
const studioApp = await setupStudioRbac({ withServerAuth });
|
|
2537
|
+
const response = await executeHttpRequest(studioApp, studioRequest(path, "member"));
|
|
2538
|
+
expect(response.status).toBe(200);
|
|
2539
|
+
});
|
|
2540
|
+
});
|
|
2541
|
+
});
|
|
2415
2542
|
describe("Custom HTTPException responses", () => {
|
|
2416
2543
|
async function setupCustomErrorRoutes() {
|
|
2417
2544
|
let handlerCalls = 0;
|
|
@@ -2448,6 +2575,49 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2448
2575
|
handlerCalls++;
|
|
2449
2576
|
throw new HTTPException(404, { message: "Legacy fallback" });
|
|
2450
2577
|
}
|
|
2578
|
+
},
|
|
2579
|
+
{
|
|
2580
|
+
method: "GET",
|
|
2581
|
+
path: "/custom/http-error-failing-items",
|
|
2582
|
+
responseType: "json",
|
|
2583
|
+
handler: async () => {
|
|
2584
|
+
handlerCalls++;
|
|
2585
|
+
throw new HTTPException(400, {
|
|
2586
|
+
message: "Existing items fail the new schema",
|
|
2587
|
+
cause: { failingItems: [{
|
|
2588
|
+
index: 0,
|
|
2589
|
+
data: {},
|
|
2590
|
+
field: "input"
|
|
2591
|
+
}] }
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
},
|
|
2595
|
+
{
|
|
2596
|
+
method: "GET",
|
|
2597
|
+
path: "/custom/http-error-unsafe-cause",
|
|
2598
|
+
responseType: "json",
|
|
2599
|
+
handler: async () => {
|
|
2600
|
+
handlerCalls++;
|
|
2601
|
+
throw new HTTPException(400, {
|
|
2602
|
+
message: "Unsafe cause",
|
|
2603
|
+
cause: {
|
|
2604
|
+
secret: "do-not-leak",
|
|
2605
|
+
failingItems: "not-an-array"
|
|
2606
|
+
}
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
},
|
|
2610
|
+
{
|
|
2611
|
+
method: "GET",
|
|
2612
|
+
path: "/custom/details-status-error",
|
|
2613
|
+
responseType: "json",
|
|
2614
|
+
handler: async () => {
|
|
2615
|
+
handlerCalls++;
|
|
2616
|
+
throw Object.assign(/* @__PURE__ */ new Error("Details status"), {
|
|
2617
|
+
details: { status: 422 },
|
|
2618
|
+
cause: { failingItems: [{ index: 0 }] }
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2451
2621
|
}
|
|
2452
2622
|
];
|
|
2453
2623
|
const mutableServerRoutes = SERVER_ROUTES;
|
|
@@ -2499,6 +2669,261 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2499
2669
|
expect(response.data).toEqual({ error: "Legacy fallback" });
|
|
2500
2670
|
expect(custom.getHandlerCalls()).toBe(1);
|
|
2501
2671
|
});
|
|
2672
|
+
it("includes failingItems from the HTTPException cause in the JSON body", async () => {
|
|
2673
|
+
const custom = await setupCustomErrorRoutes();
|
|
2674
|
+
const response = await executeHttpRequest(custom.app, {
|
|
2675
|
+
method: "GET",
|
|
2676
|
+
path: "/api/custom/http-error-failing-items"
|
|
2677
|
+
});
|
|
2678
|
+
expect(response.status).toBe(400);
|
|
2679
|
+
expect(response.data.error).toBe("Existing items fail the new schema");
|
|
2680
|
+
expect(response.data.cause).toEqual({ failingItems: [{
|
|
2681
|
+
index: 0,
|
|
2682
|
+
data: {},
|
|
2683
|
+
field: "input"
|
|
2684
|
+
}] });
|
|
2685
|
+
});
|
|
2686
|
+
it("never forwards an arbitrary HTTPException cause", async () => {
|
|
2687
|
+
const custom = await setupCustomErrorRoutes();
|
|
2688
|
+
const response = await executeHttpRequest(custom.app, {
|
|
2689
|
+
method: "GET",
|
|
2690
|
+
path: "/api/custom/http-error-unsafe-cause"
|
|
2691
|
+
});
|
|
2692
|
+
expect(response.status).toBe(400);
|
|
2693
|
+
expect(response.data.error).toBe("Unsafe cause");
|
|
2694
|
+
expect(response.data).not.toHaveProperty("cause");
|
|
2695
|
+
expect(JSON.stringify(response.data)).not.toContain("do-not-leak");
|
|
2696
|
+
});
|
|
2697
|
+
it("keeps details.status errors as a message-only body", async () => {
|
|
2698
|
+
const custom = await setupCustomErrorRoutes();
|
|
2699
|
+
const response = await executeHttpRequest(custom.app, {
|
|
2700
|
+
method: "GET",
|
|
2701
|
+
path: "/api/custom/details-status-error"
|
|
2702
|
+
});
|
|
2703
|
+
expect(response.status).toBe(422);
|
|
2704
|
+
expect(response.data.error).toBe("Details status");
|
|
2705
|
+
expect(response.data).not.toHaveProperty("cause");
|
|
2706
|
+
});
|
|
2707
|
+
});
|
|
2708
|
+
describe("Dataset schema update rejected by existing items", () => {
|
|
2709
|
+
it("returns the failing items so clients can report how many items fail", async () => {
|
|
2710
|
+
const context = await createDefaultTestContext();
|
|
2711
|
+
const dataset = await context.mastra.datasets.create({ name: "schema-update-parity" });
|
|
2712
|
+
await dataset.addItem({ input: { prompt: "hello" } });
|
|
2713
|
+
const setup = await setupAdapter(context);
|
|
2714
|
+
const response = await executeHttpRequest(setup.app, {
|
|
2715
|
+
method: "PATCH",
|
|
2716
|
+
path: `/api/datasets/${dataset.id}`,
|
|
2717
|
+
body: { inputSchema: {
|
|
2718
|
+
type: "object",
|
|
2719
|
+
properties: { question: { type: "string" } },
|
|
2720
|
+
required: ["question"]
|
|
2721
|
+
} }
|
|
2722
|
+
});
|
|
2723
|
+
expect(response.status).toBe(400);
|
|
2724
|
+
const failingItems = response.data?.cause?.failingItems;
|
|
2725
|
+
expect(Array.isArray(failingItems)).toBe(true);
|
|
2726
|
+
expect(failingItems).toHaveLength(1);
|
|
2727
|
+
expect(failingItems[0]).toMatchObject({
|
|
2728
|
+
index: 0,
|
|
2729
|
+
field: "input"
|
|
2730
|
+
});
|
|
2731
|
+
});
|
|
2732
|
+
});
|
|
2733
|
+
describe("HTTPException thrown from a registerApiRoute custom route", () => {
|
|
2734
|
+
class ForeignHTTPException extends Error {
|
|
2735
|
+
status;
|
|
2736
|
+
constructor(status, options) {
|
|
2737
|
+
super(options.message);
|
|
2738
|
+
this.status = status;
|
|
2739
|
+
}
|
|
2740
|
+
res = void 0;
|
|
2741
|
+
getResponse() {
|
|
2742
|
+
return new Response(this.message, { status: this.status });
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
async function setupCustomRouteErrors() {
|
|
2746
|
+
const ctx = await createDefaultTestContext();
|
|
2747
|
+
const originalGetServer = ctx.mastra.getServer.bind(ctx.mastra);
|
|
2748
|
+
const routes = [
|
|
2749
|
+
registerApiRoute("/custom-errors/not-found", {
|
|
2750
|
+
method: "GET",
|
|
2751
|
+
handler: async () => {
|
|
2752
|
+
throw new HTTPException(404, { message: "Widget not found" });
|
|
2753
|
+
}
|
|
2754
|
+
}),
|
|
2755
|
+
registerApiRoute("/custom-errors/conflict", {
|
|
2756
|
+
method: "POST",
|
|
2757
|
+
handler: async () => {
|
|
2758
|
+
throw new ForeignHTTPException(409, { message: "Widget already exists" });
|
|
2759
|
+
}
|
|
2760
|
+
}),
|
|
2761
|
+
registerApiRoute("/custom-errors/custom-response", {
|
|
2762
|
+
method: "GET",
|
|
2763
|
+
handler: async () => {
|
|
2764
|
+
throw new HTTPException(429, { res: new Response(JSON.stringify({ error: "Slow down" }), {
|
|
2765
|
+
status: 429,
|
|
2766
|
+
headers: {
|
|
2767
|
+
"Content-Type": "application/json",
|
|
2768
|
+
"Retry-After": "5"
|
|
2769
|
+
}
|
|
2770
|
+
}) });
|
|
2771
|
+
}
|
|
2772
|
+
}),
|
|
2773
|
+
registerApiRoute("/custom-errors/unexpected", {
|
|
2774
|
+
method: "GET",
|
|
2775
|
+
handler: async () => {
|
|
2776
|
+
throw new Error("db password=do-not-leak");
|
|
2777
|
+
}
|
|
2778
|
+
}),
|
|
2779
|
+
registerApiRoute("/custom-errors/invalid-status", {
|
|
2780
|
+
method: "GET",
|
|
2781
|
+
handler: async () => {
|
|
2782
|
+
throw new ForeignHTTPException(NaN, { message: "bad status do-not-leak" });
|
|
2783
|
+
}
|
|
2784
|
+
})
|
|
2785
|
+
];
|
|
2786
|
+
ctx.mastra.getServer = () => ({
|
|
2787
|
+
...originalGetServer(),
|
|
2788
|
+
apiRoutes: routes
|
|
2789
|
+
});
|
|
2790
|
+
return (await setupAdapter(ctx)).app;
|
|
2791
|
+
}
|
|
2792
|
+
async function bodyText(response) {
|
|
2793
|
+
if (!response.stream) return typeof response.data === "string" ? response.data : JSON.stringify(response.data);
|
|
2794
|
+
const decoder = new TextDecoder();
|
|
2795
|
+
let text = "";
|
|
2796
|
+
for await (const chunk of response.stream) text += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
2797
|
+
return text + decoder.decode();
|
|
2798
|
+
}
|
|
2799
|
+
it("keeps the status and message of a Mastra HTTPException", async () => {
|
|
2800
|
+
const customApp = await setupCustomRouteErrors();
|
|
2801
|
+
const response = await executeHttpRequest(customApp, {
|
|
2802
|
+
method: "GET",
|
|
2803
|
+
path: "/custom-errors/not-found"
|
|
2804
|
+
});
|
|
2805
|
+
expect(response.status).toBe(404);
|
|
2806
|
+
expect(JSON.parse(await bodyText(response))).toEqual({ error: "Widget not found" });
|
|
2807
|
+
});
|
|
2808
|
+
it("keeps the status and message of an HTTPException from another hono install", async () => {
|
|
2809
|
+
const customApp = await setupCustomRouteErrors();
|
|
2810
|
+
const response = await executeHttpRequest(customApp, {
|
|
2811
|
+
method: "POST",
|
|
2812
|
+
path: "/custom-errors/conflict",
|
|
2813
|
+
body: {}
|
|
2814
|
+
});
|
|
2815
|
+
expect(response.status).toBe(409);
|
|
2816
|
+
expect(JSON.parse(await bodyText(response))).toEqual({ error: "Widget already exists" });
|
|
2817
|
+
});
|
|
2818
|
+
it("returns the HTTPException custom response with its headers", async () => {
|
|
2819
|
+
const customApp = await setupCustomRouteErrors();
|
|
2820
|
+
const response = await executeHttpRequest(customApp, {
|
|
2821
|
+
method: "GET",
|
|
2822
|
+
path: "/custom-errors/custom-response"
|
|
2823
|
+
});
|
|
2824
|
+
expect(response.status).toBe(429);
|
|
2825
|
+
expect(response.headers["retry-after"]).toBe("5");
|
|
2826
|
+
expect(JSON.parse(await bodyText(response))).toEqual({ error: "Slow down" });
|
|
2827
|
+
});
|
|
2828
|
+
it("still hides unexpected errors behind a generic 500", async () => {
|
|
2829
|
+
const customApp = await setupCustomRouteErrors();
|
|
2830
|
+
const response = await executeHttpRequest(customApp, {
|
|
2831
|
+
method: "GET",
|
|
2832
|
+
path: "/custom-errors/unexpected"
|
|
2833
|
+
});
|
|
2834
|
+
expect(response.status).toBe(500);
|
|
2835
|
+
const text = await bodyText(response);
|
|
2836
|
+
expect(text).toContain("Internal Server Error");
|
|
2837
|
+
expect(text).not.toContain("do-not-leak");
|
|
2838
|
+
});
|
|
2839
|
+
it("treats an HTTPException with a non-integer status as unexpected", async () => {
|
|
2840
|
+
const customApp = await setupCustomRouteErrors();
|
|
2841
|
+
const response = await executeHttpRequest(customApp, {
|
|
2842
|
+
method: "GET",
|
|
2843
|
+
path: "/custom-errors/invalid-status"
|
|
2844
|
+
});
|
|
2845
|
+
expect(response.status).toBe(500);
|
|
2846
|
+
const text = await bodyText(response);
|
|
2847
|
+
expect(text).toContain("Internal Server Error");
|
|
2848
|
+
expect(text).not.toContain("do-not-leak");
|
|
2849
|
+
});
|
|
2850
|
+
});
|
|
2851
|
+
describe("Dataset item rejected by the input schema", () => {
|
|
2852
|
+
it("returns the failing field paths and messages without the submitted values", async () => {
|
|
2853
|
+
const context = await createDefaultTestContext();
|
|
2854
|
+
const dataset = await context.mastra.datasets.create({
|
|
2855
|
+
name: "item-schema-parity",
|
|
2856
|
+
inputSchema: {
|
|
2857
|
+
type: "object",
|
|
2858
|
+
properties: { question: { type: "string" } },
|
|
2859
|
+
required: ["question"]
|
|
2860
|
+
}
|
|
2861
|
+
});
|
|
2862
|
+
const setup = await setupAdapter(context);
|
|
2863
|
+
const response = await executeHttpRequest(setup.app, {
|
|
2864
|
+
method: "POST",
|
|
2865
|
+
path: `/api/datasets/${dataset.id}/items`,
|
|
2866
|
+
body: { input: {
|
|
2867
|
+
question: { marker: "rejected-secret-value" },
|
|
2868
|
+
note: "submitted-secret-value"
|
|
2869
|
+
} }
|
|
2870
|
+
});
|
|
2871
|
+
expect(response.status).toBe(400);
|
|
2872
|
+
const cause = response.data?.cause;
|
|
2873
|
+
expect(cause?.field).toBe("input");
|
|
2874
|
+
expect(cause?.errors).toEqual([{
|
|
2875
|
+
path: "/question",
|
|
2876
|
+
message: expect.any(String)
|
|
2877
|
+
}]);
|
|
2878
|
+
expect(JSON.stringify(response.data)).not.toContain("submitted-secret-value");
|
|
2879
|
+
expect(JSON.stringify(response.data)).not.toContain("rejected-secret-value");
|
|
2880
|
+
});
|
|
2881
|
+
it("strips anything but path and message from validation errors", async () => {
|
|
2882
|
+
const route = {
|
|
2883
|
+
method: "GET",
|
|
2884
|
+
path: "/custom/http-error-validation-cause",
|
|
2885
|
+
responseType: "json",
|
|
2886
|
+
handler: async () => {
|
|
2887
|
+
throw new HTTPException(400, {
|
|
2888
|
+
message: "Validation failed for input",
|
|
2889
|
+
cause: {
|
|
2890
|
+
field: "input",
|
|
2891
|
+
errors: [{
|
|
2892
|
+
path: "/question",
|
|
2893
|
+
code: "invalid_type",
|
|
2894
|
+
message: "Expected string",
|
|
2895
|
+
received: "leak-me"
|
|
2896
|
+
}],
|
|
2897
|
+
rawInput: "leak-me-too"
|
|
2898
|
+
}
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2901
|
+
};
|
|
2902
|
+
const mutableServerRoutes = SERVER_ROUTES;
|
|
2903
|
+
const originalLength = mutableServerRoutes.length;
|
|
2904
|
+
mutableServerRoutes.push(route);
|
|
2905
|
+
let app;
|
|
2906
|
+
try {
|
|
2907
|
+
app = (await setupAdapter(await createDefaultTestContext())).app;
|
|
2908
|
+
} finally {
|
|
2909
|
+
mutableServerRoutes.splice(originalLength);
|
|
2910
|
+
}
|
|
2911
|
+
const response = await executeHttpRequest(app, {
|
|
2912
|
+
method: "GET",
|
|
2913
|
+
path: "/api/custom/http-error-validation-cause"
|
|
2914
|
+
});
|
|
2915
|
+
expect(response.status).toBe(400);
|
|
2916
|
+
expect(response.data).toEqual({
|
|
2917
|
+
error: "Validation failed for input",
|
|
2918
|
+
cause: {
|
|
2919
|
+
field: "input",
|
|
2920
|
+
errors: [{
|
|
2921
|
+
path: "/question",
|
|
2922
|
+
message: "Expected string"
|
|
2923
|
+
}]
|
|
2924
|
+
}
|
|
2925
|
+
});
|
|
2926
|
+
});
|
|
2502
2927
|
});
|
|
2503
2928
|
describe("Run claim conflict responses (#27638)", () => {
|
|
2504
2929
|
it.each([
|
|
@@ -2545,38 +2970,43 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2545
2970
|
describe("Route Prefix", () => {
|
|
2546
2971
|
it("should register routes at prefixed paths without double /api", async () => {
|
|
2547
2972
|
const prefixedApp = (await setupAdapter(context, { prefix: "/v2" })).app;
|
|
2548
|
-
|
|
2973
|
+
const response = await executeHttpRequest(prefixedApp, {
|
|
2549
2974
|
method: "GET",
|
|
2550
2975
|
path: "/v2/agents"
|
|
2551
|
-
})
|
|
2976
|
+
});
|
|
2977
|
+
expect(response.status).toBeLessThan(400);
|
|
2552
2978
|
});
|
|
2553
2979
|
it("should not have routes at double /api path when prefix is set", async () => {
|
|
2554
2980
|
const prefixedApp = (await setupAdapter(context, { prefix: "/v2" })).app;
|
|
2555
|
-
|
|
2981
|
+
const response = await executeHttpRequest(prefixedApp, {
|
|
2556
2982
|
method: "GET",
|
|
2557
2983
|
path: "/v2/api/agents"
|
|
2558
|
-
})
|
|
2984
|
+
});
|
|
2985
|
+
expect(response.status).toBe(404);
|
|
2559
2986
|
});
|
|
2560
2987
|
it("should normalize prefix with trailing slash", async () => {
|
|
2561
2988
|
const prefixedApp = (await setupAdapter(context, { prefix: "/mastra/" })).app;
|
|
2562
|
-
|
|
2989
|
+
const response = await executeHttpRequest(prefixedApp, {
|
|
2563
2990
|
method: "GET",
|
|
2564
2991
|
path: "/mastra/agents"
|
|
2565
|
-
})
|
|
2992
|
+
});
|
|
2993
|
+
expect(response.status).toBeLessThan(400);
|
|
2566
2994
|
});
|
|
2567
2995
|
it("should normalize prefix without leading slash", async () => {
|
|
2568
2996
|
const prefixedApp = (await setupAdapter(context, { prefix: "mastra" })).app;
|
|
2569
|
-
|
|
2997
|
+
const response = await executeHttpRequest(prefixedApp, {
|
|
2570
2998
|
method: "GET",
|
|
2571
2999
|
path: "/mastra/agents"
|
|
2572
|
-
})
|
|
3000
|
+
});
|
|
3001
|
+
expect(response.status).toBeLessThan(400);
|
|
2573
3002
|
});
|
|
2574
3003
|
it("should not have routes at double-slash path when prefix has trailing slash", async () => {
|
|
2575
3004
|
const prefixedApp = (await setupAdapter(context, { prefix: "/mastra/" })).app;
|
|
2576
|
-
|
|
3005
|
+
const response = await executeHttpRequest(prefixedApp, {
|
|
2577
3006
|
method: "GET",
|
|
2578
3007
|
path: "/mastra//agents"
|
|
2579
|
-
})
|
|
3008
|
+
});
|
|
3009
|
+
expect(response.status).toBe(404);
|
|
2580
3010
|
});
|
|
2581
3011
|
});
|
|
2582
3012
|
});
|
|
@@ -2740,10 +3170,11 @@ function createMCPRouteTestSuite(config) {
|
|
|
2740
3170
|
expect(res.data.tools).toEqual(expect.arrayContaining([expect.objectContaining({ name: "getWeather" }), expect.objectContaining({ name: "calculate" })]));
|
|
2741
3171
|
});
|
|
2742
3172
|
it("should return 404 for non-existent server", async () => {
|
|
2743
|
-
|
|
3173
|
+
const res = await executeHttpRequest(app, {
|
|
2744
3174
|
method: "GET",
|
|
2745
3175
|
path: "/api/mcp/non-existent/tools"
|
|
2746
|
-
})
|
|
3176
|
+
});
|
|
3177
|
+
expect(res.status).toBe(404);
|
|
2747
3178
|
});
|
|
2748
3179
|
});
|
|
2749
3180
|
describe("GET /api/mcp/:serverId/tools/:toolId", () => {
|
|
@@ -2760,10 +3191,11 @@ function createMCPRouteTestSuite(config) {
|
|
|
2760
3191
|
});
|
|
2761
3192
|
});
|
|
2762
3193
|
it("should return 404 for non-existent tool", async () => {
|
|
2763
|
-
|
|
3194
|
+
const res = await executeHttpRequest(app, {
|
|
2764
3195
|
method: "GET",
|
|
2765
3196
|
path: `/api/mcp/${mcpServer1.id}/tools/non-existent`
|
|
2766
|
-
})
|
|
3197
|
+
});
|
|
3198
|
+
expect(res.status).toBe(404);
|
|
2767
3199
|
});
|
|
2768
3200
|
});
|
|
2769
3201
|
describe("POST /api/mcp/:serverId/tools/:toolId/execute", () => {
|
|
@@ -2793,7 +3225,7 @@ function createMCPRouteTestSuite(config) {
|
|
|
2793
3225
|
});
|
|
2794
3226
|
});
|
|
2795
3227
|
it("should return 404 for non-existent server", async () => {
|
|
2796
|
-
|
|
3228
|
+
const res = await executeHttpRequest(app, {
|
|
2797
3229
|
method: "POST",
|
|
2798
3230
|
path: "/api/mcp/non-existent/tools/calculate/execute",
|
|
2799
3231
|
body: { data: {
|
|
@@ -2801,7 +3233,8 @@ function createMCPRouteTestSuite(config) {
|
|
|
2801
3233
|
a: 1,
|
|
2802
3234
|
b: 2
|
|
2803
3235
|
} }
|
|
2804
|
-
})
|
|
3236
|
+
});
|
|
3237
|
+
expect(res.status).toBe(404);
|
|
2805
3238
|
});
|
|
2806
3239
|
it("should return error for non-existent tool", async () => {
|
|
2807
3240
|
const res = await executeHttpRequest(app, {
|
|
@@ -3072,17 +3505,35 @@ function createMCPTransportTestSuite(config) {
|
|
|
3072
3505
|
case "multiply":
|
|
3073
3506
|
result = a * b;
|
|
3074
3507
|
break;
|
|
3075
|
-
case "divide":
|
|
3076
|
-
result = a / b;
|
|
3077
|
-
break;
|
|
3508
|
+
case "divide": result = a / b;
|
|
3078
3509
|
}
|
|
3079
3510
|
return { result };
|
|
3080
3511
|
}
|
|
3081
3512
|
});
|
|
3513
|
+
const slowToolRuns = [];
|
|
3514
|
+
const slowTool = createTool({
|
|
3515
|
+
id: "slow",
|
|
3516
|
+
description: "Waits until aborted, optionally streaming a log notification first",
|
|
3517
|
+
inputSchema: z.object({ streamFirst: z.boolean() }),
|
|
3518
|
+
execute: async ({ streamFirst }, context) => {
|
|
3519
|
+
const signal = context?.abortSignal;
|
|
3520
|
+
const run = {};
|
|
3521
|
+
slowToolRuns.push(run);
|
|
3522
|
+
signal?.addEventListener("abort", () => run.abortedAt = Date.now(), { once: true });
|
|
3523
|
+
if (streamFirst) await context?.mcp?.log?.("info", "started");
|
|
3524
|
+
await new Promise((resolve) => {
|
|
3525
|
+
const timer = setTimeout(resolve, 5e3);
|
|
3526
|
+
signal?.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
|
|
3527
|
+
});
|
|
3528
|
+
run.finishedAt = Date.now();
|
|
3529
|
+
return { aborted: !!signal?.aborted };
|
|
3530
|
+
}
|
|
3531
|
+
});
|
|
3082
3532
|
let httpServer;
|
|
3083
3533
|
let port;
|
|
3084
3534
|
let mcpServer1;
|
|
3085
3535
|
let mcpServer2;
|
|
3536
|
+
let slowServer;
|
|
3086
3537
|
let mcpClient;
|
|
3087
3538
|
let mastra;
|
|
3088
3539
|
beforeAll(async () => {
|
|
@@ -3101,9 +3552,15 @@ function createMCPTransportTestSuite(config) {
|
|
|
3101
3552
|
description: "Test MCP Server 2",
|
|
3102
3553
|
tools: {}
|
|
3103
3554
|
});
|
|
3555
|
+
slowServer = new MCPServer({
|
|
3556
|
+
name: "slow-server",
|
|
3557
|
+
version: "1.0.0",
|
|
3558
|
+
tools: { slow: slowTool }
|
|
3559
|
+
});
|
|
3104
3560
|
mastra = new Mastra$1({ mcpServers: {
|
|
3105
3561
|
"test-server-1": mcpServer1,
|
|
3106
3562
|
"test-server-2": mcpServer2,
|
|
3563
|
+
"slow-server": slowServer,
|
|
3107
3564
|
"native-fixture": new NativeMCPFixture()
|
|
3108
3565
|
} });
|
|
3109
3566
|
const serverSetup = await createServer(mastra);
|
|
@@ -3119,6 +3576,7 @@ function createMCPTransportTestSuite(config) {
|
|
|
3119
3576
|
httpServer?.close();
|
|
3120
3577
|
await mcpServer1?.close();
|
|
3121
3578
|
await mcpServer2?.close();
|
|
3579
|
+
await slowServer?.close();
|
|
3122
3580
|
}, 3e4);
|
|
3123
3581
|
describe("native MCP v2 adapter dispatch", () => {
|
|
3124
3582
|
it("dispatches HTTP through the shared adapter path", async () => {
|
|
@@ -3136,7 +3594,8 @@ function createMCPTransportTestSuite(config) {
|
|
|
3136
3594
|
path: "messages",
|
|
3137
3595
|
method: "POST"
|
|
3138
3596
|
}])("rejects the legacy $path route for a native server", async ({ path, method }) => {
|
|
3139
|
-
|
|
3597
|
+
const response = await fetch(`http://localhost:${port}/api/mcp/native-fixture/${path}`, { method });
|
|
3598
|
+
expect(response.status).toBe(404);
|
|
3140
3599
|
});
|
|
3141
3600
|
it("executes ordinary tools with the v2 request context and reports a suspended tool truthfully", async () => {
|
|
3142
3601
|
const base = `http://localhost:${port}/api/mcp/native-fixture/tools`;
|
|
@@ -3188,7 +3647,7 @@ function createMCPTransportTestSuite(config) {
|
|
|
3188
3647
|
describe("HTTP Transport (/api/mcp/:serverId/mcp)", () => {
|
|
3189
3648
|
describe("Error handling (raw HTTP)", () => {
|
|
3190
3649
|
it("should return 404 for non-existent server", async () => {
|
|
3191
|
-
|
|
3650
|
+
const res = await fetch(`http://localhost:${port}/api/mcp/non-existent/mcp`, {
|
|
3192
3651
|
method: "POST",
|
|
3193
3652
|
headers: {
|
|
3194
3653
|
"Content-Type": "application/json",
|
|
@@ -3207,7 +3666,8 @@ function createMCPTransportTestSuite(config) {
|
|
|
3207
3666
|
},
|
|
3208
3667
|
id: 1
|
|
3209
3668
|
})
|
|
3210
|
-
})
|
|
3669
|
+
});
|
|
3670
|
+
expect(res.status).toBe(404);
|
|
3211
3671
|
});
|
|
3212
3672
|
});
|
|
3213
3673
|
describe("Protocol operations (MCPClient)", () => {
|
|
@@ -3243,7 +3703,8 @@ function createMCPTransportTestSuite(config) {
|
|
|
3243
3703
|
const tools = await mcpClient.listTools();
|
|
3244
3704
|
expect(tools["server1_getWeather"]).toBeDefined();
|
|
3245
3705
|
expect(tools["server1_calculate"]).toBeDefined();
|
|
3246
|
-
|
|
3706
|
+
const server2Tools = Object.keys(tools).filter((k) => k.startsWith("server2_"));
|
|
3707
|
+
expect(server2Tools).toHaveLength(0);
|
|
3247
3708
|
});
|
|
3248
3709
|
});
|
|
3249
3710
|
describe("Tool execution errors (MCPClient)", () => {
|
|
@@ -3252,17 +3713,18 @@ function createMCPTransportTestSuite(config) {
|
|
|
3252
3713
|
let failingMastra;
|
|
3253
3714
|
let failingHttpServer;
|
|
3254
3715
|
beforeAll(async () => {
|
|
3716
|
+
const failingTool = createTool({
|
|
3717
|
+
id: "failingTool",
|
|
3718
|
+
description: "A tool that always throws an error",
|
|
3719
|
+
inputSchema: z.object({}),
|
|
3720
|
+
execute: async () => {
|
|
3721
|
+
throw new Error("Tool execution failed intentionally");
|
|
3722
|
+
}
|
|
3723
|
+
});
|
|
3255
3724
|
failingServer = new MCPServer({
|
|
3256
3725
|
name: "failingServer",
|
|
3257
3726
|
version: "1.0.0",
|
|
3258
|
-
tools: { failingTool
|
|
3259
|
-
id: "failingTool",
|
|
3260
|
-
description: "A tool that always throws an error",
|
|
3261
|
-
inputSchema: z.object({}),
|
|
3262
|
-
execute: async () => {
|
|
3263
|
-
throw new Error("Tool execution failed intentionally");
|
|
3264
|
-
}
|
|
3265
|
-
}) }
|
|
3727
|
+
tools: { failingTool }
|
|
3266
3728
|
});
|
|
3267
3729
|
failingMastra = new Mastra$1({ mcpServers: { "failing-server": failingServer } });
|
|
3268
3730
|
const serverSetup = await createServer(failingMastra);
|
|
@@ -3294,13 +3756,73 @@ function createMCPTransportTestSuite(config) {
|
|
|
3294
3756
|
});
|
|
3295
3757
|
});
|
|
3296
3758
|
});
|
|
3759
|
+
describe("HTTP Transport client disconnect", () => {
|
|
3760
|
+
it.each([{
|
|
3761
|
+
name: "before any response bytes are sent",
|
|
3762
|
+
streamFirst: false
|
|
3763
|
+
}, {
|
|
3764
|
+
name: "after the response has started streaming",
|
|
3765
|
+
streamFirst: true
|
|
3766
|
+
}])("aborts the in-flight tool call when the client disconnects $name", async ({ streamFirst }) => {
|
|
3767
|
+
const runsBefore = slowToolRuns.length;
|
|
3768
|
+
const body = JSON.stringify({
|
|
3769
|
+
jsonrpc: "2.0",
|
|
3770
|
+
id: 1,
|
|
3771
|
+
method: "tools/call",
|
|
3772
|
+
params: {
|
|
3773
|
+
name: "slow",
|
|
3774
|
+
arguments: { streamFirst },
|
|
3775
|
+
_meta: {
|
|
3776
|
+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
3777
|
+
"io.modelcontextprotocol/clientInfo": {
|
|
3778
|
+
name: "disconnect-test",
|
|
3779
|
+
version: "1.0.0"
|
|
3780
|
+
},
|
|
3781
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
3782
|
+
"io.modelcontextprotocol/logLevel": "info"
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
});
|
|
3786
|
+
let responseBytes = 0;
|
|
3787
|
+
const req = http.request({
|
|
3788
|
+
host: "localhost",
|
|
3789
|
+
port,
|
|
3790
|
+
method: "POST",
|
|
3791
|
+
path: `/api/mcp/${slowServer.id}/mcp`,
|
|
3792
|
+
headers: {
|
|
3793
|
+
"content-type": "application/json",
|
|
3794
|
+
accept: "application/json, text/event-stream",
|
|
3795
|
+
"mcp-protocol-version": "2026-07-28",
|
|
3796
|
+
"mcp-method": "tools/call",
|
|
3797
|
+
"mcp-name": "slow",
|
|
3798
|
+
"content-length": Buffer.byteLength(body)
|
|
3799
|
+
}
|
|
3800
|
+
});
|
|
3801
|
+
req.on("response", (res) => {
|
|
3802
|
+
res.on("data", (chunk) => responseBytes += chunk.length);
|
|
3803
|
+
res.on("error", () => {});
|
|
3804
|
+
});
|
|
3805
|
+
req.on("error", () => {});
|
|
3806
|
+
req.end(body);
|
|
3807
|
+
await expect.poll(() => slowToolRuns.length, { timeout: 5e3 }).toBe(runsBefore + 1);
|
|
3808
|
+
if (streamFirst) await expect.poll(() => responseBytes, { timeout: 5e3 }).toBeGreaterThan(0);
|
|
3809
|
+
else expect(responseBytes).toBe(0);
|
|
3810
|
+
const run = slowToolRuns[runsBefore];
|
|
3811
|
+
const disconnectedAt = Date.now();
|
|
3812
|
+
req.destroy();
|
|
3813
|
+
await expect.poll(() => run.finishedAt, { timeout: 6e3 }).toBeDefined();
|
|
3814
|
+
expect(run.abortedAt).toBeDefined();
|
|
3815
|
+
expect(run.abortedAt - disconnectedAt).toBeLessThan(1e3);
|
|
3816
|
+
}, 15e3);
|
|
3817
|
+
});
|
|
3297
3818
|
describe("SSE Transport (/api/mcp/:serverId/sse)", () => {
|
|
3298
3819
|
describe("Error handling (raw HTTP)", () => {
|
|
3299
3820
|
it("should return 404 for non-existent server on GET /sse", async () => {
|
|
3300
|
-
|
|
3821
|
+
const res = await fetch(`http://localhost:${port}/api/mcp/non-existent/sse`);
|
|
3822
|
+
expect(res.status).toBe(404);
|
|
3301
3823
|
});
|
|
3302
3824
|
it("should return 404 for non-existent server on POST /messages", async () => {
|
|
3303
|
-
|
|
3825
|
+
const res = await fetch(`http://localhost:${port}/api/mcp/non-existent/messages`, {
|
|
3304
3826
|
method: "POST",
|
|
3305
3827
|
headers: { "Content-Type": "application/json" },
|
|
3306
3828
|
body: JSON.stringify({
|
|
@@ -3308,14 +3830,16 @@ function createMCPTransportTestSuite(config) {
|
|
|
3308
3830
|
method: "test",
|
|
3309
3831
|
id: 1
|
|
3310
3832
|
})
|
|
3311
|
-
})
|
|
3833
|
+
});
|
|
3834
|
+
expect(res.status).toBe(404);
|
|
3312
3835
|
});
|
|
3313
3836
|
});
|
|
3314
3837
|
describe("MCP 2.x servers (MCPClient)", () => {
|
|
3315
3838
|
it("does not serve the legacy SSE transport for a 2.x MCPServer", async (ctx) => {
|
|
3316
3839
|
if (mcpServer1.mcpVersion !== 2) ctx.skip();
|
|
3317
|
-
|
|
3318
|
-
expect((
|
|
3840
|
+
const res = await fetch(`http://localhost:${port}/api/mcp/${mcpServer1.id}/sse`);
|
|
3841
|
+
expect(res.status).toBe(404);
|
|
3842
|
+
const messages = await fetch(`http://localhost:${port}/api/mcp/${mcpServer1.id}/messages`, {
|
|
3319
3843
|
method: "POST",
|
|
3320
3844
|
headers: { "Content-Type": "application/json" },
|
|
3321
3845
|
body: JSON.stringify({
|
|
@@ -3323,7 +3847,8 @@ function createMCPTransportTestSuite(config) {
|
|
|
3323
3847
|
method: "tools/list",
|
|
3324
3848
|
id: 1
|
|
3325
3849
|
})
|
|
3326
|
-
})
|
|
3850
|
+
});
|
|
3851
|
+
expect(messages.status).toBe(404);
|
|
3327
3852
|
});
|
|
3328
3853
|
it("fails to connect through the SSE URL without downgrading", async (ctx) => {
|
|
3329
3854
|
if (mcpServer1.mcpVersion !== 2) ctx.skip();
|
|
@@ -3400,7 +3925,8 @@ function createMultipartTestSuite(config) {
|
|
|
3400
3925
|
body: formData
|
|
3401
3926
|
});
|
|
3402
3927
|
expect(response.status).toBe(200);
|
|
3403
|
-
|
|
3928
|
+
const result = await response.json();
|
|
3929
|
+
expect(result).toMatchObject({
|
|
3404
3930
|
success: true,
|
|
3405
3931
|
hasAudio: true
|
|
3406
3932
|
});
|
|
@@ -3435,10 +3961,11 @@ function createMultipartTestSuite(config) {
|
|
|
3435
3961
|
language: "en",
|
|
3436
3962
|
format: "mp3"
|
|
3437
3963
|
}));
|
|
3438
|
-
|
|
3964
|
+
const response = await fetch(`${serverInfo.baseUrl}/test/upload-with-options`, {
|
|
3439
3965
|
method: "POST",
|
|
3440
3966
|
body: formData
|
|
3441
|
-
})
|
|
3967
|
+
});
|
|
3968
|
+
expect(response.status).toBe(200);
|
|
3442
3969
|
expect(receivedBody.options).toBeDefined();
|
|
3443
3970
|
expect(typeof receivedBody.options).toBe("object");
|
|
3444
3971
|
expect(receivedBody.options.language).toBe("en");
|
|
@@ -3468,10 +3995,11 @@ function createMultipartTestSuite(config) {
|
|
|
3468
3995
|
])], { type: "audio/wav" });
|
|
3469
3996
|
formData.append("audio", audioBlob, "test.wav");
|
|
3470
3997
|
formData.append("name", "test-recording");
|
|
3471
|
-
|
|
3998
|
+
const response = await fetch(`${serverInfo.baseUrl}/test/upload-with-string`, {
|
|
3472
3999
|
method: "POST",
|
|
3473
4000
|
body: formData
|
|
3474
|
-
})
|
|
4001
|
+
});
|
|
4002
|
+
expect(response.status).toBe(200);
|
|
3475
4003
|
expect(receivedBody.name).toBe("test-recording");
|
|
3476
4004
|
});
|
|
3477
4005
|
it("should reject files exceeding size limit", async () => {
|
|
@@ -3493,10 +4021,11 @@ function createMultipartTestSuite(config) {
|
|
|
3493
4021
|
const formData = new FormData();
|
|
3494
4022
|
const largeBlob = new Blob([/* @__PURE__ */ new Uint8Array(200)], { type: "audio/wav" });
|
|
3495
4023
|
formData.append("audio", largeBlob, "large.wav");
|
|
3496
|
-
|
|
4024
|
+
const response = await fetch(`${serverInfo.baseUrl}/test/upload-limited`, {
|
|
3497
4025
|
method: "POST",
|
|
3498
4026
|
body: formData
|
|
3499
|
-
})
|
|
4027
|
+
});
|
|
4028
|
+
expect(response.status).toBeGreaterThanOrEqual(400);
|
|
3500
4029
|
});
|
|
3501
4030
|
it("should handle empty FormData gracefully", async () => {
|
|
3502
4031
|
const { adapter, app } = await setupAdapter(context);
|
|
@@ -3529,7 +4058,8 @@ function createMultipartTestSuite(config) {
|
|
|
3529
4058
|
body: formData
|
|
3530
4059
|
});
|
|
3531
4060
|
expect(response.status).toBe(200);
|
|
3532
|
-
|
|
4061
|
+
const result = await response.json();
|
|
4062
|
+
expect(result).toMatchObject({
|
|
3533
4063
|
success: true,
|
|
3534
4064
|
bodyKeys: []
|
|
3535
4065
|
});
|
|
@@ -3571,7 +4101,8 @@ function createMultipartTestSuite(config) {
|
|
|
3571
4101
|
body: formData
|
|
3572
4102
|
});
|
|
3573
4103
|
expect(response.status).toBe(200);
|
|
3574
|
-
|
|
4104
|
+
const result = await response.json();
|
|
4105
|
+
expect(result).toMatchObject({
|
|
3575
4106
|
hasFile1: true,
|
|
3576
4107
|
hasFile2: true
|
|
3577
4108
|
});
|
|
@@ -3600,7 +4131,8 @@ function createMultipartTestSuite(config) {
|
|
|
3600
4131
|
body: JSON.stringify({ message: "Hello, World!" })
|
|
3601
4132
|
});
|
|
3602
4133
|
expect(response.status).toBe(200);
|
|
3603
|
-
|
|
4134
|
+
const result = await response.json();
|
|
4135
|
+
expect(result).toMatchObject({
|
|
3604
4136
|
success: true,
|
|
3605
4137
|
message: "Hello, World!"
|
|
3606
4138
|
});
|
|
@@ -3700,7 +4232,8 @@ function createHttpLoggingTestSuite(config) {
|
|
|
3700
4232
|
logSpy = vi.spyOn(adapter.logger, "info");
|
|
3701
4233
|
await adapter.init();
|
|
3702
4234
|
await addRoute(app, "GET", "/test", () => ({ message: "success" }));
|
|
3703
|
-
|
|
4235
|
+
const response = await executeRequest(app, "GET", "http://localhost/test");
|
|
4236
|
+
expect(response.status).toBe(200);
|
|
3704
4237
|
expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/GET \/test 200 \d+ms/), expect.objectContaining({
|
|
3705
4238
|
method: "GET",
|
|
3706
4239
|
path: "/test",
|
|
@@ -3860,7 +4393,7 @@ function createHttpLoggingTestSuite(config) {
|
|
|
3860
4393
|
* request body for DELETE the same way it does for POST/PUT/PATCH.
|
|
3861
4394
|
*/
|
|
3862
4395
|
function createBodyLimitTestSuite(config) {
|
|
3863
|
-
const { suiteName = "Body Size Limit", maxSize = 100, createApp, setupAdapter, registerRoute, executeRequest, executeRequestWithoutContentLength, cleanupApp } = config;
|
|
4396
|
+
const { suiteName = "Body Size Limit", maxSize = 100, createApp, setupAdapter, registerRoute, executeRequest, executeRequestWithoutContentLength, skipNonJsonStreamedBody, cleanupApp } = config;
|
|
3864
4397
|
describe(suiteName, () => {
|
|
3865
4398
|
const oversizedPayload = JSON.stringify({ padding: "x".repeat(maxSize * 4) });
|
|
3866
4399
|
it("enforces a route-specific limit without global body limit options", async () => {
|
|
@@ -3878,35 +4411,155 @@ function createBodyLimitTestSuite(config) {
|
|
|
3878
4411
|
return { receivedBody: body };
|
|
3879
4412
|
}
|
|
3880
4413
|
});
|
|
3881
|
-
|
|
4414
|
+
const response = await executeRequest(wiredApp, "POST", "http://localhost/test/route-body-limit", {
|
|
3882
4415
|
headers: { "Content-Type": "application/json" },
|
|
3883
4416
|
body: oversizedPayload
|
|
3884
|
-
})).status).toBe(413);
|
|
3885
|
-
expect(handlerCalled).toBe(false);
|
|
3886
|
-
await cleanupApp?.(wiredApp);
|
|
3887
|
-
});
|
|
3888
|
-
if (executeRequestWithoutContentLength) it("rejects an oversized route body without Content-Length before handler execution", async () => {
|
|
3889
|
-
const mastra = new Mastra$1({});
|
|
3890
|
-
const app = createApp();
|
|
3891
|
-
const { adapter, app: wiredApp } = await setupAdapter(app, mastra);
|
|
3892
|
-
let handlerCalled = false;
|
|
3893
|
-
await registerRoute(adapter, wiredApp, {
|
|
3894
|
-
method: "POST",
|
|
3895
|
-
path: "/test/route-body-limit-no-content-length",
|
|
3896
|
-
responseType: "json",
|
|
3897
|
-
maxBodySize: maxSize,
|
|
3898
|
-
handler: async ({ body }) => {
|
|
3899
|
-
handlerCalled = true;
|
|
3900
|
-
return { receivedBody: body };
|
|
3901
|
-
}
|
|
3902
4417
|
});
|
|
3903
|
-
expect((
|
|
3904
|
-
headers: { "Content-Type": "application/json" },
|
|
3905
|
-
body: oversizedPayload
|
|
3906
|
-
})).status).toBe(413);
|
|
4418
|
+
expect(response.status).toBe(413);
|
|
3907
4419
|
expect(handlerCalled).toBe(false);
|
|
3908
4420
|
await cleanupApp?.(wiredApp);
|
|
3909
4421
|
});
|
|
4422
|
+
if (executeRequestWithoutContentLength) {
|
|
4423
|
+
it("rejects an oversized route body without Content-Length before handler execution", async () => {
|
|
4424
|
+
const mastra = new Mastra$1({});
|
|
4425
|
+
const app = createApp();
|
|
4426
|
+
const { adapter, app: wiredApp } = await setupAdapter(app, mastra);
|
|
4427
|
+
let handlerCalled = false;
|
|
4428
|
+
await registerRoute(adapter, wiredApp, {
|
|
4429
|
+
method: "POST",
|
|
4430
|
+
path: "/test/route-body-limit-no-content-length",
|
|
4431
|
+
responseType: "json",
|
|
4432
|
+
maxBodySize: maxSize,
|
|
4433
|
+
handler: async ({ body }) => {
|
|
4434
|
+
handlerCalled = true;
|
|
4435
|
+
return { receivedBody: body };
|
|
4436
|
+
}
|
|
4437
|
+
});
|
|
4438
|
+
const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/route-body-limit-no-content-length", {
|
|
4439
|
+
headers: { "Content-Type": "application/json" },
|
|
4440
|
+
body: oversizedPayload
|
|
4441
|
+
});
|
|
4442
|
+
expect(response.status).toBe(413);
|
|
4443
|
+
expect(handlerCalled).toBe(false);
|
|
4444
|
+
await cleanupApp?.(wiredApp);
|
|
4445
|
+
});
|
|
4446
|
+
it.skipIf(skipNonJsonStreamedBody).each(["text/plain", "application/octet-stream"])("rejects an oversized %s body without Content-Length before handler execution", async (contentType) => {
|
|
4447
|
+
const mastra = new Mastra$1({});
|
|
4448
|
+
const app = createApp();
|
|
4449
|
+
const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
|
|
4450
|
+
maxSize,
|
|
4451
|
+
onError: () => ({ error: "Request body too large" })
|
|
4452
|
+
});
|
|
4453
|
+
let handlerCalled = false;
|
|
4454
|
+
await registerRoute(adapter, wiredApp, {
|
|
4455
|
+
method: "POST",
|
|
4456
|
+
path: "/test/body-limit-no-content-length-non-json",
|
|
4457
|
+
responseType: "json",
|
|
4458
|
+
handler: async () => {
|
|
4459
|
+
handlerCalled = true;
|
|
4460
|
+
return { ok: true };
|
|
4461
|
+
}
|
|
4462
|
+
});
|
|
4463
|
+
const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-non-json", {
|
|
4464
|
+
headers: { "Content-Type": contentType },
|
|
4465
|
+
body: "x".repeat(maxSize * 4)
|
|
4466
|
+
});
|
|
4467
|
+
expect(response.status).toBe(413);
|
|
4468
|
+
expect(handlerCalled).toBe(false);
|
|
4469
|
+
await cleanupApp?.(wiredApp);
|
|
4470
|
+
});
|
|
4471
|
+
const boundary = "bodylimitboundary";
|
|
4472
|
+
const multipart = (parts) => parts.map((p) => `--${boundary}\r\nContent-Disposition: form-data; name="${p.name}"` + (p.filename ? `; filename="${p.filename}"\r\nContent-Type: application/octet-stream` : "") + `\r\n\r\n${p.value}\r\n`).join("") + `--${boundary}--\r\n`;
|
|
4473
|
+
const multipartCases = {
|
|
4474
|
+
"many small fields": multipart(Array.from({ length: 20 }, (_, i) => ({
|
|
4475
|
+
name: `f${i}`,
|
|
4476
|
+
value: "x".repeat(Math.floor(maxSize / 2))
|
|
4477
|
+
}))),
|
|
4478
|
+
"one oversized file": multipart([{
|
|
4479
|
+
name: "file",
|
|
4480
|
+
filename: "f.bin",
|
|
4481
|
+
value: "x".repeat(maxSize * 4)
|
|
4482
|
+
}])
|
|
4483
|
+
};
|
|
4484
|
+
it.each(Object.keys(multipartCases))("rejects an oversized multipart body (%s) without Content-Length before handler execution", async (caseName) => {
|
|
4485
|
+
const mastra = new Mastra$1({});
|
|
4486
|
+
const app = createApp();
|
|
4487
|
+
const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
|
|
4488
|
+
maxSize,
|
|
4489
|
+
onError: () => ({ error: "Request body too large" })
|
|
4490
|
+
});
|
|
4491
|
+
let handlerCalled = false;
|
|
4492
|
+
await registerRoute(adapter, wiredApp, {
|
|
4493
|
+
method: "POST",
|
|
4494
|
+
path: "/test/body-limit-no-content-length-multipart",
|
|
4495
|
+
responseType: "json",
|
|
4496
|
+
handler: async () => {
|
|
4497
|
+
handlerCalled = true;
|
|
4498
|
+
return { ok: true };
|
|
4499
|
+
}
|
|
4500
|
+
});
|
|
4501
|
+
const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-multipart", {
|
|
4502
|
+
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
|
|
4503
|
+
body: multipartCases[caseName]
|
|
4504
|
+
});
|
|
4505
|
+
expect(response.status).toBe(413);
|
|
4506
|
+
expect(handlerCalled).toBe(false);
|
|
4507
|
+
await cleanupApp?.(wiredApp);
|
|
4508
|
+
});
|
|
4509
|
+
it("passes a multipart body within the limit without Content-Length to the handler", async () => {
|
|
4510
|
+
const mastra = new Mastra$1({});
|
|
4511
|
+
const app = createApp();
|
|
4512
|
+
const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
|
|
4513
|
+
maxSize,
|
|
4514
|
+
onError: () => ({ error: "Request body too large" })
|
|
4515
|
+
});
|
|
4516
|
+
let receivedBody;
|
|
4517
|
+
await registerRoute(adapter, wiredApp, {
|
|
4518
|
+
method: "POST",
|
|
4519
|
+
path: "/test/body-limit-no-content-length-multipart-within",
|
|
4520
|
+
responseType: "json",
|
|
4521
|
+
handler: async ({ a }) => {
|
|
4522
|
+
receivedBody = { a };
|
|
4523
|
+
return { ok: true };
|
|
4524
|
+
}
|
|
4525
|
+
});
|
|
4526
|
+
const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-multipart-within", {
|
|
4527
|
+
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
|
|
4528
|
+
body: multipart([{
|
|
4529
|
+
name: "a",
|
|
4530
|
+
value: "ok"
|
|
4531
|
+
}])
|
|
4532
|
+
});
|
|
4533
|
+
expect(response.status).toBe(200);
|
|
4534
|
+
expect(receivedBody).toEqual({ a: "ok" });
|
|
4535
|
+
await cleanupApp?.(wiredApp);
|
|
4536
|
+
});
|
|
4537
|
+
it("passes a JSON body within the limit without Content-Length to the handler", async () => {
|
|
4538
|
+
const mastra = new Mastra$1({});
|
|
4539
|
+
const app = createApp();
|
|
4540
|
+
const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
|
|
4541
|
+
maxSize,
|
|
4542
|
+
onError: () => ({ error: "Request body too large" })
|
|
4543
|
+
});
|
|
4544
|
+
let receivedBody;
|
|
4545
|
+
await registerRoute(adapter, wiredApp, {
|
|
4546
|
+
method: "POST",
|
|
4547
|
+
path: "/test/body-limit-no-content-length-within",
|
|
4548
|
+
responseType: "json",
|
|
4549
|
+
handler: async ({ message }) => {
|
|
4550
|
+
receivedBody = { message };
|
|
4551
|
+
return { ok: true };
|
|
4552
|
+
}
|
|
4553
|
+
});
|
|
4554
|
+
const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-within", {
|
|
4555
|
+
headers: { "Content-Type": "application/json" },
|
|
4556
|
+
body: JSON.stringify({ message: "hello" })
|
|
4557
|
+
});
|
|
4558
|
+
expect(response.status).toBe(200);
|
|
4559
|
+
expect(receivedBody).toEqual({ message: "hello" });
|
|
4560
|
+
await cleanupApp?.(wiredApp);
|
|
4561
|
+
});
|
|
4562
|
+
}
|
|
3910
4563
|
it.each(["POST", "DELETE"])("rejects an oversized %s body with 413", async (method) => {
|
|
3911
4564
|
const mastra = new Mastra$1({});
|
|
3912
4565
|
const app = createApp();
|
|
@@ -3920,15 +4573,107 @@ function createBodyLimitTestSuite(config) {
|
|
|
3920
4573
|
responseType: "json",
|
|
3921
4574
|
handler: async ({ body }) => ({ receivedBody: body })
|
|
3922
4575
|
});
|
|
3923
|
-
|
|
4576
|
+
const response = await executeRequest(wiredApp, method, "http://localhost/test/body-limit", {
|
|
3924
4577
|
headers: { "Content-Type": "application/json" },
|
|
3925
4578
|
body: oversizedPayload
|
|
3926
|
-
})
|
|
4579
|
+
});
|
|
4580
|
+
expect(response.status).toBe(413);
|
|
3927
4581
|
await cleanupApp?.(wiredApp);
|
|
3928
4582
|
});
|
|
3929
4583
|
});
|
|
3930
4584
|
}
|
|
3931
4585
|
//#endregion
|
|
3932
|
-
|
|
4586
|
+
//#region src/custom-route-fga-test-suite.ts
|
|
4587
|
+
/**
|
|
4588
|
+
* Custom `registerApiRoute()` handlers read path params from the URL. FGA must
|
|
4589
|
+
* authorize that same resource, so a query or body field named like a path
|
|
4590
|
+
* param must not change which resource is checked.
|
|
4591
|
+
*/
|
|
4592
|
+
function createCustomRouteFGATestSuite(config) {
|
|
4593
|
+
const { suiteName = "Custom route FGA", startServer } = config;
|
|
4594
|
+
describe(suiteName, () => {
|
|
4595
|
+
let close;
|
|
4596
|
+
afterEach(async () => {
|
|
4597
|
+
await close?.();
|
|
4598
|
+
close = void 0;
|
|
4599
|
+
});
|
|
4600
|
+
const setup = async () => {
|
|
4601
|
+
const check = vi.fn(async (_user, { resource }) => resource.id === "open");
|
|
4602
|
+
const fga = {
|
|
4603
|
+
check,
|
|
4604
|
+
require: vi.fn(),
|
|
4605
|
+
filterAccessible: vi.fn()
|
|
4606
|
+
};
|
|
4607
|
+
const routeFGA = {
|
|
4608
|
+
resourceType: "doc",
|
|
4609
|
+
resourceIdParam: "docId",
|
|
4610
|
+
permission: "docs:read"
|
|
4611
|
+
};
|
|
4612
|
+
const handler = async (c) => c.json({ docId: c.req.param("docId") });
|
|
4613
|
+
const mastra = new Mastra({
|
|
4614
|
+
logger: false,
|
|
4615
|
+
server: {
|
|
4616
|
+
fga,
|
|
4617
|
+
apiRoutes: [registerApiRoute("/docs/:docId", {
|
|
4618
|
+
method: "GET",
|
|
4619
|
+
fga: routeFGA,
|
|
4620
|
+
handler
|
|
4621
|
+
}), registerApiRoute("/docs/:docId", {
|
|
4622
|
+
method: "POST",
|
|
4623
|
+
fga: routeFGA,
|
|
4624
|
+
handler
|
|
4625
|
+
})]
|
|
4626
|
+
}
|
|
4627
|
+
});
|
|
4628
|
+
const originalGetServer = mastra.getServer.bind(mastra);
|
|
4629
|
+
mastra.getServer = () => ({
|
|
4630
|
+
...originalGetServer(),
|
|
4631
|
+
auth: {
|
|
4632
|
+
authenticateToken: async (token) => token === "valid-token" ? { id: "user-1" } : null,
|
|
4633
|
+
authorize: async () => true
|
|
4634
|
+
}
|
|
4635
|
+
});
|
|
4636
|
+
const server = await startServer(mastra);
|
|
4637
|
+
close = server.close;
|
|
4638
|
+
const send = (path, init = {}) => server.request(path, {
|
|
4639
|
+
...init,
|
|
4640
|
+
headers: {
|
|
4641
|
+
authorization: "Bearer valid-token",
|
|
4642
|
+
...init.headers
|
|
4643
|
+
}
|
|
4644
|
+
});
|
|
4645
|
+
return {
|
|
4646
|
+
check,
|
|
4647
|
+
send
|
|
4648
|
+
};
|
|
4649
|
+
};
|
|
4650
|
+
const checkedIds = (check) => check.mock.calls.map(([, input]) => input.resource.id);
|
|
4651
|
+
it("allows the permitted resource and denies the forbidden one", async () => {
|
|
4652
|
+
const { send } = await setup();
|
|
4653
|
+
const allowed = await send("/docs/open");
|
|
4654
|
+
expect(allowed.status).toBe(200);
|
|
4655
|
+
expect(await allowed.json()).toEqual({ docId: "open" });
|
|
4656
|
+
expect((await send("/docs/locked")).status).toBe(403);
|
|
4657
|
+
});
|
|
4658
|
+
it("checks the path param when the query has a field with the same name", async () => {
|
|
4659
|
+
const { check, send } = await setup();
|
|
4660
|
+
const response = await send("/docs/locked?docId=open");
|
|
4661
|
+
expect(response.status).toBe(403);
|
|
4662
|
+
expect(checkedIds(check)).toEqual(["locked"]);
|
|
4663
|
+
});
|
|
4664
|
+
it("checks the path param when the JSON body has a field with the same name", async () => {
|
|
4665
|
+
const { check, send } = await setup();
|
|
4666
|
+
const response = await send("/docs/locked", {
|
|
4667
|
+
method: "POST",
|
|
4668
|
+
headers: { "content-type": "application/json" },
|
|
4669
|
+
body: JSON.stringify({ docId: "open" })
|
|
4670
|
+
});
|
|
4671
|
+
expect(response.status).toBe(403);
|
|
4672
|
+
expect(checkedIds(check)).toEqual(["locked"]);
|
|
4673
|
+
});
|
|
4674
|
+
});
|
|
4675
|
+
}
|
|
4676
|
+
//#endregion
|
|
4677
|
+
export { consumeSSEStream, createBodyLimitTestSuite, createCustomRouteFGATestSuite, createDefaultTestContext, createHttpLoggingTestSuite, createMCPRouteTestSuite, createMCPTransportTestSuite, createMultipartTestSuite, createRouteAdapterTestSuite, createStreamWithSensitiveData, createStreamWithUnserializableChunk, expectSerializedStreamChunks };
|
|
3933
4678
|
|
|
3934
4679
|
//# sourceMappingURL=index.js.map
|