dotnet / dotnet/aspnetcore

Implement an alternative to startup running twice when using WebApplicationFactory

Open
#26,487 17 comments 13 reactions 0 assignees View on GitHub
affected-few area-minimal area-mvc enhancement feature-mvc-testing investigate severity-major
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 6h
Merged PRs (30d)
290

Description

So, I know it's [by design](https://github.com/dotnet/aspnetcore/issues/19404#issuecomment-593653580), but I’m running into another instance where startup getting called twice when using `WebApplicationFactory` is causing me major headaches.

I added some code for this specific instance below, but the short version is that when I’m adding Auth into my API, it runs fine when doing startup normally, but when using the web host factory it's messing up my auth setup with a `System.InvalidOperationException : Scheme already exists: Identity.Application error.` error.

## New Feature Request
Maybe I’m just not getting the best way to override things, but in my mind it makes more sense to have (at the the option of using) a distinct StartupTesting or something of that nature that can be run once to configure my testing host exactly how I want. This is how Laravel does it an it seems more manageable.
 
 Related to #19404
 

## Details on this particular error
When using Auth, the API will run fine, but the integration tests will break, throwing a `-------- System.InvalidOperationException : Scheme already exists: Identity.Application` error.

I started googling for this and it seems like the main resolution is generally to remove `AddDefaultIdentity` to either stop a clash with `IdentityHostingStartup` or prevent [IdentityHostintgStartup.cs](https://stackoverflow.com/questions/51161729/addidentity-fails-invalidoperationexception-scheme-already-exists-identity) from causing some overlap.

I'm not using AddDefaultIdentity and I'm not seeing a IdentityHostintgStartup.cs get generated, so I'm not quite sure what the deal is here. Presumably, something is calling `AddAuthentication` with the same identity scheme twice. This may be be due to `CustomWebApplicationFactory` running through startup multiple times, but I need to investigate more.

It does look like, when debugging any integration test that `services.AddIdentity().AddEntityFrameworkStores().AddDefaultTokenProviders();` is getting hit twice and, when commenting that line out, I get a different error: `-------- System.InvalidOperationException : Scheme already exists: Bearer` which, again, is presumably happening because of startup getting run twice in `CustomWebApplicationFactory`.

WebAppFactory
```csharp

namespace VetClinic.Api.Tests
{
using Infrastructure.Persistence.Contexts;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Respawn;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using WebApi;

public class CustomWebApplicationFactory : WebApplicationFactory
{
// checkpoint for respawn to clear the database when spenning up each time
private static Checkpoint checkpoint = new Checkpoint
{

};

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");

builder.ConfigureServices(async services =>
{
services.AddEntityFrameworkInMemoryDatabase();

// Create a new service provider.
var provider = services
.AddEntityFrameworkInMemoryDatabase()
.BuildServiceProvider();

// Add a database context (VetClinicDbContext) using an in-memory
// database for testing.
services.AddDbContext(options =>
{
options.UseInMemoryDatabase("InMemoryDbForTesting");
options.UseInternalServiceProvider(provider);
});

// Build the service provider.
var sp = services.BuildServiceProvider();

// Create a scope to obtain a reference to the database
// context (ApplicationDbContext).
using (var scope = sp.CreateScope())
{
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService();

// Ensure the database is created.
db.Database.EnsureCreated();

try
{
await checkpoint.Reset(db.Database.GetDbConnection());
}
catch
{
}
}
});
}

public HttpClient GetAnonymousClient()
{
return CreateClient();
}
}
}
```

Startup
```csharp
namespace WebApi
{
using Application;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Infrastructure.Persistence;
using Infrastructure.Shared;
using Infrastructure.Persistence.Seeders;
using Infrastructure.Persistence.Contexts;
using WebApi.Extensions;
using Infrastructure.Identity;
using Infrastructure.Identity.Entities;
using Microsoft.AspNetCore.Identity;
using Infrastructure.Identity.Seeders;
using WebApi.Services;
using Application.Interfaces;

public class StartupDevelopment
{
public IConfiguration _config { get; }
public StartupDevelopment(IConfiguration configuration)
{
_config = configuration;
}

// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
services.AddCorsService("MyCorsPolicy");
services.AddApplicationLayer();
services.AddIdentityInfrastructure(_config);
services.AddPersistenceInfrastructure(_config);
services.AddSharedInfrastructure(_config);
services.AddControllers()
.AddNewtonsoftJson();
services.AddApiVersioningExtension();
services.AddHealthChecks();
services.AddSingleton();

#region Dynamic Services
services.AddSwaggerExtension();
#endregion
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseDeveloperExceptionPage();

#region Entity Context Region - Do Not Delete

using (var context = app.ApplicationServices.GetService())
{
context.Database.EnsureCreated();

#region VetClinicDbContext Seeder Region - Do Not Delete

PetSeeder.SeedSamplePetData(app.ApplicationServices.GetService());
VetSeeder.SeedSampleVetData(app.ApplicationServices.GetService());
CitySeeder.SeedSampleCityData(app.ApplicationServices.GetService());
#endregion
}

#endregion

#region Identity Context Region - Do Not Delete

var userManager = app.ApplicationServices.GetService>();
var roleManager = app.ApplicationServices.GetService>();
RoleSeeder.SeedDemoRolesAsync(roleManager);

// user seeders -- do not delete this comment
pdevitoSeeder.SeedUserAsync(userManager);

#endregion

app.UseCors("MyCorsPolicy");

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseErrorHandlingMiddleware();
app.UseEndpoints(endpoints =>
{
endpoints.MapHealthChecks("/api/health");
endpoints.MapControllers();
});

#region Dynamic App
app.UseSwaggerExtension();
#endregion
}
}
}
```

Identity Extension
```csharp
namespace Infrastructure.Identity
{
using Application.Exceptions;
using Application.Interfaces;
using Application.Wrappers;
using Domain.Settings;
using Infrastructure.Identity.Entities;
using Infrastructure.Identity.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using System;
using System.Text;

public static class ServiceExtensions
{
public static void AddIdentityInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
/*services.AddDbContext(options =>
options.UseInMemoryDatabase("IdentityDb"));*/
if (configuration.GetValue("UseInMemoryDatabase"))
{
services.AddDbContext(options =>
options.UseInMemoryDatabase("IdentityDb"));
}
else
{
services.AddDbContext(options =>
options.UseSqlServer(
configuration.GetConnectionString("IdentityConnection"),
b => b.MigrationsAssembly(typeof(IdentityDbContext).Assembly.FullName)));
}
services.AddIdentity().AddEntityFrameworkStores().AddDefaultTokenProviders();

#region Services
services.AddScoped();
#endregion

// for craftsman updates to work appropriately, do not remove identity option lines
services.Configure(options =>
{
options.User.RequireUniqueEmail = true;

options.Password.RequiredLength = 6;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
});

services.Configure(configuration.GetSection("JwtSettings"));
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(o =>
{
o.RequireHttpsMetadata = false;
o.SaveToken = false;
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
ValidIssuer = configuration["JwtSettings:Issuer"],
ValidAudience = configuration["JwtSettings:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JwtSettings:Key"]))
};
o.Events = new JwtBearerEvents()
{
OnAuthenticationFailed = c =>
{
c.NoResult();
c.Response.StatusCode = 500;
c.Response.ContentType = "text/plain";
return c.Response.WriteAsync(c.Exception.ToString());
},
OnChallenge = context =>
{
context.HandleResponse();
context.Response.StatusCode = 401;
context.Response.ContentType = "application/json";
var result = JsonConvert.SerializeObject(new Response("You are not Authorized"));
return context.Response.WriteAsync(result);
},
OnForbidden = context =>
{
context.Response.StatusCode = 403;
context.Response.ContentType = "application/json";
var result = JsonConvert.SerializeObject(new Response("You are not authorized to access this resource"));
return context.Response.WriteAsync(result);
},
};
});
}
}
}
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.