From 4cbee76a1543565a739831646e453ebc6e1803df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 13:39:16 +0000 Subject: [PATCH 1/3] feat(mcp): add JWT auth guard and identity resolution to /mcp endpoint - Protect POST /mcp with JwtAuthGuard; missing/invalid/expired tokens return 401 with structured error - Add IdentityService that resolves the authenticated user's primary Department from the UserDepartment join table - MCPController now extracts userId and organizationId from the validated JWT (req.user), delegates scope resolution to IdentityService, and includes { scope: { organizationId, departmentId } } in every response - Update MCPModule to import AuthModule (JwtStrategy) and PrismaModule - Add supertest + @types/supertest for integration testing - Rewrite mcp.controller.spec.ts for the auth-aware controller API - Update mcp.module.spec.ts with new module dependencies and providers - Add mcp.integration.spec.ts covering authenticated happy path and all rejection paths (missing token, invalid token, expired token, wrong secret) - Update app.module.spec.ts and db-client.mock.ts for the new shape Co-authored-by: Andrea Mazzucchelli --- apps/api/package.json | 2 + apps/api/src/__mocks__/db-client.mock.ts | 4 + apps/api/src/app.module.spec.ts | 24 +- apps/api/src/mcp/identity.service.ts | 34 +++ apps/api/src/mcp/mcp.controller.spec.ts | 301 ++++++++++++----------- apps/api/src/mcp/mcp.controller.ts | 37 ++- apps/api/src/mcp/mcp.integration.spec.ts | 210 ++++++++++++++++ apps/api/src/mcp/mcp.module.spec.ts | 81 +++++- apps/api/src/mcp/mcp.module.ts | 5 + pnpm-lock.yaml | 162 ++++++++++++ 10 files changed, 692 insertions(+), 168 deletions(-) create mode 100644 apps/api/src/mcp/identity.service.ts create mode 100644 apps/api/src/mcp/mcp.integration.spec.ts diff --git a/apps/api/package.json b/apps/api/package.json index 5df6b7c..08b3338 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -43,8 +43,10 @@ "@types/passport": "^1.0.17", "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.2.0", "eslint": "^9.39.1", "jest": "^30.4.2", + "supertest": "^7.2.2", "ts-jest": "^29.4.11", "typescript": "5.9.2" } diff --git a/apps/api/src/__mocks__/db-client.mock.ts b/apps/api/src/__mocks__/db-client.mock.ts index 2a5f5e7..3f0096e 100644 --- a/apps/api/src/__mocks__/db-client.mock.ts +++ b/apps/api/src/__mocks__/db-client.mock.ts @@ -17,6 +17,9 @@ export const mockPrismaClient = { findUnique: jest.fn(), findFirst: jest.fn(), }, + userDepartment: { + findFirst: jest.fn(), + }, }; export class PrismaClient { @@ -24,6 +27,7 @@ export class PrismaClient { $disconnect = mockPrismaClient.$disconnect; user = mockPrismaClient.user; organization = mockPrismaClient.organization; + userDepartment = mockPrismaClient.userDepartment; } export const Prisma = {}; diff --git a/apps/api/src/app.module.spec.ts b/apps/api/src/app.module.spec.ts index f84ce15..305d043 100644 --- a/apps/api/src/app.module.spec.ts +++ b/apps/api/src/app.module.spec.ts @@ -3,6 +3,7 @@ import { AppModule } from "./app.module"; import { MCPController } from "./mcp/mcp.controller"; import { GoogleStrategy } from "./auth/strategies/google.strategy"; import { PrismaService } from "./prisma/prisma.service"; +import type { AuthenticatedUser } from "./auth/auth.types"; /** Stub that replaces GoogleStrategy so tests don't need real OAuth credentials. */ class MockGoogleStrategy { @@ -15,6 +16,19 @@ class MockPrismaService { $disconnect = jest.fn().mockResolvedValue(undefined); user = { findUnique: jest.fn(), create: jest.fn() }; organization = { findFirst: jest.fn() }; + userDepartment = { findFirst: jest.fn().mockResolvedValue(null) }; +} + +const mockJwtUser: AuthenticatedUser = { + id: "test_user", + email: "test@example.com", + organizationId: "test_org", + role: "member", +}; + +function makeMockReq(user: AuthenticatedUser = mockJwtUser) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { user } as any; } describe("AppModule", () => { @@ -79,12 +93,10 @@ describe("AppModule", () => { .compile(); const controller = module.get(MCPController); - const response = await controller.handleMCP({ - userId: "test_user", - organizationId: "test_org", - departmentId: "test_dept", - query: "integration check", - }); + const response = await controller.handleMCP( + { query: "integration check" }, + makeMockReq(), + ); expect(response.answer).toBe( "Based on company standards: integration check", diff --git a/apps/api/src/mcp/identity.service.ts b/apps/api/src/mcp/identity.service.ts new file mode 100644 index 0000000..b4a5d42 --- /dev/null +++ b/apps/api/src/mcp/identity.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; + +export interface ResolvedScope { + organizationId: string; + departmentId: string | null; +} + +@Injectable() +export class IdentityService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Resolves the identity scope for a request. + * + * The organizationId comes directly from the validated JWT claim. + * The departmentId is looked up from the UserDepartment join table, + * selecting the row flagged as the user's Primary Department. + * Returns null for departmentId when no primary department is configured. + */ + async resolveScope( + userId: string, + organizationId: string, + ): Promise { + const primaryDept = await this.prisma.userDepartment.findFirst({ + where: { userId, isPrimary: true }, + }); + + return { + organizationId, + departmentId: primaryDept?.departmentId ?? null, + }; + } +} diff --git a/apps/api/src/mcp/mcp.controller.spec.ts b/apps/api/src/mcp/mcp.controller.spec.ts index 54f5e2b..e4c7f2a 100644 --- a/apps/api/src/mcp/mcp.controller.spec.ts +++ b/apps/api/src/mcp/mcp.controller.spec.ts @@ -1,217 +1,226 @@ import { MCPController } from "./mcp.controller"; -import type { MCPRequest } from "@cortex/shared"; +import { IdentityService } from "./identity.service"; +import type { AuthenticatedUser } from "../auth/auth.types"; + +const mockUser: AuthenticatedUser = { + id: "user_123", + email: "alice@example.com", + organizationId: "org_456", + role: "member", +}; + +function makeReq(user: AuthenticatedUser = mockUser) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { user } as any; +} describe("MCPController", () => { let controller: MCPController; + let identityService: jest.Mocked; beforeEach(() => { - controller = new MCPController(); + identityService = { + resolveScope: jest.fn().mockResolvedValue({ + organizationId: "org_456", + departmentId: "dept_789", + }), + } as unknown as jest.Mocked; + + controller = new MCPController(identityService); }); - describe("handleMCP", () => { - const validRequest: MCPRequest = { - userId: "user_123", - organizationId: "org_456", - departmentId: "dept_789", - query: "What is the coding standard?", - }; + afterEach(() => { + jest.clearAllMocks(); + }); - it("should return a response with context, policy, and answer", async () => { - const result = await controller.handleMCP(validRequest); + describe("handleMCP", () => { + it("returns a response with scope, context, policy, and answer", async () => { + const result = await controller.handleMCP( + { query: "What is the coding standard?" }, + makeReq(), + ); + expect(result).toHaveProperty("scope"); expect(result).toHaveProperty("context"); expect(result).toHaveProperty("policy"); expect(result).toHaveProperty("answer"); }); - it("should return the fixed context array with company architecture entries", async () => { - const result = await controller.handleMCP(validRequest); + it("delegates identity resolution to IdentityService with userId and organizationId from req.user", async () => { + await controller.handleMCP( + { query: "test" }, + makeReq(mockUser), + ); - expect(result.context).toEqual([ - "Company uses modular monolith architecture", - "No direct DB access from controllers", - ]); + expect(identityService.resolveScope).toHaveBeenCalledWith( + mockUser.id, + mockUser.organizationId, + ); }); - it("should return exactly two context entries", async () => { - const result = await controller.handleMCP(validRequest); - - expect(result.context).toHaveLength(2); - }); + it("includes the resolved organizationId and departmentId in scope", async () => { + identityService.resolveScope.mockResolvedValueOnce({ + organizationId: "org_abc", + departmentId: "dept_xyz", + }); - it("should return the fixed policy with TypeScript and clean architecture rules", async () => { - const result = await controller.handleMCP(validRequest); + const result = await controller.handleMCP( + { query: "test" }, + makeReq({ ...mockUser, organizationId: "org_abc" }), + ); - expect(result.policy).toEqual({ - rules: ["Use TypeScript", "Follow clean architecture"], + expect(result.scope).toEqual({ + organizationId: "org_abc", + departmentId: "dept_xyz", }); }); - it("should return policy rules as an array with exactly two entries", async () => { - const result = await controller.handleMCP(validRequest); + it("returns null departmentId when user has no primary department", async () => { + identityService.resolveScope.mockResolvedValueOnce({ + organizationId: "org_456", + departmentId: null, + }); - expect(result.policy.rules).toHaveLength(2); - expect(Array.isArray(result.policy.rules)).toBe(true); + const result = await controller.handleMCP( + { query: "test" }, + makeReq(), + ); + + expect(result.scope.departmentId).toBeNull(); }); - it("should include the query in the answer using the expected format", async () => { - const result = await controller.handleMCP(validRequest); + it("includes the query verbatim in the answer", async () => { + const result = await controller.handleMCP( + { query: "How do I structure my service layer?" }, + makeReq(), + ); expect(result.answer).toBe( - "Based on company standards: What is the coding standard?" + "Based on company standards: How do I structure my service layer?", ); }); - it("should prefix the answer with 'Based on company standards: '", async () => { - const result = await controller.handleMCP(validRequest); + it("prefixes the answer with 'Based on company standards: '", async () => { + const result = await controller.handleMCP( + { query: "any question" }, + makeReq(), + ); expect(result.answer).toMatch(/^Based on company standards: /); }); - it("should reflect the query verbatim in the answer", async () => { - const query = "How do I structure my service layer?"; - const result = await controller.handleMCP({ ...validRequest, query }); - - expect(result.answer).toBe(`Based on company standards: ${query}`); - }); - - it("should handle an empty query string", async () => { - const result = await controller.handleMCP({ ...validRequest, query: "" }); - - expect(result.answer).toBe("Based on company standards: "); - expect(result.context).toHaveLength(2); - expect(result.policy.rules).toHaveLength(2); - }); - - it("should handle a query with special characters", async () => { - const specialQuery = 'What about "quotes" & brackets?'; - const result = await controller.handleMCP({ - ...validRequest, - query: specialQuery, - }); + it("returns the stub context array", async () => { + const result = await controller.handleMCP( + { query: "test" }, + makeReq(), + ); - expect(result.answer).toBe(`Based on company standards: ${specialQuery}`); + expect(result.context).toEqual([ + "Company uses modular monolith architecture", + "No direct DB access from controllers", + ]); }); - it("should handle a query with newlines and whitespace", async () => { - const multilineQuery = "First line\nSecond line\t tabbed"; - const result = await controller.handleMCP({ - ...validRequest, - query: multilineQuery, - }); - - expect(result.answer).toBe( - `Based on company standards: ${multilineQuery}` + it("returns the stub policy with TypeScript and clean architecture rules", async () => { + const result = await controller.handleMCP( + { query: "test" }, + makeReq(), ); - }); - it("should not use userId in the response", async () => { - const resultA = await controller.handleMCP({ - ...validRequest, - userId: "user_aaa", - }); - const resultB = await controller.handleMCP({ - ...validRequest, - userId: "user_bbb", + expect(result.policy).toEqual({ + rules: ["Use TypeScript", "Follow clean architecture"], }); - - expect(resultA.answer).toBe(resultB.answer); - expect(resultA.context).toEqual(resultB.context); - expect(resultA.policy).toEqual(resultB.policy); }); - it("should not use organizationId in the response", async () => { - const resultA = await controller.handleMCP({ - ...validRequest, - organizationId: "org_111", - }); - const resultB = await controller.handleMCP({ - ...validRequest, - organizationId: "org_999", - }); + it("returns a Promise (async method)", () => { + const returnValue = controller.handleMCP( + { query: "test" }, + makeReq(), + ); - expect(resultA.answer).toBe(resultB.answer); - expect(resultA.context).toEqual(resultB.context); - expect(resultA.policy).toEqual(resultB.policy); + expect(returnValue).toBeInstanceOf(Promise); }); - it("should not use departmentId in the response", async () => { - const resultA = await controller.handleMCP({ - ...validRequest, - departmentId: "dept_aaa", - }); - const resultB = await controller.handleMCP({ - ...validRequest, - departmentId: "dept_zzz", - }); + it("handles an empty query string", async () => { + const result = await controller.handleMCP({ query: "" }, makeReq()); - expect(resultA.answer).toBe(resultB.answer); - expect(resultA.context).toEqual(resultB.context); - expect(resultA.policy).toEqual(resultB.policy); + expect(result.answer).toBe("Based on company standards: "); + expect(result.context).toHaveLength(2); + expect(result.policy.rules).toHaveLength(2); }); - it("should return consistent context regardless of query", async () => { - const result1 = await controller.handleMCP({ - ...validRequest, - query: "query one", - }); - const result2 = await controller.handleMCP({ - ...validRequest, - query: "query two", - }); + it("handles a query with special characters", async () => { + const specialQuery = 'What about "quotes" & brackets?'; + const result = await controller.handleMCP( + { query: specialQuery }, + makeReq(), + ); - expect(result1.context).toEqual(result2.context); + expect(result.answer).toBe(`Based on company standards: ${specialQuery}`); }); - it("should return consistent policy regardless of query", async () => { - const result1 = await controller.handleMCP({ - ...validRequest, - query: "query one", - }); - const result2 = await controller.handleMCP({ - ...validRequest, - query: "completely different query", - }); + it("calls resolveScope once per request", async () => { + await controller.handleMCP({ query: "a" }, makeReq()); + await controller.handleMCP({ query: "b" }, makeReq()); - expect(result1.policy).toEqual(result2.policy); + expect(identityService.resolveScope).toHaveBeenCalledTimes(2); }); - it("should return a Promise (async method)", () => { - const returnValue = controller.handleMCP(validRequest); - - expect(returnValue).toBeInstanceOf(Promise); + it("passes different users to resolveScope correctly", async () => { + const userA: AuthenticatedUser = { + id: "user_aaa", + email: "a@example.com", + organizationId: "org_111", + role: "member", + }; + const userB: AuthenticatedUser = { + id: "user_bbb", + email: "b@example.com", + organizationId: "org_222", + role: "admin", + }; + + await controller.handleMCP({ query: "q" }, makeReq(userA)); + await controller.handleMCP({ query: "q" }, makeReq(userB)); + + expect(identityService.resolveScope).toHaveBeenNthCalledWith( + 1, + "user_aaa", + "org_111", + ); + expect(identityService.resolveScope).toHaveBeenNthCalledWith( + 2, + "user_bbb", + "org_222", + ); }); - it("should return context as an array of strings", async () => { - const result = await controller.handleMCP(validRequest); + it("returns answer as a string", async () => { + const result = await controller.handleMCP({ query: "test" }, makeReq()); - result.context.forEach((item) => { - expect(typeof item).toBe("string"); - }); + expect(typeof result.answer).toBe("string"); }); - it("should return policy rules as an array of strings", async () => { - const result = await controller.handleMCP(validRequest); + it("returns context as an array of strings", async () => { + const result = await controller.handleMCP({ query: "test" }, makeReq()); - result.policy.rules.forEach((rule) => { - expect(typeof rule).toBe("string"); - }); + result.context.forEach((item) => expect(typeof item).toBe("string")); }); - it("should return answer as a string", async () => { - const result = await controller.handleMCP(validRequest); + it("returns policy rules as an array of strings", async () => { + const result = await controller.handleMCP({ query: "test" }, makeReq()); - expect(typeof result.answer).toBe("string"); + result.policy.rules.forEach((rule) => expect(typeof rule).toBe("string")); }); - it("should handle a very long query string", async () => { + it("handles a very long query string", async () => { const longQuery = "A".repeat(10000); - const result = await controller.handleMCP({ - ...validRequest, - query: longQuery, - }); + const result = await controller.handleMCP( + { query: longQuery }, + makeReq(), + ); expect(result.answer).toBe(`Based on company standards: ${longQuery}`); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api/src/mcp/mcp.controller.ts b/apps/api/src/mcp/mcp.controller.ts index c0cea08..243f99f 100644 --- a/apps/api/src/mcp/mcp.controller.ts +++ b/apps/api/src/mcp/mcp.controller.ts @@ -1,7 +1,16 @@ -import { Controller, Post, Body } from "@nestjs/common"; -import type { MCPRequest } from "@cortex/shared"; +import { Controller, Post, Body, Req, UseGuards } from "@nestjs/common"; +import type { Request } from "express"; +import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard"; +import type { AuthenticatedUser } from "../auth/auth.types"; +import { IdentityService } from "./identity.service"; +import type { ResolvedScope } from "./identity.service"; + +interface MCPQueryBody { + query: string; +} interface MCPResponse { + scope: ResolvedScope; context: string[]; policy: { rules: string[]; @@ -9,25 +18,41 @@ interface MCPResponse { answer: string; } +interface RequestWithJwtUser extends Request { + user: AuthenticatedUser; +} + @Controller("mcp") export class MCPController { + constructor(private readonly identityService: IdentityService) {} + @Post() - async handleMCP(@Body() body: MCPRequest): Promise { + @UseGuards(JwtAuthGuard) + async handleMCP( + @Body() body: MCPQueryBody, + @Req() req: RequestWithJwtUser, + ): Promise { const { query } = body; + const { id: userId, organizationId } = req.user; + + const scope = await this.identityService.resolveScope( + userId, + organizationId, + ); - // STEP 2: mock policy + // Stub policy – will be replaced with real policy evaluation in a later slice. const policy = { rules: ["Use TypeScript", "Follow clean architecture"], }; - // STEP 3: mock context retrieval + // Stub context – will be replaced with retrieval in a later slice. const context = [ "Company uses modular monolith architecture", "No direct DB access from controllers", ]; - // STEP 4: response assembly return { + scope, context, policy, answer: `Based on company standards: ${query}`, diff --git a/apps/api/src/mcp/mcp.integration.spec.ts b/apps/api/src/mcp/mcp.integration.spec.ts new file mode 100644 index 0000000..986ecae --- /dev/null +++ b/apps/api/src/mcp/mcp.integration.spec.ts @@ -0,0 +1,210 @@ +import { Test } from "@nestjs/testing"; +import type { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import * as jwt from "jsonwebtoken"; +import { MCPModule } from "./mcp.module"; +import { AuthModule } from "../auth/auth.module"; +import { PrismaService } from "../prisma/prisma.service"; +import { GoogleStrategy } from "../auth/strategies/google.strategy"; + +const TEST_JWT_SECRET = "test-secret-for-mcp-integration"; + +/** Stub so tests don't require real OAuth credentials. */ +class MockGoogleStrategy { + name = "google"; +} + +const mockPrisma = { + $connect: jest.fn().mockResolvedValue(undefined), + $disconnect: jest.fn().mockResolvedValue(undefined), + user: { findUnique: jest.fn() }, + organization: { findUnique: jest.fn() }, + userDepartment: { findFirst: jest.fn() }, +}; + +function issueToken( + payload: { + sub: string; + email: string; + organizationId: string; + role: string; + }, + options: jwt.SignOptions = {}, +): string { + return jwt.sign(payload, TEST_JWT_SECRET, { + expiresIn: "1h", + ...options, + }); +} + +describe("MCP Integration", () => { + let app: INestApplication; + + beforeAll(async () => { + process.env["JWT_SECRET"] = TEST_JWT_SECRET; + + const moduleRef = await Test.createTestingModule({ + imports: [MCPModule, AuthModule], + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useValue(mockPrisma) + .compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + delete process.env["JWT_SECRET"]; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("authenticated happy path", () => { + it("returns 201 with scope, context, policy, and answer for a valid JWT", async () => { + const token = issueToken({ + sub: "user_123", + email: "alice@example.com", + organizationId: "org_456", + role: "member", + }); + + mockPrisma.userDepartment.findFirst.mockResolvedValueOnce({ + id: "ud_1", + userId: "user_123", + departmentId: "dept_789", + isPrimary: true, + }); + + const response = await request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", `Bearer ${token}`) + .send({ query: "What are the coding standards?" }) + .expect(201); + + expect(response.body).toMatchObject({ + scope: { + organizationId: "org_456", + departmentId: "dept_789", + }, + answer: "Based on company standards: What are the coding standards?", + }); + expect(Array.isArray(response.body.context)).toBe(true); + expect(Array.isArray(response.body.policy.rules)).toBe(true); + }); + + it("resolves organizationId from JWT claim, not from request body", async () => { + const token = issueToken({ + sub: "user_123", + email: "alice@example.com", + organizationId: "org_from_jwt", + role: "member", + }); + + mockPrisma.userDepartment.findFirst.mockResolvedValueOnce(null); + + const response = await request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", `Bearer ${token}`) + .send({ query: "test" }) + .expect(201); + + expect(response.body.scope.organizationId).toBe("org_from_jwt"); + }); + + it("returns null departmentId when user has no primary department", async () => { + const token = issueToken({ + sub: "user_no_dept", + email: "nodept@example.com", + organizationId: "org_456", + role: "member", + }); + + mockPrisma.userDepartment.findFirst.mockResolvedValueOnce(null); + + const response = await request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", `Bearer ${token}`) + .send({ query: "test" }) + .expect(201); + + expect(response.body.scope.departmentId).toBeNull(); + }); + }); + + describe("auth rejection", () => { + it("returns 401 when Authorization header is missing", async () => { + await request(app.getHttpServer()) + .post("/mcp") + .send({ query: "test" }) + .expect(401); + }); + + it("returns 401 when Bearer token value is invalid", async () => { + await request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", "Bearer not.a.valid.jwt") + .send({ query: "test" }) + .expect(401); + }); + + it("returns 401 when token is signed with wrong secret", async () => { + const wrongSecretToken = jwt.sign( + { + sub: "user_123", + email: "alice@example.com", + organizationId: "org_456", + role: "member", + }, + "wrong-secret", + { expiresIn: "1h" }, + ); + + await request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", `Bearer ${wrongSecretToken}`) + .send({ query: "test" }) + .expect(401); + }); + + it("returns 401 when token is expired", async () => { + const expiredToken = issueToken( + { + sub: "user_123", + email: "alice@example.com", + organizationId: "org_456", + role: "member", + }, + { expiresIn: "-1s" }, + ); + + await request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", `Bearer ${expiredToken}`) + .send({ query: "test" }) + .expect(401); + }); + + it("returns 401 for a malformed Authorization header (no Bearer prefix)", async () => { + await request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", "justtoken") + .send({ query: "test" }) + .expect(401); + }); + + it("does not call IdentityService when auth fails", async () => { + await request(app.getHttpServer()) + .post("/mcp") + .send({ query: "test" }) + .expect(401); + + expect(mockPrisma.userDepartment.findFirst).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/mcp/mcp.module.spec.ts b/apps/api/src/mcp/mcp.module.spec.ts index 4f4df87..08e37da 100644 --- a/apps/api/src/mcp/mcp.module.spec.ts +++ b/apps/api/src/mcp/mcp.module.spec.ts @@ -1,40 +1,101 @@ import { Test } from "@nestjs/testing"; import { MCPModule } from "./mcp.module"; import { MCPController } from "./mcp.controller"; +import { IdentityService } from "./identity.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { GoogleStrategy } from "../auth/strategies/google.strategy"; + +/** Stub so tests don't require real OAuth credentials. */ +class MockGoogleStrategy { + name = "google"; +} + +/** Stub so tests don't require a real database connection. */ +const mockPrismaService = { + $connect: jest.fn().mockResolvedValue(undefined), + $disconnect: jest.fn().mockResolvedValue(undefined), + user: { findUnique: jest.fn() }, + organization: { findUnique: jest.fn() }, + userDepartment: { findFirst: jest.fn() }, +}; describe("MCPModule", () => { + beforeAll(() => { + // JwtStrategy and JwtModule.registerAsync require JWT_SECRET at startup. + process.env["JWT_SECRET"] = "test-secret-for-mcp-module-spec"; + }); + + afterAll(() => { + delete process.env["JWT_SECRET"]; + }); + it("should be defined and compile successfully", async () => { const module = await Test.createTestingModule({ imports: [MCPModule], - }).compile(); + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useValue(mockPrismaService) + .compile(); expect(module).toBeDefined(); }); + it("should not throw when creating the module", async () => { + await expect( + Test.createTestingModule({ + imports: [MCPModule], + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useValue(mockPrismaService) + .compile(), + ).resolves.not.toThrow(); + }); + it("should provide MCPController", async () => { const module = await Test.createTestingModule({ imports: [MCPModule], - }).compile(); + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useValue(mockPrismaService) + .compile(); const controller = module.get(MCPController); expect(controller).toBeDefined(); expect(controller).toBeInstanceOf(MCPController); }); - it("should not throw when creating the module", async () => { - await expect( - Test.createTestingModule({ - imports: [MCPModule], - }).compile() - ).resolves.not.toThrow(); + it("should provide IdentityService", async () => { + const module = await Test.createTestingModule({ + imports: [MCPModule], + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useValue(mockPrismaService) + .compile(); + + const service = module.get(IdentityService); + expect(service).toBeDefined(); + expect(service).toBeInstanceOf(IdentityService); }); it("should expose MCPController handleMCP method via module", async () => { const module = await Test.createTestingModule({ imports: [MCPModule], - }).compile(); + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useValue(mockPrismaService) + .compile(); const controller = module.get(MCPController); expect(typeof controller.handleMCP).toBe("function"); }); -}); \ No newline at end of file +}); diff --git a/apps/api/src/mcp/mcp.module.ts b/apps/api/src/mcp/mcp.module.ts index b19fc22..5889ec8 100644 --- a/apps/api/src/mcp/mcp.module.ts +++ b/apps/api/src/mcp/mcp.module.ts @@ -1,7 +1,12 @@ import { Module } from "@nestjs/common"; import { MCPController } from "./mcp.controller"; +import { IdentityService } from "./identity.service"; +import { AuthModule } from "../auth/auth.module"; +import { PrismaModule } from "../prisma/prisma.module"; @Module({ + imports: [AuthModule, PrismaModule], controllers: [MCPController], + providers: [IdentityService], }) export class MCPModule {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81039b3..12f7f47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,12 +103,18 @@ importers: '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 + '@types/supertest': + specifier: ^7.2.0 + version: 7.2.0 eslint: specifier: ^9.39.1 version: 9.39.1(jiti@2.7.0) jest: specifier: ^30.4.2 version: 30.4.2(@types/node@22.15.3) + supertest: + specifier: ^7.2.2 + version: 7.2.2 ts-jest: specifier: ^29.4.11 version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(jest-util@30.4.1)(jest@30.4.2(@types/node@22.15.3))(typescript@5.9.2) @@ -1132,6 +1138,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1149,6 +1159,9 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1361,6 +1374,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -1397,6 +1413,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -1444,6 +1463,12 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/superagent@8.1.10': + resolution: {integrity: sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==} + + '@types/supertest@7.2.0': + resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1808,10 +1833,16 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -2027,6 +2058,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2038,6 +2073,9 @@ packages: resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==} engines: {node: '>= 6'} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2075,6 +2113,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -2149,6 +2190,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -2168,6 +2213,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -2486,6 +2534,14 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2638,6 +2694,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hono@4.12.21: resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==} engines: {node: '>=16.9.0'} @@ -3190,6 +3250,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3210,6 +3274,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -3884,6 +3953,14 @@ packages: babel-plugin-macros: optional: true + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -5211,6 +5288,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.6': optional: true + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5227,6 +5306,10 @@ snapshots: dependencies: consola: 3.4.2 + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@pkgjs/parseargs@0.11.0': optional: true @@ -5452,6 +5535,8 @@ snapshots: dependencies: '@types/node': 22.15.3 + '@types/cookiejar@2.1.5': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -5501,6 +5586,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 22.15.3 + '@types/methods@1.1.4': {} + '@types/ms@2.1.0': {} '@types/node@22.15.3': @@ -5560,6 +5647,18 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/superagent@8.1.10': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.15.3 + form-data: 4.0.6 + + '@types/supertest@7.2.0': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.10 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -5960,8 +6059,12 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asap@2.0.6: {} + async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -6201,6 +6304,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@2.20.3: {} commander@4.1.1: {} @@ -6210,6 +6317,8 @@ snapshots: array-timsort: 1.0.3 esprima: 4.0.1 + component-emitter@1.3.1: {} + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -6235,6 +6344,8 @@ snapshots: cookie@0.7.2: {} + cookiejar@2.1.4: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -6305,6 +6416,8 @@ snapshots: defu@6.1.7: {} + delayed-stream@1.0.0: {} + denque@2.1.0: {} depd@2.0.0: {} @@ -6316,6 +6429,11 @@ snapshots: detect-newline@3.1.0: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -6770,6 +6888,20 @@ snapshots: typescript: 5.9.3 webpack: 5.106.0 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -6922,6 +7054,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hono@4.12.21: {} html-escaper@2.0.2: {} @@ -7635,6 +7771,8 @@ snapshots: merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -7652,6 +7790,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@2.6.0: {} + mimic-fn@2.1.0: {} minimatch@10.2.5: @@ -8403,6 +8543,28 @@ snapshots: client-only: 0.0.1 react: 19.2.0 + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.2 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@7.2.0: dependencies: has-flag: 4.0.0 From 47eb396674fc61aef5399dba264e48f7a4a724cf Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:57:46 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20CodeRabbit=20Chat:=20Add=20u?= =?UTF-8?q?nit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/mcp/identity.service.spec.ts | 201 ++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 apps/api/src/mcp/identity.service.spec.ts diff --git a/apps/api/src/mcp/identity.service.spec.ts b/apps/api/src/mcp/identity.service.spec.ts new file mode 100644 index 0000000..76840e4 --- /dev/null +++ b/apps/api/src/mcp/identity.service.spec.ts @@ -0,0 +1,201 @@ +import { IdentityService } from "./identity.service"; +import type { PrismaService } from "../prisma/prisma.service"; + +/** Minimal mock shape for PrismaService covering only what IdentityService uses. */ +function makeMockPrisma() { + return { + userDepartment: { + findFirst: jest.fn(), + }, + } as unknown as jest.Mocked; +} + +describe("IdentityService", () => { + let service: IdentityService; + let prisma: ReturnType; + + beforeEach(() => { + prisma = makeMockPrisma(); + service = new IdentityService(prisma); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("resolveScope", () => { + it("returns organizationId and departmentId when a primary department exists", async () => { + prisma.userDepartment.findFirst.mockResolvedValueOnce({ + id: "ud_1", + userId: "user_123", + departmentId: "dept_789", + isPrimary: true, + }); + + const result = await service.resolveScope("user_123", "org_456"); + + expect(result).toEqual({ + organizationId: "org_456", + departmentId: "dept_789", + }); + }); + + it("returns null departmentId when no primary department row exists", async () => { + prisma.userDepartment.findFirst.mockResolvedValueOnce(null); + + const result = await service.resolveScope("user_no_dept", "org_456"); + + expect(result).toEqual({ + organizationId: "org_456", + departmentId: null, + }); + }); + + it("queries userDepartment with userId and isPrimary: true", async () => { + prisma.userDepartment.findFirst.mockResolvedValueOnce(null); + + await service.resolveScope("user_abc", "org_xyz"); + + expect(prisma.userDepartment.findFirst).toHaveBeenCalledWith({ + where: { userId: "user_abc", isPrimary: true }, + }); + }); + + it("passes organizationId through from the parameter, not from the database", async () => { + prisma.userDepartment.findFirst.mockResolvedValueOnce({ + id: "ud_2", + userId: "user_123", + departmentId: "dept_from_db", + isPrimary: true, + }); + + const result = await service.resolveScope("user_123", "org_from_jwt"); + + expect(result.organizationId).toBe("org_from_jwt"); + }); + + it("uses departmentId from the matched UserDepartment row, not the row's own id", async () => { + prisma.userDepartment.findFirst.mockResolvedValueOnce({ + id: "join_table_row_id", + userId: "user_123", + departmentId: "the_real_dept_id", + isPrimary: true, + }); + + const result = await service.resolveScope("user_123", "org_456"); + + expect(result.departmentId).toBe("the_real_dept_id"); + expect(result.departmentId).not.toBe("join_table_row_id"); + }); + + it("forwards the exact userId to the prisma query", async () => { + prisma.userDepartment.findFirst.mockResolvedValueOnce(null); + + await service.resolveScope("user_exact_id_check", "org_456"); + + expect(prisma.userDepartment.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: "user_exact_id_check" }), + }), + ); + }); + + it("always queries with isPrimary: true regardless of what the db returns", async () => { + prisma.userDepartment.findFirst.mockResolvedValueOnce({ + id: "ud_3", + userId: "user_123", + departmentId: "dept_456", + isPrimary: true, + }); + + await service.resolveScope("user_123", "org_456"); + + expect(prisma.userDepartment.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ isPrimary: true }), + }), + ); + }); + + it("calls userDepartment.findFirst exactly once per resolveScope call", async () => { + prisma.userDepartment.findFirst.mockResolvedValueOnce(null); + + await service.resolveScope("user_123", "org_456"); + + expect(prisma.userDepartment.findFirst).toHaveBeenCalledTimes(1); + }); + + it("issues separate db queries for separate resolveScope calls", async () => { + prisma.userDepartment.findFirst + .mockResolvedValueOnce({ id: "ud_a", userId: "user_a", departmentId: "dept_a", isPrimary: true }) + .mockResolvedValueOnce(null); + + const [resultA, resultB] = await Promise.all([ + service.resolveScope("user_a", "org_a"), + service.resolveScope("user_b", "org_b"), + ]); + + expect(prisma.userDepartment.findFirst).toHaveBeenCalledTimes(2); + expect(resultA.departmentId).toBe("dept_a"); + expect(resultB.departmentId).toBeNull(); + }); + + it("returns a Promise", () => { + prisma.userDepartment.findFirst.mockResolvedValueOnce(null); + + const returnValue = service.resolveScope("user_123", "org_456"); + + expect(returnValue).toBeInstanceOf(Promise); + }); + + it("different users with the same organizationId get independent dept lookups", async () => { + prisma.userDepartment.findFirst + .mockResolvedValueOnce({ id: "ud_1", userId: "user_1", departmentId: "dept_for_user1", isPrimary: true }) + .mockResolvedValueOnce({ id: "ud_2", userId: "user_2", departmentId: "dept_for_user2", isPrimary: true }); + + const result1 = await service.resolveScope("user_1", "shared_org"); + const result2 = await service.resolveScope("user_2", "shared_org"); + + expect(result1.departmentId).toBe("dept_for_user1"); + expect(result2.departmentId).toBe("dept_for_user2"); + expect(result1.organizationId).toBe("shared_org"); + expect(result2.organizationId).toBe("shared_org"); + }); + + it("handles an empty string userId without throwing", async () => { + prisma.userDepartment.findFirst.mockResolvedValueOnce(null); + + const result = await service.resolveScope("", "org_456"); + + expect(result.departmentId).toBeNull(); + expect(result.organizationId).toBe("org_456"); + expect(prisma.userDepartment.findFirst).toHaveBeenCalledWith({ + where: { userId: "", isPrimary: true }, + }); + }); + + it("propagates errors thrown by the prisma query", async () => { + const dbError = new Error("Database connection lost"); + prisma.userDepartment.findFirst.mockRejectedValueOnce(dbError); + + await expect(service.resolveScope("user_123", "org_456")).rejects.toThrow( + "Database connection lost", + ); + }); + + it("scope object has exactly the organizationId and departmentId keys", async () => { + prisma.userDepartment.findFirst.mockResolvedValueOnce({ + id: "ud_1", + userId: "user_123", + departmentId: "dept_789", + isPrimary: true, + }); + + const result = await service.resolveScope("user_123", "org_456"); + + expect(Object.keys(result).sort()).toEqual( + ["departmentId", "organizationId"].sort(), + ); + }); + }); +}); From cb10d5727926e16ba577fe7dc598a8124465c5b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 14:04:13 +0000 Subject: [PATCH 3/3] fix(mcp): address all CodeRabbit review feedback - identity.service.ts: add department.organizationId filter to the findFirst query to prevent cross-organization department access - identity.service.spec.ts: update exact toHaveBeenCalledWith assertions to match the new where clause shape; fix makeMockPrisma cast so jest.fn() methods are accessible on the mock without Prisma type interference - mcp.integration.spec.ts: save and restore JWT_SECRET around the suite instead of unconditionally deleting it - mcp.module.spec.ts: save and restore JWT_SECRET; replace .resolves.not.toThrow() with .resolves.toBeDefined() - app.module.spec.ts: replace .resolves.not.toThrow() with .resolves.toBeDefined() Co-authored-by: Andrea Mazzucchelli --- apps/api/src/app.module.spec.ts | 2 +- apps/api/src/mcp/identity.service.spec.ts | 18 +++++++++++++----- apps/api/src/mcp/identity.service.ts | 5 ++++- apps/api/src/mcp/mcp.integration.spec.ts | 8 +++++++- apps/api/src/mcp/mcp.module.spec.ts | 11 +++++++++-- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/api/src/app.module.spec.ts b/apps/api/src/app.module.spec.ts index 305d043..9ab212e 100644 --- a/apps/api/src/app.module.spec.ts +++ b/apps/api/src/app.module.spec.ts @@ -64,7 +64,7 @@ describe("AppModule", () => { .overrideProvider(PrismaService) .useClass(MockPrismaService) .compile(), - ).resolves.not.toThrow(); + ).resolves.toBeDefined(); }); it("should provide MCPController via imported MCPModule", async () => { diff --git a/apps/api/src/mcp/identity.service.spec.ts b/apps/api/src/mcp/identity.service.spec.ts index 76840e4..fd29ba4 100644 --- a/apps/api/src/mcp/identity.service.spec.ts +++ b/apps/api/src/mcp/identity.service.spec.ts @@ -7,7 +7,7 @@ function makeMockPrisma() { userDepartment: { findFirst: jest.fn(), }, - } as unknown as jest.Mocked; + }; } describe("IdentityService", () => { @@ -16,7 +16,7 @@ describe("IdentityService", () => { beforeEach(() => { prisma = makeMockPrisma(); - service = new IdentityService(prisma); + service = new IdentityService(prisma as unknown as PrismaService); }); afterEach(() => { @@ -51,13 +51,17 @@ describe("IdentityService", () => { }); }); - it("queries userDepartment with userId and isPrimary: true", async () => { + it("queries userDepartment with userId, isPrimary: true, and organization constraint", async () => { prisma.userDepartment.findFirst.mockResolvedValueOnce(null); await service.resolveScope("user_abc", "org_xyz"); expect(prisma.userDepartment.findFirst).toHaveBeenCalledWith({ - where: { userId: "user_abc", isPrimary: true }, + where: { + userId: "user_abc", + isPrimary: true, + department: { organizationId: "org_xyz" }, + }, }); }); @@ -170,7 +174,11 @@ describe("IdentityService", () => { expect(result.departmentId).toBeNull(); expect(result.organizationId).toBe("org_456"); expect(prisma.userDepartment.findFirst).toHaveBeenCalledWith({ - where: { userId: "", isPrimary: true }, + where: { + userId: "", + isPrimary: true, + department: { organizationId: "org_456" }, + }, }); }); diff --git a/apps/api/src/mcp/identity.service.ts b/apps/api/src/mcp/identity.service.ts index b4a5d42..bb0519f 100644 --- a/apps/api/src/mcp/identity.service.ts +++ b/apps/api/src/mcp/identity.service.ts @@ -16,6 +16,9 @@ export class IdentityService { * The organizationId comes directly from the validated JWT claim. * The departmentId is looked up from the UserDepartment join table, * selecting the row flagged as the user's Primary Department. + * The department relation filter on organizationId ensures cross-org + * isolation: a user cannot accidentally resolve a department that belongs + * to a different organization. * Returns null for departmentId when no primary department is configured. */ async resolveScope( @@ -23,7 +26,7 @@ export class IdentityService { organizationId: string, ): Promise { const primaryDept = await this.prisma.userDepartment.findFirst({ - where: { userId, isPrimary: true }, + where: { userId, isPrimary: true, department: { organizationId } }, }); return { diff --git a/apps/api/src/mcp/mcp.integration.spec.ts b/apps/api/src/mcp/mcp.integration.spec.ts index 986ecae..3b63023 100644 --- a/apps/api/src/mcp/mcp.integration.spec.ts +++ b/apps/api/src/mcp/mcp.integration.spec.ts @@ -39,8 +39,10 @@ function issueToken( describe("MCP Integration", () => { let app: INestApplication; + let previousJwtSecret: string | undefined; beforeAll(async () => { + previousJwtSecret = process.env["JWT_SECRET"]; process.env["JWT_SECRET"] = TEST_JWT_SECRET; const moduleRef = await Test.createTestingModule({ @@ -58,7 +60,11 @@ describe("MCP Integration", () => { afterAll(async () => { await app.close(); - delete process.env["JWT_SECRET"]; + if (previousJwtSecret === undefined) { + delete process.env["JWT_SECRET"]; + } else { + process.env["JWT_SECRET"] = previousJwtSecret; + } }); beforeEach(() => { diff --git a/apps/api/src/mcp/mcp.module.spec.ts b/apps/api/src/mcp/mcp.module.spec.ts index 08e37da..0dd234f 100644 --- a/apps/api/src/mcp/mcp.module.spec.ts +++ b/apps/api/src/mcp/mcp.module.spec.ts @@ -20,13 +20,20 @@ const mockPrismaService = { }; describe("MCPModule", () => { + let previousJwtSecret: string | undefined; + beforeAll(() => { + previousJwtSecret = process.env["JWT_SECRET"]; // JwtStrategy and JwtModule.registerAsync require JWT_SECRET at startup. process.env["JWT_SECRET"] = "test-secret-for-mcp-module-spec"; }); afterAll(() => { - delete process.env["JWT_SECRET"]; + if (previousJwtSecret === undefined) { + delete process.env["JWT_SECRET"]; + } else { + process.env["JWT_SECRET"] = previousJwtSecret; + } }); it("should be defined and compile successfully", async () => { @@ -52,7 +59,7 @@ describe("MCPModule", () => { .overrideProvider(PrismaService) .useValue(mockPrismaService) .compile(), - ).resolves.not.toThrow(); + ).resolves.toBeDefined(); }); it("should provide MCPController", async () => {