如何为依赖外部身份提供者的授权ASP.NET Web API编写集成测试?
针对OpenIddict自省验证场景的集成测试方案
1. 搭建测试基础环境
在集成测试项目中,使用WebApplicationFactory<T>启动API项目,并通过ConfigureServices替换或模拟依赖服务,避免实际调用身份服务器:
public class ApiTestFactory : WebApplicationFactory<Program> { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { // 移除原有OpenIddict验证服务,替换为模拟实现 var validationServiceDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOpenIddictValidationService)); if (validationServiceDescriptor != null) { services.Remove(validationServiceDescriptor); } // 模拟自省验证逻辑:根据令牌前缀判断是否有效 services.AddSingleton<IOpenIddictValidationService>(sp => { var mock = new Mock<IOpenIddictValidationService>(); mock.Setup(s => s.IntrospectTokenAsync( It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())) .ReturnsAsync((string token, string _, CancellationToken _) => { if (token.StartsWith("test-valid-token")) { return new OpenIddictValidationIntrospectionResult { IsActive = true, Claims = new Dictionary<string, object> { { "sub", "test-user-001" }, { "client_id", "test-web-client" } } }; } return new OpenIddictValidationIntrospectionResult { IsActive = false }; }); return mock.Object; }); // 模拟获取额外声明的服务(替换原调用身份服务器的逻辑) var claimServiceDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IAdditionalClaimProvider)); if (claimServiceDescriptor != null) { services.Remove(claimServiceDescriptor); services.AddSingleton<IAdditionalClaimProvider>(sp => { var mock = new Mock<IAdditionalClaimProvider>(); mock.Setup(s => s.GetUserClaimsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .ReturnsAsync((string userId, CancellationToken _) => { return new Dictionary<string, object> { { "role", userId == "test-user-001" ? "admin" : "user" }, { "permission", "view-data" } }; }); return mock.Object; }); } }); } }
2. 可选:通过HTTP拦截模拟身份服务器端点
如果不想直接替换服务,也可以用DelegatingHandler拦截API向身份服务器发起的请求,返回预设响应:
public class MockIdentityServerHandler : DelegatingHandler { protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { // 模拟自省端点 if (request.RequestUri?.AbsolutePath == "/connect/introspect") { var formData = await request.Content.ReadFromFormAsync(cancellationToken); var token = formData["token"]; return token == "test-valid-token" ? new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { active = true, sub = "test-user-001", client_id = "test-web-client" }) } : new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { active = false }) }; } // 模拟额外声明获取端点 if (request.RequestUri?.AbsolutePath == "/api/user/claims") { var userId = request.Headers.GetValues("X-User-Id").FirstOrDefault(); return userId == "test-user-001" ? new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { role = "admin", permission = "view-data" }) } : new HttpResponseMessage(HttpStatusCode.NotFound); } return await base.SendAsync(request, cancellationToken); } }
在测试工厂中注册该拦截器:
protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { services.AddHttpClient("IdentityServerClient") .AddHttpMessageHandler<MockIdentityServerHandler>(); services.AddSingleton<MockIdentityServerHandler>(); }); }
3. 编写测试用例
基于测试工厂,覆盖认证、授权的核心场景:
3.1 有效令牌访问受保护接口
[Fact] public async Task Get_ProtectedApi_WithValidToken_ReturnsOk() { // Arrange var factory = new ApiTestFactory(); var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", "test-valid-token"); // Act var response = await client.GetAsync("/api/data/protected"); // Assert response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(); Assert.Contains("test-user-001", content); }
3.2 无效令牌被拒绝访问
[Fact] public async Task Get_ProtectedApi_WithInvalidToken_ReturnsUnauthorized() { // Arrange var factory = new ApiTestFactory(); var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", "test-invalid-token"); // Act var response = await client.GetAsync("/api/data/protected"); // Assert Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); }
3.3 验证角色权限控制
[Fact] public async Task Get_AdminApi_WithAdminClaim_ReturnsOk() { // Arrange var factory = new ApiTestFactory(); var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", "test-valid-token"); // Act var response = await client.GetAsync("/api/admin/manage"); // Assert response.EnsureSuccessStatusCode(); } [Fact] public async Task Get_AdminApi_WithoutAdminClaim_ReturnsForbidden() { // Arrange var factory = new ApiTestFactory(); var client = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // 替换模拟服务,返回普通用户角色 var claimServiceDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IAdditionalClaimProvider)); if (claimServiceDescriptor != null) { services.Remove(claimServiceDescriptor); services.AddSingleton<IAdditionalClaimProvider>(sp => { var mock = new Mock<IAdditionalClaimProvider>(); mock.Setup(s => s.GetUserClaimsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .ReturnsAsync(new Dictionary<string, object> { { "role", "user" } }); return mock.Object; }); } }); }).CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", "test-valid-token"); // Act var response = await client.GetAsync("/api/admin/manage"); // Assert Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); }
关键注意事项
- 模拟逻辑要完全覆盖业务中的认证授权规则,比如令牌有效性判断、声明内容、角色权限映射等。
- 若使用
OpenIddictValidationEvents事件处理程序,需确保模拟逻辑能触发对应事件,或直接替换事件委托。 - 补充边界场景测试:如令牌过期、客户端ID不匹配、缺失必要声明等。
内容的提问来源于stack exchange,提问作者dmdany07
相关产品推荐
相关产品推荐

