diff --git a/backend/src/mcp-groups/__tests__/mcp-groups-seeding.service.spec.ts b/backend/src/mcp-groups/__tests__/mcp-groups-seeding.service.spec.ts new file mode 100644 index 00000000..6866a703 --- /dev/null +++ b/backend/src/mcp-groups/__tests__/mcp-groups-seeding.service.spec.ts @@ -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) { + return { + transaction: jest.fn().mockImplementation((fn: (tx: any) => Promise) => fn(tx)), + execute: jest.fn().mockResolvedValue({ rows: [] }), + ...tx, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('McpGroupsSeedingService – multi-tenant isolation', () => { + let service: McpGroupsSeedingService; + let groupsRepository: McpGroupsRepository; + let tx: ReturnType; + let db: ReturnType; + + 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).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).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", + ); + }); + }); +}); diff --git a/backend/src/mcp-groups/__tests__/mcp-groups.controller.spec.ts b/backend/src/mcp-groups/__tests__/mcp-groups.controller.spec.ts new file mode 100644 index 00000000..5c558395 --- /dev/null +++ b/backend/src/mcp-groups/__tests__/mcp-groups.controller.spec.ts @@ -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); + }); +}); diff --git a/backend/src/mcp-groups/mcp-groups-seeding.service.ts b/backend/src/mcp-groups/mcp-groups-seeding.service.ts index a23bad7d..67092d4b 100644 --- a/backend/src/mcp-groups/mcp-groups-seeding.service.ts +++ b/backend/src/mcp-groups/mcp-groups-seeding.service.ts @@ -79,6 +79,9 @@ export class McpGroupsSeedingService { /** * Sync all templates to the database * + * This is a platform-level bootstrap operation; servers are created with + * organizationId = null so they are visible to all orgs as shared defaults. + * * @param force - Force update even if template hash matches * @returns Summary of sync operation */ @@ -88,7 +91,7 @@ export class McpGroupsSeedingService { const results: TemplateSyncResult[] = []; for (const slug of slugs) { - const result = await this.syncTemplate(slug, force); + const result = await this.syncTemplate(slug, force, null); results.push(result); } @@ -113,9 +116,14 @@ export class McpGroupsSeedingService { * * @param slug - Template slug to sync * @param force - Force update even if template hash matches + * @param organizationId - The org that owns the created servers, or null for platform-level bootstrap * @returns Sync result for the template */ - async syncTemplate(slug: string, force = false): Promise { + async syncTemplate( + slug: string, + force = false, + organizationId: string | null = null, + ): Promise { const template = MCP_GROUP_TEMPLATES[slug]; if (!template) { throw new Error(`Template '${slug}' not found`); @@ -126,7 +134,7 @@ export class McpGroupsSeedingService { if (!existingGroup) { // Create new group from template - return this.createGroupFromTemplate(template, templateHash); + return this.createGroupFromTemplate(template, templateHash, organizationId); } // Check if update is needed @@ -143,7 +151,7 @@ export class McpGroupsSeedingService { } // Update existing group - return this.updateGroupFromTemplate(existingGroup.id, template, templateHash); + return this.updateGroupFromTemplate(existingGroup.id, template, templateHash, organizationId); } /** @@ -152,6 +160,7 @@ export class McpGroupsSeedingService { private async createGroupFromTemplate( template: McpGroupTemplate, templateHash: string, + organizationId: string | null, ): Promise { this.logger.log(`Creating group '${template.slug}' from template...`); @@ -174,7 +183,7 @@ export class McpGroupsSeedingService { // Create servers and relationships let serversSynced = 0; for (const serverTemplate of template.servers) { - const server = await this.createServer(tx, group.id, serverTemplate); + const server = await this.createServer(tx, group.id, serverTemplate, organizationId); await this.createGroupServerRelation(tx, group.id, server.id, serverTemplate); serversSynced++; } @@ -198,6 +207,7 @@ export class McpGroupsSeedingService { groupId: string, template: McpGroupTemplate, templateHash: string, + organizationId: string | null, ): Promise { this.logger.log(`Updating group '${template.slug}' from template...`); @@ -279,6 +289,7 @@ export class McpGroupsSeedingService { command: serverTemplate.command ?? null, args: serverTemplate.args ?? null, groupId, + organizationId, enabled: true, }) .returning(); @@ -321,11 +332,14 @@ export class McpGroupsSeedingService { /** * Create a server from a template + * + * @param organizationId - Org that owns this server instance, or null for platform-level servers */ private async createServer( tx: NodePgDatabase, groupId: string, serverTemplate: any, + organizationId: string | null, ): Promise<{ id: string }> { const [server] = await tx .insert(mcpServers) @@ -337,6 +351,7 @@ export class McpGroupsSeedingService { command: serverTemplate.command ?? null, args: serverTemplate.args ?? null, groupId, + organizationId, enabled: true, }) .returning(); diff --git a/backend/src/mcp-groups/mcp-groups.controller.ts b/backend/src/mcp-groups/mcp-groups.controller.ts index 2af6e26d..9fda7688 100644 --- a/backend/src/mcp-groups/mcp-groups.controller.ts +++ b/backend/src/mcp-groups/mcp-groups.controller.ts @@ -10,6 +10,7 @@ import { Post, ParseUUIDPipe, Query, + UnauthorizedException, } from '@nestjs/common'; import { ApiCreatedResponse, @@ -177,10 +178,13 @@ export class McpGroupsController { @ApiOperation({ summary: 'Import a group template (admin only)' }) @ApiOkResponse({ type: ImportGroupTemplateResponse }) async importTemplate( - @CurrentAuth() _auth: AuthContext | null, + @CurrentAuth() auth: AuthContext | null, @Param('slug') slug: string, @Body() body: ImportTemplateRequestDto, ): Promise { - return this.mcpGroupsService.importTemplate(slug, body); + if (!auth?.organizationId) { + throw new UnauthorizedException('Organization context is required to import a template'); + } + return this.mcpGroupsService.importTemplate(slug, auth.organizationId, body); } } diff --git a/backend/src/mcp-groups/mcp-groups.service.ts b/backend/src/mcp-groups/mcp-groups.service.ts index 20132ba1..187ea8b7 100644 --- a/backend/src/mcp-groups/mcp-groups.service.ts +++ b/backend/src/mcp-groups/mcp-groups.service.ts @@ -138,9 +138,14 @@ export class McpGroupsService implements OnModuleInit { async importTemplate( slug: string, + organizationId: string, input?: ImportTemplateRequestDto, ): Promise { - const result: TemplateSyncResult = await this.seedingService.syncTemplate(slug); + const result: TemplateSyncResult = await this.seedingService.syncTemplate( + slug, + false, + organizationId, + ); const group = await this.getGroupBySlug(slug); // If cache tokens were provided, create tools for each server