~jan0sch/smederee

Showing details for patch 7184525e4d0f70ff84ace7335642afc9eb4bf136.
2022-12-31 (Sat), 4:05 PM - Jens Grassel - 7184525e4d0f70ff84ace7335642afc9eb4bf136

Add tests for `AuthenticationRoutes`.

- add `AuthenticationFailure` enum
- implement basic tests using `TestAuthenticationRepository` as dummy repo
Summary of changes
2 files added
  • modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala
  • modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala
1 files modified with 25 lines added and 6 lines removed
  • modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala with 25 added and 6 removed lines
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala	2025-02-01 06:43:19.599491011 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala	2025-02-01 06:43:19.599491011 +0000
@@ -39,6 +39,23 @@
 import org.slf4j.LoggerFactory
 import play.twirl.api._
 
+/** Enumeration of possible kinds of authentication failures.
+  */
+enum AuthenticationFailure {
+
+  /** The account was not found in the database.
+    */
+  case AccountNotFound extends AuthenticationFailure
+
+  /** The account is locked.
+    */
+  case AccountLocked extends AuthenticationFailure
+
+  /** The provided password was wrong.
+    */
+  case WrongPassword extends AuthenticationFailure
+}
+
 /** The routes for handling the authentication related tasks like login, logout, password reset, unlocking etc.
   *
   * @param clock
@@ -98,13 +115,15 @@
               login <- check match {
                 case None =>
                   // No account was found!
-                  Sync[F].pure(None)
+                  Sync[F].pure(Left(AuthenticationFailure.AccountNotFound))
                 case Some((true, true)) =>
                   // The password is valid and the attempts are below or equal to the limit.
-                  Sync[F].delay(user)
+                  Sync[F].delay(user.toRight(AuthenticationFailure.AccountNotFound))
                 case Some((false, true)) =>
                   // The password is wrong and the attempts are below or equal to the limit.
-                  user.map(_.uid).traverse(repo.incrementFailedAttempts) *> Sync[F].pure(None)
+                  user.map(_.uid).traverse(repo.incrementFailedAttempts) *> Sync[F].pure(
+                    Left(AuthenticationFailure.WrongPassword)
+                  )
                 case Some((_, false)) =>
                   // The login attempts are above the limit.
                   for {
@@ -114,17 +133,17 @@
                       .traverse(uid =>
                         Sync[F].delay(log.info(s"Locking account $uid (too many authentication failures)!"))
                       )
-                  } yield None
+                  } yield Left(AuthenticationFailure.AccountLocked)
               }
               response <- login match {
-                case None =>
+                case Left(_) =>
                   BadRequest(
                     views.html.login()(loginPath, csrf, title = "Smederee - Login to your account".some)(
                       formData,
                       Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!")))
                     )
                   )
-                case Some(account) =>
+                case Right(account) =>
                   for {
                     session <- Sync[F].delay(
                       Session.create(
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala	2025-02-01 06:43:19.599491011 +0000
@@ -0,0 +1,298 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import java.nio.charset.StandardCharsets
+
+import cats.effect._
+import cats.syntax.all._
+import com.typesafe.config.ConfigFactory
+import de.smederee.hub.Generators._
+import de.smederee.hub.config._
+import de.smederee.hub.forms._
+import de.smederee.hub.forms.types._
+import de.smederee.security.{ SignAndValidate, SignedToken }
+import fs2.Stream
+import org.http4s._
+import org.http4s.headers._
+import org.http4s.implicits._
+import org.http4s.server._
+import org.http4s.twirl.TwirlInstances._
+import pureconfig.ConfigSource
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+class AuthenticationRoutesTest extends CatsEffectSuite {
+  val loginPath = uri"/login"
+
+  protected final val configuration: SmedereeHubConfig =
+    ConfigSource.fromConfig(ConfigFactory.load(getClass.getClassLoader)).loadOrThrow[SmedereeHubConfig]
+
+  test("GET /login must return the login form for guest users") {
+    val expectedHtml = views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)()
+
+    val clock                = java.time.Clock.systemUTC
+    val authenticationConfig = configuration.service.authentication
+    val repo                 = new TestAuthenticationRepository[IO](List.empty, List.empty)
+    val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
+
+    def service: HttpRoutes[IO] =
+      Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+
+    def request = Request[IO](method = Method.GET, uri = loginPath)
+
+    def response: IO[Response[IO]] = service.orNotFound.run(request)
+
+    val test = for {
+      result <- response
+      body   <- result.as[String]
+    } yield (result, body)
+
+    test.map { output =>
+      val (result, body) = output
+      assertEquals(result.status, Status.Ok)
+      assertEquals(body, expectedHtml.toString)
+    }
+  }
+
+  test("POST /login must return 415 - Unsupported Media Type if the request is malformed") {
+    val clock                = java.time.Clock.systemUTC
+    val authenticationConfig = configuration.service.authentication
+    val repo                 = new TestAuthenticationRepository[IO](List.empty, List.empty)
+    val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
+
+    def service: HttpRoutes[IO] =
+      Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+
+    val payload = "This is not the request body you are looking for.".getBytes(StandardCharsets.UTF_8)
+    def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload)
+
+    def response: IO[Response[IO]] = service.orNotFound.run(request)
+
+    val test = for {
+      result <- response
+      body   <- result.as[String]
+    } yield (result, body)
+
+    test.map { output =>
+      val (result, body) = output
+      assertEquals(result.status, Status.UnsupportedMediaType)
+      assertEquals(
+        body,
+        "Media type supplied in Content-Type header is not supported. Expected one of the following media ranges: application/*"
+      )
+    }
+  }
+
+  test("POST /login must return 400 - Bad Request if the form data is invalid") {
+    val expectedErrors = LoginForm.validate(Map.empty).leftMap(FormErrors.fromNec).swap.toOption.get
+    val expectedHtml =
+      views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)(formErrors = expectedErrors)
+
+    val clock                = java.time.Clock.systemUTC
+    val authenticationConfig = configuration.service.authentication
+    val repo                 = new TestAuthenticationRepository[IO](List.empty, List.empty)
+    val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
+
+    def service: HttpRoutes[IO] =
+      Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+
+    val payload = UrlForm.empty
+    def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload)
+
+    def response: IO[Response[IO]] = service.orNotFound.run(request)
+
+    val test = for {
+      result <- response
+      body   <- result.as[String]
+    } yield (result, body)
+
+    test.map { output =>
+      val (result, body) = output
+      assertEquals(result.status, Status.BadRequest)
+      assertEquals(body, expectedHtml.toString)
+    }
+  }
+
+  test("POST /login must return 400 - Bad Request AND increase failed attempts if the credentials are invalid") {
+    genValidAccount.sample match {
+      case None => fail("Could not generate data samples!")
+      case Some(account) =>
+        val clock                = java.time.Clock.systemUTC
+        val authenticationConfig = configuration.service.authentication
+        val repo                 = new TestAuthenticationRepository[IO](List(account), List.empty)
+        val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
+
+        def service: HttpRoutes[IO] =
+          Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+
+        val payload = UrlForm.empty
+          .updateFormField(LoginForm.fieldName.toString, account.name.toString)
+          .updateFormField(LoginForm.fieldPassword.toString, "doesn't matter")
+        def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload)
+
+        val expectedHtml =
+          views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)(
+            formData = Map(LoginForm.fieldName.toString -> account.name.toString),
+            Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!")))
+          )
+
+        def response: IO[Response[IO]] = service.orNotFound.run(request)
+
+        val test = for {
+          result <- response
+          body   <- result.as[String]
+          failed <- repo.getFailed
+        } yield (result, body, failed)
+
+        test.map { output =>
+          val (result, body, failed) = output
+          assertEquals(result.status, Status.BadRequest)
+          assertEquals(failed.find(_._1 === account.uid), Option((account.uid, 1)))
+          assertEquals(body, expectedHtml.toString)
+        }
+    }
+  }
+
+  test(
+    "POST /login must return 400 - Bad Request AND lock the account if the credentials are invalid and failed attempts are too high"
+  ) {
+    genValidAccount.sample match {
+      case None => fail("Could not generate data samples!")
+      case Some(account) =>
+        val clock                = java.time.Clock.systemUTC
+        val authenticationConfig = configuration.service.authentication
+        val repo                 = new TestAuthenticationRepository[IO](List(account), List.empty)
+        val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
+
+        for (_ <- 0 to authenticationConfig.lockAfter.toInt) yield repo.incrementFailedAttempts(account.uid)
+
+        def service: HttpRoutes[IO] =
+          Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+
+        val payload = UrlForm.empty
+          .updateFormField(LoginForm.fieldName.toString, account.name.toString)
+          .updateFormField(LoginForm.fieldPassword.toString, "doesn't matter")
+        def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload)
+
+        val expectedHtml =
+          views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)(
+            formData = Map(LoginForm.fieldName.toString -> account.name.toString),
+            Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!")))
+          )
+
+        def response: IO[Response[IO]] = service.orNotFound.run(request)
+
+        val test = for {
+          result <- response
+          body   <- result.as[String]
+          locked <- repo.getLocked
+        } yield (result, body, locked)
+
+        test.map { output =>
+          val (result, body, locked) = output
+          assertEquals(result.status, Status.BadRequest)
+          assert(locked.exists(_ === account.uid), s"Account was not locked! ($locked)")
+          assertEquals(body, expectedHtml.toString)
+        }
+    }
+  }
+
+  test("POST /login must return 400 - Bad Request if the credentials are valid but the account is locked") {
+    genValidAccount.sample match {
+      case None => fail("Could not generate data samples!")
+      case Some(account) =>
+        val clock                = java.time.Clock.systemUTC
+        val authenticationConfig = configuration.service.authentication
+        // A locked account is not supposed to be returned by findAccount, so we leave the account list empty.
+        val repo            = new TestAuthenticationRepository[IO](List.empty, List.empty)
+        val signAndValidate = SignAndValidate(configuration.service.authentication.cookieSecret)
+
+        def service: HttpRoutes[IO] =
+          Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+
+        val payload = UrlForm.empty
+          .updateFormField(LoginForm.fieldName.toString, account.name.toString)
+          .updateFormField(LoginForm.fieldPassword.toString, "doesn't matter")
+        def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload)
+
+        val expectedHtml =
+          views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)(
+            formData = Map(LoginForm.fieldName.toString -> account.name.toString),
+            Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!")))
+          )
+
+        def response: IO[Response[IO]] = service.orNotFound.run(request)
+
+        val test = for {
+          result <- response
+          body   <- result.as[String]
+        } yield (result, body)
+
+        test.map { output =>
+          val (result, body) = output
+          assertEquals(result.status, Status.BadRequest)
+          assertEquals(body, expectedHtml.toString)
+        }
+    }
+  }
+
+  test("POST /login must return 303 - See Other AND set the authentication cookie if the credentials are valid") {
+    genValidAccount.sample match {
+      case None => fail("Could not generate data samples!")
+      case Some(account) =>
+        val clock                = java.time.Clock.systemUTC
+        val authenticationConfig = configuration.service.authentication
+        val repo                 = new TestAuthenticationRepository[IO](List(account), List.empty)
+        val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
+
+        def service: HttpRoutes[IO] =
+          Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+
+        val payload = UrlForm.empty
+          .updateFormField(LoginForm.fieldName.toString, account.name.toString)
+          .updateFormField(LoginForm.fieldPassword.toString, repo.DefaultPassword)
+        def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload)
+
+        val expectedHtml =
+          views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)(
+            formData = Map(LoginForm.fieldName.toString -> account.name.toString),
+            Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!")))
+          )
+
+        def response: IO[Response[IO]] = service.orNotFound.run(request)
+
+        val test = for {
+          result <- response
+          body   <- result.as[String]
+        } yield (result, body)
+
+        test.map { output =>
+          val (result, body) = output
+          assertEquals(result.status, Status.SeeOther)
+          assert(body.isEmpty, "Response body must be empty!")
+          result.cookies.find(_.name === Constants.authenticationCookieName.toString).map(_.content) match {
+            case None          => fail("Authentication cookie not set!")
+            case Some(content) => assert(SignedToken.from(content).flatMap(signAndValidate.validate).nonEmpty)
+          }
+        }
+    }
+  }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala	2025-02-01 06:43:19.599491011 +0000
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import java.nio.charset.StandardCharsets
+import java.util.UUID
+
+import cats.effect._
+import cats.syntax.all._
+
+/** An implementation of a [[AuthenticationRepository]] for testing purposes.
+  *
+  * @param accounts
+  *   A list of accounts from which data might be returned.
+  * @param sessions
+  *   A list of sessions from which data might be returned.
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+class TestAuthenticationRepository[F[_]: Sync](accounts: List[Account], sessions: List[Session])
+    extends AuthenticationRepository[F] {
+  val DefaultPassword = "My voice is my passport. Verify me."
+
+  private var failed = Map.empty[UserId, Int]
+  private var locked = List.empty[UserId]
+
+  /** Return the map of user ids and related failed login attempts.
+    *
+    * @return
+    *   A map of user ids pointing to an integer holding the number of failed login attempts.
+    */
+  def getFailed: F[Map[UserId, Int]] = Sync[F].delay(failed)
+
+  /** Return the list of locked user ids.
+    *
+    * @return
+    *   A list with user ids that are locked.
+    */
+  def getLocked: F[List[UserId]] = Sync[F].delay(locked)
+
+  override def lockAccount(uid: UserId)(token: Option[UnlockToken]): F[Int] = {
+    locked = uid :: locked
+    Sync[F].pure(1)
+  }
+
+  override def findAccountByName(name: Username): F[Option[Account]] = Sync[F].pure(accounts.headOption)
+
+  override def resetFailedAttempts(uid: UserId): F[Int] = {
+    failed = failed.filterNot(_._1 === uid)
+    Sync[F].pure(1)
+  }
+
+  override def createUserSession(session: Session): F[Int] = Sync[F].pure(1)
+
+  override def incrementFailedAttempts(uid: UserId): F[Int] = {
+    failed = failed |+| Map(uid -> 1)
+    Sync[F].pure(1)
+  }
+
+  override def findUserSession(id: SessionId): F[Option[Session]] = Sync[F].pure(sessions.headOption)
+
+  override def findLockedAccount(name: Username)(token: UnlockToken): F[Option[Account]] =
+    Sync[F].pure(accounts.headOption)
+
+  override def findPasswordHashAndAttempts(uid: UserId): F[Option[(PasswordHash, Int)]] =
+    Sync[F].delay(
+      Option((Password(DefaultPassword.getBytes(StandardCharsets.UTF_8)).encode, failed.withDefaultValue(0)(uid)))
+    )
+
+  override def findAccount(uid: UserId): F[Option[Account]] = Sync[F].pure(accounts.headOption)
+
+  override def findAccountByEmail(email: Email): F[Option[Account]] = Sync[F].pure(accounts.headOption)
+
+  override def unlockAccount(uid: UserId): F[Int] = {
+    locked = locked.filterNot(_ === uid)
+    Sync[F].pure(1)
+  }
+
+  override def deleteUserSession(id: SessionId): F[Int] = Sync[F].pure(1)
+
+}