Saturday, February 25, 2012

Write your own blog engine, part 3

Authenticating the user

I can't let everyone add posts to my blog; that means having a method to distinguish between a visitor and the owner of the blog. One way to do that would be to check the IP of the incoming connection and verify that it belongs to a list of known IPs; however, that would limit me to a fixed number of computers. What if I'm visiting someone and come up with a new idea for a blog post?

No, the correct way of handling this is to add a login screen and verify that the user and password are correct. I am going to use forms authentication so the result of the login process would be a cookie that would re-authenticate the user on subsequent page views. The process will be:

  • user hits an URL that requires authentication
  • user gets redirected to the /Account/LogOn page (and the previous URL gets saved)
  • user enters credentials and clicks the Submit button
  • the credentials are verified; if they are not valid, the user gets redirected to the home page
  • if the credentials are correct, the user is redirected back to the saved URL which allows him access because he is now authenticated

The new acceptance test will check for the presence of that cookie as a result of a POST call to the /Account/LogOn URL:

    [TestMethod]
    public void LoggingInReturnsAuthenticationCookie()
    {
      const string COOKIE_NAME = ".ASPXAUTH";
      const string POST_DATA = "Username=user&Password=pass";

      using (var web = new WebClient())
      {
        web.Headers["Content-Type"] = "application/x-www-form-urlencoded";
        web.UploadString(BASE_URL + "/Account/LogOn", POST_DATA);
        var cookie = web.ResponseHeaders["Set-Cookie"];
        Assert.IsTrue(cookie.StartsWith(COOKIE_NAME));
      }
    }

For now, the test fails with the error "The remote server returned an error: (404) Not Found." – that's because I haven't created the Account controller. I will do that but, of course, not until I write the AccountControllerTests class:

  [TestClass]
  public class AccountControllerTests
  {
    [TestMethod]
    public void POST_LogOnRedirectsToHomePageForInvalidCredentials()
    {
      var sut = new AccountController();

      var result = sut.LogOn(new User()) as RedirectToRouteResult;

      Assert.AreEqual("Home", result.RouteValues["controller"]);
      Assert.AreEqual("Index", result.RouteValues["action"]);
    }
  }

ReSharper tells me that I need to add a reference to System.Web for this to work, so I do.

Of course, nothing compiles. Time to add the new controller:

  public class AccountController : Controller
  {
    [HttpPost]
    public ActionResult LogOn(User user)
    {
      return null;
    }
  }

Note the use of the [HttpPost] attribute; I need that because I will have a different method called when there's a GET request to that URL.

I also need to write the User class; add it in the Models folder, together with the Post class:

  public class User
  {
    public string Username { get; set; }
    public string Password { get; set; }
  }

The unit test will fail, of course; to make it pass I need to change the LogOn method to:

    [HttpPost]
    public ActionResult LogOn(User user)
    {
      return RedirectToAction("Index", "Home");
    }

While writing this part I had problems with a test that was apparently running forever. Just in case you hit this problem, here's how to change the default timeout (you can stop the stuck test by right-clicking on it and choosing "Stop Test Run"): double-click the Local.testsettings file in the Solution Items folder at the top of the solution tree, click the Test Timeouts section on the left hand side and change the value in the bottom part of the window ("Mark an individual test as failed if its execution time exceeds") to 10 seconds. Click Apply and then Close.

The unit test passes but the acceptance test fails with the weird error "Content-Length or Chunked Encoding cannot be set for an operation that does not write data." Searching for it tells me that's because of the redirect; the page I'm POST-ing to returns a redirect response and the WebClient stupidly tries to follow the redirect. I found a solution here (thanks, Peter) so I'll add a new class to the MyBlogEngine.Tests project:

  public class MyWebClient : WebClient
  {
    protected override WebRequest GetWebRequest(Uri address)
    {
      var wr = base.GetWebRequest(address);
      if (wr is HttpWebRequest)
        (wr as HttpWebRequest).AllowAutoRedirect = false;
      
      return wr;
    }
  }

Replacing new WebClient() with new MyWebClient() in the LoggingInReturnsAuthenticationCookie acceptance test makes it fail with a better error message: "Object reference not set to an instance of an object." That's because no cookie is being returned. Good - back to the unit tests.

Ok, I need a way for the controller to validate the user. I could do that in the controller but I don't like violating the Single Responsibility Principle so I will pass a UserService interface to the controller.

Here's the new unit test:

    [TestMethod]
    public void POST_LogOnRedirectsToPreviousPageForValidCredentials()
    {
      const string RETURN_URL = "url";
      var user = new User { Username = "x", Password = "y" };
      var cookies = new HttpCookieCollection();
      var service = new Mock<UserService>();
      service
        .Setup(it => it.ValidateUser(user, cookies))
        .Returns(true);
      var sut = new AccountController(service.Object);
      sut.ViewData["ReturnUrl"] = RETURN_URL;

      var result = sut.LogOn(user) as RedirectResult;

      Assert.AreEqual(RETURN_URL, result.Url);
    }

This means adding a new UserService interface to the Services folder:

  public interface UserService
  {
    bool ValidateUser(User user, HttpCookieCollection cookies);
  }

and also adding a constructor to the AccountController class:

    public AccountController(UserService userService)
    {
    }

(Change the first unit test in the AccountControllerTests class to pass on a null as the UserService argument so that everything compiles.)

The new unit test fails because the result of the method call is not a RedirectResult. I need to change the whole AccountController class to make it pass:

  public class AccountController : Controller
  {
    public AccountController(UserService userService)
    {
      this.userService = userService;
    }

    [HttpPost]
    public ActionResult LogOn(User user)
    {
      if (userService.ValidateUser(user, Response.Cookies))
        return Redirect((string) ViewData["ResponseUrl"]);

      return RedirectToAction("Index", "Home");
    }

    //

    private readonly UserService userService;
  }

The test fails, unfortunately, because Response is null when running from the test engine (as opposed to running it "for real"). Fortunately, Scott Hanselman came up with a solution: MvcMockHelpers. I've changed his version a bit (and of course I'm only using Moq), so add a new class to the MyBlogEngine.Tests project:

  public static class MvcMockHelpers
  {
    public static HttpContextBase FakeHttpContext()
    {
      var context = new Mock<HttpContextBase>();
      var request = new Mock<HttpRequestBase>();
      var response = new Mock<HttpResponseBase>();
      var session = new Mock<HttpSessionStateBase>();
      var server = new Mock<HttpServerUtilityBase>();

      context.Setup(ctx => ctx.Request).Returns(request.Object);
      context.Setup(ctx => ctx.Response).Returns(response.Object);
      context.Setup(ctx => ctx.Session).Returns(session.Object);
      context.Setup(ctx => ctx.Server).Returns(server.Object);

      return context.Object;
    }

    public static HttpContextBase FakeHttpContext(string url)
    {
      var context = FakeHttpContext();
      context.Request.SetupRequestUrl(url);
      return context;
    }

    public static void SetFakeControllerContext(this Controller controller)
    {
      var httpContext = FakeHttpContext();
      var context = new ControllerContext(new RequestContext(httpContext, new RouteData()), controller);
      controller.ControllerContext = context;
    }

    public static void SetHttpMethodResult(this HttpRequestBase request, string httpMethod)
    {
      Mock.Get(request)
        .Setup(req => req.HttpMethod)
        .Returns(httpMethod);
    }

    public static void SetupRequestUrl(this HttpRequestBase request, string url)
    {
      if (url == null)
        throw new ArgumentNullException("url");

      if (!url.StartsWith("~/"))
        throw new ArgumentException("Sorry, we expect a virtual url starting with \"~/\".");

      var mock = Mock.Get(request);

      mock.Setup(req => req.QueryString)
        .Returns(GetQueryStringParameters(url));
      mock.Setup(req => req.AppRelativeCurrentExecutionFilePath)
        .Returns(GetUrlFileName(url));
      mock.Setup(req => req.PathInfo)
        .Returns(string.Empty);
    }

    public static void SetupRequestForm(this HttpRequestBase request, NameValueCollection form)
    {
      Mock.Get(request)
        .Setup(req => req.Form)
        .Returns(form);
    }

    public static void SetupRequestHeaders(this HttpRequestBase request, NameValueCollection headers)
    {
      Mock.Get(request)
        .Setup(req => req.Headers)
        .Returns(headers);
    }

    public static void SetupRequestAcceptTypes(this HttpRequestBase request, IEnumerable<string> acceptTypes)
    {
      Mock.Get(request)
        .Setup(req => req.AcceptTypes)
        .Returns(acceptTypes.ToArray());
    }

    public static void SetUpCookies(this HttpRequestBase request, HttpCookieCollection cookies)
    {
      Mock.Get(request)
        .Setup(req => req.Cookies)
        .Returns(cookies);
    }

    public static void SetUpCookies(this HttpResponseBase request, HttpCookieCollection cookies)
    {
      Mock.Get(request)
        .Setup(req => req.Cookies)
        .Returns(cookies);
    }

    //

    private static NameValueCollection GetQueryStringParameters(string url)
    {
      if (!url.Contains("?"))
        return null;

      var parameters = new NameValueCollection();

      var parts = url.Split("?".ToCharArray());
      var keys = parts[1].Split("&".ToCharArray());

      foreach (var key in keys)
      {
        var part = key.Split("=".ToCharArray());
        parameters.Add(part[0], part[1]);
      }

      return parameters;
    }

    private static string GetUrlFileName(string url)
    {
      return url.Contains("?") ? url.Substring(0, url.IndexOf("?")) : url;
    }
  }

I can now change the unit test to:

    [TestMethod]
    public void POST_LogOnRedirectsToPreviousPageForValidCredentials()
    {
      const string RETURN_URL = "url";
      var user = new User { Username = "x", Password = "y" };
      var cookies = new HttpCookieCollection();
      var service = new Mock<UserService>();
      service
        .Setup(it => it.ValidateUser(user, cookies))
        .Returns(true);
      var sut = new AccountController(service.Object);
      sut.SetFakeControllerContext();
      sut.Response.SetUpCookies(cookies);
      sut.ViewData["ReturnUrl"] = RETURN_URL;

      var result = sut.LogOn(user) as RedirectResult;

      Assert.AreEqual(RETURN_URL, result.Url);
    }

This time it passes. Of course, the other unit test has to change accordingly:

    [TestMethod]
    public void POST_LogOnRedirectsToHomePageForInvalidCredentials()
    {
      var service = new Mock<UserService>();
      var sut = new AccountController(service.Object);
      sut.SetFakeControllerContext();

      var result = sut.LogOn(new User()) as RedirectToRouteResult;

      Assert.AreEqual("Home", result.RouteValues["controller"]);
      Assert.AreEqual("Index", result.RouteValues["action"]);
    }

All the tests pass except for the latest acceptance test. I'm still not done with the unit tests, of course - what happens if ViewData["ReturnUrl"] is null? I decide that I am going to redirect to the home page in that case and add a new unit test to verify that:

    [TestMethod]
    public void POST_LogOnRedirectsToHomePageForValidCredentialsIfNoReturnUrl()
    {
      var user = new User { Username = "x", Password = "y" };
      var cookies = new HttpCookieCollection();
      var service = new Mock<UserService>();
      service
        .Setup(it => it.ValidateUser(user, cookies))
        .Returns(true);
      var sut = new AccountController(service.Object);
      sut.SetFakeControllerContext();
      sut.Response.SetUpCookies(cookies);

      var result = sut.LogOn(user) as RedirectToRouteResult;

      Assert.AreEqual("Home", result.RouteValues["controller"]);
      Assert.AreEqual("Index", result.RouteValues["action"]);
    }

The test fails; I'll change the LogOn method to fix it:

    [HttpPost]
    public ActionResult LogOn(User user)
    {
      var returnUrl = ViewData["ReturnUrl"] as string;

      if (userService.ValidateUser(user, Response.Cookies) && returnUrl != null)
        return Redirect(returnUrl);

      return RedirectToAction("Index", "Home");
    }

The test passes and I broke nothing else. Ok, time to make the acceptance test pass too. That means creating an implementation for the UserService interface. As this is a simple personal blog I won't go into heavy user management here; instead, I will just hardcode a user and password and check against those. Add a new class to the Services folder:

  public class HardcodedUserService : UserService
  {
    public bool ValidateUser(User user, HttpCookieCollection cookies)
    {
      if (user == null)
        return false;

      if (user.Username.ToLowerInvariant() != "user" || user.Password != "pass")
        return false;

      AddAuthenticationCookie(user.Username, cookies);
      return true;
    }

    //

    private static void AddAuthenticationCookie(string username, HttpCookieCollection cookies)
    {
      var authTicket = new FormsAuthenticationTicket(1, username, DateTime.Now, DateTime.Now.AddMinutes(30), true, "");

      var cookieContents = FormsAuthentication.Encrypt(authTicket);
      var cookie = new HttpCookie(FormsAuthentication.FormsCookieName, cookieContents)
      {
        Expires = authTicket.Expiration,
        Path = FormsAuthentication.FormsCookiePath
      };

      cookies.Add(cookie);
    }
  }

The MunqMvc3Startup.PreStart method has to be modified too:

    public static void PreStart()
    {
      DependencyResolver.SetResolver(new MunqDependencyResolver());

      var ioc = MunqDependencyResolver.Container;
      ioc.Register<PostRepository>(c => new DbPostRepository());
      ioc.Register<UserService>(c => new HardcodedUserService());
    }

All the tests are finally passing. I am not done yet, though: the acceptance test does verify the most important part (the logon POST) but there's still the problem of an actual user trying to log on. For this a GET method will also be required. Here's the corresponding unit test:

    [TestMethod]
    public void GET_LogOnReturnsView()
    {
      var service = new Mock<UserService>();
      var sut = new AccountController(service.Object);

      var result = sut.LogOn() as ViewResult;

      Assert.IsNotNull(result);
      Assert.AreEqual("", result.ViewName);
    }

and here is the matching method on the controller:

    [HttpGet]
    public ActionResult LogOn()
    {
      return View(new User());
    }

Of course, for this to actually work I am going to need a view. Add an Account folder under Views and a LogOn view under that:

@{
  ViewBag.Title = "LogOn";
}

@model object

<h2>@ViewBag.Title</h2>

@using(Html.BeginForm())
{
  <fieldset>
    <legend>Enter credentials</legend>
    @Html.EditorForModel()

    <input type="submit" value="Submit"/>
  </fieldset>
}

If I now go to http://localhost:63516/Account/LogOn I can see the form and test it. However, there is no feedback - both the correct and the wrong credentials will redirect me to the home page. (There's no return URL here.) I should fix that… if the credentials are incorrect I should redisplay the view. That means changing the first unit test to:

    [TestMethod]
    public void POST_LogOnReturnsViewForInvalidCredentials()
    {
      var service = new Mock<UserService>();
      var sut = new AccountController(service.Object);
      sut.SetFakeControllerContext();

      var result = sut.LogOn(new User()) as ViewResult;

      Assert.AreEqual("", result.ViewName);
    }

Change the LogOn method to make this pass:

    [HttpPost]
    public ActionResult LogOn(User user)
    {
      var returnUrl = ViewData["ReturnUrl"] as string;

      if (userService.ValidateUser(user, Response.Cookies))
      {
        return returnUrl != null
                 ? (ActionResult) Redirect(returnUrl)
                 : RedirectToAction("Index", "Home");
      }

      ModelState.AddModelError("", "Invalid user or password");

      return View(user);
    }

I should also change the view to show the error:

@{
  ViewBag.Title = "LogOn";
}

@model object

<h2>@ViewBag.Title</h2>

@Html.ValidationSummary()

@using(Html.BeginForm())
{
  <fieldset>
    <legend>Enter credentials</legend>
    @Html.EditorForModel()

    <input type="submit" value="Submit"/>
  </fieldset>
}

All the tests are passing and I can now see what happens when I enter the incorrect credentials. Good milestone but there's still a lot to do. For one thing, if you press Submit without entering a username the app will crash. That's because of the ToLowerInvariant call on a null reference. That's what I get for not having tests I guess. I'll fix that right away by adding a HardcodedUserServiceTests class:

  [TestClass]
  public class HardcodedUserServiceTests
  {
    [TestMethod]
    public void ValidateUserReturnsFalseForNullUsername()
    {
      var sut = new HardcodedUserService();

      var result = sut.ValidateUser(new User(), new HttpCookieCollection());

      Assert.IsFalse(result);
    }
  }

The test fails, of course; the ValidateUser method has to change to make it pass:

    public bool ValidateUser(User user, HttpCookieCollection cookies)
    {
      if (user == null)
        return false;

      if (user.Username == null || user.Username.ToLowerInvariant() != "user" || user.Password != "pass")
        return false;

      AddAuthenticationCookie(user.Username, cookies);
      return true;
    }

All the tests are passing now and the application is no longer crashing if I click the Submit button without entering a username. Good.

The final thing I want to do now is refactor the AcceptanceTests class; right now, because the GET request is in a method marked with the [TestInitialize] attribute it executes even for the test that actually needs a POST, which is bad. Here is the refactored class:

  [TestClass]
  public class AcceptanceTests
  {
    [TestMethod]
    public void HomePageHasCorrectTitle()
    {
      Get();

      var title = root.SelectSingleNode("/html/head/title").InnerText;
      Assert.AreEqual("MyBlogEngine", title);
    }

    [TestMethod]
    public void HomePageReturnsMostRecentArticles()
    {
      Get();

      var articles = root.SelectNodes("/html/body/article").ToList();
      Assert.IsTrue(articles.Any());
      var topArticle = articles.First();
      var header = topArticle.SelectSingleNode("header");
      Assert.IsNotNull(header);
      var title = header.SelectSingleNode("h2");
      Assert.IsNotNull(title);
      var date = header.SelectSingleNode("//time[@pubdate]");
      Assert.IsNotNull(date);
    }

    [TestMethod]
    public void LoggingInReturnsAuthenticationCookie()
    {
      const string COOKIE_NAME = ".ASPXAUTH";
      const string POST_DATA = "Username=user&Password=pass";

      using (var web = new MyWebClient())
      {
        web.Headers["Content-Type"] = "application/x-www-form-urlencoded";
        web.UploadString(BASE_URL + "/Account/LogOn", POST_DATA);
        var cookie = web.ResponseHeaders["Set-Cookie"];
        Assert.IsTrue(cookie.StartsWith(COOKIE_NAME));
      }
    }

    //

    private const string BASE_URL = "http://localhost:63516/";
    private HtmlDocument doc;
    private HtmlNode root;

    private void Get()
    {
      using (var web = new WebClient())
      {
        var html = web.DownloadString(BASE_URL);

        doc = new HtmlDocument();
        doc.LoadHtml(html);
        root = doc.DocumentNode;
      }
    }
  }

The tests are still passing so I'm satisfied with what I've accomplished so far. Next time I'll work on adding new posts.

No comments: