-
Notifications
You must be signed in to change notification settings - Fork 21
fix(backend): stamp org-scoped organizationId on MCP group template server imports | ENG-213 #306
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| import { describe, it, expect, beforeEach, jest, mock } from 'bun:test'; | ||
| import { McpGroupsSeedingService } from '../mcp-groups-seeding.service'; | ||
| import { McpGroupsRepository } from '../mcp-groups.repository'; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Minimal mock template registry (avoids loading real JSON fixtures) | ||
| // --------------------------------------------------------------------------- | ||
| const MOCK_TEMPLATE = { | ||
| slug: 'test-group', | ||
| name: 'Test Group', | ||
| description: 'A test group', | ||
| credentialContractName: 'test-cred', | ||
| credentialMapping: null, | ||
| defaultDockerImage: 'test-image:latest', | ||
| version: { major: 1, minor: 0, patch: 0 }, | ||
| servers: [ | ||
| { | ||
| name: 'test-server', | ||
| description: 'A test server', | ||
| transportType: 'http' as const, | ||
| endpoint: 'http://localhost:8080', | ||
| recommended: true, | ||
| defaultSelected: true, | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| mock.module('../mcp-group-templates', () => ({ | ||
| MCP_GROUP_TEMPLATES: { 'test-group': MOCK_TEMPLATE }, | ||
| computeTemplateHash: (_t: unknown) => 'mock-hash-abc', | ||
| })); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // DB / transaction helpers | ||
| // --------------------------------------------------------------------------- | ||
| function makeTx(insertedServerId = 'server-uuid-1', insertedGroupId = 'group-uuid-1') { | ||
| const returning = jest.fn(); | ||
| const values = jest.fn().mockReturnValue({ returning }); | ||
| const set = jest.fn().mockReturnValue({ where: jest.fn().mockReturnValue({ returning }) }); | ||
| // where() must be both awaitable (resolves to []) AND have .limit() for different call-sites | ||
| const makeWhereResult = () => { | ||
| const p = Promise.resolve([]) as any; | ||
| p.limit = jest.fn().mockResolvedValue([]); | ||
| return p; | ||
| }; | ||
| const where = jest.fn().mockImplementation(makeWhereResult); | ||
| const select = jest.fn().mockReturnValue({ from: jest.fn().mockReturnValue({ where }) }); | ||
| const insert = jest.fn().mockReturnValue({ values }); | ||
| const update = jest.fn().mockReturnValue({ set }); | ||
| const deleteFrom = jest.fn().mockReturnValue({ where: jest.fn().mockResolvedValue([]) }); | ||
|
|
||
| // Default returning sequences: first call → group, subsequent → server | ||
| let callCount = 0; | ||
| returning.mockImplementation(() => { | ||
| callCount++; | ||
| if (callCount === 1) { | ||
| return Promise.resolve([{ id: insertedGroupId, slug: 'test-group', templateHash: null }]); | ||
| } | ||
| return Promise.resolve([{ id: insertedServerId }]); | ||
| }); | ||
|
|
||
| return { insert, select, update, delete: deleteFrom, returning, values }; | ||
| } | ||
|
|
||
| function makeDb(tx: ReturnType<typeof makeTx>) { | ||
| return { | ||
| transaction: jest.fn().mockImplementation((fn: (tx: any) => Promise<any>) => fn(tx)), | ||
| execute: jest.fn().mockResolvedValue({ rows: [] }), | ||
| ...tx, | ||
| }; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Tests | ||
| // --------------------------------------------------------------------------- | ||
| describe('McpGroupsSeedingService – multi-tenant isolation', () => { | ||
| let service: McpGroupsSeedingService; | ||
| let groupsRepository: McpGroupsRepository; | ||
| let tx: ReturnType<typeof makeTx>; | ||
| let db: ReturnType<typeof makeDb>; | ||
|
|
||
| beforeEach(() => { | ||
| tx = makeTx(); | ||
| db = makeDb(tx); | ||
|
|
||
| groupsRepository = { | ||
| findBySlug: jest.fn().mockResolvedValue(null), // group does not yet exist → create path | ||
| } as unknown as McpGroupsRepository; | ||
|
|
||
| service = new McpGroupsSeedingService(db as any, groupsRepository); | ||
| }); | ||
|
|
||
| describe('syncTemplate – create path (group does not exist yet)', () => { | ||
| it('stamps created servers with the provided organizationId', async () => { | ||
| const orgId = 'org-abc-123'; | ||
| await service.syncTemplate('test-group', false, orgId); | ||
|
|
||
| // Find the insert call for mcp_servers | ||
| const insertCalls = tx.insert.mock.calls; | ||
| // tx.insert is called once for mcpGroups, once for mcpServers | ||
| expect(insertCalls.length).toBeGreaterThanOrEqual(2); | ||
|
|
||
| // The values() arg for the server insert should include organizationId | ||
| const serverValuesCall = tx.values.mock.calls.find( | ||
| ([vals]: any[]) => vals && 'transportType' in vals, | ||
| ); | ||
| expect(serverValuesCall).toBeDefined(); | ||
| expect(serverValuesCall![0].organizationId).toBe(orgId); | ||
| }); | ||
|
|
||
| it('stamps created servers with null organizationId when called from syncAllTemplates (bootstrap)', async () => { | ||
| await service.syncAllTemplates(); | ||
|
|
||
| const serverValuesCall = tx.values.mock.calls.find( | ||
| ([vals]: any[]) => vals && 'transportType' in vals, | ||
| ); | ||
| expect(serverValuesCall).toBeDefined(); | ||
| expect(serverValuesCall![0].organizationId).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('syncTemplate – update path (group already exists)', () => { | ||
| beforeEach(() => { | ||
| (groupsRepository.findBySlug as ReturnType<typeof jest.fn>).mockResolvedValue({ | ||
| id: 'group-uuid-existing', | ||
| slug: 'test-group', | ||
| templateHash: 'old-hash', // different → triggers update | ||
| }); | ||
| }); | ||
|
|
||
| it('stamps newly created servers with the provided organizationId on update', async () => { | ||
| const orgId = 'org-xyz-456'; | ||
|
|
||
| // select().from().where().limit() returns [] → no existing server → insert branch | ||
| await service.syncTemplate('test-group', false, orgId); | ||
|
|
||
| const serverValuesCall = tx.values.mock.calls.find( | ||
| ([vals]: any[]) => vals && 'transportType' in vals, | ||
| ); | ||
| expect(serverValuesCall).toBeDefined(); | ||
| expect(serverValuesCall![0].organizationId).toBe(orgId); | ||
| }); | ||
| }); | ||
|
|
||
| describe('syncTemplate – skipped path (template hash matches)', () => { | ||
| it('returns skipped action and does not insert any servers', async () => { | ||
| (groupsRepository.findBySlug as ReturnType<typeof jest.fn>).mockResolvedValue({ | ||
| id: 'group-uuid-existing', | ||
| slug: 'test-group', | ||
| templateHash: 'mock-hash-abc', // same hash → skip | ||
| }); | ||
|
|
||
| const result = await service.syncTemplate('test-group', false, 'org-any'); | ||
| expect(result.action).toBe('skipped'); | ||
| expect(tx.insert).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('syncTemplate – unknown slug', () => { | ||
| it('throws when template slug is not found', async () => { | ||
| await expect(service.syncTemplate('nonexistent-slug')).rejects.toThrow( | ||
| "Template 'nonexistent-slug' not found", | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import { describe, it, expect, beforeEach, jest } from 'bun:test'; | ||
| import { UnauthorizedException } from '@nestjs/common'; | ||
| import { McpGroupsController } from '../mcp-groups.controller'; | ||
| import { McpGroupsService } from '../mcp-groups.service'; | ||
| import type { AuthContext } from '../../auth/types'; | ||
| import type { ImportTemplateRequestDto } from '../dto/mcp-groups.dto'; | ||
|
|
||
| function makeService() { | ||
| return { | ||
| importTemplate: jest.fn().mockResolvedValue({ action: 'created', group: { id: 'g1' } }), | ||
| listGroups: jest.fn().mockResolvedValue([]), | ||
| listGroupsWithServers: jest.fn().mockResolvedValue([]), | ||
| listTemplates: jest.fn().mockReturnValue([]), | ||
| getGroup: jest.fn(), | ||
| getGroupBySlug: jest.fn(), | ||
| createGroup: jest.fn(), | ||
| updateGroup: jest.fn(), | ||
| deleteGroup: jest.fn(), | ||
| getServersInGroup: jest.fn().mockResolvedValue([]), | ||
| addServerToGroup: jest.fn(), | ||
| removeServerFromGroup: jest.fn(), | ||
| updateServerInGroup: jest.fn(), | ||
| syncTemplates: jest.fn(), | ||
| } as unknown as McpGroupsService; | ||
| } | ||
|
|
||
| const AUTH_WITH_ORG: AuthContext = { | ||
| userId: 'user-1', | ||
| organizationId: 'org-123', | ||
| roles: ['ADMIN'], | ||
| isAuthenticated: true, | ||
| provider: 'test', | ||
| }; | ||
|
|
||
| const AUTH_NO_ORG: AuthContext = { | ||
| userId: 'user-1', | ||
| organizationId: null, | ||
| roles: ['ADMIN'], | ||
| isAuthenticated: true, | ||
| provider: 'test', | ||
| }; | ||
|
|
||
| describe('McpGroupsController.importTemplate', () => { | ||
| let controller: McpGroupsController; | ||
| let service: McpGroupsService; | ||
|
|
||
| beforeEach(() => { | ||
| service = makeService(); | ||
| controller = new McpGroupsController(service); | ||
| }); | ||
|
|
||
| it('throws UnauthorizedException when auth is null', async () => { | ||
| await expect( | ||
| controller.importTemplate(null, 'some-slug', {} as ImportTemplateRequestDto), | ||
| ).rejects.toBeInstanceOf(UnauthorizedException); | ||
| expect(service.importTemplate).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('throws UnauthorizedException when organizationId is null', async () => { | ||
| await expect( | ||
| controller.importTemplate(AUTH_NO_ORG, 'some-slug', {} as ImportTemplateRequestDto), | ||
| ).rejects.toBeInstanceOf(UnauthorizedException); | ||
| expect(service.importTemplate).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('passes organizationId to the service when auth context is valid', async () => { | ||
| const body: ImportTemplateRequestDto = { serverCacheTokens: {} }; | ||
| await controller.importTemplate(AUTH_WITH_ORG, 'my-template', body); | ||
|
|
||
| expect(service.importTemplate).toHaveBeenCalledWith('my-template', 'org-123', body); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -138,9 +138,14 @@ export class McpGroupsService implements OnModuleInit { | |
|
|
||
| async importTemplate( | ||
| slug: string, | ||
| organizationId: string, | ||
| input?: ImportTemplateRequestDto, | ||
| ): Promise<ImportGroupTemplateResponse> { | ||
| const result: TemplateSyncResult = await this.seedingService.syncTemplate(slug); | ||
| const result: TemplateSyncResult = await this.seedingService.syncTemplate( | ||
| slug, | ||
| false, | ||
| organizationId, | ||
| ); | ||
|
Comment on lines
+144
to
+148
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| const group = await this.getGroupBySlug(slug); | ||
|
|
||
| // If cache tokens were provided, create tools for each server | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only the insert branch now writes
organizationId; whenupdateGroupFromTemplatefinds an existing server by name, it updates that row without setting/scopingorganizationId, so shared (NULL) or other-org rows remain reused and mutable during org imports. This means a template update/import path can still operate on non-tenant-scoped server records, undermining the isolation this change is intended to enforce.Useful? React with 👍 / 👎.