在 ASP.NET Core 中模拟 IPrincipal

Posted

技术标签:

【中文标题】在 ASP.NET Core 中模拟 IPrincipal【英文标题】:Mocking IPrincipal in ASP.NET Core 【发布时间】:2016-07-24 23:41:33 【问题描述】:

我有一个 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);

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

【问题讨论】:

【参考方案1】:

控制器的User is accessed 通过HttpContext of the controller。后者is stored 内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 时,请确保将显式authenticationType 传递给构造函数。这可以确保IsAuthenticated 可以正常工作(如果您在代码中使用它来确定用户是否经过身份验证)。

【讨论】:

在我的例子中,new Claim(ClaimTypes.Name, "1") 匹配控制器对user.Identity.Name 的使用;但除此之外,这正是我想要实现的目标...... Danke schon! 经过无数小时的搜索,这篇文章终于让我满意了。在我的核心 2.0 项目控制器方法中,我利用 User.FindFirstValue(ClaimTypes.NameIdentifier); 在我正在创建的对象上设置 userId,但由于主体为空而失败。这为我解决了这个问题。感谢您的精彩回答! 我也搜索了无数小时以使 UserManager.GetUserAsync 工作,这是我找到缺失链接的唯一地方。谢谢!需要设置一个包含声明的 ClaimsIdentity,而不是使用 GenericIdentity。 @JavierBuffa 单一声明不支持多个值;相反,会有多个具有相同类型的声明。所以传递给 ClaimsIdentity 构造函数的数组只能有多个角色声明,例如new Claim(ClaimTypes.Role, "role1"), new Claim(ClaimTypes.Role, "role2"). @DeanP 这假设您在一个单元测试中,您正在创建一个控制器(这里:SomeController),并且您希望实例化该控制器,然后对其执行控制器操作作为你的单元测试。 – 您通常不希望在实际的应用程序代码中这样做。【参考方案2】:

在以前的版本中,您可以直接在控制器上设置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);

【讨论】:

【参考方案3】:

还可以使用现有的类,只在需要时进行模拟。

var user = new Mock<ClaimsPrincipal>();
_controller.ControllerContext = new ControllerContext

    HttpContext = new DefaultHttpContext
    
        User = user.Object
    
;

【讨论】:

【参考方案4】:

就我而言,我需要使用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();

【讨论】:

【参考方案5】:

我想直接点击我的控制器,然后像 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;
    ..................

【讨论】:

【参考方案6】:

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

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

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

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

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());

【讨论】:

谢谢。我正在为 my 对象做类似的事情。我只是希望像 IPrinicpal 这样常见的东西,会有一些“开箱即用”的东西。但显然不是! 另外,User是ControllerBase的成员变量。这就是为什么在早期版本的 ASP.NET 中人们嘲笑 HttpContext,并从那里获取 IPrincipal。不能只从一个独立的类中获取 User,比如 ProductionFactory【参考方案7】:

我有一个棕地 .net 4.8 项目,我需要将其转换为 .net 5.0,我希望尽可能多地保留原始代码,包括单元/集成测试。控制器的测试非常依赖于上下文,所以我创建了这个扩展方法来启用设置令牌、声明和标题:

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

【讨论】:

以上是关于在 ASP.NET Core 中模拟 IPrincipal的主要内容,如果未能解决你的问题,请参考以下文章

使用模拟的 ASP.NET Core 无法访问用户身份

《ASP.NET Core 6框架揭秘》实例演示[27]:ASP.NET Core 6 Minimal API的模拟实现

ASP.NET Core 中的 Windows 身份验证模拟是不是已死?

ASP.NET Core真实管道详解[1]

如何保护 ASP.NET Core Web API 免受被盗 JWT 令牌以进行模拟

[ASP.NET Core Web应用上使用个人用户帐户存储在应用程序中的Postman模拟登录时出现400错误请求错误