@dxheroes/local-mcp-backend 0.10.0 → 0.12.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/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +60 -0
- package/dist/__tests__/integration/mcp-proxy-auth-http.test.js +38 -1
- package/dist/__tests__/integration/mcp-proxy-auth-http.test.js.map +1 -1
- package/dist/__tests__/integration/multi-tenant.test.js +75 -111
- package/dist/__tests__/integration/multi-tenant.test.js.map +1 -1
- package/dist/__tests__/integration/oauth-authorize-callback.test.js.map +1 -1
- package/dist/__tests__/integration/proxy-auth.test.js +51 -0
- package/dist/__tests__/integration/proxy-auth.test.js.map +1 -1
- package/dist/__tests__/integration/sharing-credentials.test.js +79 -56
- package/dist/__tests__/integration/sharing-credentials.test.js.map +1 -1
- package/dist/__tests__/unit/mcp-registry.test.js +187 -0
- package/dist/__tests__/unit/mcp-registry.test.js.map +1 -0
- package/dist/__tests__/unit/mcp.service.test.js +1448 -82
- package/dist/__tests__/unit/mcp.service.test.js.map +1 -1
- package/dist/__tests__/unit/oauth.service.test.js +282 -0
- package/dist/__tests__/unit/oauth.service.test.js.map +1 -0
- package/dist/__tests__/unit/profiles.service.test.js +1175 -0
- package/dist/__tests__/unit/profiles.service.test.js.map +1 -0
- package/dist/__tests__/unit/proxy.service.test.js +2049 -0
- package/dist/__tests__/unit/proxy.service.test.js.map +1 -0
- package/dist/__tests__/unit/settings.service.test.js +214 -0
- package/dist/__tests__/unit/settings.service.test.js.map +1 -0
- package/dist/__tests__/unit/sharing.service.test.js +878 -0
- package/dist/__tests__/unit/sharing.service.test.js.map +1 -1
- package/dist/app.module.js +2 -0
- package/dist/app.module.js.map +1 -1
- package/dist/main.js +29 -2
- package/dist/main.js.map +1 -1
- package/dist/modules/auth/auth.config.js +114 -31
- package/dist/modules/auth/auth.config.js.map +1 -1
- package/dist/modules/auth/mcp-oauth.guard.js +32 -7
- package/dist/modules/auth/mcp-oauth.guard.js.map +1 -1
- package/dist/modules/database/prisma.service.js +0 -43
- package/dist/modules/database/prisma.service.js.map +1 -1
- package/dist/modules/health/health.controller.js +1 -1
- package/dist/modules/health/health.controller.js.map +1 -1
- package/dist/modules/mcp/mcp-presets.js +33 -40
- package/dist/modules/mcp/mcp-presets.js.map +1 -1
- package/dist/modules/mcp/mcp.controller.js +39 -28
- package/dist/modules/mcp/mcp.controller.js.map +1 -1
- package/dist/modules/mcp/mcp.module.js +8 -10
- package/dist/modules/mcp/mcp.module.js.map +1 -1
- package/dist/modules/mcp/mcp.service.js +217 -59
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/oauth/oauth.service.js +19 -9
- package/dist/modules/oauth/oauth.service.js.map +1 -1
- package/dist/modules/organization-domains/blacklisted-domains.js +83 -0
- package/dist/modules/organization-domains/blacklisted-domains.js.map +1 -0
- package/dist/modules/organization-domains/organization-domains.controller.js +82 -0
- package/dist/modules/organization-domains/organization-domains.controller.js.map +1 -0
- package/dist/modules/organization-domains/organization-domains.module.js +27 -0
- package/dist/modules/organization-domains/organization-domains.module.js.map +1 -0
- package/dist/modules/organization-domains/organization-domains.service.js +83 -0
- package/dist/modules/organization-domains/organization-domains.service.js.map +1 -0
- package/dist/modules/profiles/profiles.module.js +3 -1
- package/dist/modules/profiles/profiles.module.js.map +1 -1
- package/dist/modules/profiles/profiles.service.js +86 -107
- package/dist/modules/profiles/profiles.service.js.map +1 -1
- package/dist/modules/proxy/proxy.service.js +51 -48
- package/dist/modules/proxy/proxy.service.js.map +1 -1
- package/dist/modules/settings/settings.constants.js +1 -2
- package/dist/modules/settings/settings.constants.js.map +1 -1
- package/dist/modules/settings/settings.controller.js +11 -4
- package/dist/modules/settings/settings.controller.js.map +1 -1
- package/dist/modules/settings/settings.service.js +9 -6
- package/dist/modules/settings/settings.service.js.map +1 -1
- package/dist/modules/sharing/sharing.controller.js +20 -0
- package/dist/modules/sharing/sharing.controller.js.map +1 -1
- package/dist/modules/sharing/sharing.service.js +166 -4
- package/dist/modules/sharing/sharing.service.js.map +1 -1
- package/package.json +10 -7
- package/src/__tests__/integration/mcp-proxy-auth-http.test.ts +36 -4
- package/src/__tests__/integration/multi-tenant.test.ts +74 -137
- package/src/__tests__/integration/oauth-authorize-callback.test.ts +6 -2
- package/src/__tests__/integration/proxy-auth.test.ts +37 -5
- package/src/__tests__/integration/sharing-credentials.test.ts +68 -55
- package/src/__tests__/unit/mcp-registry.test.ts +231 -0
- package/src/__tests__/unit/mcp.service.test.ts +1514 -86
- package/src/__tests__/unit/oauth.service.test.ts +320 -0
- package/src/__tests__/unit/profiles.service.test.ts +1168 -0
- package/src/__tests__/unit/proxy.service.test.ts +1932 -0
- package/src/__tests__/unit/settings.service.test.ts +234 -0
- package/src/__tests__/unit/sharing.service.test.ts +749 -0
- package/src/app.module.ts +2 -0
- package/src/main.ts +29 -2
- package/src/modules/auth/auth.config.ts +110 -32
- package/src/modules/auth/mcp-oauth.guard.ts +35 -8
- package/src/modules/database/prisma.service.ts +0 -49
- package/src/modules/health/health.controller.ts +1 -1
- package/src/modules/mcp/mcp-presets.ts +40 -28
- package/src/modules/mcp/mcp.controller.ts +33 -24
- package/src/modules/mcp/mcp.module.ts +7 -9
- package/src/modules/mcp/mcp.service.ts +276 -71
- package/src/modules/oauth/oauth.service.ts +27 -9
- package/src/modules/organization-domains/blacklisted-domains.ts +90 -0
- package/src/modules/organization-domains/organization-domains.controller.ts +41 -0
- package/src/modules/organization-domains/organization-domains.module.ts +15 -0
- package/src/modules/organization-domains/organization-domains.service.ts +60 -0
- package/src/modules/profiles/profiles.module.ts +2 -1
- package/src/modules/profiles/profiles.service.ts +82 -96
- package/src/modules/proxy/proxy.service.ts +46 -52
- package/src/modules/settings/settings.constants.ts +0 -1
- package/src/modules/settings/settings.controller.ts +11 -3
- package/src/modules/settings/settings.service.ts +12 -4
- package/src/modules/sharing/sharing.controller.ts +11 -0
- package/src/modules/sharing/sharing.service.ts +169 -5
- package/dist/modules/mcp/mcp-seed.service.js +0 -84
- package/dist/modules/mcp/mcp-seed.service.js.map +0 -1
- package/src/modules/mcp/mcp-seed.service.ts +0 -84
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
|
|
2
|
-
> @dxheroes/local-mcp-backend@0.
|
|
2
|
+
> @dxheroes/local-mcp-backend@0.12.0 build /home/runner/work/local-mcp-gateway/local-mcp-gateway/apps/backend
|
|
3
3
|
> nest build
|
|
4
4
|
|
|
5
5
|
- [46m[1m TSC [22m[49m[36m Initializing type checker...[39m
|
|
6
6
|
✔ [46m[1m TSC [22m[49m[36m Initializing type checker...[39m
|
|
7
7
|
[32m> [39m[42m[1m TSC [22m[49m[32m Found 0 issues.[39m
|
|
8
8
|
[36m> [39m[46m[1m SWC [22m[49m [36mRunning...[39m
|
|
9
|
-
Successfully compiled:
|
|
9
|
+
Successfully compiled: 68 files with swc (106.21ms)
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.0](https://github.com/DXHeroes/local-mcp-gateway/compare/backend-v0.11.0...backend-v0.12.0) (2026-03-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **auth:** improve user organization handling during signup ([8447d08](https://github.com/DXHeroes/local-mcp-gateway/commit/8447d08e55274973e33d97fcc3b66a269b96be51))
|
|
9
|
+
* **docs:** enhance documentation and user onboarding experience ([27adcf5](https://github.com/DXHeroes/local-mcp-gateway/commit/27adcf58b55926406fdc2a926a61ccfb92fad798))
|
|
10
|
+
* **invitation:** implement organization invitation acceptance flow ([c3731c2](https://github.com/DXHeroes/local-mcp-gateway/commit/c3731c2e0ced853fa37868b9f560edb4bf0cff2f))
|
|
11
|
+
* **mcp:** enhance MCP presets and server configuration ([d5ce8f3](https://github.com/DXHeroes/local-mcp-gateway/commit/d5ce8f3adb9513c03df2e97c2ba1236926350387))
|
|
12
|
+
* **mcp:** enrich server creation with metadata and enhance documentation ([fb3d901](https://github.com/DXHeroes/local-mcp-gateway/commit/fb3d901d4ab97308732dbdb15218ec9968cf56c9))
|
|
13
|
+
* **mcp:** streamline MCP presets and enhance server configuration ([9eb1f2c](https://github.com/DXHeroes/local-mcp-gateway/commit/9eb1f2c17ae8dea5d7d8035474adcfde9da56474))
|
|
14
|
+
* **organization-domains:** add auto-join domain management for organizations ([2e5071d](https://github.com/DXHeroes/local-mcp-gateway/commit/2e5071d82811725df5ef1729cc2f657d11fade14))
|
|
15
|
+
* **organization-domains:** implement blacklisted domains for auto-join prevention ([5226d48](https://github.com/DXHeroes/local-mcp-gateway/commit/5226d481b94b6fd192cfe46e385143b0f642a4d5))
|
|
16
|
+
* **tests:** enhance integration tests for MCP services and sharing functionality ([d4550ba](https://github.com/DXHeroes/local-mcp-gateway/commit/d4550baefb3e623500e444cb6cbcfd911b88fefb))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Bug Fixes
|
|
20
|
+
|
|
21
|
+
* **oauth:** standardize token type casing and enhance error logging ([47ef48b](https://github.com/DXHeroes/local-mcp-gateway/commit/47ef48ba45be779b059bf32faa062af2cea3eee4))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Dependencies
|
|
25
|
+
|
|
26
|
+
* The following workspace dependencies were updated
|
|
27
|
+
* dependencies
|
|
28
|
+
* @dxheroes/local-mcp-core bumped to 0.9.0
|
|
29
|
+
* @dxheroes/local-mcp-database bumped to 0.6.0
|
|
30
|
+
* @dxheroes/mcp-abra-flexi bumped to 0.4.0
|
|
31
|
+
* @dxheroes/mcp-fakturoid bumped to 0.4.0
|
|
32
|
+
* @dxheroes/mcp-gemini-deep-research bumped to 0.5.8
|
|
33
|
+
* @dxheroes/mcp-merk bumped to 0.3.8
|
|
34
|
+
* @dxheroes/mcp-toggl bumped to 0.3.8
|
|
35
|
+
* devDependencies
|
|
36
|
+
* @dxheroes/local-mcp-config bumped to 0.4.13
|
|
37
|
+
|
|
38
|
+
## [0.11.0](https://github.com/DXHeroes/local-mcp-gateway/compare/backend-v0.10.0...backend-v0.11.0) (2026-03-16)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
### Features
|
|
42
|
+
|
|
43
|
+
* **auth:** enable toggle for email and password authentication ([a0c1dd0](https://github.com/DXHeroes/local-mcp-gateway/commit/a0c1dd0a192ffd4199af2bb00d9e83f1c526be5c))
|
|
44
|
+
* **auth:** enhance MCP OAuth guard to support session cookies ([cc2f0d0](https://github.com/DXHeroes/local-mcp-gateway/commit/cc2f0d092150710691e8258c2fa3d891575e5a76))
|
|
45
|
+
* **dependencies:** add new MCP packages to backend dependencies ([a29fdf4](https://github.com/DXHeroes/local-mcp-gateway/commit/a29fdf4c4af4db0b4df31a9b8a7d7f0fec5a5755))
|
|
46
|
+
* **seo:** enhance SEO and session handling across frontend and backend ([2178043](https://github.com/DXHeroes/local-mcp-gateway/commit/21780436a9e93da63e3c6cec8318b4f8d1a6dbf6))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
### Dependencies
|
|
50
|
+
|
|
51
|
+
* The following workspace dependencies were updated
|
|
52
|
+
* dependencies
|
|
53
|
+
* @dxheroes/local-mcp-core bumped to 0.8.1
|
|
54
|
+
* @dxheroes/local-mcp-database bumped to 0.5.4
|
|
55
|
+
* @dxheroes/mcp-abra-flexi bumped to 0.3.3
|
|
56
|
+
* @dxheroes/mcp-fakturoid bumped to 0.3.3
|
|
57
|
+
* @dxheroes/mcp-gemini-deep-research bumped to 0.5.7
|
|
58
|
+
* @dxheroes/mcp-merk bumped to 0.3.7
|
|
59
|
+
* @dxheroes/mcp-toggl bumped to 0.3.7
|
|
60
|
+
* devDependencies
|
|
61
|
+
* @dxheroes/local-mcp-config bumped to 0.4.12
|
|
62
|
+
|
|
3
63
|
## [0.10.0](https://github.com/DXHeroes/local-mcp-gateway/compare/backend-v0.9.2...backend-v0.10.0) (2026-03-12)
|
|
4
64
|
|
|
5
65
|
|
|
@@ -10,7 +10,8 @@ function createConfigService(values) {
|
|
|
10
10
|
}
|
|
11
11
|
function createAuthServiceMock() {
|
|
12
12
|
return {
|
|
13
|
-
validateMcpToken: vi.fn().mockResolvedValue(null)
|
|
13
|
+
validateMcpToken: vi.fn().mockResolvedValue(null),
|
|
14
|
+
getSession: vi.fn().mockResolvedValue(null)
|
|
14
15
|
};
|
|
15
16
|
}
|
|
16
17
|
async function startTestApp(options) {
|
|
@@ -241,6 +242,42 @@ describe('MCP proxy auth HTTP contract', ()=>{
|
|
|
241
242
|
});
|
|
242
243
|
expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');
|
|
243
244
|
});
|
|
245
|
+
it('accepts session cookie when no Bearer token is present', async ()=>{
|
|
246
|
+
authService.getSession.mockResolvedValue({
|
|
247
|
+
user: {
|
|
248
|
+
id: 'cookie-user',
|
|
249
|
+
name: 'Cookie User',
|
|
250
|
+
email: 'cookie@example.com'
|
|
251
|
+
},
|
|
252
|
+
session: {
|
|
253
|
+
id: 'sess-1',
|
|
254
|
+
userId: 'cookie-user'
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
const response = await fetch(`${baseUrl}/api/mcp/test`, {
|
|
258
|
+
headers: {
|
|
259
|
+
cookie: 'better-auth.session_token=valid-session'
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
const body = await response.json();
|
|
263
|
+
expect(response.status).toBe(200);
|
|
264
|
+
expect(body).toEqual({
|
|
265
|
+
ok: true,
|
|
266
|
+
userId: 'cookie-user'
|
|
267
|
+
});
|
|
268
|
+
expect(authService.getSession).toHaveBeenCalled();
|
|
269
|
+
});
|
|
270
|
+
it('returns 401 when session cookie is invalid and no Bearer token', async ()=>{
|
|
271
|
+
authService.getSession.mockResolvedValue(null);
|
|
272
|
+
const response = await fetch(`${baseUrl}/api/mcp/test`, {
|
|
273
|
+
headers: {
|
|
274
|
+
cookie: 'better-auth.session_token=expired'
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
const body = await response.json();
|
|
278
|
+
expect(response.status).toBe(401);
|
|
279
|
+
expect(body.message).toBe('Bearer token required');
|
|
280
|
+
});
|
|
244
281
|
});
|
|
245
282
|
|
|
246
283
|
//# sourceMappingURL=mcp-proxy-auth-http.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/__tests__/integration/mcp-proxy-auth-http.test.ts"],"sourcesContent":["import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';\nimport type { ConfigService } from '@nestjs/config';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { AllExceptionsFilter } from '../../common/filters/all-exceptions.filter.js';\nimport { AuthService, type AuthUser } from '../../modules/auth/auth.service.js';\nimport { McpOAuthGuard } from '../../modules/auth/mcp-oauth.guard.js';\nimport {\n createMcpProtectedResourceMetadata,\n resolvePublicAuthBaseUrl,\n resolvePublicBackendOrigin,\n} from '../../modules/auth/mcp-oauth.utils.js';\n\ntype MockAuthService = {\n validateMcpToken: ReturnType<typeof vi.fn>;\n};\n\ntype MockConfigService = {\n get: ReturnType<typeof vi.fn>;\n};\n\nfunction createConfigService(values: Record<string, unknown>): MockConfigService {\n return {\n get: vi.fn((key: string) => values[key]),\n };\n}\n\nfunction createAuthServiceMock(): MockAuthService {\n return {\n validateMcpToken: vi.fn().mockResolvedValue(null),\n };\n}\n\nasync function startTestApp(options?: {\n backendUrl?: string;\n}): Promise<{\n close: () => Promise<void>;\n authService: MockAuthService;\n baseUrl: string;\n}> {\n const authService = createAuthServiceMock();\n const configService = createConfigService({\n 'app.port': 3001,\n BETTER_AUTH_URL: options?.backendUrl ?? 'http://localhost:3001',\n });\n const guard = new McpOAuthGuard(authService as unknown as AuthService, configService as never);\n const filter = new AllExceptionsFilter();\n const backendOrigin = resolvePublicBackendOrigin(configService as unknown as ConfigService);\n const authBaseUrl = resolvePublicAuthBaseUrl(configService as unknown as ConfigService);\n\n const createRequest = (req: IncomingMessage) => {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);\n return {\n method: req.method ?? 'GET',\n url: `${url.pathname}${url.search}`,\n headers: req.headers,\n query: Object.fromEntries(url.searchParams.entries()),\n user: undefined,\n };\n };\n\n const createResponse = (res: ServerResponse) => {\n const response = {\n setHeader(name: string, value: string) {\n res.setHeader(name, value);\n return response;\n },\n status(statusCode: number) {\n res.statusCode = statusCode;\n return response;\n },\n json(body: unknown) {\n res.setHeader('content-type', 'application/json');\n res.end(JSON.stringify(body));\n },\n };\n\n return response;\n };\n\n const handleGuardedRoute = async (\n nodeRequest: IncomingMessage,\n nodeResponse: ServerResponse,\n responseBody: (request: ReturnType<typeof createRequest>) => Record<string, unknown>\n ) => {\n const request = createRequest(nodeRequest);\n const response = createResponse(nodeResponse);\n const context = {\n switchToHttp: () => ({\n getRequest: () => request,\n getResponse: () => response,\n }),\n } as never;\n\n const host = {\n switchToHttp: () => ({\n getRequest: () => request,\n getResponse: () => response,\n }),\n } as never;\n\n try {\n await guard.canActivate(context);\n response.json(responseBody(request));\n } catch (error) {\n filter.catch(error, host);\n }\n };\n\n const server = await new Promise<import('node:http').Server>((resolve) => {\n const listeningServer = createServer(async (req, res) => {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);\n\n if (req.method === 'GET' && url.pathname === '/api/mcp/test') {\n await handleGuardedRoute(req, res, (request) => ({\n ok: true,\n userId: (request as { user?: AuthUser }).user?.id ?? null,\n }));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/api/mcp/sse') {\n await handleGuardedRoute(req, res, (request) => ({\n ok: true,\n token: request.query.access_token ?? null,\n userId: (request as { user?: AuthUser }).user?.id ?? null,\n }));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') {\n res.setHeader('content-type', 'application/json');\n res.end(JSON.stringify(createMcpProtectedResourceMetadata(configService as unknown as ConfigService)));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify({\n issuer: backendOrigin,\n authorization_endpoint: `${authBaseUrl}/mcp/authorize`,\n token_endpoint: `${authBaseUrl}/mcp/token`,\n registration_endpoint: `${authBaseUrl}/mcp/register`,\n jwks_uri: `${authBaseUrl}/mcp/jwks`,\n response_types_supported: ['code'],\n grant_types_supported: ['authorization_code', 'refresh_token'],\n code_challenge_methods_supported: ['S256'],\n })\n );\n return;\n }\n\n if (req.method === 'POST' && url.pathname === '/api/auth/mcp/register') {\n res.statusCode = 201;\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify({\n client_id: 'cursor-client',\n client_secret: 'cursor-secret',\n redirect_uris: ['https://cursor.sh/callback'],\n })\n );\n return;\n }\n\n res.statusCode = 404;\n res.end();\n }).listen(0, () => resolve(listeningServer));\n });\n const address = server.address();\n if (!address || typeof address === 'string') {\n throw new Error('Failed to resolve test server address');\n }\n\n return {\n close: () =>\n new Promise<void>((resolve, reject) => {\n server.close((error) => {\n if (error) {\n reject(error);\n return;\n }\n resolve();\n });\n }),\n authService,\n baseUrl: `http://127.0.0.1:${address.port}`,\n };\n}\n\ndescribe('MCP proxy auth HTTP contract', () => {\n let close: (() => Promise<void>) | undefined;\n let authService: MockAuthService;\n let baseUrl: string;\n\n beforeEach(async () => {\n const result = await startTestApp({\n backendUrl: 'http://localhost:9631',\n });\n close = result.close;\n authService = result.authService;\n baseUrl = result.baseUrl;\n });\n\n afterEach(async () => {\n if (close) {\n await close();\n }\n });\n\n it('returns 401 with MCP discovery headers when token is missing', async () => {\n const response = await fetch(`${baseUrl}/api/mcp/test`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(401);\n expect(response.headers.get('access-control-expose-headers')).toBe('WWW-Authenticate');\n expect(response.headers.get('www-authenticate')).toContain('resource_metadata=');\n expect(response.headers.get('www-authenticate')).toContain('resource_metadata_uri=');\n expect(body.message).toBe('Bearer token required');\n });\n\n it('serves protected resource metadata with Better Auth-backed URLs', async () => {\n const response = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({\n resource: 'http://localhost:9631/api/mcp',\n authorization_servers: ['http://localhost:9631'],\n bearer_methods_supported: ['header'],\n scopes_supported: ['openid', 'profile', 'email', 'offline_access'],\n jwks_uri: 'http://localhost:9631/api/auth/mcp/jwks',\n resource_signing_alg_values_supported: ['RS256', 'none'],\n });\n });\n\n it('serves authorization server metadata with MCP registration endpoint', async () => {\n const response = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body.registration_endpoint).toBe('http://localhost:9631/api/auth/mcp/register');\n expect(body.authorization_endpoint).toBe('http://localhost:9631/api/auth/mcp/authorize');\n expect(body.token_endpoint).toBe('http://localhost:9631/api/auth/mcp/token');\n });\n\n it('exposes the advertised registration endpoint', async () => {\n const response = await fetch(`${baseUrl}/api/auth/mcp/register`, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n redirect_uris: ['https://cursor.sh/callback'],\n }),\n });\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(201);\n expect(body.client_id).toBe('cursor-client');\n });\n\n it('accepts access_token query params for SSE fallback', async () => {\n authService.validateMcpToken.mockResolvedValue({\n id: 'user-sse',\n name: 'SSE User',\n email: 'sse@example.com',\n });\n\n const response = await fetch(`${baseUrl}/api/mcp/sse?access_token=sse-token`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({\n ok: true,\n token: 'sse-token',\n userId: 'user-sse',\n });\n expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');\n });\n});\n"],"names":["createServer","afterEach","beforeEach","describe","expect","it","vi","AllExceptionsFilter","McpOAuthGuard","createMcpProtectedResourceMetadata","resolvePublicAuthBaseUrl","resolvePublicBackendOrigin","createConfigService","values","get","fn","key","createAuthServiceMock","validateMcpToken","mockResolvedValue","startTestApp","options","authService","configService","BETTER_AUTH_URL","backendUrl","guard","filter","backendOrigin","authBaseUrl","createRequest","req","url","URL","headers","host","method","pathname","search","query","Object","fromEntries","searchParams","entries","user","undefined","createResponse","res","response","setHeader","name","value","status","statusCode","json","body","end","JSON","stringify","handleGuardedRoute","nodeRequest","nodeResponse","responseBody","request","context","switchToHttp","getRequest","getResponse","canActivate","error","catch","server","Promise","resolve","listeningServer","ok","userId","id","token","access_token","issuer","authorization_endpoint","token_endpoint","registration_endpoint","jwks_uri","response_types_supported","grant_types_supported","code_challenge_methods_supported","client_id","client_secret","redirect_uris","listen","address","Error","close","reject","baseUrl","port","result","fetch","toBe","toContain","message","toEqual","resource","authorization_servers","bearer_methods_supported","scopes_supported","resource_signing_alg_values_supported","email","toHaveBeenCalledWith"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../src/__tests__/integration/mcp-proxy-auth-http.test.ts"],"sourcesContent":["import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';\nimport type { ConfigService } from '@nestjs/config';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { AllExceptionsFilter } from '../../common/filters/all-exceptions.filter.js';\nimport { AuthService, type AuthUser } from '../../modules/auth/auth.service.js';\nimport { McpOAuthGuard } from '../../modules/auth/mcp-oauth.guard.js';\nimport {\n createMcpProtectedResourceMetadata,\n resolvePublicAuthBaseUrl,\n resolvePublicBackendOrigin,\n} from '../../modules/auth/mcp-oauth.utils.js';\n\ntype MockAuthService = {\n validateMcpToken: ReturnType<typeof vi.fn>;\n getSession: ReturnType<typeof vi.fn>;\n};\n\ntype MockConfigService = {\n get: ReturnType<typeof vi.fn>;\n};\n\nfunction createConfigService(values: Record<string, unknown>): MockConfigService {\n return {\n get: vi.fn((key: string) => values[key]),\n };\n}\n\nfunction createAuthServiceMock(): MockAuthService {\n return {\n validateMcpToken: vi.fn().mockResolvedValue(null),\n getSession: vi.fn().mockResolvedValue(null),\n };\n}\n\nasync function startTestApp(options?: { backendUrl?: string }): Promise<{\n close: () => Promise<void>;\n authService: MockAuthService;\n baseUrl: string;\n}> {\n const authService = createAuthServiceMock();\n const configService = createConfigService({\n 'app.port': 3001,\n BETTER_AUTH_URL: options?.backendUrl ?? 'http://localhost:3001',\n });\n const guard = new McpOAuthGuard(authService as unknown as AuthService, configService as never);\n const filter = new AllExceptionsFilter();\n const backendOrigin = resolvePublicBackendOrigin(configService as unknown as ConfigService);\n const authBaseUrl = resolvePublicAuthBaseUrl(configService as unknown as ConfigService);\n\n const createRequest = (req: IncomingMessage) => {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);\n return {\n method: req.method ?? 'GET',\n url: `${url.pathname}${url.search}`,\n headers: req.headers,\n query: Object.fromEntries(url.searchParams.entries()),\n user: undefined,\n };\n };\n\n const createResponse = (res: ServerResponse) => {\n const response = {\n setHeader(name: string, value: string) {\n res.setHeader(name, value);\n return response;\n },\n status(statusCode: number) {\n res.statusCode = statusCode;\n return response;\n },\n json(body: unknown) {\n res.setHeader('content-type', 'application/json');\n res.end(JSON.stringify(body));\n },\n };\n\n return response;\n };\n\n const handleGuardedRoute = async (\n nodeRequest: IncomingMessage,\n nodeResponse: ServerResponse,\n responseBody: (request: ReturnType<typeof createRequest>) => Record<string, unknown>\n ) => {\n const request = createRequest(nodeRequest);\n const response = createResponse(nodeResponse);\n const context = {\n switchToHttp: () => ({\n getRequest: () => request,\n getResponse: () => response,\n }),\n } as never;\n\n const host = {\n switchToHttp: () => ({\n getRequest: () => request,\n getResponse: () => response,\n }),\n } as never;\n\n try {\n await guard.canActivate(context);\n response.json(responseBody(request));\n } catch (error) {\n filter.catch(error, host);\n }\n };\n\n const server = await new Promise<import('node:http').Server>((resolve) => {\n const listeningServer = createServer(async (req, res) => {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);\n\n if (req.method === 'GET' && url.pathname === '/api/mcp/test') {\n await handleGuardedRoute(req, res, (request) => ({\n ok: true,\n userId: (request as { user?: AuthUser }).user?.id ?? null,\n }));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/api/mcp/sse') {\n await handleGuardedRoute(req, res, (request) => ({\n ok: true,\n token: request.query.access_token ?? null,\n userId: (request as { user?: AuthUser }).user?.id ?? null,\n }));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') {\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify(\n createMcpProtectedResourceMetadata(configService as unknown as ConfigService)\n )\n );\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify({\n issuer: backendOrigin,\n authorization_endpoint: `${authBaseUrl}/mcp/authorize`,\n token_endpoint: `${authBaseUrl}/mcp/token`,\n registration_endpoint: `${authBaseUrl}/mcp/register`,\n jwks_uri: `${authBaseUrl}/mcp/jwks`,\n response_types_supported: ['code'],\n grant_types_supported: ['authorization_code', 'refresh_token'],\n code_challenge_methods_supported: ['S256'],\n })\n );\n return;\n }\n\n if (req.method === 'POST' && url.pathname === '/api/auth/mcp/register') {\n res.statusCode = 201;\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify({\n client_id: 'cursor-client',\n client_secret: 'cursor-secret',\n redirect_uris: ['https://cursor.sh/callback'],\n })\n );\n return;\n }\n\n res.statusCode = 404;\n res.end();\n }).listen(0, () => resolve(listeningServer));\n });\n const address = server.address();\n if (!address || typeof address === 'string') {\n throw new Error('Failed to resolve test server address');\n }\n\n return {\n close: () =>\n new Promise<void>((resolve, reject) => {\n server.close((error) => {\n if (error) {\n reject(error);\n return;\n }\n resolve();\n });\n }),\n authService,\n baseUrl: `http://127.0.0.1:${address.port}`,\n };\n}\n\ndescribe('MCP proxy auth HTTP contract', () => {\n let close: (() => Promise<void>) | undefined;\n let authService: MockAuthService;\n let baseUrl: string;\n\n beforeEach(async () => {\n const result = await startTestApp({\n backendUrl: 'http://localhost:9631',\n });\n close = result.close;\n authService = result.authService;\n baseUrl = result.baseUrl;\n });\n\n afterEach(async () => {\n if (close) {\n await close();\n }\n });\n\n it('returns 401 with MCP discovery headers when token is missing', async () => {\n const response = await fetch(`${baseUrl}/api/mcp/test`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(401);\n expect(response.headers.get('access-control-expose-headers')).toBe('WWW-Authenticate');\n expect(response.headers.get('www-authenticate')).toContain('resource_metadata=');\n expect(response.headers.get('www-authenticate')).toContain('resource_metadata_uri=');\n expect(body.message).toBe('Bearer token required');\n });\n\n it('serves protected resource metadata with Better Auth-backed URLs', async () => {\n const response = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({\n resource: 'http://localhost:9631/api/mcp',\n authorization_servers: ['http://localhost:9631'],\n bearer_methods_supported: ['header'],\n scopes_supported: ['openid', 'profile', 'email', 'offline_access'],\n jwks_uri: 'http://localhost:9631/api/auth/mcp/jwks',\n resource_signing_alg_values_supported: ['RS256', 'none'],\n });\n });\n\n it('serves authorization server metadata with MCP registration endpoint', async () => {\n const response = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body.registration_endpoint).toBe('http://localhost:9631/api/auth/mcp/register');\n expect(body.authorization_endpoint).toBe('http://localhost:9631/api/auth/mcp/authorize');\n expect(body.token_endpoint).toBe('http://localhost:9631/api/auth/mcp/token');\n });\n\n it('exposes the advertised registration endpoint', async () => {\n const response = await fetch(`${baseUrl}/api/auth/mcp/register`, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n redirect_uris: ['https://cursor.sh/callback'],\n }),\n });\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(201);\n expect(body.client_id).toBe('cursor-client');\n });\n\n it('accepts access_token query params for SSE fallback', async () => {\n authService.validateMcpToken.mockResolvedValue({\n id: 'user-sse',\n name: 'SSE User',\n email: 'sse@example.com',\n });\n\n const response = await fetch(`${baseUrl}/api/mcp/sse?access_token=sse-token`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({\n ok: true,\n token: 'sse-token',\n userId: 'user-sse',\n });\n expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');\n });\n\n it('accepts session cookie when no Bearer token is present', async () => {\n authService.getSession.mockResolvedValue({\n user: { id: 'cookie-user', name: 'Cookie User', email: 'cookie@example.com' },\n session: { id: 'sess-1', userId: 'cookie-user' },\n });\n\n const response = await fetch(`${baseUrl}/api/mcp/test`, {\n headers: { cookie: 'better-auth.session_token=valid-session' },\n });\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({ ok: true, userId: 'cookie-user' });\n expect(authService.getSession).toHaveBeenCalled();\n });\n\n it('returns 401 when session cookie is invalid and no Bearer token', async () => {\n authService.getSession.mockResolvedValue(null);\n\n const response = await fetch(`${baseUrl}/api/mcp/test`, {\n headers: { cookie: 'better-auth.session_token=expired' },\n });\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(401);\n expect(body.message).toBe('Bearer token required');\n });\n});\n"],"names":["createServer","afterEach","beforeEach","describe","expect","it","vi","AllExceptionsFilter","McpOAuthGuard","createMcpProtectedResourceMetadata","resolvePublicAuthBaseUrl","resolvePublicBackendOrigin","createConfigService","values","get","fn","key","createAuthServiceMock","validateMcpToken","mockResolvedValue","getSession","startTestApp","options","authService","configService","BETTER_AUTH_URL","backendUrl","guard","filter","backendOrigin","authBaseUrl","createRequest","req","url","URL","headers","host","method","pathname","search","query","Object","fromEntries","searchParams","entries","user","undefined","createResponse","res","response","setHeader","name","value","status","statusCode","json","body","end","JSON","stringify","handleGuardedRoute","nodeRequest","nodeResponse","responseBody","request","context","switchToHttp","getRequest","getResponse","canActivate","error","catch","server","Promise","resolve","listeningServer","ok","userId","id","token","access_token","issuer","authorization_endpoint","token_endpoint","registration_endpoint","jwks_uri","response_types_supported","grant_types_supported","code_challenge_methods_supported","client_id","client_secret","redirect_uris","listen","address","Error","close","reject","baseUrl","port","result","fetch","toBe","toContain","message","toEqual","resource","authorization_servers","bearer_methods_supported","scopes_supported","resource_signing_alg_values_supported","email","toHaveBeenCalledWith","session","cookie","toHaveBeenCalled"],"mappings":"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"}
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Integration Tests:
|
|
2
|
+
* Integration Tests: Per-user MCP isolation with sharing
|
|
3
3
|
*
|
|
4
|
-
* Verifies that
|
|
5
|
-
*
|
|
4
|
+
* Verifies that:
|
|
5
|
+
* - McpService returns only servers owned by the user + shared servers
|
|
6
|
+
* - ProfilesService org-scopes profiles (per-user, no system profiles)
|
|
7
|
+
* - Cross-user access is denied unless shared
|
|
6
8
|
*/ import { ForbiddenException } from "@nestjs/common";
|
|
7
9
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
-
/** Sentinel value for unauthenticated MCP access */ const UNAUTHENTICATED_ID = '__unauthenticated__';
|
|
9
10
|
import { McpService } from "../../modules/mcp/mcp.service.js";
|
|
10
11
|
import { McpRegistry } from "../../modules/mcp/mcp-registry.js";
|
|
11
12
|
import { ProfilesService } from "../../modules/profiles/profiles.service.js";
|
|
12
13
|
const PROFILES = [];
|
|
13
14
|
const SERVERS = [];
|
|
14
|
-
// ────────────────────────────────────────────────
|
|
15
|
-
// Helper: build a mock PrismaService over in-memory data
|
|
16
|
-
// ────────────────────────────────────────────────
|
|
17
15
|
function buildMockPrisma() {
|
|
18
16
|
return {
|
|
19
17
|
profile: {
|
|
@@ -25,8 +23,15 @@ function buildMockPrisma() {
|
|
|
25
23
|
if (whereObj?.OR) {
|
|
26
24
|
const orConds = whereObj.OR;
|
|
27
25
|
result = result.filter((p)=>orConds.some((cond)=>{
|
|
26
|
+
// Handle combined conditions like { userId, organizationId }
|
|
27
|
+
if ('userId' in cond && 'organizationId' in cond) {
|
|
28
|
+
return p.userId === cond.userId && p.organizationId === cond.organizationId;
|
|
29
|
+
}
|
|
28
30
|
if ('organizationId' in cond) {
|
|
29
|
-
return
|
|
31
|
+
return p.organizationId === cond.organizationId;
|
|
32
|
+
}
|
|
33
|
+
if ('userId' in cond) {
|
|
34
|
+
return p.userId === cond.userId;
|
|
30
35
|
}
|
|
31
36
|
if ('id' in cond && cond.id?.in) {
|
|
32
37
|
return cond.id.in.includes(p.id);
|
|
@@ -45,8 +50,8 @@ function buildMockPrisma() {
|
|
|
45
50
|
id: `profile-${Date.now()}`,
|
|
46
51
|
name: data.name,
|
|
47
52
|
description: data.description ?? null,
|
|
48
|
-
userId: data.userId
|
|
49
|
-
organizationId: data.organizationId
|
|
53
|
+
userId: data.userId,
|
|
54
|
+
organizationId: data.organizationId,
|
|
50
55
|
mcpServers: []
|
|
51
56
|
};
|
|
52
57
|
PROFILES.push(profile);
|
|
@@ -64,8 +69,8 @@ function buildMockPrisma() {
|
|
|
64
69
|
if (whereObj?.OR) {
|
|
65
70
|
const orConds = whereObj.OR;
|
|
66
71
|
result = result.filter((s)=>orConds.some((cond)=>{
|
|
67
|
-
if ('
|
|
68
|
-
return
|
|
72
|
+
if ('userId' in cond) {
|
|
73
|
+
return s.userId === cond.userId;
|
|
69
74
|
}
|
|
70
75
|
if ('id' in cond && cond.id?.in) {
|
|
71
76
|
return cond.id.in.includes(s.id);
|
|
@@ -87,8 +92,7 @@ function buildMockPrisma() {
|
|
|
87
92
|
config: data.config,
|
|
88
93
|
apiKeyConfig: data.apiKeyConfig ?? null,
|
|
89
94
|
oauthConfig: data.oauthConfig ?? null,
|
|
90
|
-
userId: data.userId
|
|
91
|
-
organizationId: data.organizationId ?? null,
|
|
95
|
+
userId: data.userId,
|
|
92
96
|
profiles: [],
|
|
93
97
|
oauthToken: null,
|
|
94
98
|
toolsCache: []
|
|
@@ -104,31 +108,21 @@ function buildMockPrisma() {
|
|
|
104
108
|
},
|
|
105
109
|
mcpServerToolsCache: {
|
|
106
110
|
findMany: vi.fn().mockResolvedValue([])
|
|
107
|
-
},
|
|
108
|
-
gatewaySetting: {
|
|
109
|
-
upsert: vi.fn().mockResolvedValue({})
|
|
110
111
|
}
|
|
111
112
|
};
|
|
112
113
|
}
|
|
113
114
|
// ────────────────────────────────────────────────
|
|
114
|
-
// Tests: ProfilesService data isolation
|
|
115
|
+
// Tests: ProfilesService data isolation (org-scoped, per-user)
|
|
115
116
|
// ────────────────────────────────────────────────
|
|
116
117
|
describe('Multi-tenant data isolation', ()=>{
|
|
117
118
|
describe('ProfilesService', ()=>{
|
|
118
119
|
let profilesService;
|
|
119
120
|
let prisma;
|
|
120
121
|
let proxyService;
|
|
122
|
+
let sharingService;
|
|
121
123
|
beforeEach(()=>{
|
|
122
|
-
// Reset in-memory data
|
|
123
124
|
PROFILES.length = 0;
|
|
124
125
|
PROFILES.push({
|
|
125
|
-
id: 'sys-profile',
|
|
126
|
-
name: 'default',
|
|
127
|
-
description: 'System default profile',
|
|
128
|
-
userId: null,
|
|
129
|
-
organizationId: null,
|
|
130
|
-
mcpServers: []
|
|
131
|
-
}, {
|
|
132
126
|
id: 'org-a-profile',
|
|
133
127
|
name: 'org-a-dev',
|
|
134
128
|
description: 'Org A dev profile',
|
|
@@ -147,28 +141,25 @@ describe('Multi-tenant data isolation', ()=>{
|
|
|
147
141
|
proxyService = {
|
|
148
142
|
getToolsForServer: vi.fn().mockResolvedValue([])
|
|
149
143
|
};
|
|
150
|
-
|
|
144
|
+
sharingService = {
|
|
145
|
+
getSharedResourceIds: vi.fn().mockResolvedValue([]),
|
|
146
|
+
isSharedWith: vi.fn().mockResolvedValue(false),
|
|
147
|
+
getPermission: vi.fn().mockResolvedValue(null)
|
|
148
|
+
};
|
|
149
|
+
profilesService = new ProfilesService(prisma, proxyService, sharingService);
|
|
151
150
|
});
|
|
152
|
-
it('Org A sees org A profiles
|
|
151
|
+
it('Org A sees org A profiles, not Org B profiles', async ()=>{
|
|
153
152
|
const result = await profilesService.findAll('user-a', 'org-a');
|
|
154
153
|
const ids = result.map((p)=>p.id);
|
|
155
|
-
expect(ids).toContain('sys-profile');
|
|
156
154
|
expect(ids).toContain('org-a-profile');
|
|
157
155
|
expect(ids).not.toContain('org-b-profile');
|
|
158
156
|
});
|
|
159
|
-
it('Org B sees org B profiles
|
|
157
|
+
it('Org B sees org B profiles, not Org A profiles', async ()=>{
|
|
160
158
|
const result = await profilesService.findAll('user-b', 'org-b');
|
|
161
159
|
const ids = result.map((p)=>p.id);
|
|
162
|
-
expect(ids).toContain('sys-profile');
|
|
163
160
|
expect(ids).toContain('org-b-profile');
|
|
164
161
|
expect(ids).not.toContain('org-a-profile');
|
|
165
162
|
});
|
|
166
|
-
it('system profiles (organizationId=null) are visible to all orgs', async ()=>{
|
|
167
|
-
const resultA = await profilesService.findAll('user-a', 'org-a');
|
|
168
|
-
const resultB = await profilesService.findAll('user-b', 'org-b');
|
|
169
|
-
expect(resultA.some((p)=>p.id === 'sys-profile')).toBe(true);
|
|
170
|
-
expect(resultB.some((p)=>p.id === 'sys-profile')).toBe(true);
|
|
171
|
-
});
|
|
172
163
|
it('creating a profile sets userId and organizationId', async ()=>{
|
|
173
164
|
const created = await profilesService.create({
|
|
174
165
|
name: 'a-new'
|
|
@@ -179,67 +170,37 @@ describe('Multi-tenant data isolation', ()=>{
|
|
|
179
170
|
it('Org A cannot access Org B profile by ID', async ()=>{
|
|
180
171
|
await expect(profilesService.findById('org-b-profile', 'user-a', 'org-a')).rejects.toThrow(ForbiddenException);
|
|
181
172
|
});
|
|
182
|
-
it('Org A can access system profile by ID', async ()=>{
|
|
183
|
-
const profile = await profilesService.findById('sys-profile', 'user-a', 'org-a');
|
|
184
|
-
expect(profile).toBeDefined();
|
|
185
|
-
expect(profile.id).toBe('sys-profile');
|
|
186
|
-
});
|
|
187
|
-
it('unauthenticated user sees all profiles', async ()=>{
|
|
188
|
-
const result = await profilesService.findAll(UNAUTHENTICATED_ID);
|
|
189
|
-
// Unauthenticated findAll queries without WHERE filter
|
|
190
|
-
expect(prisma.profile.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
191
|
-
orderBy: {
|
|
192
|
-
name: 'asc'
|
|
193
|
-
}
|
|
194
|
-
}));
|
|
195
|
-
// The mock returns all profiles for filterless queries
|
|
196
|
-
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
197
|
-
});
|
|
198
173
|
});
|
|
199
174
|
// ────────────────────────────────────────────────
|
|
200
|
-
// Tests: McpService
|
|
175
|
+
// Tests: McpService per-user isolation
|
|
201
176
|
// ────────────────────────────────────────────────
|
|
202
|
-
describe('McpService', ()=>{
|
|
177
|
+
describe('McpService — per-user isolation', ()=>{
|
|
203
178
|
let mcpService;
|
|
204
179
|
let prisma;
|
|
205
180
|
let registry;
|
|
206
181
|
let debugService;
|
|
182
|
+
let sharingService;
|
|
207
183
|
beforeEach(()=>{
|
|
208
|
-
// Reset in-memory data
|
|
209
184
|
SERVERS.length = 0;
|
|
210
185
|
SERVERS.push({
|
|
211
|
-
id: '
|
|
212
|
-
name: 'System Builtin',
|
|
213
|
-
type: 'builtin',
|
|
214
|
-
config: '{"builtinId":"fetch"}',
|
|
215
|
-
apiKeyConfig: null,
|
|
216
|
-
oauthConfig: null,
|
|
217
|
-
userId: null,
|
|
218
|
-
organizationId: null,
|
|
219
|
-
profiles: [],
|
|
220
|
-
oauthToken: null,
|
|
221
|
-
toolsCache: []
|
|
222
|
-
}, {
|
|
223
|
-
id: 'org-a-server',
|
|
186
|
+
id: 'user-a-server',
|
|
224
187
|
name: 'A Custom',
|
|
225
188
|
type: 'external',
|
|
226
189
|
config: '{"command":"node"}',
|
|
227
190
|
apiKeyConfig: '{"apiKey":"a-key"}',
|
|
228
191
|
oauthConfig: null,
|
|
229
192
|
userId: 'user-a',
|
|
230
|
-
organizationId: 'org-a',
|
|
231
193
|
profiles: [],
|
|
232
194
|
oauthToken: null,
|
|
233
195
|
toolsCache: []
|
|
234
196
|
}, {
|
|
235
|
-
id: '
|
|
197
|
+
id: 'user-b-server',
|
|
236
198
|
name: 'B Custom',
|
|
237
199
|
type: 'external',
|
|
238
200
|
config: '{"command":"node"}',
|
|
239
201
|
apiKeyConfig: '{"apiKey":"b-key"}',
|
|
240
202
|
oauthConfig: null,
|
|
241
203
|
userId: 'user-b',
|
|
242
|
-
organizationId: 'org-b',
|
|
243
204
|
profiles: [],
|
|
244
205
|
oauthToken: null,
|
|
245
206
|
toolsCache: []
|
|
@@ -252,37 +213,47 @@ describe('Multi-tenant data isolation', ()=>{
|
|
|
252
213
|
}),
|
|
253
214
|
updateLog: vi.fn().mockResolvedValue({})
|
|
254
215
|
};
|
|
255
|
-
|
|
216
|
+
sharingService = {
|
|
217
|
+
getSharedResourceIds: vi.fn().mockResolvedValue([]),
|
|
218
|
+
isSharedWith: vi.fn().mockResolvedValue(false),
|
|
219
|
+
getPermission: vi.fn().mockResolvedValue(null)
|
|
220
|
+
};
|
|
221
|
+
mcpService = new McpService(prisma, registry, debugService, sharingService);
|
|
256
222
|
});
|
|
257
|
-
it('
|
|
223
|
+
it('User A sees only own servers (no shared)', async ()=>{
|
|
258
224
|
const result = await mcpService.findAll('user-a', 'org-a');
|
|
259
225
|
const ids = result.map((s)=>s.id);
|
|
260
|
-
expect(ids).toContain('
|
|
261
|
-
expect(ids).toContain('
|
|
262
|
-
expect(ids).not.toContain('org-b-server');
|
|
226
|
+
expect(ids).toContain('user-a-server');
|
|
227
|
+
expect(ids).not.toContain('user-b-server');
|
|
263
228
|
});
|
|
264
|
-
it('
|
|
265
|
-
|
|
229
|
+
it('User A sees own + shared servers', async ()=>{
|
|
230
|
+
sharingService.getSharedResourceIds.mockResolvedValue([
|
|
231
|
+
'user-b-server'
|
|
232
|
+
]);
|
|
233
|
+
const result = await mcpService.findAll('user-a', 'org-a');
|
|
266
234
|
const ids = result.map((s)=>s.id);
|
|
267
|
-
expect(ids).toContain('
|
|
268
|
-
expect(ids).toContain('
|
|
269
|
-
expect(ids).not.toContain('org-a-server');
|
|
270
|
-
});
|
|
271
|
-
it('builtin/system servers (organizationId=null) are visible to all orgs', async ()=>{
|
|
272
|
-
const resultA = await mcpService.findAll('user-a', 'org-a');
|
|
273
|
-
const resultB = await mcpService.findAll('user-b', 'org-b');
|
|
274
|
-
expect(resultA.some((s)=>s.id === 'sys-server')).toBe(true);
|
|
275
|
-
expect(resultB.some((s)=>s.id === 'sys-server')).toBe(true);
|
|
235
|
+
expect(ids).toContain('user-a-server');
|
|
236
|
+
expect(ids).toContain('user-b-server');
|
|
276
237
|
});
|
|
277
|
-
it('
|
|
278
|
-
|
|
238
|
+
it('User A cannot access User B server by ID without sharing', async ()=>{
|
|
239
|
+
sharingService.isSharedWith.mockResolvedValue(false);
|
|
240
|
+
await expect(mcpService.findById('user-b-server', 'user-a')).rejects.toThrow(ForbiddenException);
|
|
279
241
|
});
|
|
280
|
-
it('
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
242
|
+
it('User A can access User B server when shared', async ()=>{
|
|
243
|
+
prisma.mcpServer.findUnique.mockResolvedValueOnce({
|
|
244
|
+
id: 'user-b-server',
|
|
245
|
+
userId: 'user-b'
|
|
246
|
+
}) // assertAccess
|
|
247
|
+
.mockResolvedValueOnce({
|
|
248
|
+
...SERVERS[1],
|
|
249
|
+
oauthToken: null,
|
|
250
|
+
toolsCache: []
|
|
251
|
+
}); // findById
|
|
252
|
+
sharingService.isSharedWith.mockResolvedValue(true);
|
|
253
|
+
const result = await mcpService.findById('user-b-server', 'user-a');
|
|
254
|
+
expect(result).toBeDefined();
|
|
284
255
|
});
|
|
285
|
-
it('Creating a server
|
|
256
|
+
it('Creating a server sets userId only (no organizationId)', async ()=>{
|
|
286
257
|
await mcpService.create({
|
|
287
258
|
name: 'New',
|
|
288
259
|
type: 'external',
|
|
@@ -292,28 +263,21 @@ describe('Multi-tenant data isolation', ()=>{
|
|
|
292
263
|
}, 'user-a', 'org-a');
|
|
293
264
|
expect(prisma.mcpServer.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
294
265
|
data: expect.objectContaining({
|
|
295
|
-
userId: 'user-a'
|
|
296
|
-
organizationId: 'org-a'
|
|
266
|
+
userId: 'user-a'
|
|
297
267
|
})
|
|
298
268
|
}));
|
|
269
|
+
const callData = prisma.mcpServer.create.mock.calls[0][0].data;
|
|
270
|
+
expect(callData.organizationId).toBeUndefined();
|
|
299
271
|
});
|
|
300
|
-
it('
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
expect(prisma.mcpServer.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
304
|
-
orderBy: {
|
|
305
|
-
name: 'asc'
|
|
306
|
-
}
|
|
307
|
-
}));
|
|
308
|
-
});
|
|
309
|
-
it('Org A cannot delete Org B server', async ()=>{
|
|
310
|
-
await expect(mcpService.delete('org-b-server', 'user-a', 'org-a')).rejects.toThrow(ForbiddenException);
|
|
272
|
+
it('User A cannot delete User B server', async ()=>{
|
|
273
|
+
sharingService.getPermission.mockResolvedValue(null);
|
|
274
|
+
await expect(mcpService.delete('user-b-server', 'user-a')).rejects.toThrow(ForbiddenException);
|
|
311
275
|
});
|
|
312
|
-
it('
|
|
313
|
-
await mcpService.delete('
|
|
276
|
+
it('User A can delete own server', async ()=>{
|
|
277
|
+
await mcpService.delete('user-a-server', 'user-a');
|
|
314
278
|
expect(prisma.mcpServer.delete).toHaveBeenCalledWith({
|
|
315
279
|
where: {
|
|
316
|
-
id: '
|
|
280
|
+
id: 'user-a-server'
|
|
317
281
|
}
|
|
318
282
|
});
|
|
319
283
|
});
|