@mastra/server-adapters-test-suite 0.1.3 → 0.1.4-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,10 @@ 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";
28
+ import { createDurableAgent, createEventedAgent } from "@mastra/core/agent/durable";
27
29
  import { Mastra as Mastra$1 } from "@mastra/core/mastra";
30
+ import http from "http";
28
31
  import { MCPServerBase } from "@mastra/core/mcp";
29
32
  import { RequestContext } from "@mastra/core/request-context";
30
33
  import { standardSchemaToJSONSchema } from "@mastra/core/schema";
@@ -60,6 +63,7 @@ function getRouteSpecificSchemaDefaults(route) {
60
63
  path: "entityName"
61
64
  } };
62
65
  if (route.path === "/agent-controller/:controllerId/sessions" && route.method === "POST") return { body: { resourceId: "test-resource" } };
66
+ if (route.path === "/mcp/:serverId/events/:eventName/emit") return { body: { data: { id: "test-ticket" } } };
63
67
  if (route.path === "/datasets/:datasetId/compare") return { body: {
64
68
  experimentIds: ["test-experiment-a", "test-experiment-b"],
65
69
  baselineId: "test-experiment-a"
@@ -343,6 +347,7 @@ function getDefaultValidPathParams(route) {
343
347
  if (route.path.includes(":id") && route.path.includes("/mcp/v0/servers/")) params.id = "test-server-1";
344
348
  if (route.path.includes(":serverId")) params.serverId = "test-server-1";
345
349
  if (route.path.includes(":toolId") && route.path.includes("/mcp/")) params.toolId = "getWeather";
350
+ if (route.path.includes(":eventName")) params.eventName = "ticket.created";
346
351
  if (route.path.includes(":workspaceId")) params.workspaceId = "test-workspace";
347
352
  if (route.path.includes(":skillName")) params.skillName = "test-skill";
348
353
  if (route.path.includes(":referencePath")) params.referencePath = "test-reference.md";
@@ -820,7 +825,11 @@ async function createDefaultTestContext() {
820
825
  appResources: { "ui://test/app": {
821
826
  name: "Test App",
822
827
  html: "<html><body>Test</body></html>"
823
- } }
828
+ } },
829
+ events: { "ticket.created": defineEvent({
830
+ description: "A ticket was created",
831
+ payloadSchema: z$3.object({ id: z$3.string() })
832
+ }) }
824
833
  });
825
834
  const mcpServer2 = new MCPServer({
826
835
  name: "Test Server 2",
@@ -965,7 +974,8 @@ async function createDefaultTestContext() {
965
974
  "trace-aggregate",
966
975
  "thread-query",
967
976
  "thread-query-rows",
968
- "span-query"
977
+ "span-query",
978
+ "span-aggregate"
969
979
  ]);
970
980
  vi.spyOn(observability, "queryTraces").mockResolvedValue({
971
981
  traces: [],
@@ -991,6 +1001,10 @@ async function createDefaultTestContext() {
991
1001
  spans: [],
992
1002
  page: { next: null }
993
1003
  });
1004
+ vi.spyOn(observability, "aggregateSpans").mockResolvedValue({
1005
+ rows: [],
1006
+ truncated: false
1007
+ });
994
1008
  await observability.createSpan({ span: {
995
1009
  spanId: "test-span",
996
1010
  traceId: "test-trace",
@@ -2156,7 +2170,9 @@ function createRouteAdapterTestSuite(config) {
2156
2170
  "/editor/builder/registries/:registryId/install",
2157
2171
  "/background-tasks/stream",
2158
2172
  "/agents/:agentId/observe",
2173
+ "/mcp/:serverId/events/:eventName/stream",
2159
2174
  "/agents/:agentId/recover",
2175
+ "/agents/:agentId/runs/:runId/abort",
2160
2176
  "/agents/:agentId/plans/file",
2161
2177
  "/tool-providers/:providerId/connections/:connectionId",
2162
2178
  "/tool-providers/:providerId/connections/:connectionId/usage",
@@ -2168,7 +2184,9 @@ function createRouteAdapterTestSuite(config) {
2168
2184
  ];
2169
2185
  const excludedPrefixes = ["/datasets", "/agent-controller"];
2170
2186
  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 routesByCategory = SERVER_ROUTES.filter((r) => !isExcluded(r)).reduce((acc, route) => {
2187
+ const activeRoutes = SERVER_ROUTES.filter((r) => !isExcluded(r));
2188
+ const devOnlyRoutes = ["/mcp/:serverId/events/:eventName/emit"];
2189
+ const routesByCategory = activeRoutes.reduce((acc, route) => {
2172
2190
  const category = route.path.split("/")[1] || "root";
2173
2191
  if (!acc[category]) acc[category] = [];
2174
2192
  acc[category].push(route);
@@ -2179,6 +2197,14 @@ function createRouteAdapterTestSuite(config) {
2179
2197
  routes.forEach((route) => {
2180
2198
  const testName = `${route.method} ${route.path}`;
2181
2199
  describe(testName, () => {
2200
+ if (devOnlyRoutes.includes(route.path)) {
2201
+ beforeEach(() => {
2202
+ vi.stubEnv("MASTRA_DEV", "true");
2203
+ });
2204
+ afterEach(() => {
2205
+ vi.unstubAllEnvs();
2206
+ });
2207
+ }
2182
2208
  it("should execute with valid request", async () => {
2183
2209
  const request = buildRouteRequest(route);
2184
2210
  const httpRequest = {
@@ -2438,6 +2464,83 @@ function createRouteAdapterTestSuite(config) {
2438
2464
  });
2439
2465
  });
2440
2466
  });
2467
+ describe("Studio-only RBAC", () => {
2468
+ const rolePermissions = {
2469
+ viewer: ["agents:read"],
2470
+ member: ["agents:read", "tools:execute"]
2471
+ };
2472
+ const studioAuth = {
2473
+ authenticateToken: async (token) => rolePermissions[token] ? {
2474
+ id: `user_${token}`,
2475
+ role: token
2476
+ } : null,
2477
+ authorize: async () => true
2478
+ };
2479
+ const studioRbac = {
2480
+ getPermissions: async (user) => rolePermissions[user.role] ?? [],
2481
+ getRoles: async (user) => [user.role]
2482
+ };
2483
+ async function setupStudioRbac(options) {
2484
+ const ctx = await createDefaultTestContext();
2485
+ const originalGetServer = ctx.mastra.getServer.bind(ctx.mastra);
2486
+ const customRoute = registerApiRoute("/studio-rbac/custom", {
2487
+ method: "GET",
2488
+ requiresPermission: "tools:execute",
2489
+ handler: async (c) => c.json({ ok: true })
2490
+ });
2491
+ ctx.mastra.getServer = () => ({
2492
+ ...originalGetServer(),
2493
+ apiRoutes: [customRoute],
2494
+ ...options.withServerAuth ? { auth: studioAuth } : {}
2495
+ });
2496
+ ctx.mastra.getStudio = () => ({
2497
+ auth: studioAuth,
2498
+ rbac: studioRbac
2499
+ });
2500
+ ctx.customRouteAuthConfig = /* @__PURE__ */ new Map([["GET:/studio-rbac/custom", true]]);
2501
+ const route = {
2502
+ method: "GET",
2503
+ path: "/test/studio-rbac",
2504
+ responseType: "json",
2505
+ requiresPermission: "tools:execute",
2506
+ handler: async () => ({ ok: true })
2507
+ };
2508
+ const mutableServerRoutes = SERVER_ROUTES;
2509
+ const originalLength = mutableServerRoutes.length;
2510
+ mutableServerRoutes.push(route);
2511
+ try {
2512
+ return (await setupAdapter(ctx)).app;
2513
+ } finally {
2514
+ mutableServerRoutes.splice(originalLength);
2515
+ }
2516
+ }
2517
+ const studioRequest = (path, role) => ({
2518
+ method: "GET",
2519
+ path,
2520
+ headers: {
2521
+ Authorization: `Bearer ${role}`,
2522
+ "x-mastra-client-type": "studio"
2523
+ }
2524
+ });
2525
+ describe.each([{
2526
+ label: "studio auth only",
2527
+ withServerAuth: false
2528
+ }, {
2529
+ label: "studio auth + server auth without server RBAC",
2530
+ withServerAuth: true
2531
+ }])("$label", ({ withServerAuth }) => {
2532
+ it.each(["/api/test/studio-rbac", "/studio-rbac/custom"])("denies a studio user without the required permission on %s", async (path) => {
2533
+ const studioApp = await setupStudioRbac({ withServerAuth });
2534
+ const response = await executeHttpRequest(studioApp, studioRequest(path, "viewer"));
2535
+ expect(response.status).toBe(403);
2536
+ });
2537
+ it.each(["/api/test/studio-rbac", "/studio-rbac/custom"])("allows a studio user with the required permission on %s", async (path) => {
2538
+ const studioApp = await setupStudioRbac({ withServerAuth });
2539
+ const response = await executeHttpRequest(studioApp, studioRequest(path, "member"));
2540
+ expect(response.status).toBe(200);
2541
+ });
2542
+ });
2543
+ });
2441
2544
  describe("Custom HTTPException responses", () => {
2442
2545
  async function setupCustomErrorRoutes() {
2443
2546
  let handlerCalls = 0;
@@ -2474,6 +2577,49 @@ function createRouteAdapterTestSuite(config) {
2474
2577
  handlerCalls++;
2475
2578
  throw new HTTPException(404, { message: "Legacy fallback" });
2476
2579
  }
2580
+ },
2581
+ {
2582
+ method: "GET",
2583
+ path: "/custom/http-error-failing-items",
2584
+ responseType: "json",
2585
+ handler: async () => {
2586
+ handlerCalls++;
2587
+ throw new HTTPException(400, {
2588
+ message: "Existing items fail the new schema",
2589
+ cause: { failingItems: [{
2590
+ index: 0,
2591
+ data: {},
2592
+ field: "input"
2593
+ }] }
2594
+ });
2595
+ }
2596
+ },
2597
+ {
2598
+ method: "GET",
2599
+ path: "/custom/http-error-unsafe-cause",
2600
+ responseType: "json",
2601
+ handler: async () => {
2602
+ handlerCalls++;
2603
+ throw new HTTPException(400, {
2604
+ message: "Unsafe cause",
2605
+ cause: {
2606
+ secret: "do-not-leak",
2607
+ failingItems: "not-an-array"
2608
+ }
2609
+ });
2610
+ }
2611
+ },
2612
+ {
2613
+ method: "GET",
2614
+ path: "/custom/details-status-error",
2615
+ responseType: "json",
2616
+ handler: async () => {
2617
+ handlerCalls++;
2618
+ throw Object.assign(/* @__PURE__ */ new Error("Details status"), {
2619
+ details: { status: 422 },
2620
+ cause: { failingItems: [{ index: 0 }] }
2621
+ });
2622
+ }
2477
2623
  }
2478
2624
  ];
2479
2625
  const mutableServerRoutes = SERVER_ROUTES;
@@ -2525,6 +2671,261 @@ function createRouteAdapterTestSuite(config) {
2525
2671
  expect(response.data).toEqual({ error: "Legacy fallback" });
2526
2672
  expect(custom.getHandlerCalls()).toBe(1);
2527
2673
  });
2674
+ it("includes failingItems from the HTTPException cause in the JSON body", async () => {
2675
+ const custom = await setupCustomErrorRoutes();
2676
+ const response = await executeHttpRequest(custom.app, {
2677
+ method: "GET",
2678
+ path: "/api/custom/http-error-failing-items"
2679
+ });
2680
+ expect(response.status).toBe(400);
2681
+ expect(response.data.error).toBe("Existing items fail the new schema");
2682
+ expect(response.data.cause).toEqual({ failingItems: [{
2683
+ index: 0,
2684
+ data: {},
2685
+ field: "input"
2686
+ }] });
2687
+ });
2688
+ it("never forwards an arbitrary HTTPException cause", async () => {
2689
+ const custom = await setupCustomErrorRoutes();
2690
+ const response = await executeHttpRequest(custom.app, {
2691
+ method: "GET",
2692
+ path: "/api/custom/http-error-unsafe-cause"
2693
+ });
2694
+ expect(response.status).toBe(400);
2695
+ expect(response.data.error).toBe("Unsafe cause");
2696
+ expect(response.data).not.toHaveProperty("cause");
2697
+ expect(JSON.stringify(response.data)).not.toContain("do-not-leak");
2698
+ });
2699
+ it("keeps details.status errors as a message-only body", async () => {
2700
+ const custom = await setupCustomErrorRoutes();
2701
+ const response = await executeHttpRequest(custom.app, {
2702
+ method: "GET",
2703
+ path: "/api/custom/details-status-error"
2704
+ });
2705
+ expect(response.status).toBe(422);
2706
+ expect(response.data.error).toBe("Details status");
2707
+ expect(response.data).not.toHaveProperty("cause");
2708
+ });
2709
+ });
2710
+ describe("Dataset schema update rejected by existing items", () => {
2711
+ it("returns the failing items so clients can report how many items fail", async () => {
2712
+ const context = await createDefaultTestContext();
2713
+ const dataset = await context.mastra.datasets.create({ name: "schema-update-parity" });
2714
+ await dataset.addItem({ input: { prompt: "hello" } });
2715
+ const setup = await setupAdapter(context);
2716
+ const response = await executeHttpRequest(setup.app, {
2717
+ method: "PATCH",
2718
+ path: `/api/datasets/${dataset.id}`,
2719
+ body: { inputSchema: {
2720
+ type: "object",
2721
+ properties: { question: { type: "string" } },
2722
+ required: ["question"]
2723
+ } }
2724
+ });
2725
+ expect(response.status).toBe(400);
2726
+ const failingItems = response.data?.cause?.failingItems;
2727
+ expect(Array.isArray(failingItems)).toBe(true);
2728
+ expect(failingItems).toHaveLength(1);
2729
+ expect(failingItems[0]).toMatchObject({
2730
+ index: 0,
2731
+ field: "input"
2732
+ });
2733
+ });
2734
+ });
2735
+ describe("HTTPException thrown from a registerApiRoute custom route", () => {
2736
+ class ForeignHTTPException extends Error {
2737
+ status;
2738
+ constructor(status, options) {
2739
+ super(options.message);
2740
+ this.status = status;
2741
+ }
2742
+ res = void 0;
2743
+ getResponse() {
2744
+ return new Response(this.message, { status: this.status });
2745
+ }
2746
+ }
2747
+ async function setupCustomRouteErrors() {
2748
+ const ctx = await createDefaultTestContext();
2749
+ const originalGetServer = ctx.mastra.getServer.bind(ctx.mastra);
2750
+ const routes = [
2751
+ registerApiRoute("/custom-errors/not-found", {
2752
+ method: "GET",
2753
+ handler: async () => {
2754
+ throw new HTTPException(404, { message: "Widget not found" });
2755
+ }
2756
+ }),
2757
+ registerApiRoute("/custom-errors/conflict", {
2758
+ method: "POST",
2759
+ handler: async () => {
2760
+ throw new ForeignHTTPException(409, { message: "Widget already exists" });
2761
+ }
2762
+ }),
2763
+ registerApiRoute("/custom-errors/custom-response", {
2764
+ method: "GET",
2765
+ handler: async () => {
2766
+ throw new HTTPException(429, { res: new Response(JSON.stringify({ error: "Slow down" }), {
2767
+ status: 429,
2768
+ headers: {
2769
+ "Content-Type": "application/json",
2770
+ "Retry-After": "5"
2771
+ }
2772
+ }) });
2773
+ }
2774
+ }),
2775
+ registerApiRoute("/custom-errors/unexpected", {
2776
+ method: "GET",
2777
+ handler: async () => {
2778
+ throw new Error("db password=do-not-leak");
2779
+ }
2780
+ }),
2781
+ registerApiRoute("/custom-errors/invalid-status", {
2782
+ method: "GET",
2783
+ handler: async () => {
2784
+ throw new ForeignHTTPException(NaN, { message: "bad status do-not-leak" });
2785
+ }
2786
+ })
2787
+ ];
2788
+ ctx.mastra.getServer = () => ({
2789
+ ...originalGetServer(),
2790
+ apiRoutes: routes
2791
+ });
2792
+ return (await setupAdapter(ctx)).app;
2793
+ }
2794
+ async function bodyText(response) {
2795
+ if (!response.stream) return typeof response.data === "string" ? response.data : JSON.stringify(response.data);
2796
+ const decoder = new TextDecoder();
2797
+ let text = "";
2798
+ for await (const chunk of response.stream) text += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
2799
+ return text + decoder.decode();
2800
+ }
2801
+ it("keeps the status and message of a Mastra HTTPException", async () => {
2802
+ const customApp = await setupCustomRouteErrors();
2803
+ const response = await executeHttpRequest(customApp, {
2804
+ method: "GET",
2805
+ path: "/custom-errors/not-found"
2806
+ });
2807
+ expect(response.status).toBe(404);
2808
+ expect(JSON.parse(await bodyText(response))).toEqual({ error: "Widget not found" });
2809
+ });
2810
+ it("keeps the status and message of an HTTPException from another hono install", async () => {
2811
+ const customApp = await setupCustomRouteErrors();
2812
+ const response = await executeHttpRequest(customApp, {
2813
+ method: "POST",
2814
+ path: "/custom-errors/conflict",
2815
+ body: {}
2816
+ });
2817
+ expect(response.status).toBe(409);
2818
+ expect(JSON.parse(await bodyText(response))).toEqual({ error: "Widget already exists" });
2819
+ });
2820
+ it("returns the HTTPException custom response with its headers", async () => {
2821
+ const customApp = await setupCustomRouteErrors();
2822
+ const response = await executeHttpRequest(customApp, {
2823
+ method: "GET",
2824
+ path: "/custom-errors/custom-response"
2825
+ });
2826
+ expect(response.status).toBe(429);
2827
+ expect(response.headers["retry-after"]).toBe("5");
2828
+ expect(JSON.parse(await bodyText(response))).toEqual({ error: "Slow down" });
2829
+ });
2830
+ it("still hides unexpected errors behind a generic 500", async () => {
2831
+ const customApp = await setupCustomRouteErrors();
2832
+ const response = await executeHttpRequest(customApp, {
2833
+ method: "GET",
2834
+ path: "/custom-errors/unexpected"
2835
+ });
2836
+ expect(response.status).toBe(500);
2837
+ const text = await bodyText(response);
2838
+ expect(text).toContain("Internal Server Error");
2839
+ expect(text).not.toContain("do-not-leak");
2840
+ });
2841
+ it("treats an HTTPException with a non-integer status as unexpected", async () => {
2842
+ const customApp = await setupCustomRouteErrors();
2843
+ const response = await executeHttpRequest(customApp, {
2844
+ method: "GET",
2845
+ path: "/custom-errors/invalid-status"
2846
+ });
2847
+ expect(response.status).toBe(500);
2848
+ const text = await bodyText(response);
2849
+ expect(text).toContain("Internal Server Error");
2850
+ expect(text).not.toContain("do-not-leak");
2851
+ });
2852
+ });
2853
+ describe("Dataset item rejected by the input schema", () => {
2854
+ it("returns the failing field paths and messages without the submitted values", async () => {
2855
+ const context = await createDefaultTestContext();
2856
+ const dataset = await context.mastra.datasets.create({
2857
+ name: "item-schema-parity",
2858
+ inputSchema: {
2859
+ type: "object",
2860
+ properties: { question: { type: "string" } },
2861
+ required: ["question"]
2862
+ }
2863
+ });
2864
+ const setup = await setupAdapter(context);
2865
+ const response = await executeHttpRequest(setup.app, {
2866
+ method: "POST",
2867
+ path: `/api/datasets/${dataset.id}/items`,
2868
+ body: { input: {
2869
+ question: { marker: "rejected-secret-value" },
2870
+ note: "submitted-secret-value"
2871
+ } }
2872
+ });
2873
+ expect(response.status).toBe(400);
2874
+ const cause = response.data?.cause;
2875
+ expect(cause?.field).toBe("input");
2876
+ expect(cause?.errors).toEqual([{
2877
+ path: "/question",
2878
+ message: expect.any(String)
2879
+ }]);
2880
+ expect(JSON.stringify(response.data)).not.toContain("submitted-secret-value");
2881
+ expect(JSON.stringify(response.data)).not.toContain("rejected-secret-value");
2882
+ });
2883
+ it("strips anything but path and message from validation errors", async () => {
2884
+ const route = {
2885
+ method: "GET",
2886
+ path: "/custom/http-error-validation-cause",
2887
+ responseType: "json",
2888
+ handler: async () => {
2889
+ throw new HTTPException(400, {
2890
+ message: "Validation failed for input",
2891
+ cause: {
2892
+ field: "input",
2893
+ errors: [{
2894
+ path: "/question",
2895
+ code: "invalid_type",
2896
+ message: "Expected string",
2897
+ received: "leak-me"
2898
+ }],
2899
+ rawInput: "leak-me-too"
2900
+ }
2901
+ });
2902
+ }
2903
+ };
2904
+ const mutableServerRoutes = SERVER_ROUTES;
2905
+ const originalLength = mutableServerRoutes.length;
2906
+ mutableServerRoutes.push(route);
2907
+ let app;
2908
+ try {
2909
+ app = (await setupAdapter(await createDefaultTestContext())).app;
2910
+ } finally {
2911
+ mutableServerRoutes.splice(originalLength);
2912
+ }
2913
+ const response = await executeHttpRequest(app, {
2914
+ method: "GET",
2915
+ path: "/api/custom/http-error-validation-cause"
2916
+ });
2917
+ expect(response.status).toBe(400);
2918
+ expect(response.data).toEqual({
2919
+ error: "Validation failed for input",
2920
+ cause: {
2921
+ field: "input",
2922
+ errors: [{
2923
+ path: "/question",
2924
+ message: "Expected string"
2925
+ }]
2926
+ }
2927
+ });
2928
+ });
2528
2929
  });
2529
2930
  describe("Run claim conflict responses (#27638)", () => {
2530
2931
  it.each([
@@ -2613,6 +3014,278 @@ function createRouteAdapterTestSuite(config) {
2613
3014
  });
2614
3015
  }
2615
3016
  //#endregion
3017
+ //#region src/durable-disconnect-test-suite.ts
3018
+ function deferred() {
3019
+ let resolve;
3020
+ return {
3021
+ promise: new Promise((r) => {
3022
+ resolve = r;
3023
+ }),
3024
+ resolve
3025
+ };
3026
+ }
3027
+ function createDurableDisconnectTestSuite(config) {
3028
+ describe.each([
3029
+ "plain",
3030
+ "durable",
3031
+ "evented"
3032
+ ])("HTTP observer disconnect: %s (COR-1489)", (kind) => {
3033
+ let server;
3034
+ let fixture;
3035
+ let mastra;
3036
+ afterEach(async () => {
3037
+ if (mastra) await mastra.shutdown();
3038
+ if (server) await server.close();
3039
+ if (fixture) await fixture.close();
3040
+ server = void 0;
3041
+ mastra = void 0;
3042
+ fixture = void 0;
3043
+ });
3044
+ for (const route of [
3045
+ "stream",
3046
+ "stream-alias",
3047
+ "stream-until-idle",
3048
+ "resume-stream",
3049
+ "resume-alias",
3050
+ "resume-stream-until-idle",
3051
+ "approve-tool-call",
3052
+ "decline-tool-call"
3053
+ ]) for (const explicitAbort of [
3054
+ false,
3055
+ true,
3056
+ "run",
3057
+ "threadless"
3058
+ ]) {
3059
+ if (typeof explicitAbort === "string" && (kind === "plain" || !["stream", "stream-alias"].includes(route))) continue;
3060
+ const threadless = explicitAbort === "threadless";
3061
+ it(`${route}: disconnect ${explicitAbort ? `then explicit ${explicitAbort === true ? "thread" : explicitAbort} abort stops execution` : "preserves durable execution and replay"}`, async () => {
3062
+ fixture = await config.setupModel();
3063
+ const entered = deferred();
3064
+ const gate = deferred();
3065
+ const disconnected = deferred();
3066
+ const calls = [];
3067
+ let toolSignal;
3068
+ const storage = new InMemoryStore();
3069
+ const memory = new MockMemory({ storage });
3070
+ const slow = createTool({
3071
+ id: "slow",
3072
+ description: "Execute a numbered step",
3073
+ inputSchema: z.object({ n: z.number() }),
3074
+ execute: async ({ n }, context) => {
3075
+ calls.push(n);
3076
+ if (n === 1) {
3077
+ toolSignal = context?.abortSignal;
3078
+ entered.resolve();
3079
+ await Promise.race([gate.promise, new Promise((resolve) => {
3080
+ if (toolSignal?.aborted) return resolve();
3081
+ toolSignal?.addEventListener("abort", () => resolve(), { once: true });
3082
+ })]);
3083
+ }
3084
+ return { n };
3085
+ }
3086
+ });
3087
+ const research = createTool({
3088
+ id: "research",
3089
+ description: "Background research",
3090
+ inputSchema: z.object({}),
3091
+ background: { enabled: true },
3092
+ execute: async () => ({ done: true })
3093
+ });
3094
+ let authorizations = 0;
3095
+ const authorize = createTool({
3096
+ id: "authorize",
3097
+ description: "Authorize the steps",
3098
+ inputSchema: z.object({}),
3099
+ requireApproval: true,
3100
+ execute: async () => {
3101
+ authorizations++;
3102
+ return { approved: true };
3103
+ }
3104
+ });
3105
+ const resumed = route.startsWith("resume") || route.endsWith("tool-call");
3106
+ const offset = resumed ? 1 : 0;
3107
+ const inner = new Agent({
3108
+ id: "disconnect-agent",
3109
+ name: "Disconnect agent",
3110
+ instructions: "Execute three steps.",
3111
+ model: fixture.model,
3112
+ ...threadless ? {} : { memory },
3113
+ tools: {
3114
+ slow,
3115
+ research,
3116
+ authorize
3117
+ },
3118
+ backgroundTasks: { tools: { research: true } }
3119
+ });
3120
+ const agent = kind === "durable" ? createDurableAgent({ agent: inner }) : kind === "evented" ? createEventedAgent({ agent: inner }) : inner;
3121
+ const otherAgent = createDurableAgent({ agent: new Agent({
3122
+ id: "other-agent",
3123
+ name: "Other agent",
3124
+ instructions: "Do not run.",
3125
+ model: fixture.model
3126
+ }) });
3127
+ mastra = new Mastra$1({
3128
+ agents: {
3129
+ agent,
3130
+ otherAgent
3131
+ },
3132
+ storage,
3133
+ logger: false,
3134
+ backgroundTasks: {
3135
+ enabled: true,
3136
+ globalConcurrency: 5,
3137
+ perAgentConcurrency: 3
3138
+ }
3139
+ });
3140
+ server = await config.startServer(mastra, (path) => {
3141
+ if (!resumed || !path.endsWith("/stream")) disconnected.resolve();
3142
+ });
3143
+ const base = `${server.baseUrl}/api/agents/agent`;
3144
+ if (resumed) fixture.addResponse(0, { toolCalls: [{
3145
+ id: "authorize-1",
3146
+ name: "authorize",
3147
+ arguments: {}
3148
+ }] });
3149
+ for (let n = 1; n <= 3; n++) fixture.addResponse(offset + n - 1, { toolCalls: [{
3150
+ id: `slow-${n}`,
3151
+ name: "slow",
3152
+ arguments: { n }
3153
+ }] });
3154
+ fixture.addResponse(offset + 3, { content: "done: 3 results" });
3155
+ const runId = `disconnect-${kind}-${route}-${explicitAbort}`;
3156
+ const thread = {
3157
+ thread: runId,
3158
+ resource: "disconnect-user"
3159
+ };
3160
+ if (resumed) {
3161
+ const parked = await fetch(`${base}/stream`, {
3162
+ method: "POST",
3163
+ headers: { "content-type": "application/json" },
3164
+ body: JSON.stringify({
3165
+ messages: "Start",
3166
+ runId,
3167
+ memory: thread,
3168
+ maxSteps: 6
3169
+ })
3170
+ });
3171
+ expect(parked.status).toBe(200);
3172
+ const reader = parked.body.getReader();
3173
+ let received = "";
3174
+ while (!received.includes("tool-call-approval")) {
3175
+ const { done, value } = await reader.read();
3176
+ expect(done).toBe(false);
3177
+ received += new TextDecoder().decode(value);
3178
+ }
3179
+ await reader.cancel();
3180
+ }
3181
+ const client = new AbortController();
3182
+ const request = fetch(`${base}/${route === "stream-alias" ? "stream" : route === "resume-alias" ? "resume-stream" : route}`, {
3183
+ method: "POST",
3184
+ headers: { "content-type": "application/json" },
3185
+ signal: client.signal,
3186
+ body: JSON.stringify({
3187
+ ...resumed ? {
3188
+ resumeData: { approved: true },
3189
+ toolCallId: "authorize-1"
3190
+ } : { messages: "Start" },
3191
+ runId,
3192
+ ...threadless ? {} : { memory: thread },
3193
+ maxSteps: 6,
3194
+ ...["stream-alias", "resume-alias"].includes(route) ? { untilIdle: true } : {}
3195
+ })
3196
+ }).then(async (response) => {
3197
+ expect(response.status).toBe(200);
3198
+ await response.text();
3199
+ }).catch((error) => {
3200
+ if (error.name !== "AbortError") throw error;
3201
+ });
3202
+ try {
3203
+ await entered.promise;
3204
+ client.abort();
3205
+ await disconnected.promise;
3206
+ await request;
3207
+ if (kind !== "plain" && explicitAbort) {
3208
+ const runScoped = typeof explicitAbort === "string";
3209
+ if (runScoped) {
3210
+ const denied = await fetch(`${server.baseUrl}/api/agents/otherAgent/runs/${runId}/abort`, { method: "POST" });
3211
+ expect(denied.status).toBe(403);
3212
+ expect(toolSignal?.aborted).toBe(false);
3213
+ }
3214
+ const response = await fetch(`${base}/${runScoped ? `runs/${runId}/abort` : "threads/abort"}`, {
3215
+ method: "POST",
3216
+ headers: { "content-type": "application/json" },
3217
+ ...runScoped ? {} : { body: JSON.stringify({
3218
+ threadId: runId,
3219
+ resourceId: thread.resource,
3220
+ expectedRunId: runId
3221
+ }) }
3222
+ });
3223
+ const result = await response.json();
3224
+ expect(response.status).toBe(200);
3225
+ expect(result).toEqual({ aborted: true });
3226
+ }
3227
+ const completes = kind !== "plain" && !explicitAbort;
3228
+ if (!completes) await vi.waitFor(() => expect(toolSignal?.aborted).toBe(true));
3229
+ else expect(toolSignal?.aborted).toBe(false);
3230
+ const observing = kind === "plain" ? void 0 : fetch(`${base}/observe`, {
3231
+ method: "POST",
3232
+ headers: { "content-type": "application/json" },
3233
+ body: JSON.stringify({ runId })
3234
+ });
3235
+ gate.resolve();
3236
+ if (observing) {
3237
+ const response = await observing;
3238
+ expect(response.status).toBe(200);
3239
+ const replay = await response.text();
3240
+ expect(replay).toContain("finish");
3241
+ if (completes) expect(replay).toContain("done: 3 results");
3242
+ else expect(replay).toContain("abort");
3243
+ }
3244
+ await vi.waitFor(() => expect(calls).toEqual(completes ? [
3245
+ 1,
3246
+ 2,
3247
+ 3
3248
+ ] : [1]));
3249
+ expect(fixture.getRequestCount()).toBe(offset + (completes ? 4 : 1));
3250
+ if (resumed) expect(authorizations).toBe(route === "decline-tool-call" ? 0 : 1);
3251
+ if (kind !== "plain") {
3252
+ const workflows = await storage.getStore("workflows");
3253
+ await vi.waitFor(async () => {
3254
+ for (const workflowName of ["durable-agentic-loop", "durable-agentic-execution"]) expect(await workflows.loadWorkflowSnapshot({
3255
+ workflowName,
3256
+ runId
3257
+ })).toBeFalsy();
3258
+ });
3259
+ }
3260
+ if (completes) {
3261
+ await vi.waitFor(async () => {
3262
+ const { messages } = await memory.recall({
3263
+ threadId: runId,
3264
+ resourceId: thread.resource
3265
+ });
3266
+ expect(messages.flatMap((m) => m.content.parts ?? []).some((p) => p.type === "text" && p.text === "done: 3 results")).toBe(true);
3267
+ });
3268
+ const replay = await fetch(`${base}/observe`, {
3269
+ method: "POST",
3270
+ headers: { "content-type": "application/json" },
3271
+ body: JSON.stringify({ runId })
3272
+ }).then((response) => response.text());
3273
+ expect(replay).toContain("done: 3 results");
3274
+ expect(replay).toContain("finish");
3275
+ const lateAbort = await fetch(`${base}/runs/${runId}/abort`, { method: "POST" });
3276
+ expect(lateAbort.status).toBe(200);
3277
+ expect(await lateAbort.json()).toEqual({ aborted: false });
3278
+ expect(fixture.getRequestCount()).toBe(offset + 4);
3279
+ }
3280
+ } finally {
3281
+ gate.resolve();
3282
+ client.abort();
3283
+ }
3284
+ }, 3e4);
3285
+ }
3286
+ });
3287
+ }
3288
+ //#endregion
2616
3289
  //#region src/mcp-route-test-suite.ts
2617
3290
  /**
2618
3291
  * Creates a standardized integration test suite for MCP registry routes
@@ -3111,10 +3784,30 @@ function createMCPTransportTestSuite(config) {
3111
3784
  return { result };
3112
3785
  }
3113
3786
  });
3787
+ const slowToolRuns = [];
3788
+ const slowTool = createTool({
3789
+ id: "slow",
3790
+ description: "Waits until aborted, optionally streaming a log notification first",
3791
+ inputSchema: z.object({ streamFirst: z.boolean() }),
3792
+ execute: async ({ streamFirst }, context) => {
3793
+ const signal = context?.abortSignal;
3794
+ const run = {};
3795
+ slowToolRuns.push(run);
3796
+ signal?.addEventListener("abort", () => run.abortedAt = Date.now(), { once: true });
3797
+ if (streamFirst) await context?.mcp?.log?.("info", "started");
3798
+ await new Promise((resolve) => {
3799
+ const timer = setTimeout(resolve, 5e3);
3800
+ signal?.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
3801
+ });
3802
+ run.finishedAt = Date.now();
3803
+ return { aborted: !!signal?.aborted };
3804
+ }
3805
+ });
3114
3806
  let httpServer;
3115
3807
  let port;
3116
3808
  let mcpServer1;
3117
3809
  let mcpServer2;
3810
+ let slowServer;
3118
3811
  let mcpClient;
3119
3812
  let mastra;
3120
3813
  beforeAll(async () => {
@@ -3133,9 +3826,15 @@ function createMCPTransportTestSuite(config) {
3133
3826
  description: "Test MCP Server 2",
3134
3827
  tools: {}
3135
3828
  });
3829
+ slowServer = new MCPServer({
3830
+ name: "slow-server",
3831
+ version: "1.0.0",
3832
+ tools: { slow: slowTool }
3833
+ });
3136
3834
  mastra = new Mastra$1({ mcpServers: {
3137
3835
  "test-server-1": mcpServer1,
3138
3836
  "test-server-2": mcpServer2,
3837
+ "slow-server": slowServer,
3139
3838
  "native-fixture": new NativeMCPFixture()
3140
3839
  } });
3141
3840
  const serverSetup = await createServer(mastra);
@@ -3151,6 +3850,7 @@ function createMCPTransportTestSuite(config) {
3151
3850
  httpServer?.close();
3152
3851
  await mcpServer1?.close();
3153
3852
  await mcpServer2?.close();
3853
+ await slowServer?.close();
3154
3854
  }, 3e4);
3155
3855
  describe("native MCP v2 adapter dispatch", () => {
3156
3856
  it("dispatches HTTP through the shared adapter path", async () => {
@@ -3330,6 +4030,65 @@ function createMCPTransportTestSuite(config) {
3330
4030
  });
3331
4031
  });
3332
4032
  });
4033
+ describe("HTTP Transport client disconnect", () => {
4034
+ it.each([{
4035
+ name: "before any response bytes are sent",
4036
+ streamFirst: false
4037
+ }, {
4038
+ name: "after the response has started streaming",
4039
+ streamFirst: true
4040
+ }])("aborts the in-flight tool call when the client disconnects $name", async ({ streamFirst }) => {
4041
+ const runsBefore = slowToolRuns.length;
4042
+ const body = JSON.stringify({
4043
+ jsonrpc: "2.0",
4044
+ id: 1,
4045
+ method: "tools/call",
4046
+ params: {
4047
+ name: "slow",
4048
+ arguments: { streamFirst },
4049
+ _meta: {
4050
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
4051
+ "io.modelcontextprotocol/clientInfo": {
4052
+ name: "disconnect-test",
4053
+ version: "1.0.0"
4054
+ },
4055
+ "io.modelcontextprotocol/clientCapabilities": {},
4056
+ "io.modelcontextprotocol/logLevel": "info"
4057
+ }
4058
+ }
4059
+ });
4060
+ let responseBytes = 0;
4061
+ const req = http.request({
4062
+ host: "localhost",
4063
+ port,
4064
+ method: "POST",
4065
+ path: `/api/mcp/${slowServer.id}/mcp`,
4066
+ headers: {
4067
+ "content-type": "application/json",
4068
+ accept: "application/json, text/event-stream",
4069
+ "mcp-protocol-version": "2026-07-28",
4070
+ "mcp-method": "tools/call",
4071
+ "mcp-name": "slow",
4072
+ "content-length": Buffer.byteLength(body)
4073
+ }
4074
+ });
4075
+ req.on("response", (res) => {
4076
+ res.on("data", (chunk) => responseBytes += chunk.length);
4077
+ res.on("error", () => {});
4078
+ });
4079
+ req.on("error", () => {});
4080
+ req.end(body);
4081
+ await expect.poll(() => slowToolRuns.length, { timeout: 5e3 }).toBe(runsBefore + 1);
4082
+ if (streamFirst) await expect.poll(() => responseBytes, { timeout: 5e3 }).toBeGreaterThan(0);
4083
+ else expect(responseBytes).toBe(0);
4084
+ const run = slowToolRuns[runsBefore];
4085
+ const disconnectedAt = Date.now();
4086
+ req.destroy();
4087
+ await expect.poll(() => run.finishedAt, { timeout: 6e3 }).toBeDefined();
4088
+ expect(run.abortedAt).toBeDefined();
4089
+ expect(run.abortedAt - disconnectedAt).toBeLessThan(1e3);
4090
+ }, 15e3);
4091
+ });
3333
4092
  describe("SSE Transport (/api/mcp/:serverId/sse)", () => {
3334
4093
  describe("Error handling (raw HTTP)", () => {
3335
4094
  it("should return 404 for non-existent server on GET /sse", async () => {
@@ -3908,7 +4667,7 @@ function createHttpLoggingTestSuite(config) {
3908
4667
  * request body for DELETE the same way it does for POST/PUT/PATCH.
3909
4668
  */
3910
4669
  function createBodyLimitTestSuite(config) {
3911
- const { suiteName = "Body Size Limit", maxSize = 100, createApp, setupAdapter, registerRoute, executeRequest, executeRequestWithoutContentLength, cleanupApp } = config;
4670
+ const { suiteName = "Body Size Limit", maxSize = 100, createApp, setupAdapter, registerRoute, executeRequest, executeRequestWithoutContentLength, skipNonJsonStreamedBody, cleanupApp } = config;
3912
4671
  describe(suiteName, () => {
3913
4672
  const oversizedPayload = JSON.stringify({ padding: "x".repeat(maxSize * 4) });
3914
4673
  it("enforces a route-specific limit without global body limit options", async () => {
@@ -3934,29 +4693,147 @@ function createBodyLimitTestSuite(config) {
3934
4693
  expect(handlerCalled).toBe(false);
3935
4694
  await cleanupApp?.(wiredApp);
3936
4695
  });
3937
- if (executeRequestWithoutContentLength) it("rejects an oversized route body without Content-Length before handler execution", async () => {
3938
- const mastra = new Mastra$1({});
3939
- const app = createApp();
3940
- const { adapter, app: wiredApp } = await setupAdapter(app, mastra);
3941
- let handlerCalled = false;
3942
- await registerRoute(adapter, wiredApp, {
3943
- method: "POST",
3944
- path: "/test/route-body-limit-no-content-length",
3945
- responseType: "json",
3946
- maxBodySize: maxSize,
3947
- handler: async ({ body }) => {
3948
- handlerCalled = true;
3949
- return { receivedBody: body };
3950
- }
4696
+ if (executeRequestWithoutContentLength) {
4697
+ it("rejects an oversized route body without Content-Length before handler execution", async () => {
4698
+ const mastra = new Mastra$1({});
4699
+ const app = createApp();
4700
+ const { adapter, app: wiredApp } = await setupAdapter(app, mastra);
4701
+ let handlerCalled = false;
4702
+ await registerRoute(adapter, wiredApp, {
4703
+ method: "POST",
4704
+ path: "/test/route-body-limit-no-content-length",
4705
+ responseType: "json",
4706
+ maxBodySize: maxSize,
4707
+ handler: async ({ body }) => {
4708
+ handlerCalled = true;
4709
+ return { receivedBody: body };
4710
+ }
4711
+ });
4712
+ const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/route-body-limit-no-content-length", {
4713
+ headers: { "Content-Type": "application/json" },
4714
+ body: oversizedPayload
4715
+ });
4716
+ expect(response.status).toBe(413);
4717
+ expect(handlerCalled).toBe(false);
4718
+ await cleanupApp?.(wiredApp);
3951
4719
  });
3952
- const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/route-body-limit-no-content-length", {
3953
- headers: { "Content-Type": "application/json" },
3954
- body: oversizedPayload
4720
+ it.skipIf(skipNonJsonStreamedBody).each(["text/plain", "application/octet-stream"])("rejects an oversized %s body without Content-Length before handler execution", async (contentType) => {
4721
+ const mastra = new Mastra$1({});
4722
+ const app = createApp();
4723
+ const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
4724
+ maxSize,
4725
+ onError: () => ({ error: "Request body too large" })
4726
+ });
4727
+ let handlerCalled = false;
4728
+ await registerRoute(adapter, wiredApp, {
4729
+ method: "POST",
4730
+ path: "/test/body-limit-no-content-length-non-json",
4731
+ responseType: "json",
4732
+ handler: async () => {
4733
+ handlerCalled = true;
4734
+ return { ok: true };
4735
+ }
4736
+ });
4737
+ const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-non-json", {
4738
+ headers: { "Content-Type": contentType },
4739
+ body: "x".repeat(maxSize * 4)
4740
+ });
4741
+ expect(response.status).toBe(413);
4742
+ expect(handlerCalled).toBe(false);
4743
+ await cleanupApp?.(wiredApp);
3955
4744
  });
3956
- expect(response.status).toBe(413);
3957
- expect(handlerCalled).toBe(false);
3958
- await cleanupApp?.(wiredApp);
3959
- });
4745
+ const boundary = "bodylimitboundary";
4746
+ 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`;
4747
+ const multipartCases = {
4748
+ "many small fields": multipart(Array.from({ length: 20 }, (_, i) => ({
4749
+ name: `f${i}`,
4750
+ value: "x".repeat(Math.floor(maxSize / 2))
4751
+ }))),
4752
+ "one oversized file": multipart([{
4753
+ name: "file",
4754
+ filename: "f.bin",
4755
+ value: "x".repeat(maxSize * 4)
4756
+ }])
4757
+ };
4758
+ it.each(Object.keys(multipartCases))("rejects an oversized multipart body (%s) without Content-Length before handler execution", async (caseName) => {
4759
+ const mastra = new Mastra$1({});
4760
+ const app = createApp();
4761
+ const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
4762
+ maxSize,
4763
+ onError: () => ({ error: "Request body too large" })
4764
+ });
4765
+ let handlerCalled = false;
4766
+ await registerRoute(adapter, wiredApp, {
4767
+ method: "POST",
4768
+ path: "/test/body-limit-no-content-length-multipart",
4769
+ responseType: "json",
4770
+ handler: async () => {
4771
+ handlerCalled = true;
4772
+ return { ok: true };
4773
+ }
4774
+ });
4775
+ const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-multipart", {
4776
+ headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
4777
+ body: multipartCases[caseName]
4778
+ });
4779
+ expect(response.status).toBe(413);
4780
+ expect(handlerCalled).toBe(false);
4781
+ await cleanupApp?.(wiredApp);
4782
+ });
4783
+ it("passes a multipart body within the limit without Content-Length to the handler", async () => {
4784
+ const mastra = new Mastra$1({});
4785
+ const app = createApp();
4786
+ const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
4787
+ maxSize,
4788
+ onError: () => ({ error: "Request body too large" })
4789
+ });
4790
+ let receivedBody;
4791
+ await registerRoute(adapter, wiredApp, {
4792
+ method: "POST",
4793
+ path: "/test/body-limit-no-content-length-multipart-within",
4794
+ responseType: "json",
4795
+ handler: async ({ a }) => {
4796
+ receivedBody = { a };
4797
+ return { ok: true };
4798
+ }
4799
+ });
4800
+ const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-multipart-within", {
4801
+ headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
4802
+ body: multipart([{
4803
+ name: "a",
4804
+ value: "ok"
4805
+ }])
4806
+ });
4807
+ expect(response.status).toBe(200);
4808
+ expect(receivedBody).toEqual({ a: "ok" });
4809
+ await cleanupApp?.(wiredApp);
4810
+ });
4811
+ it("passes a JSON body within the limit without Content-Length to the handler", async () => {
4812
+ const mastra = new Mastra$1({});
4813
+ const app = createApp();
4814
+ const { adapter, app: wiredApp } = await setupAdapter(app, mastra, {
4815
+ maxSize,
4816
+ onError: () => ({ error: "Request body too large" })
4817
+ });
4818
+ let receivedBody;
4819
+ await registerRoute(adapter, wiredApp, {
4820
+ method: "POST",
4821
+ path: "/test/body-limit-no-content-length-within",
4822
+ responseType: "json",
4823
+ handler: async ({ message }) => {
4824
+ receivedBody = { message };
4825
+ return { ok: true };
4826
+ }
4827
+ });
4828
+ const response = await executeRequestWithoutContentLength(wiredApp, "POST", "http://localhost/test/body-limit-no-content-length-within", {
4829
+ headers: { "Content-Type": "application/json" },
4830
+ body: JSON.stringify({ message: "hello" })
4831
+ });
4832
+ expect(response.status).toBe(200);
4833
+ expect(receivedBody).toEqual({ message: "hello" });
4834
+ await cleanupApp?.(wiredApp);
4835
+ });
4836
+ }
3960
4837
  it.each(["POST", "DELETE"])("rejects an oversized %s body with 413", async (method) => {
3961
4838
  const mastra = new Mastra$1({});
3962
4839
  const app = createApp();
@@ -3980,6 +4857,97 @@ function createBodyLimitTestSuite(config) {
3980
4857
  });
3981
4858
  }
3982
4859
  //#endregion
3983
- export { consumeSSEStream, createBodyLimitTestSuite, createDefaultTestContext, createHttpLoggingTestSuite, createMCPRouteTestSuite, createMCPTransportTestSuite, createMultipartTestSuite, createRouteAdapterTestSuite, createStreamWithSensitiveData, createStreamWithUnserializableChunk, expectSerializedStreamChunks };
4860
+ //#region src/custom-route-fga-test-suite.ts
4861
+ /**
4862
+ * Custom `registerApiRoute()` handlers read path params from the URL. FGA must
4863
+ * authorize that same resource, so a query or body field named like a path
4864
+ * param must not change which resource is checked.
4865
+ */
4866
+ function createCustomRouteFGATestSuite(config) {
4867
+ const { suiteName = "Custom route FGA", startServer } = config;
4868
+ describe(suiteName, () => {
4869
+ let close;
4870
+ afterEach(async () => {
4871
+ await close?.();
4872
+ close = void 0;
4873
+ });
4874
+ const setup = async () => {
4875
+ const check = vi.fn(async (_user, { resource }) => resource.id === "open");
4876
+ const fga = {
4877
+ check,
4878
+ require: vi.fn(),
4879
+ filterAccessible: vi.fn()
4880
+ };
4881
+ const routeFGA = {
4882
+ resourceType: "doc",
4883
+ resourceIdParam: "docId",
4884
+ permission: "docs:read"
4885
+ };
4886
+ const handler = async (c) => c.json({ docId: c.req.param("docId") });
4887
+ const mastra = new Mastra({
4888
+ logger: false,
4889
+ server: {
4890
+ fga,
4891
+ apiRoutes: [registerApiRoute("/docs/:docId", {
4892
+ method: "GET",
4893
+ fga: routeFGA,
4894
+ handler
4895
+ }), registerApiRoute("/docs/:docId", {
4896
+ method: "POST",
4897
+ fga: routeFGA,
4898
+ handler
4899
+ })]
4900
+ }
4901
+ });
4902
+ const originalGetServer = mastra.getServer.bind(mastra);
4903
+ mastra.getServer = () => ({
4904
+ ...originalGetServer(),
4905
+ auth: {
4906
+ authenticateToken: async (token) => token === "valid-token" ? { id: "user-1" } : null,
4907
+ authorize: async () => true
4908
+ }
4909
+ });
4910
+ const server = await startServer(mastra);
4911
+ close = server.close;
4912
+ const send = (path, init = {}) => server.request(path, {
4913
+ ...init,
4914
+ headers: {
4915
+ authorization: "Bearer valid-token",
4916
+ ...init.headers
4917
+ }
4918
+ });
4919
+ return {
4920
+ check,
4921
+ send
4922
+ };
4923
+ };
4924
+ const checkedIds = (check) => check.mock.calls.map(([, input]) => input.resource.id);
4925
+ it("allows the permitted resource and denies the forbidden one", async () => {
4926
+ const { send } = await setup();
4927
+ const allowed = await send("/docs/open");
4928
+ expect(allowed.status).toBe(200);
4929
+ expect(await allowed.json()).toEqual({ docId: "open" });
4930
+ expect((await send("/docs/locked")).status).toBe(403);
4931
+ });
4932
+ it("checks the path param when the query has a field with the same name", async () => {
4933
+ const { check, send } = await setup();
4934
+ const response = await send("/docs/locked?docId=open");
4935
+ expect(response.status).toBe(403);
4936
+ expect(checkedIds(check)).toEqual(["locked"]);
4937
+ });
4938
+ it("checks the path param when the JSON body has a field with the same name", async () => {
4939
+ const { check, send } = await setup();
4940
+ const response = await send("/docs/locked", {
4941
+ method: "POST",
4942
+ headers: { "content-type": "application/json" },
4943
+ body: JSON.stringify({ docId: "open" })
4944
+ });
4945
+ expect(response.status).toBe(403);
4946
+ expect(checkedIds(check)).toEqual(["locked"]);
4947
+ });
4948
+ });
4949
+ }
4950
+ //#endregion
4951
+ export { consumeSSEStream, createBodyLimitTestSuite, createCustomRouteFGATestSuite, createDefaultTestContext, createDurableDisconnectTestSuite, createHttpLoggingTestSuite, createMCPRouteTestSuite, createMCPTransportTestSuite, createMultipartTestSuite, createRouteAdapterTestSuite, createStreamWithSensitiveData, createStreamWithUnserializableChunk, expectSerializedStreamChunks };
3984
4952
 
3985
4953
  //# sourceMappingURL=index.js.map