/** * Identity Linking Controller Tests * * Integration tests for identity linking API endpoints. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { ExecutionContext } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { IdentityLinkingController } from "./identity-linking.controller"; import { IdentityLinkingService } from "./identity-linking.service"; import { IdentityResolutionService } from "./identity-resolution.service"; import { AuthGuard } from "../auth/guards/auth.guard"; import type { FederatedIdentity } from "./types/oidc.types"; import type { CreateIdentityMappingDto, UpdateIdentityMappingDto, VerifyIdentityDto, ResolveIdentityDto, BulkResolveIdentityDto, } from "./dto/identity-linking.dto"; describe("IdentityLinkingController", () => { let controller: IdentityLinkingController; let identityLinkingService: IdentityLinkingService; let identityResolutionService: IdentityResolutionService; const mockIdentity: FederatedIdentity = { id: "identity-id", localUserId: "local-user-id", remoteUserId: "remote-user-id", remoteInstanceId: "remote-instance-id", oidcSubject: "oidc-subject", email: "user@example.com", metadata: {}, createdAt: new Date(), updatedAt: new Date(), }; const mockUser = { id: "local-user-id", email: "user@example.com", name: "Test User", }; beforeEach(async () => { const mockIdentityLinkingService = { verifyIdentity: vi.fn(), createIdentityMapping: vi.fn(), updateIdentityMapping: vi.fn(), validateIdentityMapping: vi.fn(), listUserIdentities: vi.fn(), revokeIdentityMapping: vi.fn(), }; const mockIdentityResolutionService = { resolveIdentity: vi.fn(), reverseResolveIdentity: vi.fn(), bulkResolveIdentities: vi.fn(), }; const mockAuthGuard = { canActivate: (context: ExecutionContext) => { const request = context.switchToHttp().getRequest(); request.user = mockUser; return true; }, }; const module: TestingModule = await Test.createTestingModule({ controllers: [IdentityLinkingController], providers: [ { provide: IdentityLinkingService, useValue: mockIdentityLinkingService }, { provide: IdentityResolutionService, useValue: mockIdentityResolutionService }, { provide: Reflector, useValue: { getAllAndOverride: vi.fn(() => []) } }, ], }) .overrideGuard(AuthGuard) .useValue(mockAuthGuard) .compile(); controller = module.get(IdentityLinkingController); identityLinkingService = module.get(IdentityLinkingService); identityResolutionService = module.get(IdentityResolutionService); }); afterEach(() => { vi.clearAllMocks(); }); describe("POST /identity/verify", () => { it("should have AuthGuard and Throttle decorators applied", () => { // Verify that the endpoint has proper guards and rate limiting const verifyMetadata = Reflect.getMetadata( "__guards__", IdentityLinkingController.prototype.verifyIdentity ); expect(verifyMetadata).toBeDefined(); }); it("should verify identity with valid request", async () => { const dto: VerifyIdentityDto = { localUserId: "local-user-id", remoteUserId: "remote-user-id", remoteInstanceId: "remote-instance-id", oidcToken: "valid-token", timestamp: Date.now(), signature: "valid-signature", }; identityLinkingService.verifyIdentity.mockResolvedValue({ verified: true, localUserId: "local-user-id", remoteUserId: "remote-user-id", remoteInstanceId: "remote-instance-id", email: "user@example.com", }); const result = await controller.verifyIdentity(dto); expect(result.verified).toBe(true); expect(result.localUserId).toBe("local-user-id"); expect(identityLinkingService.verifyIdentity).toHaveBeenCalledWith(dto); }); it("should return verification failure", async () => { const dto: VerifyIdentityDto = { localUserId: "local-user-id", remoteUserId: "remote-user-id", remoteInstanceId: "remote-instance-id", oidcToken: "invalid-token", timestamp: Date.now(), signature: "invalid-signature", }; identityLinkingService.verifyIdentity.mockResolvedValue({ verified: false, error: "Invalid signature", }); const result = await controller.verifyIdentity(dto); expect(result.verified).toBe(false); expect(result.error).toBe("Invalid signature"); }); }); describe("POST /identity/resolve", () => { it("should resolve remote user to local user", async () => { const dto: ResolveIdentityDto = { remoteInstanceId: "remote-instance-id", remoteUserId: "remote-user-id", }; identityResolutionService.resolveIdentity.mockResolvedValue({ found: true, localUserId: "local-user-id", remoteUserId: "remote-user-id", remoteInstanceId: "remote-instance-id", email: "user@example.com", }); const result = await controller.resolveIdentity(dto); expect(result.found).toBe(true); expect(result.localUserId).toBe("local-user-id"); expect(identityResolutionService.resolveIdentity).toHaveBeenCalledWith( "remote-instance-id", "remote-user-id" ); }); it("should return not found when mapping does not exist", async () => { const dto: ResolveIdentityDto = { remoteInstanceId: "remote-instance-id", remoteUserId: "unknown-user-id", }; identityResolutionService.resolveIdentity.mockResolvedValue({ found: false, remoteUserId: "unknown-user-id", remoteInstanceId: "remote-instance-id", }); const result = await controller.resolveIdentity(dto); expect(result.found).toBe(false); expect(result.localUserId).toBeUndefined(); }); }); describe("POST /identity/bulk-resolve", () => { it("should resolve multiple remote users", async () => { const dto: BulkResolveIdentityDto = { remoteInstanceId: "remote-instance-id", remoteUserIds: ["remote-user-1", "remote-user-2", "unknown-user"], }; identityResolutionService.bulkResolveIdentities.mockResolvedValue({ mappings: { "remote-user-1": "local-user-1", "remote-user-2": "local-user-2", }, notFound: ["unknown-user"], }); const result = await controller.bulkResolveIdentity(dto); expect(result.mappings).toEqual({ "remote-user-1": "local-user-1", "remote-user-2": "local-user-2", }); expect(result.notFound).toEqual(["unknown-user"]); }); }); describe("GET /identity/me", () => { it("should return current user's federated identities", async () => { identityLinkingService.listUserIdentities.mockResolvedValue([mockIdentity]); const result = await controller.getCurrentUserIdentities(mockUser); expect(result).toHaveLength(1); expect(result[0]).toEqual(mockIdentity); expect(identityLinkingService.listUserIdentities).toHaveBeenCalledWith("local-user-id"); }); it("should return empty array if no identities", async () => { identityLinkingService.listUserIdentities.mockResolvedValue([]); const result = await controller.getCurrentUserIdentities(mockUser); expect(result).toEqual([]); }); }); describe("POST /identity/link", () => { it("should create identity mapping", async () => { const dto: CreateIdentityMappingDto = { remoteInstanceId: "remote-instance-id", remoteUserId: "remote-user-id", oidcSubject: "oidc-subject", email: "user@example.com", metadata: { source: "manual" }, }; identityLinkingService.createIdentityMapping.mockResolvedValue(mockIdentity); const result = await controller.createIdentityMapping(mockUser, dto); expect(result).toEqual(mockIdentity); expect(identityLinkingService.createIdentityMapping).toHaveBeenCalledWith( "local-user-id", dto ); }); }); describe("PATCH /identity/:remoteInstanceId", () => { it("should update identity mapping", async () => { const remoteInstanceId = "remote-instance-id"; const dto: UpdateIdentityMappingDto = { metadata: { updated: true }, }; const updatedIdentity = { ...mockIdentity, metadata: { updated: true } }; identityLinkingService.updateIdentityMapping.mockResolvedValue(updatedIdentity); const result = await controller.updateIdentityMapping(mockUser, remoteInstanceId, dto); expect(result.metadata).toEqual({ updated: true }); expect(identityLinkingService.updateIdentityMapping).toHaveBeenCalledWith( "local-user-id", remoteInstanceId, dto ); }); }); describe("DELETE /identity/:remoteInstanceId", () => { it("should revoke identity mapping", async () => { const remoteInstanceId = "remote-instance-id"; identityLinkingService.revokeIdentityMapping.mockResolvedValue(undefined); const result = await controller.revokeIdentityMapping(mockUser, remoteInstanceId); expect(result).toEqual({ success: true }); expect(identityLinkingService.revokeIdentityMapping).toHaveBeenCalledWith( "local-user-id", remoteInstanceId ); }); }); describe("GET /identity/:remoteInstanceId/validate", () => { it("should validate existing identity mapping", async () => { const remoteInstanceId = "remote-instance-id"; identityLinkingService.validateIdentityMapping.mockResolvedValue({ valid: true, localUserId: "local-user-id", remoteUserId: "remote-user-id", remoteInstanceId: "remote-instance-id", }); const result = await controller.validateIdentityMapping(mockUser, remoteInstanceId); expect(result.valid).toBe(true); expect(result.localUserId).toBe("local-user-id"); }); it("should return invalid if mapping not found", async () => { const remoteInstanceId = "unknown-instance-id"; identityLinkingService.validateIdentityMapping.mockResolvedValue({ valid: false, error: "Identity mapping not found", }); const result = await controller.validateIdentityMapping(mockUser, remoteInstanceId); expect(result.valid).toBe(false); expect(result.error).toContain("not found"); }); }); });