/** * Identity Linking Service Tests * * Tests for cross-instance identity verification and mapping. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { IdentityLinkingService } from "./identity-linking.service"; import { OIDCService } from "./oidc.service"; import { SignatureService } from "./signature.service"; import { FederationAuditService } from "./audit.service"; import { PrismaService } from "../prisma/prisma.service"; import type { IdentityVerificationRequest, CreateIdentityMappingDto, UpdateIdentityMappingDto, } from "./types/identity-linking.types"; import type { FederatedIdentity } from "./types/oidc.types"; describe("IdentityLinkingService", () => { let service: IdentityLinkingService; let oidcService: OIDCService; let signatureService: SignatureService; let auditService: FederationAuditService; let prismaService: PrismaService; const mockFederatedIdentity: 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(), }; beforeEach(async () => { const mockOIDCService = { linkFederatedIdentity: vi.fn(), getFederatedIdentity: vi.fn(), getUserFederatedIdentities: vi.fn(), revokeFederatedIdentity: vi.fn(), validateToken: vi.fn(), }; const mockSignatureService = { verifyMessage: vi.fn(), validateTimestamp: vi.fn(), }; const mockAuditService = { logIdentityVerification: vi.fn(), logIdentityLinking: vi.fn(), logIdentityRevocation: vi.fn(), }; const mockPrismaService = { federatedIdentity: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), }, }; const module: TestingModule = await Test.createTestingModule({ providers: [ IdentityLinkingService, { provide: OIDCService, useValue: mockOIDCService }, { provide: SignatureService, useValue: mockSignatureService }, { provide: FederationAuditService, useValue: mockAuditService }, { provide: PrismaService, useValue: mockPrismaService }, ], }).compile(); service = module.get(IdentityLinkingService); oidcService = module.get(OIDCService); signatureService = module.get(SignatureService); auditService = module.get(FederationAuditService); prismaService = module.get(PrismaService); }); afterEach(() => { vi.clearAllMocks(); }); describe("verifyIdentity", () => { it("should verify identity with valid signature and token", async () => { const request: IdentityVerificationRequest = { localUserId: "local-user-id", remoteUserId: "remote-user-id", remoteInstanceId: "remote-instance-id", oidcToken: "valid-token", timestamp: Date.now(), signature: "valid-signature", }; signatureService.validateTimestamp.mockReturnValue(true); signatureService.verifyMessage.mockResolvedValue({ valid: true }); oidcService.validateToken.mockReturnValue({ valid: true, userId: "remote-user-id", instanceId: "remote-instance-id", email: "user@example.com", }); oidcService.getFederatedIdentity.mockResolvedValue(mockFederatedIdentity); const result = await service.verifyIdentity(request); expect(result.verified).toBe(true); expect(result.localUserId).toBe("local-user-id"); expect(result.remoteUserId).toBe("remote-user-id"); expect(result.remoteInstanceId).toBe("remote-instance-id"); expect(signatureService.validateTimestamp).toHaveBeenCalledWith(request.timestamp); expect(signatureService.verifyMessage).toHaveBeenCalled(); expect(oidcService.validateToken).toHaveBeenCalledWith("valid-token", "remote-instance-id"); expect(auditService.logIdentityVerification).toHaveBeenCalled(); }); it("should reject identity with invalid signature", async () => { const request: IdentityVerificationRequest = { localUserId: "local-user-id", remoteUserId: "remote-user-id", remoteInstanceId: "remote-instance-id", oidcToken: "valid-token", timestamp: Date.now(), signature: "invalid-signature", }; signatureService.validateTimestamp.mockReturnValue(true); signatureService.verifyMessage.mockResolvedValue({ valid: false, error: "Invalid signature", }); const result = await service.verifyIdentity(request); expect(result.verified).toBe(false); expect(result.error).toContain("Invalid signature"); expect(oidcService.validateToken).not.toHaveBeenCalled(); }); it("should reject identity with expired timestamp", async () => { const request: IdentityVerificationRequest = { localUserId: "local-user-id", remoteUserId: "remote-user-id", remoteInstanceId: "remote-instance-id", oidcToken: "valid-token", timestamp: Date.now() - 10 * 60 * 1000, // 10 minutes ago signature: "valid-signature", }; signatureService.validateTimestamp.mockReturnValue(false); const result = await service.verifyIdentity(request); expect(result.verified).toBe(false); expect(result.error).toContain("expired"); expect(signatureService.verifyMessage).not.toHaveBeenCalled(); }); it("should reject identity with invalid OIDC token", async () => { const request: IdentityVerificationRequest = { localUserId: "local-user-id", remoteUserId: "remote-user-id", remoteInstanceId: "remote-instance-id", oidcToken: "invalid-token", timestamp: Date.now(), signature: "valid-signature", }; signatureService.validateTimestamp.mockReturnValue(true); signatureService.verifyMessage.mockResolvedValue({ valid: true }); oidcService.validateToken.mockReturnValue({ valid: false, error: "Invalid token", }); const result = await service.verifyIdentity(request); expect(result.verified).toBe(false); expect(result.error).toContain("Invalid token"); }); it("should reject identity if mapping does not exist", async () => { const request: IdentityVerificationRequest = { localUserId: "local-user-id", remoteUserId: "remote-user-id", remoteInstanceId: "remote-instance-id", oidcToken: "valid-token", timestamp: Date.now(), signature: "valid-signature", }; signatureService.validateTimestamp.mockReturnValue(true); signatureService.verifyMessage.mockResolvedValue({ valid: true }); oidcService.validateToken.mockReturnValue({ valid: true, userId: "remote-user-id", instanceId: "remote-instance-id", }); oidcService.getFederatedIdentity.mockResolvedValue(null); const result = await service.verifyIdentity(request); expect(result.verified).toBe(false); expect(result.error).toContain("not found"); }); }); describe("resolveLocalIdentity", () => { it("should resolve remote user to local user", async () => { prismaService.federatedIdentity.findFirst.mockResolvedValue(mockFederatedIdentity as never); const result = await service.resolveLocalIdentity("remote-instance-id", "remote-user-id"); expect(result).not.toBeNull(); expect(result?.localUserId).toBe("local-user-id"); expect(result?.remoteUserId).toBe("remote-user-id"); expect(result?.email).toBe("user@example.com"); }); it("should return null when mapping not found", async () => { prismaService.federatedIdentity.findFirst.mockResolvedValue(null); const result = await service.resolveLocalIdentity("remote-instance-id", "unknown-user-id"); expect(result).toBeNull(); }); }); describe("resolveRemoteIdentity", () => { it("should resolve local user to remote user", async () => { oidcService.getFederatedIdentity.mockResolvedValue(mockFederatedIdentity); const result = await service.resolveRemoteIdentity("local-user-id", "remote-instance-id"); expect(result).not.toBeNull(); expect(result?.remoteUserId).toBe("remote-user-id"); expect(result?.localUserId).toBe("local-user-id"); }); it("should return null when mapping not found", async () => { oidcService.getFederatedIdentity.mockResolvedValue(null); const result = await service.resolveRemoteIdentity("unknown-user-id", "remote-instance-id"); expect(result).toBeNull(); }); }); describe("createIdentityMapping", () => { it("should create identity mapping with valid data", async () => { const dto: CreateIdentityMappingDto = { remoteInstanceId: "remote-instance-id", remoteUserId: "remote-user-id", oidcSubject: "oidc-subject", email: "user@example.com", metadata: { source: "manual" }, }; oidcService.linkFederatedIdentity.mockResolvedValue(mockFederatedIdentity); const result = await service.createIdentityMapping("local-user-id", dto); expect(result).toEqual(mockFederatedIdentity); expect(oidcService.linkFederatedIdentity).toHaveBeenCalledWith( "local-user-id", "remote-user-id", "remote-instance-id", "oidc-subject", "user@example.com", { source: "manual" } ); expect(auditService.logIdentityLinking).toHaveBeenCalled(); }); it("should validate OIDC token if provided", async () => { const dto: CreateIdentityMappingDto = { remoteInstanceId: "remote-instance-id", remoteUserId: "remote-user-id", oidcSubject: "oidc-subject", email: "user@example.com", oidcToken: "valid-token", }; oidcService.validateToken.mockReturnValue({ valid: true }); oidcService.linkFederatedIdentity.mockResolvedValue(mockFederatedIdentity); await service.createIdentityMapping("local-user-id", dto); expect(oidcService.validateToken).toHaveBeenCalledWith("valid-token", "remote-instance-id"); }); it("should throw error if OIDC token is invalid", async () => { const dto: CreateIdentityMappingDto = { remoteInstanceId: "remote-instance-id", remoteUserId: "remote-user-id", oidcSubject: "oidc-subject", email: "user@example.com", oidcToken: "invalid-token", }; oidcService.validateToken.mockReturnValue({ valid: false, error: "Invalid token", }); await expect(service.createIdentityMapping("local-user-id", dto)).rejects.toThrow( "Invalid OIDC token" ); }); }); describe("updateIdentityMapping", () => { it("should update identity mapping metadata", async () => { const dto: UpdateIdentityMappingDto = { metadata: { updated: true }, }; const updatedIdentity = { ...mockFederatedIdentity, metadata: { updated: true } }; prismaService.federatedIdentity.findUnique.mockResolvedValue(mockFederatedIdentity as never); prismaService.federatedIdentity.update.mockResolvedValue(updatedIdentity as never); const result = await service.updateIdentityMapping( "local-user-id", "remote-instance-id", dto ); expect(result.metadata).toEqual({ updated: true }); expect(prismaService.federatedIdentity.update).toHaveBeenCalled(); }); it("should throw error if mapping not found", async () => { const dto: UpdateIdentityMappingDto = { metadata: { updated: true }, }; prismaService.federatedIdentity.findUnique.mockResolvedValue(null); await expect( service.updateIdentityMapping("unknown-user-id", "remote-instance-id", dto) ).rejects.toThrow("not found"); }); }); describe("validateIdentityMapping", () => { it("should validate existing identity mapping", async () => { oidcService.getFederatedIdentity.mockResolvedValue(mockFederatedIdentity); const result = await service.validateIdentityMapping("local-user-id", "remote-instance-id"); expect(result.valid).toBe(true); expect(result.localUserId).toBe("local-user-id"); expect(result.remoteUserId).toBe("remote-user-id"); }); it("should return invalid if mapping not found", async () => { oidcService.getFederatedIdentity.mockResolvedValue(null); const result = await service.validateIdentityMapping("unknown-user-id", "remote-instance-id"); expect(result.valid).toBe(false); expect(result.error).toContain("not found"); }); }); describe("listUserIdentities", () => { it("should list all federated identities for a user", async () => { const identities = [mockFederatedIdentity]; oidcService.getUserFederatedIdentities.mockResolvedValue(identities); const result = await service.listUserIdentities("local-user-id"); expect(result).toEqual(identities); expect(oidcService.getUserFederatedIdentities).toHaveBeenCalledWith("local-user-id"); }); it("should return empty array if user has no federated identities", async () => { oidcService.getUserFederatedIdentities.mockResolvedValue([]); const result = await service.listUserIdentities("local-user-id"); expect(result).toEqual([]); }); }); describe("revokeIdentityMapping", () => { it("should revoke identity mapping", async () => { oidcService.revokeFederatedIdentity.mockResolvedValue(undefined); await service.revokeIdentityMapping("local-user-id", "remote-instance-id"); expect(oidcService.revokeFederatedIdentity).toHaveBeenCalledWith( "local-user-id", "remote-instance-id" ); expect(auditService.logIdentityRevocation).toHaveBeenCalled(); }); }); });