在 ASP.NET 核心中模拟 IPrincipal

Mocking IPrincipal in ASP.NET Core

我有一个 ASP.NET MVC Core 应用程序,我正在为其编写单元测试。其中一种操作方法使用用户名来实现某些功能:

SettingsViewModel svm = _context.MySettings(User.Identity.Name);

这显然在单元测试中失败了。我环顾四周,所有建议都来自 .NET 4.5 到模拟 HttpContext。我相信有更好的方法可以做到这一点。我试图注入 IPrincipal,但它抛出了一个错误;我什至尝试过这个(我想是出于绝望):

public IActionResult Index(IPrincipal principal = null) {
    IPrincipal user = principal ?? User;
    SettingsViewModel svm = _context.MySettings(user.Identity.Name);
    return View(svm);
}

但这也引发了错误。 在文档中也找不到任何内容...

我希望实现抽象工厂模式。

创建一个工厂接口,专门提供用户名。

然后提供具体的 classes,一个提供 User.Identity.Name,一个提供一些其他适用于您的测试的硬编码值。

然后您可以根据生产代码和测试代码使用适当的具体 class。也许希望将工厂作为参数传递,或者根据某些配置值切换到正确的工厂。

interface IUserNameFactory
{
    string BuildUserName();
}

class ProductionFactory : IUserNameFactory
{
    public BuildUserName() { return User.Identity.Name; }
}

class MockFactory : IUserNameFactory
{
    public BuildUserName() { return "James"; }
}

IUserNameFactory factory;

if(inProductionMode)
{
    factory = new ProductionFactory();
}
else
{
    factory = new MockFactory();
}

SettingsViewModel svm = _context.MySettings(factory.BuildUserName());

在以前的版本中,您可以直接在控制器上设置 User,这样可以进行一些非常简单的单元测试。

如果您查看 ControllerBase 的源代码,您会注意到 User 是从 HttpContext 中提取的。

/// <summary>
/// Gets the <see cref="ClaimsPrincipal"/> for user associated with the executing action.
/// </summary>
public ClaimsPrincipal User => HttpContext?.User;

控制器通过ControllerContext

访问HttpContext
/// <summary>
/// Gets the <see cref="Http.HttpContext"/> for the executing action.
/// </summary>
public HttpContext HttpContext => ControllerContext.HttpContext;

您会注意到这两个是只读属性。好消息是 ControllerContext 属性 允许设置它的值,这样你就可以进入了。

所以目标是到达那个物体。在 Core HttpContext 中是抽象的,因此更容易模拟。

假设控制器像

public class MyController : Controller {
    IMyContext _context;

    public MyController(IMyContext context) {
        _context = context;
    }

    public IActionResult Index() {
        SettingsViewModel svm = _context.MySettings(User.Identity.Name);
        return View(svm);
    }

    //...other code removed for brevity 
}

使用 Moq,测试可能如下所示

public void Given_User_Index_Should_Return_ViewResult_With_Model() {
    //Arrange 
    var username = "FakeUserName";
    var identity = new GenericIdentity(username, "");

    var mockPrincipal = new Mock<ClaimsPrincipal>();
    mockPrincipal.Setup(x => x.Identity).Returns(identity);
    mockPrincipal.Setup(x => x.IsInRole(It.IsAny<string>())).Returns(true);

    var mockHttpContext = new Mock<HttpContext>();
    mockHttpContext.Setup(m => m.User).Returns(mockPrincipal.Object);

    var model = new SettingsViewModel() {
        //...other code removed for brevity
    };

    var mockContext = new Mock<IMyContext>();
    mockContext.Setup(m => m.MySettings(username)).Returns(model);

    var controller = new MyController(mockContext.Object) {
        ControllerContext = new ControllerContext {
            HttpContext = mockHttpContext.Object
        }
    };

    //Act
    var viewResult = controller.Index() as ViewResult;

    //Assert
    Assert.IsNotNull(viewResult);
    Assert.IsNotNull(viewResult.Model);
    Assert.AreEqual(model, viewResult.Model);
}

控制器的User is accessed through the HttpContext of the controller. The latter is stored within the ControllerContext.

设置用户的最简单方法是为构造的用户分配不同的 HttpContext。为此,我们可以使用 DefaultHttpContext,这样我们就不必模拟所有内容。然后我们只需在控制器上下文中使用 HttpContext 并将其传递给控制器​​实例:

var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[]
{
    new Claim(ClaimTypes.Name, "example name"),
    new Claim(ClaimTypes.NameIdentifier, "1"),
    new Claim("custom-claim", "example claim value"),
}, "mock"));

var controller = new SomeController(dependencies…);
controller.ControllerContext = new ControllerContext()
{
    HttpContext = new DefaultHttpContext() { User = user }
};

创建您自己的 ClaimsIdentity, make sure to pass an explicit authenticationType to the constructor. This makes sure that IsAuthenticated 将正常工作(如果您在代码中使用它来确定用户是否已通过身份验证)。

也可以使用现有的 类,并仅在需要时进行模拟。

var user = new Mock<ClaimsPrincipal>();
_controller.ControllerContext = new ControllerContext
{
    HttpContext = new DefaultHttpContext
    {
        User = user.Object
    }
};

在我的例子中,我需要使用 Request.HttpContext.User.Identity.IsAuthenticatedRequest.HttpContext.User.Identity.Name 和位于控制器外部的一些业务逻辑。我能够结合使用 Nkosi、Calin 和 Poke 的答案:

var identity = new Mock<IIdentity>();
identity.SetupGet(i => i.IsAuthenticated).Returns(true);
identity.SetupGet(i => i.Name).Returns("FakeUserName");

var mockPrincipal = new Mock<ClaimsPrincipal>();
mockPrincipal.Setup(x => x.Identity).Returns(identity.Object);

var mockAuthHandler = new Mock<ICustomAuthorizationHandler>();
mockAuthHandler.Setup(x => x.CustomAuth(It.IsAny<ClaimsPrincipal>(), ...)).Returns(true).Verifiable();

var controller = new MyController(...);

var mockHttpContext = new Mock<HttpContext>();
mockHttpContext.Setup(m => m.User).Returns(mockPrincipal.Object);

controller.ControllerContext = new ControllerContext();
controller.ControllerContext.HttpContext = new DefaultHttpContext()
{
    User = mockPrincipal.Object
};

var result = controller.Get() as OkObjectResult;
//Assert results

mockAuthHandler.Verify();

我想直接点击我的控制器,然后像 AutoFac 一样使用 DI。为此,我首先注册 ContextController.

var identity = new GenericIdentity("Test User");
var httpContext = new DefaultHttpContext()
{
    User = new GenericPrincipal(identity, null)
};

var context = new ControllerContext { HttpContext = httpContext};
builder.RegisterInstance(context);

接下来我在注册控制器时启用 属性 注入。

  builder.RegisterAssemblyTypes(assembly)
                    .Where(t => t.Name.EndsWith("Controller")).PropertiesAutowired();

然后 User.Identity.Name 被填充,在我的 Controller 上调用方法时我不需要做任何特殊的事情。

public async Task<ActionResult<IEnumerable<Employee>>> Get()
{
    var requestedBy = User.Identity?.Name;
    ..................

我有一个 brownfield .net 4.8 项目需要转换为 .net 5.0,我想尽可能多地保留原始代码,包括 unit-/integration 测试。控制器的测试非常依赖上下文,所以我创建了这个扩展方法来启用设置令牌、声明和 headers:

public static void AddContextMock(
    this ControllerBase controller,
    IEnumerable<(string key, string value)> claims = null,
    IEnumerable<(string key, string value)> tokens = null,
    IEnumerable<(string key, string value)> headers = null)
{
    HttpContext mockContext = new DefaultHttpContext();
    if(claims != null)
    {
        mockContext.User = SetupClaims(claims);
    }
    if(tokens != null)
    {
        mockContext.RequestServices = SetupTokens(tokens);
    }
    if(headers != null)
    {
        SetupHeaders(mockContext, headers);
    }

    controller.ControllerContext = new ControllerContext()
    {
        HttpContext = mockContext
    };
}

private static void SetupHeaders(HttpContext mockContext, IEnumerable<(string key, string value)> headers)
{
    foreach(var header in headers)
    {
        mockContext.Request.Headers.Add(header.key, header.value);
    }
}

private static ClaimsPrincipal SetupClaims(IEnumerable<(string key, string value)> claimValues)
{
    var claims = claimValues.Select(c => new Claim(c.key, c.value));
    return new ClaimsPrincipal(new ClaimsIdentity(claims, "mock"));
}

private static IServiceProvider SetupTokens(IEnumerable<(string key, string value)> tokenValues)
{
    var mockServiceProvider = new Mock<IServiceProvider>();
    var authenticationServiceMock = new Mock<IAuthenticationService>();
    var authResult = AuthenticateResult.Success(
        new AuthenticationTicket(new ClaimsPrincipal(), null));
    var tokens = tokenValues.Select(t => new AuthenticationToken { Name = t.key, Value = t.value });
    authResult.Properties.StoreTokens(tokens);

    authenticationServiceMock
        .Setup(x => x.AuthenticateAsync(It.IsAny<HttpContext>(), null))
        .ReturnsAsync(authResult);

    mockServiceProvider.Setup(_ => _.GetService(typeof(IAuthenticationService))).Returns(authenticationServiceMock.Object);
    return mockServiceProvider.Object;
}

这使用 Moq,但可以适应其他模拟框架。身份验证类型被硬编码为“模拟”,因为我依赖默认身份验证,但这也可以提供。

它是这样使用的:

_controllerUnderTest.AddContextMock(
    claims: new[]
    {
        (ClaimTypes.Name, "UserName"),
        (ClaimTypes.MobilePhone, "1234"),
    },
    tokens: new[] 
    { 
        ("access_token", "accessTokenValue") 
    },
    headers: new[]
    {
        ("header", "headerValue")
    });