Mockowanie IPrincipal w ASP.NET Core

89

Mam aplikację ASP.NET MVC Core, dla której piszę testy jednostkowe. Jedna z metod akcji używa nazwy użytkownika dla niektórych funkcji:

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

co oczywiście kończy się niepowodzeniem w teście jednostkowym. Rozejrzałem się i wszystkie sugestie pochodzą z .NET 4.5, aby udawać HttpContext. Jestem pewien, że jest na to lepszy sposób. Próbowałem wstrzyknąć IPrincipal, ale spowodowało to błąd; i nawet próbowałem tego (chyba z desperacji):

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

ale to również spowodowało błąd. Nie udało się też znaleźć niczego w dokumentach ...

Felix
źródło

Odpowiedzi:

179

Dostęp do kontrolera uzyskuje się poprzez kontroler . Ten ostatni jest przechowywany w .User HttpContextControllerContext

Najłatwiejszym sposobem ustawienia użytkownika jest przypisanie innego HttpContext z konstruowanym użytkownikiem. Możemy użyć DefaultHttpContextdo tego celu, w ten sposób nie musimy ze wszystkiego kpić. Następnie używamy tego HttpContext w kontekście kontrolera i przekazujemy go do instancji kontrolera:

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 }
};

Tworząc własne ClaimsIdentity, pamiętaj o przekazaniu konstruktorowi jawnego authenticationType. Daje to pewność, że IsAuthenticatedbędzie działać poprawnie (w przypadku, gdy użyjesz tego w swoim kodzie do ustalenia, czy użytkownik jest uwierzytelniony).

szturchać
źródło
7
W moim przypadku było new Claim(ClaimTypes.Name, "1")to dopasowanie użycia kontrolera user.Identity.Name; ale poza tym to jest dokładnie to, co chciałem osiągnąć ... Danke schon!
Felix
Po niezliczonych godzinach poszukiwań ten post w końcu doprowadził mnie do kwadratu. W mojej podstawowej metodzie kontrolera projektu 2.0 używałem User.FindFirstValue(ClaimTypes.NameIdentifier);do ustawienia userId w obiekcie, który tworzyłem, i kończyło się to niepowodzeniem, ponieważ Principal był pusty. To naprawiło to dla mnie. Dzięki za świetną odpowiedź!
Timothy Randall
Szukałem również niezliczonych godzin, aby uruchomić UserManager.GetUserAsync i jest to jedyne miejsce, w którym znalazłem brakujące łącze. Dzięki! Musisz skonfigurować ClaimsIdentity zawierającą Claim, a nie używać GenericIdentity.
Etienne Charland
17

W poprzednich wersjach można było ustawić Userbezpośrednio na kontrolerze, co umożliwiało bardzo łatwe testy jednostkowe.

Jeśli spojrzysz na kod źródłowy ControllerBase , zauważysz, że Userplik jest wyodrębniany z HttpContext.

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

a kontroler uzyskuje dostęp do pliku HttpContextviaControllerContext

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

Zauważysz, że te dwie właściwości są tylko do odczytu. Dobrą wiadomością jest to, że ControllerContextwłaściwość pozwala na ustawienie jej wartości, tak aby była Twoja droga.

Więc celem jest dotarcie do tego obiektu. W Core HttpContextjest abstrakcyjny, więc o wiele łatwiej jest kpić.

Zakładając, że kontroler lubi

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 
}

Używając Moq, test mógłby wyglądać tak

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);
}
Nkosi
źródło
3

Istnieje również możliwość korzystania z istniejących klas i mockowania tylko w razie potrzeby.

var user = new Mock<ClaimsPrincipal>();
_controller.ControllerContext = new ControllerContext
{
    HttpContext = new DefaultHttpContext
    {
        User = user.Object
    }
};
Calin
źródło
3

W moim przypadku potrzebne do wykorzystania Request.HttpContext.User.Identity.IsAuthenticated, Request.HttpContext.User.Identity.Namea niektóre logika siedzi poza sterownikiem. Udało mi się użyć do tego kombinacji odpowiedzi Nkosi, Calina i 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();
Łukasz
źródło
2

Chciałbym zaimplementować abstrakcyjny wzór fabryki.

Utwórz interfejs dla fabryki specjalnie w celu podawania nazw użytkowników.

Następnie podaj konkretne klasy, jedną, która zapewnia User.Identity.Namei taką, która zapewnia inną zakodowaną wartość, która działa w twoich testach.

Następnie możesz użyć odpowiedniej konkretnej klasy w zależności od kodu produkcyjnego i testowego. Być może chce przekazać fabrykę jako parametr lub przełączyć się do właściwej fabryki w oparciu o jakąś wartość konfiguracyjną.

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());
James Wood
źródło
Dziękuję Ci. Robię coś podobnego dla moich obiektów. Miałem nadzieję, że w przypadku tak powszechnej rzeczy, jak IPrinicpal, będzie coś „po wyjęciu z pudełka”. Ale najwyraźniej nie!
Felix
Ponadto User jest zmienną składową ControllerBase. Dlatego we wcześniejszych wersjach ASP.NET ludzie szydzili z HttpContext i stamtąd otrzymywali IPrincipal. Nie można po prostu pobrać User z samodzielnej klasy, takiej jak ProductionFactory
Felix
1

Chcę bezpośrednio uderzyć w moje kontrolery i po prostu używać DI jak AutoFac. Aby to zrobić, najpierw się rejestruję 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);

Następnie włączam wstrzykiwanie właściwości, gdy rejestruję kontrolery.

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

Następnie User.Identity.Namejest wypełniony i nie muszę robić nic specjalnego podczas wywoływania metody na moim kontrolerze.

public async Task<ActionResult<IEnumerable<Employee>>> Get()
{
    var requestedBy = User.Identity?.Name;
    ..................
Devgig
źródło