@mastra/server-adapters-test-suite 0.1.3 → 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 +13 -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 +721 -27
- 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 +7 -7
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";
|
|
@@ -60,6 +62,7 @@ function getRouteSpecificSchemaDefaults(route) {
|
|
|
60
62
|
path: "entityName"
|
|
61
63
|
} };
|
|
62
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" } } };
|
|
63
66
|
if (route.path === "/datasets/:datasetId/compare") return { body: {
|
|
64
67
|
experimentIds: ["test-experiment-a", "test-experiment-b"],
|
|
65
68
|
baselineId: "test-experiment-a"
|
|
@@ -343,6 +346,7 @@ function getDefaultValidPathParams(route) {
|
|
|
343
346
|
if (route.path.includes(":id") && route.path.includes("/mcp/v0/servers/")) params.id = "test-server-1";
|
|
344
347
|
if (route.path.includes(":serverId")) params.serverId = "test-server-1";
|
|
345
348
|
if (route.path.includes(":toolId") && route.path.includes("/mcp/")) params.toolId = "getWeather";
|
|
349
|
+
if (route.path.includes(":eventName")) params.eventName = "ticket.created";
|
|
346
350
|
if (route.path.includes(":workspaceId")) params.workspaceId = "test-workspace";
|
|
347
351
|
if (route.path.includes(":skillName")) params.skillName = "test-skill";
|
|
348
352
|
if (route.path.includes(":referencePath")) params.referencePath = "test-reference.md";
|
|
@@ -820,7 +824,11 @@ async function createDefaultTestContext() {
|
|
|
820
824
|
appResources: { "ui://test/app": {
|
|
821
825
|
name: "Test App",
|
|
822
826
|
html: "<html><body>Test</body></html>"
|
|
823
|
-
} }
|
|
827
|
+
} },
|
|
828
|
+
events: { "ticket.created": defineEvent({
|
|
829
|
+
description: "A ticket was created",
|
|
830
|
+
payloadSchema: z$3.object({ id: z$3.string() })
|
|
831
|
+
}) }
|
|
824
832
|
});
|
|
825
833
|
const mcpServer2 = new MCPServer({
|
|
826
834
|
name: "Test Server 2",
|
|
@@ -965,7 +973,8 @@ async function createDefaultTestContext() {
|
|
|
965
973
|
"trace-aggregate",
|
|
966
974
|
"thread-query",
|
|
967
975
|
"thread-query-rows",
|
|
968
|
-
"span-query"
|
|
976
|
+
"span-query",
|
|
977
|
+
"span-aggregate"
|
|
969
978
|
]);
|
|
970
979
|
vi.spyOn(observability, "queryTraces").mockResolvedValue({
|
|
971
980
|
traces: [],
|
|
@@ -991,6 +1000,10 @@ async function createDefaultTestContext() {
|
|
|
991
1000
|
spans: [],
|
|
992
1001
|
page: { next: null }
|
|
993
1002
|
});
|
|
1003
|
+
vi.spyOn(observability, "aggregateSpans").mockResolvedValue({
|
|
1004
|
+
rows: [],
|
|
1005
|
+
truncated: false
|
|
1006
|
+
});
|
|
994
1007
|
await observability.createSpan({ span: {
|
|
995
1008
|
spanId: "test-span",
|
|
996
1009
|
traceId: "test-trace",
|
|
@@ -2156,6 +2169,7 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2156
2169
|
"/editor/builder/registries/:registryId/install",
|
|
2157
2170
|
"/background-tasks/stream",
|
|
2158
2171
|
"/agents/:agentId/observe",
|
|
2172
|
+
"/mcp/:serverId/events/:eventName/stream",
|
|
2159
2173
|
"/agents/:agentId/recover",
|
|
2160
2174
|
"/agents/:agentId/plans/file",
|
|
2161
2175
|
"/tool-providers/:providerId/connections/:connectionId",
|
|
@@ -2168,7 +2182,9 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2168
2182
|
];
|
|
2169
2183
|
const excludedPrefixes = ["/datasets", "/agent-controller"];
|
|
2170
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));
|
|
2171
|
-
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) => {
|
|
2172
2188
|
const category = route.path.split("/")[1] || "root";
|
|
2173
2189
|
if (!acc[category]) acc[category] = [];
|
|
2174
2190
|
acc[category].push(route);
|
|
@@ -2179,6 +2195,14 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2179
2195
|
routes.forEach((route) => {
|
|
2180
2196
|
const testName = `${route.method} ${route.path}`;
|
|
2181
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
|
+
}
|
|
2182
2206
|
it("should execute with valid request", async () => {
|
|
2183
2207
|
const request = buildRouteRequest(route);
|
|
2184
2208
|
const httpRequest = {
|
|
@@ -2438,6 +2462,83 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2438
2462
|
});
|
|
2439
2463
|
});
|
|
2440
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
|
+
});
|
|
2441
2542
|
describe("Custom HTTPException responses", () => {
|
|
2442
2543
|
async function setupCustomErrorRoutes() {
|
|
2443
2544
|
let handlerCalls = 0;
|
|
@@ -2474,6 +2575,49 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2474
2575
|
handlerCalls++;
|
|
2475
2576
|
throw new HTTPException(404, { message: "Legacy fallback" });
|
|
2476
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
|
+
}
|
|
2477
2621
|
}
|
|
2478
2622
|
];
|
|
2479
2623
|
const mutableServerRoutes = SERVER_ROUTES;
|
|
@@ -2525,6 +2669,261 @@ function createRouteAdapterTestSuite(config) {
|
|
|
2525
2669
|
expect(response.data).toEqual({ error: "Legacy fallback" });
|
|
2526
2670
|
expect(custom.getHandlerCalls()).toBe(1);
|
|
2527
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
|
+
});
|
|
2528
2927
|
});
|
|
2529
2928
|
describe("Run claim conflict responses (#27638)", () => {
|
|
2530
2929
|
it.each([
|
|
@@ -3111,10 +3510,30 @@ function createMCPTransportTestSuite(config) {
|
|
|
3111
3510
|
return { result };
|
|
3112
3511
|
}
|
|
3113
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
|
+
});
|
|
3114
3532
|
let httpServer;
|
|
3115
3533
|
let port;
|
|
3116
3534
|
let mcpServer1;
|
|
3117
3535
|
let mcpServer2;
|
|
3536
|
+
let slowServer;
|
|
3118
3537
|
let mcpClient;
|
|
3119
3538
|
let mastra;
|
|
3120
3539
|
beforeAll(async () => {
|
|
@@ -3133,9 +3552,15 @@ function createMCPTransportTestSuite(config) {
|
|
|
3133
3552
|
description: "Test MCP Server 2",
|
|
3134
3553
|
tools: {}
|
|
3135
3554
|
});
|
|
3555
|
+
slowServer = new MCPServer({
|
|
3556
|
+
name: "slow-server",
|
|
3557
|
+
version: "1.0.0",
|
|
3558
|
+
tools: { slow: slowTool }
|
|
3559
|
+
});
|
|
3136
3560
|
mastra = new Mastra$1({ mcpServers: {
|
|
3137
3561
|
"test-server-1": mcpServer1,
|
|
3138
3562
|
"test-server-2": mcpServer2,
|
|
3563
|
+
"slow-server": slowServer,
|
|
3139
3564
|
"native-fixture": new NativeMCPFixture()
|
|
3140
3565
|
} });
|
|
3141
3566
|
const serverSetup = await createServer(mastra);
|
|
@@ -3151,6 +3576,7 @@ function createMCPTransportTestSuite(config) {
|
|
|
3151
3576
|
httpServer?.close();
|
|
3152
3577
|
await mcpServer1?.close();
|
|
3153
3578
|
await mcpServer2?.close();
|
|
3579
|
+
await slowServer?.close();
|
|
3154
3580
|
}, 3e4);
|
|
3155
3581
|
describe("native MCP v2 adapter dispatch", () => {
|
|
3156
3582
|
it("dispatches HTTP through the shared adapter path", async () => {
|
|
@@ -3330,6 +3756,65 @@ function createMCPTransportTestSuite(config) {
|
|
|
3330
3756
|
});
|
|
3331
3757
|
});
|
|
3332
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
|
+
});
|
|
3333
3818
|
describe("SSE Transport (/api/mcp/:serverId/sse)", () => {
|
|
3334
3819
|
describe("Error handling (raw HTTP)", () => {
|
|
3335
3820
|
it("should return 404 for non-existent server on GET /sse", async () => {
|
|
@@ -3908,7 +4393,7 @@ function createHttpLoggingTestSuite(config) {
|
|
|
3908
4393
|
* request body for DELETE the same way it does for POST/PUT/PATCH.
|
|
3909
4394
|
*/
|
|
3910
4395
|
function createBodyLimitTestSuite(config) {
|
|
3911
|
-
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;
|
|
3912
4397
|
describe(suiteName, () => {
|
|
3913
4398
|
const oversizedPayload = JSON.stringify({ padding: "x".repeat(maxSize * 4) });
|
|
3914
4399
|
it("enforces a route-specific limit without global body limit options", async () => {
|
|
@@ -3934,29 +4419,147 @@ function createBodyLimitTestSuite(config) {
|
|
|
3934
4419
|
expect(handlerCalled).toBe(false);
|
|
3935
4420
|
await cleanupApp?.(wiredApp);
|
|
3936
4421
|
});
|
|
3937
|
-
if (executeRequestWithoutContentLength)
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
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);
|
|
3951
4445
|
});
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
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);
|
|
3955
4470
|
});
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
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
|
+
}
|
|
3960
4563
|
it.each(["POST", "DELETE"])("rejects an oversized %s body with 413", async (method) => {
|
|
3961
4564
|
const mastra = new Mastra$1({});
|
|
3962
4565
|
const app = createApp();
|
|
@@ -3980,6 +4583,97 @@ function createBodyLimitTestSuite(config) {
|
|
|
3980
4583
|
});
|
|
3981
4584
|
}
|
|
3982
4585
|
//#endregion
|
|
3983
|
-
|
|
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 };
|
|
3984
4678
|
|
|
3985
4679
|
//# sourceMappingURL=index.js.map
|