Files
stack/apps/api/src/federation/identity-linking.controller.spec.ts
Jason Woltje 1390da2e74
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
fix(#290): Secure identity verification endpoint
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>
2026-02-03 21:36:31 -06:00

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