setting up tests
continuous-integration/drone/push Build is failing Details

drone-setup
Override-6 2 years ago
parent d35a61212a
commit dc501fded6

@ -2,4 +2,6 @@
emitter.url=<server url here>
emitter.cert=<x509 certificate path here>
endpoint.port=<enter port here>
endpoint.port=<enter port here>
database.prefix=database

@ -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(() =>

@ -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

@ -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)
}
}

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

@ -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")
}

@ -4,6 +4,7 @@ import org.tbasket.error.ExceptionEnum
enum AuthException extends ExceptionEnum {
case InvalidPassword
case InvalidEmail
case UserNotFound
case UserAlreadyRegistered
}

@ -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"}""")
}

@ -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(

@ -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))

@ -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)
}

@ -0,0 +1,6 @@
test-database {
dataSourceClassName = org.sqlite.SQLiteDataSource
dataSource {
url = "jdbc:sqlite:/tmp/test-database.sqlite"
}
}

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout disableAnsi="false"
pattern="%style{[%d{HH:mm:ss,SSS}]}{magenta} [%highlight{%-5p}{FATAL=red, ERROR=red, WARN=yellow, INFO=blue, DEBUG=green, TRACE=normal} _ %-6logger] %style{-}{normal} %highlight{%m%n}{FATAL=red, ERROR=red, WARN=yellow, INFO=blue, DEBUG=green, TRACE=normal}"/>
</Console>
<RollingFile name="LogFile"
fileName="log/server-current.log"
filePattern="log/archives/server-%d{yyyy-MM-dd}.log"
append="true">
<PatternLayout disableAnsi="false"
pattern="[%d{HH:mm:ss,SSS}] [%-5p _ %-6logger] - %m%n"/>
<Policies>
<TimeBasedTriggeringPolicy/>
</Policies>
</RollingFile>
</Appenders>
<Loggers>
<Logger level="WARN" name="JWTEmitter" additivity="false" includeLocation="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="LogFile"/>
</Logger>
<Root level="WARN" includeLocation="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="LogFile"/>
</Root>
</Loggers>
</Configuration>

@ -0,0 +1,8 @@
package org.tbasket.test
object TestEmitter:
val PORT = 5455

@ -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)
}
}

@ -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"
}

@ -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)
}