Skip to content

Commit fbd5652

Browse files
committed
test(mcp): cover publish auth proxy permissions
1 parent 8aad599 commit fbd5652

3 files changed

Lines changed: 255 additions & 1 deletion

File tree

projects/app/test/api/support/mcp/publish.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, expect, it } from 'vitest';
22
import createHandler from '@/pages/api/support/mcp/create';
3+
import deleteHandler from '@/pages/api/support/mcp/delete';
34
import listHandler from '@/pages/api/support/mcp/list';
5+
import updateHandler from '@/pages/api/support/mcp/update';
46
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
57
import { TeamApikeyCreatePermissionVal } from '@fastgpt/global/support/permission/user/constant';
68
import { MongoApp } from '@fastgpt/service/core/app/schema';
@@ -108,4 +110,131 @@ describe('support/mcp publish management', () => {
108110
expect(result.code).toBe(500);
109111
expect(await MongoMcpKey.findOne({ name: 'member proxy mcp' })).toBeNull();
110112
});
113+
114+
it('allows the owner to enable authProxy on their MCP publication', async () => {
115+
const { owner } = await getFakeUsers(1);
116+
const app = await MongoApp.create({
117+
teamId: owner.teamId,
118+
tmbId: owner.tmbId,
119+
name: 'owner update app',
120+
type: AppTypeEnum.simple
121+
});
122+
const mcp = await MongoMcpKey.create({
123+
teamId: owner.teamId,
124+
tmbId: owner.tmbId,
125+
name: 'owner update mcp',
126+
apps: []
127+
});
128+
129+
const result = await Call(updateHandler, {
130+
auth: owner,
131+
body: {
132+
id: String(mcp._id),
133+
authProxy: true,
134+
apps: [
135+
{
136+
appId: String(app._id),
137+
appName: app.name,
138+
toolName: 'owner_update_tool',
139+
description: 'Owner update tool'
140+
}
141+
]
142+
}
143+
});
144+
145+
expect(result.code).toBe(200);
146+
expect((await MongoMcpKey.findById(mcp._id).lean())?.authProxy).toBe(true);
147+
});
148+
149+
it('rejects enabling authProxy when a non-owner updates their MCP publication', async () => {
150+
const { members } = await getFakeUsers(1);
151+
const [member] = members;
152+
const app = await MongoApp.create({
153+
teamId: member.teamId,
154+
tmbId: member.tmbId,
155+
name: 'member update app',
156+
type: AppTypeEnum.simple
157+
});
158+
const mcp = await MongoMcpKey.create({
159+
teamId: member.teamId,
160+
tmbId: member.tmbId,
161+
name: 'member update mcp',
162+
apps: []
163+
});
164+
165+
const result = await Call(updateHandler, {
166+
auth: member,
167+
body: {
168+
id: String(mcp._id),
169+
authProxy: true,
170+
apps: [
171+
{
172+
appId: String(app._id),
173+
appName: app.name,
174+
toolName: 'member_update_tool',
175+
description: 'Member update tool'
176+
}
177+
]
178+
}
179+
});
180+
181+
expect(result.code).toBe(500);
182+
expect((await MongoMcpKey.findById(mcp._id).lean())?.authProxy).toBe(false);
183+
});
184+
185+
it('allows a non-owner to disable an existing authProxy setting', async () => {
186+
const { members } = await getFakeUsers(1);
187+
const [member] = members;
188+
const app = await MongoApp.create({
189+
teamId: member.teamId,
190+
tmbId: member.tmbId,
191+
name: 'member disable app',
192+
type: AppTypeEnum.simple
193+
});
194+
const mcp = await MongoMcpKey.create({
195+
teamId: member.teamId,
196+
tmbId: member.tmbId,
197+
name: 'member disable mcp',
198+
authProxy: true,
199+
apps: []
200+
});
201+
202+
const result = await Call(updateHandler, {
203+
auth: member,
204+
body: {
205+
id: String(mcp._id),
206+
authProxy: false,
207+
apps: [
208+
{
209+
appId: String(app._id),
210+
appName: app.name,
211+
toolName: 'member_disable_tool',
212+
description: 'Member disable tool'
213+
}
214+
]
215+
}
216+
});
217+
218+
expect(result.code).toBe(200);
219+
expect((await MongoMcpKey.findById(mcp._id).lean())?.authProxy).toBe(false);
220+
});
221+
222+
it('does not allow a team owner to delete another member publication', async () => {
223+
const { owner, members } = await getFakeUsers(1);
224+
const [member] = members;
225+
const mcp = await MongoMcpKey.create({
226+
teamId: member.teamId,
227+
tmbId: member.tmbId,
228+
name: 'member private mcp',
229+
apps: []
230+
});
231+
232+
const result = await Call(deleteHandler, {
233+
auth: owner,
234+
query: { id: String(mcp._id) }
235+
});
236+
237+
expect(result.code).toBe(500);
238+
expect(await MongoMcpKey.findById(mcp._id)).not.toBeNull();
239+
});
111240
});

projects/app/test/pages/api/support/mcp/server/toolList.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi } from 'vitest';
1+
import { beforeEach, describe, it, expect, vi } from 'vitest';
22
import {
33
callMcpServerTool,
44
pluginNodes2InputSchema,
@@ -175,6 +175,10 @@ describe('toolList', () => {
175175
});
176176

177177
describe('callMcpServerTool', () => {
178+
beforeEach(() => {
179+
vi.clearAllMocks();
180+
});
181+
178182
it('returns workflowTool pluginOutput using the same value source as main', async () => {
179183
vi.mocked(MongoMcpKey.findOne).mockReturnValue({
180184
lean: () => ({
@@ -279,4 +283,48 @@ describe('callMcpServerTool', () => {
279283
})
280284
);
281285
});
286+
287+
it('does not dispatch when the effective member has no app read permission', async () => {
288+
vi.mocked(MongoMcpKey.findOne).mockReturnValue({
289+
lean: () => ({
290+
teamId: 'team-id',
291+
tmbId: 'publisher-tmb-id',
292+
authProxy: false,
293+
apps: [
294+
{
295+
appId: 'app-id',
296+
toolName: 'private_tool',
297+
description: 'private tool'
298+
}
299+
]
300+
})
301+
} as any);
302+
vi.mocked(MongoApp.find).mockReturnValue({
303+
lean: () => [
304+
{
305+
_id: 'app-id',
306+
name: 'Private App',
307+
type: AppTypeEnum.workflow,
308+
teamId: 'team-id',
309+
tmbId: 'app-owner-tmb-id'
310+
}
311+
]
312+
} as any);
313+
vi.mocked(authAppByTmbId).mockRejectedValue(new Error('unAuthApp'));
314+
315+
await expect(
316+
callMcpServerTool({
317+
key: 'mcp-key',
318+
toolName: 'private_tool',
319+
inputs: {}
320+
})
321+
).rejects.toThrow('unAuthApp');
322+
323+
expect(authAppByTmbId).toHaveBeenCalledWith({
324+
tmbId: 'publisher-tmb-id',
325+
appId: 'app-id',
326+
per: expect.any(Number)
327+
});
328+
expect(dispatchWorkFlow).not.toHaveBeenCalled();
329+
});
282330
});

projects/app/test/service/support/mcp/auth.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,83 @@ describe('MCP auth proxy', () => {
9292
);
9393
});
9494

95+
it('rejects a tmbId that does not resolve to an active team member', async () => {
96+
vi.mocked(MongoTeamMember.findOne).mockReturnValue(mockLeanQuery(null) as any);
97+
98+
await expect(
99+
resolveMcpEffectiveTmbId({
100+
mcp: {
101+
teamId: 'team-id',
102+
tmbId: 'publisher-tmb-id',
103+
authProxy: true
104+
},
105+
authProxy: { tmbId: '68ad85a7463006c963799a05' }
106+
})
107+
).rejects.toBe(ERROR_ENUM.unAuthorization);
108+
});
109+
110+
it('resolves an active member in the publishing team by username', async () => {
111+
vi.mocked(MongoUser.findOne).mockReturnValue(mockLeanQuery({ _id: 'user-id' }) as any);
112+
vi.mocked(MongoTeamMember.findOne).mockReturnValue(
113+
mockLeanQuery({ _id: '68ad85a7463006c963799a05' }) as any
114+
);
115+
116+
await expect(
117+
resolveMcpEffectiveTmbId({
118+
mcp: {
119+
teamId: 'team-id',
120+
tmbId: 'publisher-tmb-id',
121+
authProxy: true
122+
},
123+
authProxy: { username: 'user@example.com' }
124+
})
125+
).resolves.toBe('68ad85a7463006c963799a05');
126+
127+
expect(MongoUser.findOne).toHaveBeenCalledWith({ username: 'user@example.com' });
128+
expect(MongoTeamMember.findOne).toHaveBeenCalledWith(
129+
expect.objectContaining({
130+
teamId: 'team-id',
131+
userId: 'user-id'
132+
})
133+
);
134+
});
135+
136+
it('rejects a username that does not resolve to an active team member', async () => {
137+
vi.mocked(MongoUser.findOne).mockReturnValue(mockLeanQuery(null) as any);
138+
139+
await expect(
140+
resolveMcpEffectiveTmbId({
141+
mcp: {
142+
teamId: 'team-id',
143+
tmbId: 'publisher-tmb-id',
144+
authProxy: true
145+
},
146+
authProxy: { username: 'missing@example.com' }
147+
})
148+
).rejects.toBe(ERROR_ENUM.unAuthorization);
149+
});
150+
151+
it('accepts username and tmbId when they resolve to the same member', async () => {
152+
vi.mocked(MongoUser.findOne).mockReturnValue(mockLeanQuery({ _id: 'user-id' }) as any);
153+
vi.mocked(MongoTeamMember.findOne)
154+
.mockReturnValueOnce(mockLeanQuery({ _id: '68ad85a7463006c963799a05' }) as any)
155+
.mockReturnValueOnce(mockLeanQuery({ _id: '68ad85a7463006c963799a05' }) as any);
156+
157+
await expect(
158+
resolveMcpEffectiveTmbId({
159+
mcp: {
160+
teamId: 'team-id',
161+
tmbId: 'publisher-tmb-id',
162+
authProxy: true
163+
},
164+
authProxy: {
165+
username: 'user@example.com',
166+
tmbId: '68ad85a7463006c963799a05'
167+
}
168+
})
169+
).resolves.toBe('68ad85a7463006c963799a05');
170+
});
171+
95172
it('rejects when username and tmbId resolve to different members', async () => {
96173
vi.mocked(MongoUser.findOne).mockReturnValue(mockLeanQuery({ _id: 'user-id' }) as any);
97174
vi.mocked(MongoTeamMember.findOne)

0 commit comments

Comments
 (0)