Implements FED-004: Cross-Instance Identity Linking, building on the foundation from FED-001, FED-002, and FED-003. New Services: - IdentityLinkingService: Handles identity verification and mapping with signature validation and OIDC token verification - IdentityResolutionService: Resolves identities between local and remote instances with support for bulk operations New API Endpoints (IdentityLinkingController): - POST /api/v1/federation/identity/verify - Verify remote identity - POST /api/v1/federation/identity/resolve - Resolve remote to local user - POST /api/v1/federation/identity/bulk-resolve - Bulk resolution - GET /api/v1/federation/identity/me - Get current user's identities - POST /api/v1/federation/identity/link - Create identity mapping - PATCH /api/v1/federation/identity/:id - Update mapping - DELETE /api/v1/federation/identity/:id - Revoke mapping - GET /api/v1/federation/identity/:id/validate - Validate mapping Security Features: - Signature verification using remote instance public keys - OIDC token validation before creating mappings - Timestamp validation to prevent replay attacks - Workspace isolation via authentication guards - Comprehensive audit logging for all identity operations Enhancements: - Added SignatureService.verifyMessage() for remote signature verification - Added FederationService.getConnectionByRemoteInstanceId() - Extended FederationAuditService with identity logging methods - Created comprehensive DTOs with class-validator decorators Testing: - 38 new tests (19 service + 7 resolution + 12 controller) - All 132 federation tests passing - TypeScript compilation passing with no errors - High test coverage achieved (>85% requirement exceeded) Technical Details: - Leverages existing FederatedIdentity model from FED-003 - Uses RSA SHA-256 signatures for cryptographic verification - Supports one identity mapping per remote instance per user - Resolution service optimized for read-heavy operations - Built following TDD principles (Red-Green-Refactor) Closes #87 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
/**
|
|
* Identity Resolution Service Tests
|
|
*
|
|
* Tests for resolving identities between local and remote instances.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { IdentityResolutionService } from "./identity-resolution.service";
|
|
import { IdentityLinkingService } from "./identity-linking.service";
|
|
import type { FederatedIdentity } from "./types/oidc.types";
|
|
|
|
describe("IdentityResolutionService", () => {
|
|
let service: IdentityResolutionService;
|
|
let identityLinkingService: IdentityLinkingService;
|
|
|
|
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(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const mockIdentityLinkingService = {
|
|
resolveLocalIdentity: vi.fn(),
|
|
resolveRemoteIdentity: vi.fn(),
|
|
};
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
IdentityResolutionService,
|
|
{ provide: IdentityLinkingService, useValue: mockIdentityLinkingService },
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<IdentityResolutionService>(IdentityResolutionService);
|
|
identityLinkingService = module.get(IdentityLinkingService);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("resolveIdentity", () => {
|
|
it("should resolve remote identity to local user", async () => {
|
|
identityLinkingService.resolveLocalIdentity.mockResolvedValue(mockIdentity);
|
|
|
|
const result = await service.resolveIdentity("remote-instance-id", "remote-user-id");
|
|
|
|
expect(result.found).toBe(true);
|
|
expect(result.localUserId).toBe("local-user-id");
|
|
expect(result.remoteUserId).toBe("remote-user-id");
|
|
expect(result.email).toBe("user@example.com");
|
|
expect(identityLinkingService.resolveLocalIdentity).toHaveBeenCalledWith(
|
|
"remote-instance-id",
|
|
"remote-user-id"
|
|
);
|
|
});
|
|
|
|
it("should return not found when mapping does not exist", async () => {
|
|
identityLinkingService.resolveLocalIdentity.mockResolvedValue(null);
|
|
|
|
const result = await service.resolveIdentity("remote-instance-id", "unknown-user-id");
|
|
|
|
expect(result.found).toBe(false);
|
|
expect(result.localUserId).toBeUndefined();
|
|
expect(result.remoteUserId).toBe("unknown-user-id");
|
|
});
|
|
});
|
|
|
|
describe("reverseResolveIdentity", () => {
|
|
it("should resolve local user to remote identity", async () => {
|
|
identityLinkingService.resolveRemoteIdentity.mockResolvedValue(mockIdentity);
|
|
|
|
const result = await service.reverseResolveIdentity("local-user-id", "remote-instance-id");
|
|
|
|
expect(result.found).toBe(true);
|
|
expect(result.remoteUserId).toBe("remote-user-id");
|
|
expect(result.localUserId).toBe("local-user-id");
|
|
expect(identityLinkingService.resolveRemoteIdentity).toHaveBeenCalledWith(
|
|
"local-user-id",
|
|
"remote-instance-id"
|
|
);
|
|
});
|
|
|
|
it("should return not found when mapping does not exist", async () => {
|
|
identityLinkingService.resolveRemoteIdentity.mockResolvedValue(null);
|
|
|
|
const result = await service.reverseResolveIdentity("unknown-user-id", "remote-instance-id");
|
|
|
|
expect(result.found).toBe(false);
|
|
expect(result.remoteUserId).toBeUndefined();
|
|
expect(result.localUserId).toBe("unknown-user-id");
|
|
});
|
|
});
|
|
|
|
describe("bulkResolveIdentities", () => {
|
|
it("should resolve multiple remote users to local users", async () => {
|
|
const mockIdentity2: FederatedIdentity = {
|
|
...mockIdentity,
|
|
id: "identity-id-2",
|
|
localUserId: "local-user-id-2",
|
|
remoteUserId: "remote-user-id-2",
|
|
};
|
|
|
|
identityLinkingService.resolveLocalIdentity
|
|
.mockResolvedValueOnce(mockIdentity)
|
|
.mockResolvedValueOnce(mockIdentity2)
|
|
.mockResolvedValueOnce(null);
|
|
|
|
const result = await service.bulkResolveIdentities("remote-instance-id", [
|
|
"remote-user-id",
|
|
"remote-user-id-2",
|
|
"unknown-user-id",
|
|
]);
|
|
|
|
expect(result.mappings["remote-user-id"]).toBe("local-user-id");
|
|
expect(result.mappings["remote-user-id-2"]).toBe("local-user-id-2");
|
|
expect(result.notFound).toEqual(["unknown-user-id"]);
|
|
expect(identityLinkingService.resolveLocalIdentity).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("should handle empty array", async () => {
|
|
const result = await service.bulkResolveIdentities("remote-instance-id", []);
|
|
|
|
expect(result.mappings).toEqual({});
|
|
expect(result.notFound).toEqual([]);
|
|
expect(identityLinkingService.resolveLocalIdentity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle all not found", async () => {
|
|
identityLinkingService.resolveLocalIdentity
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(null);
|
|
|
|
const result = await service.bulkResolveIdentities("remote-instance-id", [
|
|
"unknown-1",
|
|
"unknown-2",
|
|
]);
|
|
|
|
expect(result.mappings).toEqual({});
|
|
expect(result.notFound).toEqual(["unknown-1", "unknown-2"]);
|
|
});
|
|
});
|
|
});
|