Added @UseGuards(AuthGuard) and rate limiting (@Throttle) to /api/v1/federation/identity/verify endpoint. Configured strict rate limit (10 req/min) to prevent abuse of this previously public endpoint. Added test to verify guards are applied. Security improvement: Prevents unauthorized access and rate limit abuse of identity verification endpoint. Fixes #290 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
/**
|
|
* 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>(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");
|
|
});
|
|
});
|
|
});
|