Files
stack/apps/api/src/federation/identity-resolution.service.spec.ts
Jason Woltje 70a6bc82e0 feat(#87): implement cross-instance identity linking for federation
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>
2026-02-03 12:55:37 -06:00

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"]);
});
});
});