From dc501fded6da96897d9c4d89df3b06c27b3f0fc1 Mon Sep 17 00:00:00 2001 From: Override-6 Date: Sun, 29 Jan 2023 01:59:43 +0100 Subject: [PATCH] setting up tests --- Core/resources/server.properties | 4 +- Core/src/org/tbasket/Main.scala | 5 +- .../org/tbasket/auth/Authentificator.scala | 16 ++-- .../FileServerConfig.scala} | 28 ++++--- .../src/org/tbasket/config/ServerConfig.scala | 20 +++++ Core/src/org/tbasket/data/Database.scala | 36 +++++++-- .../src/org/tbasket/error/AuthException.scala | 1 + .../org/tbasket/handler/HandlerUtils.scala | 2 +- .../tbasket/handler/LoginPageHandler.scala | 58 +++++++------- .../tbasket/handler/RegisterPageHandler.scala | 2 +- build.sc | 34 ++++++--- tests/resources/application.conf | 6 ++ tests/resources/log4j2.xml | 32 ++++++++ tests/src/org/tbasket/test/TestEmitter.scala | 8 ++ tests/src/org/tbasket/test/TestLayers.scala | 25 ++++++ .../org/tbasket/test/TestServerConfig.scala | 26 +++++++ .../test/pages/LoginPageHandlerTests.scala | 76 +++++++++++++++++++ 17 files changed, 310 insertions(+), 69 deletions(-) rename Core/src/org/tbasket/{ServerConfig.scala => config/FileServerConfig.scala} (78%) create mode 100644 Core/src/org/tbasket/config/ServerConfig.scala create mode 100644 tests/resources/application.conf create mode 100644 tests/resources/log4j2.xml create mode 100644 tests/src/org/tbasket/test/TestEmitter.scala create mode 100644 tests/src/org/tbasket/test/TestLayers.scala create mode 100644 tests/src/org/tbasket/test/TestServerConfig.scala create mode 100644 tests/src/org/tbasket/test/pages/LoginPageHandlerTests.scala diff --git a/Core/resources/server.properties b/Core/resources/server.properties index 11d1e86..8ab616d 100644 --- a/Core/resources/server.properties +++ b/Core/resources/server.properties @@ -2,4 +2,6 @@ emitter.url= emitter.cert= -endpoint.port= \ No newline at end of file +endpoint.port= + +database.prefix=database diff --git a/Core/src/org/tbasket/Main.scala b/Core/src/org/tbasket/Main.scala index 9c45278..cbba504 100644 --- a/Core/src/org/tbasket/Main.scala +++ b/Core/src/org/tbasket/Main.scala @@ -2,6 +2,7 @@ package org.tbasket import org.apache.logging.log4j.LogManager import org.tbasket.auth.Authentificator +import org.tbasket.config.{FileServerConfig, ServerConfig} import org.tbasket.data.Database import org.tbasket.endpoint.Endpoint import pdi.jwt.algorithms.JwtAsymmetricAlgorithm @@ -43,7 +44,7 @@ object Main extends ZIOAppDefault: private def setupAuth(config: ServerConfig) = ZIO.attempt { val publicKey = config.emitterCertificate.getPublicKey - val auth = new Authentificator(config.emitterURL, publicKey, config.emitterCertificateAlgorithm) + val auth = new Authentificator(config.emitterURL, publicKey, config.emitterKeyAlgorithm) ZLayer.succeed(auth) } @@ -58,7 +59,7 @@ object Main extends ZIOAppDefault: val properties = new Properties() properties.load(in) properties - }.flatMap(p => ServerConfig(p, args.getArgs)) + }.flatMap(p => FileServerConfig(p, args.getArgs)) // add a shutdown hook to log when the server is about to get killed lang.Runtime.getRuntime.addShutdownHook(new Thread(() => diff --git a/Core/src/org/tbasket/auth/Authentificator.scala b/Core/src/org/tbasket/auth/Authentificator.scala index 3ce7bf8..123cdb1 100644 --- a/Core/src/org/tbasket/auth/Authentificator.scala +++ b/Core/src/org/tbasket/auth/Authentificator.scala @@ -9,7 +9,7 @@ import io.getquill.context.qzio.ZioJdbcContext import io.getquill.context.sql.idiom.SqlIdiom import org.apache.logging.log4j.LogManager import org.tbasket.InternalBasketServerException -import org.tbasket.auth.Authentificator.LOG +import org.tbasket.auth.Authentificator.* import org.tbasket.data.{DatabaseContext, User} import org.tbasket.error.AuthException.* import org.tbasket.error.ExceptionEnum @@ -35,6 +35,8 @@ case class JwtContent(uuid: UUID) object Authentificator: private final val LOG = LogManager.getLogger("Authentification") + private final val ValidMailPattern = "^\\w+([.-]?\\w+)*@\\w+([.-]?\\w+)*(\\.\\w{2,3})+$".r + private final val ValidPasswordPattern = ".{6,}".r class Authentificator(url: URL, key: PublicKey, algorithm: JwtAsymmetricAlgorithm) { @@ -71,9 +73,10 @@ class Authentificator(url: URL, key: PublicKey, algorithm: JwtAsymmetricAlgorith def loginUser(mail: String, password: String) = ZIO.serviceWithZIO[DatabaseContext] { ctx => import ctx.v.* + findByMail(mail) - .someOrFail(UserNotFound) - .filterOrFail(_.passwordHash == hashPassword(password))(InvalidPassword) + .someOrFail(UserNotFound)// await one second if password fails to reduce bruteforce + .filterOrElse(_.passwordHash == hashPassword(password))(ZIO.sleep(1.second) *> ZIO.fail(InvalidPassword)) } private inline def insert(user: User) = quote { @@ -93,8 +96,11 @@ class Authentificator(url: URL, key: PublicKey, algorithm: JwtAsymmetricAlgorith val hash = hashPassword(password) User(uuid, name, forename, hash, mail) } - - for + if (!ValidMailPattern.matches(mail)) + ZIO.fail(InvalidEmail) + else if (!ValidPasswordPattern.matches(password)) + ZIO.fail(InvalidPassword) + else for _ <- findByMail(mail).none.orElse(ZIO.fail(UserAlreadyRegistered)) _ <- run(insert(user)).fork yield user diff --git a/Core/src/org/tbasket/ServerConfig.scala b/Core/src/org/tbasket/config/FileServerConfig.scala similarity index 78% rename from Core/src/org/tbasket/ServerConfig.scala rename to Core/src/org/tbasket/config/FileServerConfig.scala index c4275b7..560b08b 100644 --- a/Core/src/org/tbasket/ServerConfig.scala +++ b/Core/src/org/tbasket/config/FileServerConfig.scala @@ -1,17 +1,18 @@ -package org.tbasket +package org.tbasket.config -import org.tbasket.ServerConfig.CertFactory +import org.tbasket.ServerConfigException +import org.tbasket.config.FileServerConfig.CertFactory import pdi.jwt.JwtAlgorithm -import zio.{Chunk, Task, ZIO, ZIOAppArgs} +import pdi.jwt.algorithms.JwtAsymmetricAlgorithm import zio.http.URL import zio.stream.ZStream -import pdi.jwt.algorithms.JwtAsymmetricAlgorithm +import zio.{Chunk, Task, ZIO, ZIOAppArgs} import java.nio.file.{Files, Path} import java.security.cert.{Certificate, CertificateFactory} import java.util.Properties -class ServerConfig private(userProperties: Properties, schema: Properties, arguments: Map[String, String]) { +final class FileServerConfig private(userProperties: Properties, schema: Properties, arguments: Map[String, String]) extends ServerConfig { private def getPropertySafe(name: String) = if (schema.getProperty(name) == null) { @@ -39,23 +40,28 @@ class ServerConfig private(userProperties: Properties, schema: Properties, argum | This property is required. |""".stripMargin)) - val emitterURL: URL = URL.fromString(getPropertySafe("emitter.url")) match + + + override val emitterURL: URL = URL.fromString(getPropertySafe("emitter.url")) match case Left(exception) => throw exception case Right(value) => value - val emitterCertificate: Certificate = { + override val emitterCertificate: Certificate = { val path = Path.of(getPropertySafe("emitter.cert")) val in = Files.newInputStream(path) CertFactory.generateCertificate(in) } - val emitterCertificateAlgorithm = JwtAlgorithm.RS256 + override val emitterKeyAlgorithm = JwtAlgorithm.RS256 - val endpointPort: Int = + override val endpointPort: Int = getPropertySafe("endpoint.port") .toIntOption .getOrElse(throw new ServerConfigException("endpoint.port is not an integer")) + + override val databaseConfigName: String = getPropertySafe("database.prefix") + private def schemaString = { schema.stringPropertyNames() .toArray(new Array[String](_)) @@ -65,7 +71,7 @@ class ServerConfig private(userProperties: Properties, schema: Properties, argum } -object ServerConfig { +object FileServerConfig { //TODO make certificate type configurable final val CertFactory = CertificateFactory.getInstance("X509") @@ -77,7 +83,7 @@ object ServerConfig { val schemaIn = getClass.getClassLoader.getResourceAsStream("server.properties") val schema = new Properties() schema.load(schemaIn) - new ServerConfig(userProperties, schema, args) + new FileServerConfig(userProperties, schema, args) } } diff --git a/Core/src/org/tbasket/config/ServerConfig.scala b/Core/src/org/tbasket/config/ServerConfig.scala new file mode 100644 index 0000000..ac32741 --- /dev/null +++ b/Core/src/org/tbasket/config/ServerConfig.scala @@ -0,0 +1,20 @@ +package org.tbasket.config + +import pdi.jwt.JwtAlgorithm +import pdi.jwt.algorithms.JwtAsymmetricAlgorithm +import zio.http.URL + +import java.security.cert.Certificate + +trait ServerConfig { + def emitterURL: URL + + def emitterCertificate: Certificate + + def emitterKeyAlgorithm: JwtAsymmetricAlgorithm + + def endpointPort: Int + + def databaseConfigName: String + +} diff --git a/Core/src/org/tbasket/data/Database.scala b/Core/src/org/tbasket/data/Database.scala index b911099..186c2e9 100644 --- a/Core/src/org/tbasket/data/Database.scala +++ b/Core/src/org/tbasket/data/Database.scala @@ -1,22 +1,46 @@ package org.tbasket.data +import io.getquill.* import io.getquill.context.ZioJdbc.{DataSourceLayer, QuillZioExt} import io.getquill.context.qzio.ZioContext import io.getquill.idiom.Idiom import io.getquill.jdbczio.Quill -import io.getquill.* -import org.sqlite.SQLiteDataSource -import org.tbasket.ServerConfig +import org.apache.logging.log4j.LogManager +import org.sqlite.{SQLiteDataSource, SQLiteException} +import org.tbasket.config.ServerConfig +import org.tbasket.data.Database.LOG import zio.* import java.io.Closeable +import java.sql.SQLException import java.util.Properties import javax.sql +//TODO this class is a veritable fraud class Database(config: ServerConfig): - val contextLayer = ZLayer.succeed(DatabaseContext(new SqliteZioJdbcContext(SnakeCase))) - val datasourceLayer = Quill.DataSource.fromPrefix("database") + private var initialized = false + val contextLayer = ZLayer.succeed(DatabaseContext(new SqliteZioJdbcContext(SnakeCase))) + val datasourceLayer = Quill.DataSource.fromPrefix(config.databaseConfigName).tap { ds => + if (initialized) ZIO.succeed(ds) + else ZIO.attempt { + initialized = true + val requests = new String(getClass.getResourceAsStream("/table_init.sql").readAllBytes()).split(';') + val stmnt = ds.get.getConnection + .createStatement() + requests.foreach { sql => + try { + stmnt.execute(sql) + } catch { + case e: SQLException if e.getMessage.contains("already exists") => + //do nothing + } + } + stmnt.close() + } + } - +object Database { + val LOG = LogManager.getLogger("Database") +} \ No newline at end of file diff --git a/Core/src/org/tbasket/error/AuthException.scala b/Core/src/org/tbasket/error/AuthException.scala index 3a4795a..3092507 100644 --- a/Core/src/org/tbasket/error/AuthException.scala +++ b/Core/src/org/tbasket/error/AuthException.scala @@ -4,6 +4,7 @@ import org.tbasket.error.ExceptionEnum enum AuthException extends ExceptionEnum { case InvalidPassword + case InvalidEmail case UserNotFound case UserAlreadyRegistered } \ No newline at end of file diff --git a/Core/src/org/tbasket/handler/HandlerUtils.scala b/Core/src/org/tbasket/handler/HandlerUtils.scala index 7139554..4695df7 100644 --- a/Core/src/org/tbasket/handler/HandlerUtils.scala +++ b/Core/src/org/tbasket/handler/HandlerUtils.scala @@ -14,5 +14,5 @@ object HandlerUtils { ZIO.fromEither(json.get[T](cursor).map(_.value)) .mapError(InvalidRequest(s"Missing or invalid field $name.", _)) - def errorBody(errorType: String, msg: String) = Body.fromString(s"""{"error": $errorType,"msg": "$msg"}""") + def errorBody(errorType: String, msg: String) = Body.fromString(s"""{"error": "$errorType","msg": "$msg"}""") } diff --git a/Core/src/org/tbasket/handler/LoginPageHandler.scala b/Core/src/org/tbasket/handler/LoginPageHandler.scala index 2ad4f02..640fed2 100644 --- a/Core/src/org/tbasket/handler/LoginPageHandler.scala +++ b/Core/src/org/tbasket/handler/LoginPageHandler.scala @@ -30,37 +30,35 @@ object LoginPageHandler extends PageHandler: private def getUser(json: Json) = ZIO.serviceWithZIO[Authentificator] { auth => for - mail <- HandlerUtils.parseAttribute(json, "mail", JsonCursor.field("name").isString) - password <- HandlerUtils.parseAttribute(json, "mail", JsonCursor.field("name").isString) + mail <- HandlerUtils.parseAttribute(json, "email", JsonCursor.field("email").isString) + password <- HandlerUtils.parseAttribute(json, "password", JsonCursor.field("password").isString) user <- auth.loginUser(mail, password) yield user } + private def tryPost(request: Request) = + for + body <- request + .body + .asString + .mapError(s => + InvalidRequest("Invalid request body", s.getMessage) + ) + + json <- ZIO.fromEither(body.fromJson[Json]) + .mapError(InvalidRequest("Invalid JSON body", _)) + + user <- getUser(json) + jwt <- ZIO.serviceWithZIO[Authentificator](_.requestNewJwt(user)) + yield Response( + status = Status.Found, + headers = Headers.location("/") ++ //login successful, go back to main page + Headers.setCookie(Cookie("JWT", jwt)) //and set the token cookie + ) + def post(request: Request) = - val bindSession = - for - body <- request - .body - .asString - .mapError(s => - InvalidRequest("Unparseable request body", s.getMessage) - ) - - json <- ZIO.fromEither(body.fromJson[Json]) - .mapError(InvalidRequest("Invalid JSON body", _)) - - user <- getUser(json) - jwt <- ZIO.serviceWithZIO[Authentificator](_.requestNewJwt(user)) - yield jwt - - bindSession.map { jwt => - Response( - status = Status.Found, - headers = Headers.location("/") ++ //login successful, go back to main page - Headers.setCookie(Cookie("JWT", jwt)) //and set the token cookie - ) - }.catchAll { - case UserNotFound => ZIO.attempt(Response( + tryPost(request).catchAll { + case UserNotFound => ZIO.attempt(Response( status = Status.Unauthorized, body = errorBody("unauthorized", "unknown user email"), headers = @@ -69,7 +67,7 @@ object LoginPageHandler extends PageHandler: ) // send back caller to register panel )) - case InvalidPassword => ZIO.attempt(Response( + case InvalidPassword => ZIO.attempt(Response( status = Status.Unauthorized, body = errorBody("unauthorized", "invalid password") )) @@ -79,15 +77,15 @@ object LoginPageHandler extends PageHandler: body = errorBody("invalid request", s"$cause: $msg") )) - case InternalError(e) => + case InternalError(e) => LOG.error("Internal error : ") LOG.throwing(e) ZIO.attempt(Response( status = Status.InternalServerError, body = errorBody("internal", "internal error, please contact support") )) - - case other => + + case other => LOG.error("Unhandle exception : ") LOG.throwing(other) ZIO.attempt(Response( diff --git a/Core/src/org/tbasket/handler/RegisterPageHandler.scala b/Core/src/org/tbasket/handler/RegisterPageHandler.scala index 9315cba..ab632b1 100644 --- a/Core/src/org/tbasket/handler/RegisterPageHandler.scala +++ b/Core/src/org/tbasket/handler/RegisterPageHandler.scala @@ -22,7 +22,7 @@ object RegisterPageHandler extends PageHandler { name <- parseAttribute(json, "name", JsonCursor.field("name").isString) forename <- parseAttribute(json, "forename", JsonCursor.field("forename").isString) - mail <- parseAttribute(json, "mail", JsonCursor.field("mail").isString) + mail <- parseAttribute(json, "email", JsonCursor.field("email").isString) password <- parseAttribute(json, "password", JsonCursor.field("password").isString) user <- ZIO.serviceWithZIO[Authentificator](_.registerUser(name, forename, mail, password)) diff --git a/build.sc b/build.sc index 161dee8..696bf5d 100644 --- a/build.sc +++ b/build.sc @@ -19,10 +19,7 @@ trait HttpModule extends ServerModule { ivy"dev.zio::zio-http:0.0.3", ivy"dev.zio::zio-streams:2.0.6", - ivy"io.circe::circe-core:0.14.3", - ivy"io.circe::circe-parser:0.14.3", - ivy"io.circe::circe-generic:0.14.3", - + ivy"com.github.jwt-scala::jwt-zio-json:9.1.2" ) } @@ -30,22 +27,35 @@ trait HttpModule extends ServerModule { /** * Simple module whose only job is to generate JWT Tokens * */ -object JWTEmitter extends HttpModule { -} +object JWTEmitter extends HttpModule /** * Business layer of a server * */ object Core extends HttpModule { //also handles http override def ivyDeps = super.ivyDeps() ++ Agg( - ivy"io.circe::circe-core:0.15.0-M1", - ivy"io.circe::circe-parser:0.14.3", - ivy"io.circe::circe-generic:0.14.3", - ivy"io.getquill::quill-jdbc-zio:4.6.0", ivy"org.xerial:sqlite-jdbc:3.40.0.0", - ) - //override def scalacOptions = T { Seq("-explain") } + ivy"io.circe::circe-core:0.14.3", + ivy"io.circe::circe-parser:0.14.3", + ivy"io.circe::circe-generic:0.14.3", + ) } + + +object tests extends TestModule with ServerModule { + override def ivyDeps = Agg( + ivy"dev.zio::zio-test:2.0.6", + ivy"dev.zio::zio-test-sbt:2.0.6", + ivy"dev.zio::zio-test-magnolia:2.0.6", + ) + + override def testFramework = "zio.test.sbt.ZTestFramework" + + + override def finalMainClass = "org.tbasket.test.pages.LoginPageHandlerTests" + + override def moduleDeps = Seq(Core, JWTEmitter) +} \ No newline at end of file diff --git a/tests/resources/application.conf b/tests/resources/application.conf new file mode 100644 index 0000000..b94e075 --- /dev/null +++ b/tests/resources/application.conf @@ -0,0 +1,6 @@ +test-database { + dataSourceClassName = org.sqlite.SQLiteDataSource + dataSource { + url = "jdbc:sqlite:/tmp/test-database.sqlite" + } +} \ No newline at end of file diff --git a/tests/resources/log4j2.xml b/tests/resources/log4j2.xml new file mode 100644 index 0000000..bed90cb --- /dev/null +++ b/tests/resources/log4j2.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/src/org/tbasket/test/TestEmitter.scala b/tests/src/org/tbasket/test/TestEmitter.scala new file mode 100644 index 0000000..eb6406c --- /dev/null +++ b/tests/src/org/tbasket/test/TestEmitter.scala @@ -0,0 +1,8 @@ +package org.tbasket.test + + + +object TestEmitter: + val PORT = 5455 + + \ No newline at end of file diff --git a/tests/src/org/tbasket/test/TestLayers.scala b/tests/src/org/tbasket/test/TestLayers.scala new file mode 100644 index 0000000..55cc535 --- /dev/null +++ b/tests/src/org/tbasket/test/TestLayers.scala @@ -0,0 +1,25 @@ +package org.tbasket.test + +import org.tbasket.auth.Authentificator +import org.tbasket.config.ServerConfig +import org.tbasket.data.Database +import zio.{Task, ZLayer} + +/* +* Defines required test service layers +* */ +object TestLayers { + + + val auth = { + val publicKey = TestServerConfig.emitterCertificate.getPublicKey + val auth = new Authentificator(TestServerConfig.emitterURL, publicKey, TestServerConfig.emitterKeyAlgorithm) + ZLayer.succeed(auth) + } + + val db = { + new Database(TestServerConfig) + } + + +} diff --git a/tests/src/org/tbasket/test/TestServerConfig.scala b/tests/src/org/tbasket/test/TestServerConfig.scala new file mode 100644 index 0000000..566093d --- /dev/null +++ b/tests/src/org/tbasket/test/TestServerConfig.scala @@ -0,0 +1,26 @@ +package org.tbasket.test + +import org.tbasket.config.ServerConfig +import pdi.jwt.JwtAlgorithm +import zio.http.URL +import TestEmitter.PORT +import pdi.jwt.algorithms.JwtAsymmetricAlgorithm + +import java.nio.file.{Files, Path} +import java.security.cert.{Certificate, CertificateFactory} + +object TestServerConfig extends ServerConfig { + + private final val CertFactory = CertificateFactory.getInstance("X509") + + override def emitterURL: URL = URL.fromString(s"http://localhost/$PORT").getOrElse(null) + + override def emitterCertificate: Certificate = + CertFactory.generateCertificate(Files.newInputStream(Path.of("keys/public.cert"))) + + override def emitterKeyAlgorithm: JwtAsymmetricAlgorithm = JwtAlgorithm.RS256 + + override def endpointPort: Int = 5454 + + override def databaseConfigName: String = "test-database" +} diff --git a/tests/src/org/tbasket/test/pages/LoginPageHandlerTests.scala b/tests/src/org/tbasket/test/pages/LoginPageHandlerTests.scala new file mode 100644 index 0000000..f6828bb --- /dev/null +++ b/tests/src/org/tbasket/test/pages/LoginPageHandlerTests.scala @@ -0,0 +1,76 @@ +package org.tbasket.test.pages + +import io.getquill.jdbczio.Quill +import io.getquill.{SnakeCase, SqliteZioJdbcContext} +import org.tbasket.auth.Authentificator +import org.tbasket.data.{Database, DatabaseContext} +import org.tbasket.error.RegularException.InvalidRequest +import org.tbasket.handler.HandlerUtils.parseAttribute +import org.tbasket.handler.LoginPageHandler +import org.tbasket.handler.LoginPageHandler.post +import org.tbasket.test.TestLayers +import zio.* +import zio.http.netty.client.ConnectionPool +import zio.http.* +import zio.http.model.{HeaderNames, Headers} +import zio.http.model.Headers.Header +import zio.json.* +import zio.json.ast.{Json, JsonCursor} +import zio.test.{TestAspect, *} +import zio.test.Assertion.* + +object LoginPageHandlerTests extends ZIOSpecDefault { + import LoginPageHandler.post + import TestLayers.* + + private def getJsonBody(r: Response): Task[Json] = { + for + body <- r.body.asString + json <- ZIO.fromEither(body.fromJson[Json]).mapError(new Exception(_)) + yield json + } + + private def requestsSpec = suite("erroned request body tests")( + ZIO.attempt(Map( + "empty packet" -> Body.empty, + "with no mail attribute" -> Body.fromString("""{"password":"1234"}"""), + "with no password attribute" -> Body.fromString("""{"email":"valid.mail@x.y"}"""), + "with invalid json" -> Body.fromString("""this is a corrupted json""") + )).map(_.map((name, body) => + test(name) { + for + response <- post(Request.post(body, URL.empty)) + json <- getJsonBody(response) + errorType <- parseAttribute(json, "error", JsonCursor.field("error").isString) + yield assertTrue(errorType == "invalid request") + } + )) + ) + + private def loginSpec = { + suite("login situation tests") ( + test("login with unknown account") { + for + response <- post(Request.post(Body.fromString("""{"password":"123456","email":"maximebatista18@gmail.com"}"""), URL.empty)) + json <- getJsonBody(response) + errorType <- parseAttribute(json, "error", JsonCursor.field("error").isString) + yield + //assert that the response error is of type unauthorized and headers are Location: /register + assert(errorType)(equalTo("unauthorized")) + && assert(response)(hasField("headers", _.headers, hasSameElements(Headers.location("/register")))) + } + ) + } + + override def spec = suite("/login page handler") ( + requestsSpec, + loginSpec + ).provide( + db.datasourceLayer, + db.contextLayer, + auth, + ConnectionPool.fixed(1), + Scope.default, + ClientConfig.default, + Client.live) +}