功能测试跳过授权WebApplicationFactory OIDC

Functional test skip authorization WebApplicationFactory OIDC

目标:使用模拟访问令牌创建功能测试但跳过授权(不调用端点)。测试我的网络 API 的控制器方法。 API 通过访问令牌(持有者)受到 authentication/authorization 的保护。与身份服务器 4 通信。

当前:创建了我的自定义 WebApplicationFactory,数据库被播种,访问令牌被创建。

问题:当身份服务器 4 不是 运行 时,测试失败。我不知道如何准确模拟身份服务器。自行创建的访问令牌正在运行。如果身份服务器是 运行,则测试通过授权。

MockJwtToken.cs

public static class MockJwtToken
    {
        public static string Issuer { get; } = "https://localhost:5001";
        public static string Audience { get; } = "https://localhost:5001/resources";
        public static SecurityKey SecurityKey { get; }
        public static SigningCredentials SigningCredentials { get; }

        private static readonly JwtSecurityTokenHandler STokenHandler = new JwtSecurityTokenHandler();
        private static readonly RandomNumberGenerator SRng = RandomNumberGenerator.Create();
        private static readonly byte[] SKey = new byte[32];

        static MockJwtToken()
        {
            SRng.GetBytes(SKey);
            SecurityKey = new SymmetricSecurityKey(SKey) { KeyId = Guid.NewGuid().ToString() };
            SigningCredentials = new SigningCredentials(SecurityKey, SecurityAlgorithms.HmacSha256);
        }

        public static string GenerateJwtToken(IEnumerable<Claim> claims)
        {
            return STokenHandler.WriteToken(new JwtSecurityToken(Issuer, Audience, claims, null, DateTime.UtcNow.AddMinutes(20), SigningCredentials));
        }

        public static string GenerateJwtTokenAsUser()
        {
            return GenerateJwtToken(UserClaims);
        }

        public static List<Claim> UserClaims { get; set; } = new List<Claim>
        {
            new Claim(JwtClaimTypes.PreferredUserName, "test"),
            new Claim(JwtClaimTypes.Email, "test@test.com"),
            new Claim(JwtClaimTypes.Subject, "10000000-0000-0000-0000-000000000000"),
            new Claim(JwtClaimTypes.Scope, "openid"),
            new Claim(JwtClaimTypes.Scope, "api.com:read"),
            new Claim(JwtClaimTypes.Scope, "api.com:write"),
        };
    }

那是我的自定义 WebApplicationFactory

public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<Startup>
    {
        private readonly string _connectionString = "DataSource=:memory:";
        private readonly SqliteConnection _connection;

        public CustomWebApplicationFactory()
        {
            _connection = new SqliteConnection(_connectionString);
            _connection.Open();
        }

        protected override IHost CreateHost(IHostBuilder builder)
        {
            var host = builder.Build();

            var serviceProvider = host.Services;

            using (var scope = serviceProvider.CreateScope())
            {
                var scopedServices = scope.ServiceProvider;
                var context = scopedServices.GetRequiredService<DbContext>();
                var logger = scopedServices.GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>();
                var dbInit = new DbInitializer(context);

                try
                {
                    dbInit.MigrateDatabase();
                }
                catch (Exception e)
                {
                    logger.LogError(e, "An error occurred seeding the " + $"database with test messages. Error: {e.Message}");
                }

                try
                {
                    dbInit.SeedAllEnums();
                }
                catch (Exception e)
                {
                    logger.LogError(e, "An error occurred seeding the " + $"database with test messages. Error: {e.Message}");
                }
            }

            host.Start();
            return host;
        }

        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder
                .UseSolutionRelativeContentRoot("src/Project.Api.Web")
                .ConfigureTestServices(ConfigureServices)
                .UseEnvironment("Testing");
        }

        protected virtual void ConfigureServices(IServiceCollection services)
        {
            var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<DbContext>));

            if (descriptor != null)
            {
                services.Remove(descriptor);
            }

            services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = CreateTokenValidationParameters();
                options.Audience = MockJwtToken.Audience;
                options.Authority = MockJwtToken.Issuer;
            });

            // Add ApplicationDbContext using an in-memory database for testing.
            services
                .AddEntityFrameworkSqlite()
                .AddDbContext<DbContext>(options =>
                {
                    options.UseSqlite(_connection);
                    options.UseInternalServiceProvider(services.BuildServiceProvider());
                });
        }

        protected override void Dispose(bool disposing)
        {
            base.Dispose(disposing);
            _connection.Close();
        }

        private TokenValidationParameters CreateTokenValidationParameters()
        {
            TokenValidationParameters tokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuer = false,
                ValidateAudience = false,

                ValidateIssuerSigningKey = true,
                IssuerSigningKey = MockJwtToken.SecurityKey,

                SignatureValidator = delegate (string token, TokenValidationParameters parameters)
                {
                    JwtSecurityToken jwt = new JwtSecurityToken(token);

                    return jwt;
                },
                RequireExpirationTime = true,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.Zero,
                RequireSignedTokens = false,
            };

            return tokenValidationParameters;
        }

    }

还使用我的 HttpClient 的扩展来解析响应。 这是我的第一个简单测试 class:

[TestClass]
    public class ClientsControllerTest
    {
        private static CustomWebApplicationFactory<Startup> _factory;
        private static HttpClient _client;
        private static IServiceScopeFactory _scopeFactory;
        private static IServiceScope _scope;
        private static DbContext _context;
        private static IDbInitializer _dbInit;

        [ClassInitialize]
        public static void ClassInit(TestContext testContext)
        {
            Console.WriteLine(testContext.TestName);
            _factory = new CustomWebApplicationFactory<Startup>();
            _scopeFactory = _factory.Services.GetService<IServiceScopeFactory>();
            _scope = _scopeFactory.CreateScope();
            _context = _scope.ServiceProvider.GetService<DbContext>();
            _dbInit = _scope.ServiceProvider.GetService<IDbInitializer>();
            _client = _factory.CreateClient();
        }

        [TestMethod]
        public async Task GetAllAsync_NoData_ReturnsEmptyListWithOk()
        {
            //arrange

            _dbInit.SeedClients();
            await _context.SaveChangesAsync();

            string at = MockJwtToken.GenerateJwtTokenAsUser();

            _client.DefaultRequestHeaders.Add("Authorization", "Bearer " + at);

            //act
            HttpParsedResponseMessage<ClientModel[]> msg;
            ClientModel[] clients;

            try
            {

                msg = await _client.GetAsync<ClientModel[]>("/api/clients");
            }
            catch (Exception e)
            {

                throw e;
            }

            clients = msg.ParsedObject;

            //assert
            Assert.AreEqual(HttpStatusCode.OK, msg.ResponseMessage.StatusCode);
            Assert.AreEqual("application/json; charset=utf-8", msg.ResponseMessage.Content.Headers.ContentType?.ToString());

            Assert.AreEqual(1, clients?.Length);
        }

        [ClassCleanup]
        public static void ClassCleanup()
        {
            _factory.Dispose();
        }
    }

开始测试时,服务尝试与我的身份服务器通信。我只是想嘲笑这个。这也是堆栈跟踪告诉我的:

! GetAllAsync_NoData_ReturnsEmptyListWithOk 2021-04-23 10:42:44,977 [4] INFO - Application started. Press Ctrl+C to shut down. 2021-04-23 10:42:45,033 [4] INFO - Hosting environment: Testing 2021-04-23 10:42:45,034 [4] INFO - Content root path: C:\repos\project\Project.Api\src\Project.Api.Web 2021-04-23 10:43:02,901 [8] ERROR - An unhandled exception has occurred while executing the request. System.InvalidOperationException: IDX20803: Unable to obtain configuration from: 'System.String'. ---> System.IO.IOException: IDX20804: Unable to retrieve document from: 'System.String'. ---> System.Net.Http.HttpRequestException: No connection could be made because the target machine actively refused it. ---> System.Net.Sockets.SocketException (10061): No connection could be made because the target machine actively refused it. at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port, CancellationToken cancellationToken) --- End of inner exception stack trace --- at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port, CancellationToken cancellationToken) at System.Net.Http.HttpConnectionPool.ConnectAsync(HttpRequestMessage request, Boolean allowHttp2, CancellationToken cancellationToken) at System.Net.Http.HttpConnectionPool.CreateHttp11ConnectionAsync(HttpRequestMessage request, CancellationToken cancellationToken) at System.Net.Http.HttpConnectionPool.GetHttpConnectionAsync(HttpRequestMessage request, CancellationToken cancellationToken) at System.Net.Http.HttpConnectionPool.SendWithRetryAsync(HttpRequestMessage request, Boolean doRequestAuth, CancellationToken cancellationToken) at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) at System.Net.Http.HttpClient.FinishSendAsyncBuffered(Task1 sendTask, HttpRequestMessage request, CancellationTokenSource cts, Boolean disposeCts) at Microsoft.IdentityModel.Protocols.HttpDocumentRetriever.GetDocumentAsync(String address, CancellationToken cancel) --- End of inner exception stack trace --- at Microsoft.IdentityModel.Protocols.HttpDocumentRetriever.GetDocumentAsync(String address, CancellationToken cancel) at Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever.GetAsync(String address, IDocumentRetriever retriever, CancellationToken cancel) at Microsoft.IdentityModel.Protocols.ConfigurationManager1.GetConfigurationAsync(CancellationToken cancel) --- End of inner exception stack trace --- at Microsoft.IdentityModel.Protocols.ConfigurationManager1.GetConfigurationAsync(CancellationToken cancel) at Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler.HandleAuthenticateAsync() at Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler.HandleAuthenticateAsync() at Microsoft.AspNetCore.Authentication.AuthenticationHandler1.AuthenticateAsync() at Microsoft.AspNetCore.Authentication.AuthenticationService.AuthenticateAsync(HttpContext context, String scheme) at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context) at Hellang.Middleware.ProblemDetails.ProblemDetailsMiddleware.Invoke(HttpContext context) !

问题是,我还需要覆盖 JwtBearerOptions

中的 Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration
            services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = CreateTokenValidationParameters();
                options.Audience = MockJsonWebToken.Audience;
                options.Authority = MockJsonWebToken.Issuer;
                options.Configuration = new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration()
                {
                    Issuer = MockJsonWebToken.Issuer,
                };
            });

进行此更改后,我可以关闭互联网连接,并且该服务不再尝试与任何发行人通信。

所以这里又是我自己的 CustomWebApplicationFactory。

    public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<Startup>
    {
        private readonly string _connectionString = "DataSource=:memory:";
        private readonly SqliteConnection _connection;

        public CustomWebApplicationFactory()
        {
            _connection = new SqliteConnection(_connectionString);
            _connection.Open();
        }

        protected override IHost CreateHost(IHostBuilder builder)
        {
            var host = builder.Build();

            var serviceProvider = host.Services;

            using (var scope = serviceProvider.CreateScope())
            {
                var scopedServices = scope.ServiceProvider;
                var context = scopedServices.GetRequiredService<DbContext>();
                var logger = scopedServices.GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>();
                var dbInit = new DbInitializer(context);

                try
                {
                    dbInit.MigrateDatabase();
                }
                catch (Exception e)
                {
                    logger.LogError(e, "An error occurred seeding the " + $"database with test messages. Error: {e.Message}");
                }

                try
                {
                    dbInit.SeedAllEnums();
                }
                catch (Exception e)
                {
                    logger.LogError(e, "An error occurred seeding the " + $"database with test messages. Error: {e.Message}");
                }
            }

            host.Start();
            return host;
        }

        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder
                .UseSolutionRelativeContentRoot("src/Project.Api.Web")
                .ConfigureTestServices(ConfigureServices)
                .UseEnvironment("Testing");
        }

        protected virtual void ConfigureServices(IServiceCollection services)
        {
            var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<DbContext>));

            if (descriptor != null)
            {
                services.Remove(descriptor);
            }

            services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = CreateTokenValidationParameters();
                options.Audience = MockJsonWebToken.Audience;
                options.Authority = MockJsonWebToken.Issuer;
                options.Configuration = new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration()
                {
                    Issuer = MockJsonWebToken.Issuer,
                };
            });

            services
                .AddEntityFrameworkSqlite()
                .AddDbContext<DbContext>(options =>
                {
                    options.UseSqlite(_connection);
                    options.UseInternalServiceProvider(services.BuildServiceProvider());
                });
        }

        protected override void Dispose(bool disposing)
        {
            base.Dispose(disposing);
            _connection.Close();
        }

        private TokenValidationParameters CreateTokenValidationParameters()
        {
            TokenValidationParameters tokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuer = false,
                ValidateAudience = false,

                ValidateIssuerSigningKey = true,
                IssuerSigningKey = MockJsonWebToken.SecurityKey,

                SignatureValidator = delegate (string token, TokenValidationParameters parameters)
                {
                    JwtSecurityToken jwt = new JwtSecurityToken(token);

                    return jwt;
                },
                RequireExpirationTime = true,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.Zero,
                RequireSignedTokens = false,
            };

            return tokenValidationParameters;
        }

    }

MockJwtToken 没有进一步更改。只是在这里重命名为 MockJsonWebToken 因为 JwtToken 有点多余。