单元测试ASP.Net MVC授权属性以验证重定向到登录页面

71

这很可能只是需要另一双眼睛的情况。我肯定错过了什么,但我不能弄清楚为什么无法测试这种情况。我基本上正在尝试通过使用 [Authorize] 属性标记控制器来确保未经身份验证的用户无法访问视图,并且我正在尝试使用以下代码进行测试:

[Fact]
public void ShouldRedirectToLoginForUnauthenticatedUsers()
{
    var mockControllerContext = new Mock<ControllerContext>()
                         { DefaultValue = DefaultValue.Mock };
    var controller = new MyAdminController() 
              {ControllerContext = mockControllerContext.Object};
    mockControllerContext.Setup(c =>
               c.HttpContext.Request.IsAuthenticated).Returns(false);
    var result = controller.Index();
    Assert.IsAssignableFrom<RedirectResult>(result);
}
我正在寻找的RedirectResult是某种指示用户被重定向到登录表单的迹象,但实际上总是返回ViewResult,并且在调试时可以看到Index()方法成功执行,即使用户未经过身份验证。

我做错了什么吗?测试错误吗?我应该在路由级别进行测试,以解决此类问题吗?
我知道[Authorize]属性可行,因为当我打开页面时,确实会强制显示登录屏幕 - 但我该如何在测试中验证这一点?
控制器和索引方法非常简单,以便我可以验证其行为。 我已经包含它们以保持完整性:
[Authorize]
public class MyAdminController : Controller
{
    public ActionResult Index()
    {
        return View();
    }
}

非常感谢任何帮助...


一种测试的方法是使用Microsoft.AspNetCore.Mvc.Testing提供的内存主机服务器进行集成测试,您可以在其中模拟AuthHandler。 详细信息可在此处找到:https://learn.microsoft.com/zh-cn/aspnet/core/test/integration-tests?view=aspnetcore-5.0 - Oleg Suprun
5个回答

112
你的测试方法选择有误。[Authorize] 属性确保未经授权的用户不会调用该方法,重定向结果实际上来自路由而不是控制器方法。好消息是,这已经有了测试覆盖率(作为 MVC 框架源代码的一部分),所以我认为你不需要担心它;只需确保在调用控制器方法时它能做正确的事情,并且相信框架不会在错误的情况下调用它。编辑:如果你想在单元测试中验证属性的存在,你需要使用反射检查你的控制器方法,如下例所示。此示例将验证在安装有 MVC2 的“新 ASP.NET MVC 2 项目”演示中的 ChangePassword POST 方法中是否存在 Authorize 属性。
[TestFixture]
public class AccountControllerTests {

    [Test]
    public void Verify_ChangePassword_Method_Is_Decorated_With_Authorize_Attribute() {
        var controller = new AccountController();
        var type = controller.GetType();
        var methodInfo = type.GetMethod("ChangePassword", new Type[] { typeof(ChangePasswordModel) });
        var attributes = methodInfo.GetCustomAttributes(typeof(AuthorizeAttribute), true);
        Assert.IsTrue(attributes.Any(), "No AuthorizeAttribute found on ChangePassword(ChangePasswordModel model) method");
    }
}

2
谢谢Dylan - 我以为我可能在错误的级别上进行测试。如果控制器被击中,用户已通过身份验证的“假设”想法让我感到满意。附言:你确定它在框架中测试过吗?我可以看到一些测试提供有效的IPrincipal,但没有测试无效情况;-) - RobertTheGrey
3
嗯,我没有实际验证过那个测试用例;我信任MVC团队已经把它做对了。是我的错! - Dylan Beattie
6
我喜欢为什么这不是正确方法的答案,但对于“该特性已在框架中进行了测试并正常工作”这一论点并不信服。我相信属性正常工作,这是框架的职责,但我仍然想确定我的控制器中哪些方法使用了该属性。 - Mathias
1
@Mathias - 请参考编辑部分,了解如何使用反射来验证所需属性的存在。 - Dylan Beattie
2
[Authorize] 属性确保路由引擎永远不会为未经授权的用户调用该方法 - RedirectResult 实际上来自路由,而不是您的控制器方法。这个句子的所有内容都是错误的。路由在授权过滤器运行之前很久就已经发生了 - MVC 生命周期AuthorizeAttribute(注册为IAuthorizationFilter)在授权失败时提供RedirectResult - NightOwl888
显示剩余3条评论

28

你可能在错误的层次上进行测试,但这个测试是有意义的。我的意思是,如果我使用“authorize(Roles =“Superhero”)"属性标记一个方法,如果已经标记了它,我就不需要测试。我(认为)想要测试的是未经授权的用户无法访问,而经授权的用户可以访问。

对于未经授权的用户,可以进行以下测试:

// Arrange
var user = SetupUser(isAuthenticated, roles);
var controller = SetupController(user);

// Act
SomeHelper.Invoke(controller => controller.MyAction());

// Assert
Assert.AreEqual(401,
  controller.ControllerContext.HttpContext.Response.StatusCode, "Status Code");

虽然不容易,我花了10个小时才做出来。但是这里它就是了。我希望有人可以从中受益或者说服我去做别的事情 :) (顺便提一下 - 我正在使用Rhino Mock)

[Test]
public void AuthenticatedNotIsUserRole_Should_RedirectToLogin()
{
    // Arrange
    var mocks = new MockRepository();
    var controller = new FriendsController();
    var httpContext = FakeHttpContext(mocks, true);
    controller.ControllerContext = new ControllerContext
    {
        Controller = controller,
        RequestContext = new RequestContext(httpContext, new RouteData())
    };

    httpContext.User.Expect(u => u.IsInRole("User")).Return(false);
    mocks.ReplayAll();

    // Act
    var result =
        controller.ActionInvoker.InvokeAction(controller.ControllerContext, "Index");
    var statusCode = httpContext.Response.StatusCode;

    // Assert
    Assert.IsTrue(result, "Invoker Result");
    Assert.AreEqual(401, statusCode, "Status Code");
    mocks.VerifyAll();
}

虽然没有这个辅助函数,那也不是很有用:

public static HttpContextBase FakeHttpContext(MockRepository mocks, bool isAuthenticated)
{
    var context = mocks.StrictMock<HttpContextBase>();
    var request = mocks.StrictMock<HttpRequestBase>();
    var response = mocks.StrictMock<HttpResponseBase>();
    var session = mocks.StrictMock<HttpSessionStateBase>();
    var server = mocks.StrictMock<HttpServerUtilityBase>();
    var cachePolicy = mocks.Stub<HttpCachePolicyBase>();
    var user = mocks.StrictMock<IPrincipal>();
    var identity = mocks.StrictMock<IIdentity>();
    var itemDictionary = new Dictionary<object, object>();

    identity.Expect(id => id.IsAuthenticated).Return(isAuthenticated);
    user.Expect(u => u.Identity).Return(identity).Repeat.Any();

    context.Expect(c => c.User).PropertyBehavior();
    context.User = user;
    context.Expect(ctx => ctx.Items).Return(itemDictionary).Repeat.Any();
    context.Expect(ctx => ctx.Request).Return(request).Repeat.Any();
    context.Expect(ctx => ctx.Response).Return(response).Repeat.Any();
    context.Expect(ctx => ctx.Session).Return(session).Repeat.Any();
    context.Expect(ctx => ctx.Server).Return(server).Repeat.Any();

    response.Expect(r => r.Cache).Return(cachePolicy).Repeat.Any();
    response.Expect(r => r.StatusCode).PropertyBehavior();

    return context;
}

因此你可以确认未分配角色的用户没有访问权限。我试图编写一个测试以证明相反的情况,但是在深入挖掘MVC管道两个小时后,我将其留给手动测试人员。(当我到达VirtualPathProviderViewEngine类时放弃。WTF?我不想与VirtualPath或Provider或ViewEngine打交道,更不想处理它们三者的联合!)

我很好奇为什么在这个声称“可测试”的框架中这么难。


确实有点玄学,但幸运的是,如果你坚持下去,你可以找到解决它以及后续问题的方法,就像我一样。请查看我的 Github 项目: https://github.com/ibrahimbensalah/Xania.AspNet.Simulator/blob/master/Xania.AspNet.Simulator.Tests/ - Ibrahim ben Salah
这篇文章与@Dario在帖子中提到的链接几乎完全相同。你是自己开发的吗? - M. Mimpen
是的,这个项目完全由我自己开发,并且仍在积极开发中。目前支持mvc4和mvc5的授权、模型绑定器、请求验证、Razor渲染等功能。 - Ibrahim ben Salah
这是一个非常好的答案@DanielEli!如果您有任何关于如何在.NET Core中实现它的想法,我会非常感激您能够查看我的问题:https://dev59.com/2FYM5IYBdhLWcg3wpRXa - 08Dc91wk
当我使用Moq而不是Rhino Mocks运行上面的示例时,响应中的StatusCode始终为0。有人有使用Moq的可工作示例吗? - user2966445
@M.Mimpen 虽然 Dario的只有链接回答 现在已经被删除,但无论是复制还是不复制,这个回答现在都更加重要了。(看起来你要求Dario在他的回答被删除之前提供更多信息,以便使其与这个答案相匹配...也许你可以将两个答案与你的评论结合起来,做出自己的超级答案?) - ruffin

4

为什么不使用反射来查找您正在测试的控制器类和/或操作方法上的[Authorize]属性呢?假设框架确保属性得到了遵守,这将是最简单的方法。


1
这里有两件不同的事情需要测试。(1)测试自定义属性是否真正发挥了其作用; (2)测试控制器/操作是否仍然装饰有该属性。你回答的是(2),但我认为Dario Quintana发布的链接最好地回答了(1)。 - Sebastien Martin
在现实世界中,使用Authorize属性进行注释并不是授权请求/控制器操作的唯一方式。 - Ibrahim ben Salah

3
我不同意Dylan的答案,因为“用户必须登录”并不意味着“控制器方法带有AuthorizeAttribute注释”。
为了确保在调用操作方法时“用户必须登录”,ASP.NET MVC框架会执行类似以下步骤(请耐心等待,最终会变得更简单)。
let $filters = All associated filter attributes which implement
               IAuthorizationFilter

let $invoker = instance of type ControllerActionInvoker
let $ctrlCtx = instance or mock of type ControllerContext
let $actionDesc = instance or mock of type ActionDescriptor
let $authzCtx = $invoker.InvokeAuthorizationFilters($ctrlCtx, $filters, $actionDesc);

then controller action is authorized when $authzCtx.Result is not null 

实际上,在 C# 代码中实现这个伪代码很难。不过Xania.AspNet.Simulator使得建立此类测试非常简单,并且在内部执行了这些步骤。以下是一个示例:

首先,从 NuGet 安装包(版本 1.4.0-beta4):

PM > install-package Xania.AspNet.Simulator -Pre

然后,您的测试方法可以像这样编写(假设已安装 NUnit 和 FluentAssertions):

[Test]
public void AnonymousUserIsNotAuthorized()
{
  // arrange
  var action = new ProfileController().Action(c => c.Index());
  // act
  var result = action.GetAuthorizationResult();
  // assert
  result.Should().NotBeNull(); 
}

[Test]
public void LoggedInUserIsAuthorized()
{
  // arrange
  var action = new ProfileController().Action(c => c.Index())
     // simulate authenticated user
     .Authenticate("user1", new []{"role1"});
  // act
  var result = action.GetAuthorizationResult();
  // assert
  result.Should().BeNull(); 
}

2

对于.NET Framework,我们使用这个类来验证每个MVC和API控制器是否有AuthorizeAttribute,并且每个API控制器都应该有一个RoutePrefixAttribute

[TestFixture]
public class TestControllerHasAuthorizeRole
{
    private static IEnumerable<Type> GetChildTypes<T>()
    {
        var types = typeof(Startup).Assembly.GetTypes();
        return types.Where(t => t.IsSubclassOf(typeof(T)) && !t.IsAbstract);
    }

    [Test]
    public void MvcControllersShouldHaveAuthrorizeAttribute()
    {
        var controllers = GetChildTypes<Controller>();
        foreach (var controller in controllers)
        {
            var authorizeAttribute = Attribute.GetCustomAttribute(controller, typeof(System.Web.Mvc.AuthorizeAttribute), true) as System.Web.Mvc.AuthorizeAttribute;
            Assert.IsNotNull(authorizeAttribute, $"MVC-controller {controller.FullName} does not implement AuthorizeAttribute");
        }
    }

    [Test]
    public void ApiControllersShouldHaveAuthorizeAttribute()
    {
        var controllers = GetChildTypes<ApiController>();
        foreach (var controller in controllers)
        {
            var attribute = Attribute.GetCustomAttribute(controller, typeof(System.Web.Http.AuthorizeAttribute), true) as System.Web.Http.AuthorizeAttribute;
            Assert.IsNotNull(attribute, $"API-controller {controller.FullName} does not implement AuthorizeAttribute");
        }
    }

    [Test]
    public void ApiControllersShouldHaveRoutePrefixAttribute()
    {
        var controllers = GetChildTypes<ApiController>();
        foreach (var controller in controllers)
        {
            var attribute = Attribute.GetCustomAttribute(controller, typeof(System.Web.Http.RoutePrefixAttribute), true) as System.Web.Http.RoutePrefixAttribute;
            Assert.IsNotNull(attribute, $"API-controller {controller.FullName} does not implement RoutePrefixAttribute");
            Assert.IsTrue(attribute.Prefix.StartsWith("api/", StringComparison.OrdinalIgnoreCase), $"API-controller {controller.FullName} does not have a route prefix that starts with api/");
        }
    }
}

在.NET Core和.NET 5中,这个过程会更加容易一些。MVC控制器继承自Controller,而Controller本身继承自ControllerBase。API控制器直接继承自ControllerBase,因此我们可以使用一个方法来测试MVC和API控制器:
public class AuthorizeAttributeTest
{
    private static IEnumerable<Type> GetChildTypes<T>()
    {
        var types = typeof(Startup).Assembly.GetTypes();
        return types.Where(t => t.IsSubclassOf(typeof(T)) && !t.IsAbstract);
    }

    [Fact]
    public void ApiAndMVCControllersShouldHaveAuthorizeAttribute()
    {
        var controllers = GetChildTypes<ControllerBase>();
        foreach (var controller in controllers)
        {
            var attribute = Attribute.GetCustomAttribute(controller, typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), true) as Microsoft.AspNetCore.Authorization.AuthorizeAttribute;
            Assert.NotNull(attribute);
        }
    }
}

1
+1 这是一个很棒的代码片段。感谢您的分享...即使在您回答它时,这个问题已经存在了12年 :D - C. Tewalt
@C.Tewalt 哈哈谢谢! :D - Ogglas

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接