Compare commits

..

24 Commits

Author SHA1 Message Date
Override-6 e15ed2a7f8 removed static web page dispatcher
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 8e99789947 moved /login and /register REST endpoints to /auth/. and added /auth/token-login for JWT auth
continuous-integration/drone/push Build is failing Details
2 years ago
Override-6 8d78d1f0a7 some modification in tests
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 cf3e03035f modification in tests, anticipating further development
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 1b44d8734d fix drone
continuous-integration/drone/push Build was killed Details
continuous-integration/drone Build is passing Details
2 years ago
Override-6 33287f8d33 fix drone
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 89a684f692 fix drone
continuous-integration/drone/push Build is passing Details
continuous-integration/drone Build is passing Details
2 years ago
Override-6 45810fc368 working on deployment
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 26a090952c fixed bugs
2 years ago
Override-6 bb14aef019 added further tests to register page
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 f3782f492d fixing tests
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 ceb53e5abb fixing webservice and redirection bugs
continuous-integration/drone/push Build is failing Details
2 years ago
Override-6 7b3be31271 fixing bugs
continuous-integration/drone/push Build is failing Details
2 years ago
Override-6 b22a1230c7 simple page dispatcher
continuous-integration/drone/push Build is failing Details
2 years ago
Override-6 c9f45d1bf4 adding tests to register page handler
continuous-integration/drone/push Build is failing Details
2 years ago
Override-6 8aa3c0a39a reviewed error handling and tests
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 c8247bd998 suppression dossier target et modif dans gitignore
continuous-integration/drone/push Build is passing Details
2 years ago
Maxime BATISTA f7e35a21cb Update 'drone/.drone.yml'
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 debb61e14c making test outputs cleaner
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 a3065f64af fixed tests
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 9c9917ff9f added test
continuous-integration/drone/push Build was killed Details
2 years ago
Override-6 4b80964de5 added steps in tests
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 fdfc3cbac1 JWTEmitter now runs among tests when tests are running
continuous-integration/drone/push Build is passing Details
2 years ago
Override-6 a20ba55248 modified drone setup and tests environment
continuous-integration/drone/push Build is failing Details
2 years ago

4
.gitignore vendored

@ -1,13 +1,15 @@
build
.idea
out
target
*/.gradle/
.bsp
keys
www
log
server.properties
log
*.sqlite
*.$*

@ -4,4 +4,6 @@ emitter.cert=<x509 certificate path here>
endpoint.port=<enter port here>
database.prefix=database
database.prefix=<database config prefix>
pages.location=<location to html content>

@ -1,7 +0,0 @@
package org.tbasket
/**
* exception thrown when an error occurs due to an external fault from the server.
*/
class ExternalBasketServerException(msg: String, cause: Throwable = null)
extends Exception(msg, cause) {}

@ -1,7 +0,0 @@
package org.tbasket
/**
* exception thrown when an error occurs due to an internal fault of the server or its maintainers.
*/
class InternalBasketServerException(msg: String, cause: Throwable = null)
extends Exception(msg, cause) {}

@ -23,16 +23,13 @@ object Main extends ZIOAppDefault:
final val LOG = LogManager.getLogger("Core")
override def run = ZIO.serviceWithZIO[ZIOAppArgs] { args =>
for
config <- retrieveConfig(args)
res <- setupAuth(config) <&> setupDatabase(config) <&> setupEndpoint(config)
yield
val (auth, db, ep) = res
(auth, db, ep)
}.flatMap((auth, db, ep) =>
ep.run.provide(db.datasourceLayer, db.contextLayer, auth)
)
retrieveConfig(args)
.flatMap(config => setupAuth(config) <&> setupDatabase(config) <&> setupEndpoint(config))
.flatMap {
case (auth, db, ep) =>
ep.run.provide(db.datasourceLayer, db.contextLayer, auth)
}
}
private def setupEndpoint(config: ServerConfig) = ZIO.attempt {
new Endpoint(config.endpointPort)

@ -8,13 +8,9 @@ import io.getquill.*
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.Authenticator.*
import org.tbasket.data.{DatabaseContext, User}
import org.tbasket.error.AuthException.*
import org.tbasket.error.ExceptionEnum
import org.tbasket.error.JwtException.*
import org.tbasket.error.RegularException.*
import org.tbasket.error.*
import pdi.jwt.algorithms.JwtAsymmetricAlgorithm
import pdi.jwt.{JwtClaim, JwtZIOJson}
import zio.*
@ -31,10 +27,11 @@ import javax.sql.DataSource
import scala.collection.immutable.HashMap
object Authenticator:
private final val LOG = LogManager.getLogger("Authentification")
private final val ValidMailPattern = "^\\w+([.-]?\\w+)*@\\w+([.-]?\\w+)*(\\.\\w{2,3})+$".r
private final val LOG = LogManager.getLogger("Authentification")
private final val ValidMailPattern = "^\\w+([.-]?\\w+)*@\\w+([.-]?\\w+)*(\\.\\w{2,3})+$".r
private final val ValidForenamePattern = "^[a-zA-Z\\u00C0-\\u017F-']+$".r
private final val ValidNamePattern = ValidForenamePattern
private final val ValidPasswordPattern = ".{6,}".r
case class JwtContent(uuid: UUID)
@ -55,12 +52,12 @@ class Authenticator(url: URL, key: PublicKey, algorithm: JwtAsymmetricAlgorithm)
def requestNewJwt(user: User) = {
Client.request(mkRequest(user))
.flatMap {
case Response(Ok, _, body, _, _) =>
case Response(Ok, _, body, _, _) =>
body.asString
case Response(InternalServerError, _, _, _, _) =>
ZIO.fail(new InternalBasketServerException("internal server error"))
case _ =>
ZIO.fail(new InternalBasketServerException("Received unknown response from emitter"))
case Response(InternalServerError, _, body, _, _) =>
ZIO.fail(EmitterInternalError(_)).flatMap(f => body.asString.map(f))
case r =>
ZIO.fail(UnrecognizedEmitterResponse(s"Received unknown response from emitter ${r}"))
}
}
@ -77,19 +74,11 @@ class Authenticator(url: URL, key: PublicKey, algorithm: JwtAsymmetricAlgorithm)
import ctx.v.*
findByMail(mail)
.someOrFail(UserNotFound)// await one second if password fails to reduce bruteforce
.filterOrElse(_.passwordHash == hashPassword(password))(ZIO.sleep(1.second) *> ZIO.fail(InvalidPassword))
.someOrFail(UserNotFound(s"user not found for email address $mail"))
// await one second if password fails to reduce bruteforce //FIXME this wont actually reduce bruteforce
.filterOrElse(_.passwordHash == hashPassword(password))(Clock.sleep(1.seconds) *> ZIO.fail(InvalidPassword("invalid password")))
}
private inline def insert(user: User) = quote {
query[User].insert(
_.id -> user.id,
_.name -> user.name,
_.forename -> user.forename,
_.passwordHash -> user.passwordHash,
_.mailAddress -> user.mailAddress,
)
}
def registerUser(name: String, forename: String, mail: String, password: String) = ZIO.serviceWithZIO[DatabaseContext] { ctx =>
import ctx.*
@ -98,13 +87,26 @@ class Authenticator(url: URL, key: PublicKey, algorithm: JwtAsymmetricAlgorithm)
val hash = hashPassword(password)
User(uuid, name, forename, hash, mail)
}
if (!ValidMailPattern.matches(mail))
ZIO.fail(InvalidEmail)
ZIO.fail(InvalidEmail(s"email address did not satisfy regex pattern"))
else if (!ValidPasswordPattern.matches(password))
ZIO.fail(InvalidPassword)
ZIO.fail(InvalidPassword(s"password did not satisfy regex pattern"))
else if (!ValidForenamePattern.matches(forename))
ZIO.fail(InvalidForename(s"email address did not satisfy regex pattern"))
else if (!ValidNamePattern.matches(name))
ZIO.fail(InvalidName(s"password did not satisfy regex pattern"))
else for
_ <- findByMail(mail).none.orElse(ZIO.fail(UserAlreadyRegistered))
_ <- run(insert(user)).fork
_ <- findByMail(mail).none.orElse(ZIO.fail(UserAlreadyRegistered(s"an email address already exists for '$mail'")))
_ <- run(quote {
query[User].insert(
_.id -> lift(user).id,
_.name -> lift(user).name,
_.forename -> lift(user).forename,
_.passwordHash -> lift(user).passwordHash,
_.mailAddress -> lift(user).mailAddress,
)
}).fork
yield user
}

@ -62,6 +62,9 @@ final class FileServerConfig private(userProperties: Properties, schema: Propert
override val databaseConfigName: String = getPropertySafe("database.prefix")
override def pagesLocation: Option[Path] = Some(Path.of(getPropertySafe("pages.location")))
private def schemaString = {
schema.stringPropertyNames()
.toArray(new Array[String](_))

@ -4,6 +4,7 @@ import pdi.jwt.JwtAlgorithm
import pdi.jwt.algorithms.JwtAsymmetricAlgorithm
import zio.http.URL
import java.nio.file.Path
import java.security.cert.Certificate
trait ServerConfig {
@ -17,4 +18,6 @@ trait ServerConfig {
def databaseConfigName: String
def pagesLocation: Option[Path]
}

@ -4,11 +4,14 @@ import io.getquill.context.qzio.{ZioContext, ZioJdbcContext}
import io.getquill.context.sql.idiom.SqlIdiom
import io.getquill.idiom.Idiom
import io.getquill.{Literal, NamingStrategy, SqliteDialect}
import org.apache.logging.log4j.LogManager
import io.netty.handler.codec.http.HttpMethod
import org.apache.logging.log4j.{LogManager, Logger}
import org.tbasket.auth.Authenticator
import org.tbasket.data.DatabaseContext
import org.tbasket.endpoint.Endpoint.LOG
import org.tbasket.handler.LoginPageHandler
import org.tbasket.endpoint.Endpoint.{Log, app}
import org.tbasket.error.*
import EndpointUtils.errorBody
import org.tbasket.endpoint.auth.{LoginHandler, RegisterHandler, TokenLoginHandler}
import zio.*
import zio.http.*
import zio.http.ServerConfig.LeakDetectionLevel
@ -17,74 +20,108 @@ import zio.http.model.Status
import zio.http.model.Status.InternalServerError
import zio.http.netty.client.ConnectionPool
import java.sql.Timestamp
import javax.sql.DataSource
import scala.collection.mutable
class Endpoint(port: Int):
// set generic required headers
private def applyGenerics(response: Response): Response =
response.withAccessControlAllowOrigin("*")
val run =
val config = ServerConfig.default
.port(port)
.leakDetection(LeakDetectionLevel.PARANOID)
private val app = Http.collectZIO[Request] {
case r@POST -> _ / "login" =>
LoginPageHandler.post(r)
val serverConfigLayer = ServerConfig.live(config)
Server.install(app).flatMap { port =>
Log.info(s"Listening API entries on $port")
ZIO.never
}.provideSome[DatabaseContext & Authenticator & DataSource](
Scope.default,
serverConfigLayer,
ConnectionPool.fixed(4),
ClientConfig.default,
Server.live,
Client.live
)
object Endpoint:
final val Log = LogManager.getLogger("API")
private def tryHandle(r: Request) = r match
case r@POST -> _ / "auth" / "login" =>
LoginHandler.login(r)
case r@POST -> _ / "auth" / "register" =>
RegisterHandler.register(r)
case r@method -> path =>
val ipInsights = r.remoteAddress
.map(ip => s": request received from $ip.")
.getOrElse("")
LOG.error(
Log.error(
s"Was unable to find a handler for request '$path' with method $method ${ipInsights}"
)
ZIO.succeed(Response(Status.NotFound))
}.catchAllCause(handleUnexpectedError)
def handle(r: Request) = tryHandle(r)
.catchSome(respondToRegulars)
.catchAllCause(handleUnexpectedError)
.map(applyGenerics)
private def handleUnexpectedError(cause: Cause[Throwable]): HttpApp[Any, Throwable] = {
val app = Http.collectZIO[Request] { r =>
handle(r)
}
// set generic required headers
private def applyGenerics(response: Response): Response =
response.withAccessControlAllowOrigin("*")
private def respondToRegulars: PartialFunction[Object, Task[Response]] = {
case InvalidRequest(msg, cause) => ZIO.attempt(Response(
status = Status.BadRequest,
body = errorBody("invalid request", s"$cause: $msg")
))
case InternalError(e) =>
Log.error("Internal error : ")
Log.throwing(e)
ZIO.attempt(Response(
status = Status.InternalServerError,
body = errorBody("invalid request", s"internal error: please contact support, timestamp : $timestamp")
))
}
private def timestamp = new Timestamp(java.lang.System.currentTimeMillis())
private def handleUnexpectedError(cause: Object): Task[Response] = {
def report(kind: String, value: Throwable = null, trace: StackTrace = StackTrace.none) =
LOG.error(s"Received unhandled $kind cause ${if value == null then "" else ": " + value}")
LOG.error(trace)
Log.error(s"Received unhandled $kind cause ${if value == null then "" else ": " + value}")
Log.error(trace)
cause match
case Cause.Empty => report("empty")
case Cause.Fail(e, trace) => report("failure", e, trace)
case Cause.Die(e, trace) => report("die", e, trace)
case Cause.Interrupt(fiberId, e) => report(s"interruption of $fiberId", null, e)
case Cause.Stackless(cause, _) =>
LOG.error("stackless error :")
case e: Throwable => report(e.getMessage, e)
case Cause.Empty => report("empty")
case Cause.Fail(e: Throwable, trace) => report("failure", e, trace)
case Cause.Fail(_, trace) => report("failure", null, trace)
case Cause.Die(e, trace) => report("die", e, trace)
case Cause.Interrupt(fiberId, e) => report(s"interruption of $fiberId", null, e)
case Cause.Stackless(cause, _) =>
Log.error("stackless error :")
handleUnexpectedError(cause)
case Cause.Then(left, right) =>
case Cause.Then(left, right) =>
handleUnexpectedError(left)
LOG.error("**THEN this error occurred : **")
Log.error("**THEN this error occurred : **")
handleUnexpectedError(right)
case Cause.Both(left, right) =>
case Cause.Both(left, right) =>
handleUnexpectedError(left)
LOG.error("**AND this error also occurred (async) : **")
Log.error("**AND this error also occurred (async) : **")
handleUnexpectedError(right)
Http.succeed(Response.status(InternalServerError))
ZIO.attempt(Response(
status = Status.InternalServerError,
body = errorBody("internal", s"internal error, please contact support (timestamp: $timestamp")
))
}
val run =
val config = ServerConfig.default
.port(port)
.leakDetection(LeakDetectionLevel.PARANOID)
val serverConfigLayer = ServerConfig.live(config)
Server.install(app).flatMap { port =>
LOG.info(s"Listening API entries on $port")
ZIO.never
}.provideSome[DatabaseContext & Authenticator & DataSource](
Scope.default,
serverConfigLayer,
ConnectionPool.fixed(4),
ClientConfig.default,
Server.live,
Client.live
)
object Endpoint:
final val LOG = LogManager.getLogger("API")

@ -0,0 +1,29 @@
package org.tbasket.endpoint
import io.netty.handler.codec.http.QueryStringDecoder
import org.tbasket.error.*
import zio.http.Body
import zio.http.api.openapi.OpenAPI.Parameter.QueryStyle.Form
import zio.json.*
import zio.json.ast.{Json, JsonCursor}
import zio.{Task, ZIO, http}
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import scala.deriving.Mirror
import scala.jdk.CollectionConverters.*
import scala.language.{implicitConversions, reflectiveCalls}
object EndpointUtils {
def errorBody(errorType: String, msg: String) = Body.fromString(s"""{"error": "$errorType","msg": "$msg"}""")
inline def decode[A <: Product](body: Body)(using mirror: Mirror.Of[A]) =
for
str <- body.asString
decoded <- ZIO.fromEither(str.fromJson(DeriveJsonDecoder.gen[A]))
.mapError(InvalidRequest("Invalid request body", _))
yield decoded
}

@ -1,4 +1,4 @@
package org.tbasket.handler
package org.tbasket.endpoint
/**
* tag interface for page handlers

@ -0,0 +1,57 @@
package org.tbasket.endpoint.auth
import io.getquill.*
import io.getquill.context.ZioJdbc.*
import io.getquill.context.qzio.{ZioContext, ZioJdbcContext}
import io.getquill.context.sql.idiom.SqlIdiom
import org.apache.logging.log4j.{LogManager, Logger}
import org.tbasket.auth.Authenticator
import org.tbasket.data.{DatabaseContext, User}
import org.tbasket.endpoint.EndpointUtils.{decode, errorBody}
import org.tbasket.endpoint.PageHandler
import org.tbasket.endpoint.request.LoginRequest
import org.tbasket.error.*
import zio.*
import zio.http.*
import zio.http.model.{Cookie, Header, Headers, Status}
import zio.json.*
import zio.json.ast.Json.Str
import zio.json.ast.{Json, JsonCursor}
import java.sql.SQLException
import java.util.UUID
object LoginHandler extends PageHandler:
implicit private final val Log: Logger = LogManager.getLogger("/login")
private def tryLogin(request: Request) =
for
request <- decode[LoginRequest](request.body)
user <- ZIO.serviceWithZIO[Authenticator](_.loginUser(request.email, request.password))
jwt <- ZIO.serviceWithZIO[Authenticator](_.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 login(request: Request) =
tryLogin(request).catchSome {
case UserNotFound(msg) => ZIO.attempt(Response(
status = Status.Found,
body = errorBody("unauthorized", msg),
headers =
Headers(
Headers.location("/register")
) // send back caller to register panel
))
case InvalidPassword(msg) => ZIO.attempt(Response(
status = Status.Unauthorized,
body = errorBody("unauthorized", msg)
))
}

@ -0,0 +1,57 @@
package org.tbasket.endpoint.auth
import org.apache.logging.log4j.{LogManager, Logger}
import org.tbasket.auth.Authenticator
import org.tbasket.data.User
import org.tbasket.endpoint.EndpointUtils.*
import org.tbasket.endpoint.PageHandler
import org.tbasket.endpoint.request.RegisterRequest
import org.tbasket.error.*
import zio.ZIO
import zio.http.model.{Cookie, Headers, Status}
import zio.http.{Body, Request, Response, model}
import zio.json.*
import zio.json.ast.{Json, JsonCursor}
object RegisterHandler extends PageHandler {
private def tryRegister(request: Request) =
for
request <- decode[RegisterRequest](request.body)
user <- ZIO.serviceWithZIO[Authenticator](_.registerUser(request.name, request.forename, request.email, request.password))
jwt <- ZIO.serviceWithZIO[Authenticator](_.requestNewJwt(user))
yield Response(
status = Status.Found,
headers = Headers.location("/") ++ //register successful, go back to main page
Headers.setCookie(Cookie("JWT", jwt))
)
def register(request: Request) = tryRegister(request)
.catchSome {
case InvalidEmail(msg) => ZIO.attempt(Response(
status = Status.ExpectationFailed,
body = errorBody("invalid email", msg)
))
case InvalidPassword(msg) => ZIO.attempt(Response(
status = Status.ExpectationFailed,
body = errorBody("invalid password", msg)
))
case InvalidName(msg) => ZIO.attempt(Response(
status = Status.ExpectationFailed,
body = errorBody("invalid name", msg)
))
case InvalidForename(msg) => ZIO.attempt(Response(
status = Status.ExpectationFailed,
body = errorBody("invalid forename", msg)
))
case UserAlreadyRegistered(msg) =>
ZIO.attempt(Response(
status = Status.NotAcceptable,
body = errorBody("already registered", msg),
headers = Headers.location("/login") //the account already exists so we move the user to login page
))
}
}

@ -0,0 +1,11 @@
package org.tbasket.endpoint.auth
import zio.http.Request
object TokenLoginHandler {
def login(request: Request) = {
}
}

@ -0,0 +1,6 @@
package org.tbasket.endpoint.request
case class RegisterRequest(name: String, forename: String, email: String, password: String)
case class LoginRequest(email: String, password: String)

@ -1,10 +1,17 @@
package org.tbasket.error
import org.tbasket.error.ExceptionEnum
enum AuthException extends ExceptionEnum {
case InvalidPassword
case InvalidEmail
case UserNotFound
case UserAlreadyRegistered
}
sealed class AuthException(msg: String) extends Exception(msg) with UserException
case class InvalidPassword(msg: String) extends AuthException(msg)
case class InvalidEmail(msg: String) extends AuthException(msg)
case class UserNotFound(msg: String) extends AuthException(msg)
case class UserAlreadyRegistered(msg: String) extends AuthException(msg)
case class InvalidName(msg: String) extends AuthException(msg)
case class InvalidForename(msg: String) extends AuthException(msg)

@ -1,5 +0,0 @@
package org.tbasket.error
trait ExceptionEnum extends Exception {
}

@ -1,10 +1,12 @@
package org.tbasket.error
enum JwtException extends ExceptionEnum {
case InvalidJwt(cause: String)
case ExpiredJwt
sealed class JwtException(msg: String) extends Exception(msg)
case class InvalidJwt(msg: String) extends JwtException(msg) with UserException
case InvalidEmitterResponse
case EmitterInternalError
}
case class ExpiredJwt(msg: String) extends JwtException(msg) with UserException
case class UnrecognizedEmitterResponse(msg: String) extends JwtException(msg)
case class EmitterInternalError(msg: String) extends JwtException(msg)

@ -0,0 +1,17 @@
package org.tbasket.error
import org.apache.logging.log4j.{LogManager, Logger}
import zio.http.Response
import zio.http.model.Status
import zio.{Task, ZIO}
sealed class RegularException(msg: String, cause: Throwable = null) extends Exception(msg, cause)
case class InternalError(cause: Throwable) extends Exception(cause)
case class InvalidArgumentError(cause: String) extends Exception(cause)
case class InvalidRequest(msg: String, cause: String = "") extends Exception(msg + (if cause.nonEmpty then ":" + cause else "")) with UserException

@ -1,9 +0,0 @@
package org.tbasket.error
enum RegularException extends ExceptionEnum {
case InternalError(cause: Throwable)
case InvalidArgumentError(cause: String)
case InvalidRequest(msg: String, cause: String)
}

@ -0,0 +1,10 @@
package org.tbasket.error
/**
* User exceptions are a special type of exception where stack trace is not filled as those exceptions are not meant to be printed in the console
* because they are predictable error.
*
* */
trait UserException extends Exception {
override def fillInStackTrace(): Throwable = this //do not fill stack trace
}

@ -1,18 +0,0 @@
package org.tbasket.handler
import org.tbasket.error.RegularException.InvalidRequest
import zio.{Task, ZIO}
import zio.http.Body
import zio.json.*
import zio.json.ast.{Json, JsonCursor}
import scala.language.reflectiveCalls
object HandlerUtils {
def parseAttribute[V, T <: Json {def value: V}](json: Json, name: String, cursor: JsonCursor[Json, T]): Task[V] =
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"}""")
}

@ -1,95 +0,0 @@
package org.tbasket.handler
import io.getquill.*
import io.getquill.context.ZioJdbc.*
import io.getquill.context.qzio.{ZioContext, ZioJdbcContext}
import io.getquill.context.sql.idiom.SqlIdiom
import org.apache.logging.log4j.LogManager
import org.tbasket.auth.Authenticator
import org.tbasket.data.{DatabaseContext, User}
import org.tbasket.error.AuthException.*
import org.tbasket.error.ExceptionEnum
import org.tbasket.error.JwtException.*
import org.tbasket.error.RegularException.*
import org.tbasket.handler.HandlerUtils.errorBody
import zio.http.*
import zio.http.model.{Cookie, Header, Headers, Status}
import zio.json.*
import zio.json.ast.Json.Str
import zio.json.ast.{Json, JsonCursor}
import zio.*
import java.sql.SQLException
import java.util.UUID
object LoginPageHandler extends PageHandler:
private val LOG = LogManager.getLogger("Login")
private def getUser(json: Json) =
ZIO.serviceWithZIO[Authenticator] { auth =>
for
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[Authenticator](_.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) =
tryPost(request).catchAll {
case UserNotFound => ZIO.attempt(Response(
status = Status.Unauthorized,
body = errorBody("unauthorized", "unknown user email"),
headers =
Headers(
Headers.location("/register")
) // send back caller to register panel
))
case InvalidPassword => ZIO.attempt(Response(
status = Status.Unauthorized,
body = errorBody("unauthorized", "invalid password")
))
case InvalidRequest(msg, cause) => ZIO.attempt(Response(
status = Status.Unauthorized,
body = errorBody("invalid request", s"$cause: $msg")
))
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 =>
LOG.error("Unhandle exception : ")
LOG.throwing(other)
ZIO.attempt(Response(
status = Status.InternalServerError,
body = errorBody("internal", "internal error, please contact support")
))
}

@ -1,40 +0,0 @@
package org.tbasket.handler
import org.tbasket.auth.Authenticator
import org.tbasket.data.User
import org.tbasket.error.RegularException.InvalidRequest
import org.tbasket.handler.HandlerUtils.parseAttribute
import zio.ZIO
import zio.http.model.{Cookie, Headers, Status}
import zio.http.{Request, Response, model}
import zio.json.ast.{Json, JsonCursor}
import zio.json.*
object RegisterPageHandler extends PageHandler {
private def tryPost(request: Request) =
for
body <- request.body.asString
.mapError(e => InvalidRequest("Invalid request body", e.getMessage))
json <- ZIO.fromEither(body.fromJson[Json])
.mapError(InvalidRequest("Invalid JSON body", _))
name <- parseAttribute(json, "name", JsonCursor.field("name").isString)
forename <- parseAttribute(json, "forename", JsonCursor.field("forename").isString)
mail <- parseAttribute(json, "email", JsonCursor.field("email").isString)
password <- parseAttribute(json, "password", JsonCursor.field("password").isString)
user <- ZIO.serviceWithZIO[Authenticator](_.registerUser(name, forename, mail, password))
jwt <- ZIO.serviceWithZIO[Authenticator](_.requestNewJwt(user))
yield Response(
status = Status.Found,
headers = Headers.location("/") ++ //register successful, go back to main page
Headers.setCookie(Cookie("JWT", jwt))
)
def post(request: Request) = tryPost(request).catchSome {
case x => ???
}
}

@ -19,11 +19,11 @@
</Appenders>
<Loggers>
<Logger name="JWTEmitter" additivity="false" includeLocation="false">
<Logger level="WARN" name="JWTEmitter" additivity="false" includeLocation="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="LogFile"/>
</Logger>
<Root level="ALL" includeLocation="false">
<Root level="INFO" includeLocation="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="LogFile"/>
</Root>

@ -18,12 +18,13 @@ import scala.util.chaining.scalaUtilChainingOps
object Main extends ZIOAppDefault:
private val KeyFactory = java.security.KeyFactory.getInstance("RSA")
private val EmitterPresenceHook = Path.of("/tmp/emitter.presence")
private val app = Http.collectZIO[Request] {
case r@(Method.GET | Method.POST) -> _ / "jwt" =>
case r@(Method.GET | Method.POST) -> _ =>
ZIO.serviceWithZIO[JwtGenerator](_.generateTokenResponse(r))
case _ =>
ZIO.succeed(Response(status = Status.NotFound))
ZIO.succeed(Response(status = Status.MethodNotAllowed))
}
private def parsePort(port: Option[String]): Task[Int] =
@ -54,7 +55,11 @@ object Main extends ZIOAppDefault:
}
parsePort(port) <&> loadKey(keyFile)
private def onStart(port: Int) =
Console.printLine(s"JWT AppToken open on port $port") *> ZIO.attempt {
Files.deleteIfExists(EmitterPresenceHook)
Files.createFile(EmitterPresenceHook)
}
private def startServer(port: Int, key: PrivateKey) =
val config = ServerConfig.default
@ -64,10 +69,12 @@ object Main extends ZIOAppDefault:
val generator =
new JwtGenerator(Duration.ofDays(15), key, JwtAlgorithm.RS256)
val configLayer = ServerConfig.live(config)
(Server.install(
app
) *> Console.printLine(s"JWT AppToken open on port $port") *> ZIO.never)
(Server.install(app) *> onStart(port) *> ZIO.never)
.provide(configLayer, Server.live, ZLayer.succeed(generator))
.catchAllCause(c => {
Files.writeString(EmitterPresenceHook, "A")
ZIO.failCause(c)
})
val run =
ZIO.serviceWithZIO[ZIOAppArgs](args => parseArgs(args.getArgs))

@ -15,6 +15,9 @@ trait ServerModule extends ScalaModule with ScalafmtModule {
}
trait HttpModule extends ServerModule {
override def ivyDeps = super.ivyDeps() ++ Agg(
ivy"dev.zio::zio-http:0.0.3",
ivy"dev.zio::zio-streams:2.0.6",

@ -10,22 +10,22 @@ steps:
- name: build
path: ./build
commands:
- ./mill tests.test
- ./mill --disable-ticker __.compile
- ./mill --disable-ticker tests.test
# - name: Deploy
# image: override6/tbasket-backend:latest
# depends_on:
# - 'Unit tests'
# environment:
# SSH_PRIVATE_KEY:
# from_secret: SSH_PRIVATE
# PASSWD:
# from_secret: PASSWD
# volumes:
# - name: build
# path: ./build
# commands:
# - ./mill Core.compile
# - chmod 777 drone/deliver.sh
# - drone/deliver.sh
- name: Deploy
image: override6/tbasket-backend:latest
depends_on:
- 'Unit tests'
environment:
SSH_PRIVATE_KEY:
from_secret: SSH_PRIVATE
volumes:
- name: build
path: ./build
commands:
- ./mill Core.assembly
- ./mill JWTEmitter.assembly
- chmod 777 drone/deliver.sh
- drone/deliver.sh

@ -5,5 +5,6 @@ source ./drone/prepare-deliver.sh
echo "making delivery onto '$USER@$IP:$DIR/backend'"
scp -o "StrictHostKeyChecking no" "build/libs/server-all.jar" "drone/deploy.sh" "drone/start.sh" "$USER@$IP:$DIR/backend"
mv "out/JWTEmitter/assembly.dest/out.jar" "out/JWTEmitter/assembly.dest/JWTEmitter.jar"
scp -o "StrictHostKeyChecking no" "out/Core/assembly.dest/out.jar" "out/JWTEmitter/assembly.dest/JWTEmitter.jar" "drone/deploy.sh" "drone/start.sh" "$USER@$IP:$DIR/backend"
echo "chmod 700 $DIR/backend/*; cd $DIR/backend; ./deploy.sh" | ssh -o "StrictHostKeyChecking no" "$USER@$IP"

@ -2,30 +2,35 @@
DIR=$(readlink -e "$(dirname "$0")")
PROD_SERVER_JAR_NAME="server-prod.jar"
NEW_SERVER_JAR_NAME="server-all.jar"
NEW_SERVER_JAR_NAME="out.jar"
prod_pid() {
ps -aux | tr -s " " | grep -E "\bjava -jar .*$PROD_SERVER_JAR_NAME\b" | cut -d " " -f2
PROD_EMITTER_JAR_NAME="JWTEmitter-prod.jar"
NEW_EMITTER_JAR_NAME="JWTEmitter.jar"
find_pid() {
ps -aux | tr -s " " | grep -E "\bjava -jar .*$1\b" | cut -d " " -f2
}
PROD_PID=$(prod_pid)
SERVER_PROD_PID=$(find_pid $PROD_SERVER_JAR_NAME)
EMITTER_PROD_PID=$(find_pid $PROD_EMITTER_JAR_NAME)
try_shutdown() {
if [ "$1" ]; then
#will cause the old server to gracefully shutdown
echo "shutting down old $2 version ..."
kill "$PROD_PID"
while [ ! "$(find_pid $2)" ]; do sleep 1; done #sleep until process ends
echo "$2 shut down"
fi
}
#if $PROD_PID is not empty but isn't a number, something went wrong
if [ "$PROD_PID" ] && ! echo "$PROD_PID" | grep -E -q "^[0-9]+$"; then
echo "error, unable to retrieve old server pid: $PROD_PID" >&2
exit 2
fi
try_shutdown "$SERVER_PROD_PID" "$PROD_SERVER_JAR_NAME"
try_shutdown "$EMITTER_PROD_PID" "$PROD_EMITTER_JAR_NAME"
if [ "$PROD_PID" ]; then
#will cause the old server to gracefully shutdown
echo "shutting down old server version ..."
kill "$PROD_PID"
while [ ! "$(prod_pid)" ]; do sleep 1; done #sleep until process ends
echo "server shut down"
fi
rm "$DIR/$PROD_SERVER_JAR_NAME"
mv "$DIR/$NEW_SERVER_JAR_NAME" "$DIR/$PROD_SERVER_JAR_NAME" || ls
mv "$DIR/$NEW_EMITTER_JAR_NAME" "$DIR/$PROD_EMITTER_JAR_NAME" || ls
SCREEN="basket"
@ -34,5 +39,6 @@ if ! screen -ls | grep -q -E "\b[0-9]+\.$SCREEN\b"; then
screen -S "$SCREEN" -d -m
fi
chmod 755 /run/screen
screen -d -r "$SCREEN" -X stuff $"$DIR/start.sh\n"
echo "server is started into $SCREEN screen."

@ -1,27 +0,0 @@
FILES_COMMITTED=$(git diff --name-only HEAD^)
IMAGES_COMMITTED=$(echo "$FILES_COMMITTED" | grep -E "^.*\.dockerfile$")
REPOSITORY="hub.codefirst.iut.uca.fr/maxime.batista/codefirst-docdeployer"
dockerd
push_image() ({
local IMAGE_NAME=$(basename "$1" | rev | cut -d . -f2- | rev)
cd "$(dirname "$1")"
docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASSWORD" "$REPOSITORY"
echo "Building $IMAGE_NAME..."
docker build -f "$(basename "$1")" .
docker tag "$IMAGE_NAME" "$REPOSITORY/$IMAGE_NAME:latest"
echo "Pushing $IMAGE_NAME..."
docker push "$REPOSITORY/$IMAGE_NAME"
})
echo $IMAGES_COMMITTED
for IMAGE in $IMAGES_COMMITTED; do
push_image "$IMAGE"
done

@ -1,3 +1,9 @@
DIR=$(readlink -e "$(dirname "$0")")
echo "starting emitter"
java -jar "$DIR/JWTEmitter-prod.jar"&
echo "starting server"
java -jar "$DIR/server-prod.jar"

@ -1,53 +0,0 @@
{
"results": [
{
"name" : "Test Task name not available here/\/login page handler/login situation tests/login with unknown account",
"status" : "Success",
"durationMillis" : "4103",
"annotations" : "",
"fullyQualifiedClassName" : "Test Task name not available here",
"labels" : ["\/login page handler", "login situation tests", "login with unknown account"]
},
{
"name" : "Test Task name not available here/\/login page handler/login situation tests/login with known account",
"status" : "Failure",
"durationMillis" : "1",
"annotations" : "",
"fullyQualifiedClassName" : "Test Task name not available here",
"labels" : ["\/login page handler", "login situation tests", "login with known account"]
},
{
"name" : "Test Task name not available here/\/login page handler/erroned request body tests/with no password attribute",
"status" : "Success",
"durationMillis" : "2681",
"annotations" : "",
"fullyQualifiedClassName" : "Test Task name not available here",
"labels" : ["\/login page handler", "erroned request body tests", "with no password attribute"]
},
{
"name" : "Test Task name not available here/\/login page handler/erroned request body tests/empty packet",
"status" : "Success",
"durationMillis" : "2697",
"annotations" : "",
"fullyQualifiedClassName" : "Test Task name not available here",
"labels" : ["\/login page handler", "erroned request body tests", "empty packet"]
},
{
"name" : "Test Task name not available here/\/login page handler/erroned request body tests/with no mail attribute",
"status" : "Success",
"durationMillis" : "2702",
"annotations" : "",
"fullyQualifiedClassName" : "Test Task name not available here",
"labels" : ["\/login page handler", "erroned request body tests", "with no mail attribute"]
},
{
"name" : "Test Task name not available here/\/login page handler/erroned request body tests/with invalid json",
"status" : "Success",
"durationMillis" : "2717",
"annotations" : "",
"fullyQualifiedClassName" : "Test Task name not available here",
"labels" : ["\/login page handler", "erroned request body tests", "with invalid json"]
}
]
}

@ -5,23 +5,21 @@ echo GENERATING TEMPORARY KEY PAIRS FOR TESTS
rm -r /tmp/keys &> /dev/null
mkdir -p /tmp/keys
cd /tmp/keys
keytool -genkey -noprompt \
keytool -genkeypair \
-alias key \
-keyalg RSA \
-validity 2 \
-keystore test.keystore \
-keysize 4069 \
-sigalg SHA256withRSA \
-storetype PKCS12 \
-keystore store.p12 \
-dname "CN=x.y.com, OU=TB, O=TBA, L=dzqdz, S=dqzdzq, C=GB" \
-storepass 123456789 \
-keypass 123456789
keytool -noprompt -export \
-alias key \
-keystore test.keystore \
-rfc \
-file public.cert \
-keypass 123456789 \
-storepass 123456789
keytool -v -export -file public.cert -keystore store.p12 -alias key -storepass 123456789
openssl pkcs12 -in store.p12 -nodes -nocerts -out key.pem -passin pass:123456789
openssl pkcs8 -topk8 -inform PEM -outform DER -in key.pem -out key.pcqks -nocrypt
openssl pkcs12 -in test.keystore -nodes -nocerts -out private.pcks -passin pass:123456789
openssl pkcs12 -in test.keystore -nokeys -out public.cert -passin pass:123456789
#openssl pkcs8 -topk8 -inform PEM -outform DER -in test.keystore -out private.pcks -passin pass:123456789
#openssl pkcs8 -topk8 -inform PEM -outform DER -in test.keystore -out public.cert -passin pass:123456789

@ -1,8 +1,36 @@
package org.tbasket.test
import com.sun.nio.file.ExtendedOpenOption
import io.netty.buffer.ByteBuf
import java.nio.channels.Pipe
import java.nio.file.{Files, Path, StandardOpenOption}
object TestEmitter:
val PORT = 5455
val PORT = 5457
def start(): Unit = {
println("SETTING UP LOCAL JWT EMITTER FOR TESTS")
val emitterPresence = Path.of("/tmp/emitter.presence")
Files.deleteIfExists(emitterPresence)
val process = new ProcessBuilder(
"bash",
"./mill", "--disable-ticker", "JWTEmitter.run",
"-k", "/tmp/keys/key.pcqks",
"-p", TestServerConfig.emitterURL.port.get.toString
)
.inheritIO()
.start()
Runtime.getRuntime.addShutdownHook(new Thread((() => process.destroy()): Runnable))
//the emitter will create the /tmp/emitter.presence file once it started, this is to inform us that the server is OP
while (Files.notExists(emitterPresence))
Thread.sleep(500)
if (Files.readString(emitterPresence) == "A") {
System.err.println("Emitter did not start successfully")
System.exit(1)
}
Files.delete(emitterPresence)
println("EMITTER PRESENCE DETECTED AND INITIALIZED, CONTINUING...")
}

@ -15,6 +15,7 @@ object TestLayers {
val auth = {
TestEmitter.start()
val publicKey = TestServerConfig.emitterCertificate.getPublicKey
val auth = new Authenticator(TestServerConfig.emitterURL, publicKey, TestServerConfig.emitterKeyAlgorithm)
ZLayer.succeed(auth)

@ -8,6 +8,7 @@ import pdi.jwt.algorithms.JwtAsymmetricAlgorithm
import java.nio.file.{Files, Path}
import java.security.cert.{Certificate, CertificateFactory}
import scala.reflect.io
object TestServerConfig extends ServerConfig {
new ProcessBuilder("bash", "./tests/resources/generate_keys.sh")
@ -17,7 +18,7 @@ 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 emitterURL: URL = URL.fromString(s"http://localhost:$PORT").getOrElse(null)
override def emitterCertificate: Certificate =
CertFactory.generateCertificate(Files.newInputStream(Path.of("/tmp/keys/public.cert")))
@ -27,4 +28,6 @@ object TestServerConfig extends ServerConfig {
override def endpointPort: Int = 5454
override def databaseConfigName: String = "test-database"
override def pagesLocation: Option[Path] = None
}

@ -0,0 +1,28 @@
package org.tbasket.test
import io.netty.handler.codec.http.QueryStringEncoder
import org.tbasket.error.InvalidRequest
import zio.*
import zio.http.{Body, Response}
import zio.json.*
import zio.json.ast.{Json, JsonCursor}
import scala.language.{implicitConversions, reflectiveCalls}
object TestUtils {
def parseAttribute[V, T <: Json {def value: V}](json: Json, cursor: JsonCursor[Json, T]): Task[V] =
ZIO.fromEither(json.get[T](cursor).map(_.value))
.mapError(InvalidRequest(s"Missing or invalid field $cursor.", _))
def parseAttributeOpt[V, T <: Json {def value: V}](json: Json, cursor: JsonCursor[Json, T]) =
ZIO.fromEither(json.get[T](cursor).map(_.value)).option
def getJsonBody(r: Response): Task[Json] = {
for
body <- r.body.asString
json <- ZIO.fromEither(body.fromJson[Json]).orElseSucceed(Json.Obj())
yield json
}
}

@ -0,0 +1,36 @@
package org.tbasket.test.endpoint
import io.getquill.NamingStrategy
import io.getquill.context.qzio.ZioJdbcContext
import io.getquill.context.sql.idiom.SqlIdiom
import org.tbasket.auth.Authenticator
import org.tbasket.data.DatabaseContext
import org.tbasket.endpoint.auth.LoginHandler.login
import org.tbasket.test.TestLayers.*
import zio.*
import zio.http.netty.client.ConnectionPool
import zio.http.{Client, ClientConfig, URL}
import zio.test.{Spec, TestEnvironment, ZIOSpecDefault}
import javax.sql.DataSource
abstract class TBasketPageSpec(location: String) extends ZIOSpecDefault {
protected val url = URL.fromString(s"http://localhost$location") match
case Left(exception) => throw exception
case Right(url) => url
protected def tspec: Spec[
DataSource & ClientConfig & Authenticator & ConnectionPool &
Scope & DatabaseContext & Client, Any
]
final override def spec = tspec.provide(
db.datasourceLayer,
db.contextLayer,
auth,
ConnectionPool.fixed(1),
Scope.default,
ClientConfig.default,
Client.live)
}

@ -0,0 +1,87 @@
package org.tbasket.test.endpoint.auth
import io.getquill.jdbczio.Quill
import io.getquill.{SnakeCase, SqliteZioJdbcContext}
import org.tbasket.auth.Authenticator
import org.tbasket.data.{Database, DatabaseContext}
import org.tbasket.endpoint.Endpoint
import org.tbasket.endpoint.Endpoint.handle
import org.tbasket.endpoint.auth.LoginHandler
import org.tbasket.error.*
import org.tbasket.test.TestUtils.*
import org.tbasket.test.endpoint.TBasketPageSpec
import org.tbasket.test.{TestLayers, TestUtils}
import zio.*
import zio.http.*
import zio.http.model.Headers.{Header, empty}
import zio.http.model.{HeaderNames, Headers, Status}
import zio.http.netty.client.ConnectionPool
import zio.json.*
import zio.json.ast.{Json, JsonCursor}
import zio.test.*
import zio.test.Assertion.*
object LoginHandlerTests extends TBasketPageSpec("/auth/login") {
private def requestsSpec = suite("bad request tests")(
ZIO.attempt(Map(
"empty packet" -> Body.empty,
"with no mail attribute" -> Body.fromString("""{"password":"bouhours"}"""),
"with no password attribute" -> Body.fromString("""{"email":"valid.email@not.very"}"""),
"with invalid form data" -> Body.fromString("""{this is a corrupted form data}""")
)).map(_.map { case (name, body) =>
test(name) {
for
response <- handle(Request.post(body, url))
json <- getJsonBody(response)
errorType <- parseAttribute(json, JsonCursor.field("error").isString)
yield
assert(response)(hasField("status", _.status, equalTo(Status.BadRequest)))
&& assertTrue(errorType == "invalid request")
}
})
)
private def loginSpec = {
suite("login situation tests")(
test("login with unknown account") {
for
response <- handle(Request.post(Body.fromString("""{"password":"bouhours","email":"unknownaccount@gmail.com"}"""), url))
json <- getJsonBody(response)
errorType <- parseAttribute(json, JsonCursor.field("error").isString)
yield
//assert that the response error is of type unauthorized and headers are Location: /register
assert(response)(hasField("status", _.status, equalTo(Status.Found)))
&& assert(errorType)(equalTo("unauthorized"))
&& assert(response)(hasField("headers", _.headers, contains(Headers.location("/register"))))
},
test("login with known account") {
for
response <- handle(Request.post(Body.fromString("""{"password":"123456","email":"maximebatista18@gmail.com"}"""), url))
yield
assert(response)(hasField("status", _.status, equalTo(Status.Found)))
&& assert(response)(hasField("body", _.body, equalTo(Body.empty)))
&& assert(response)(hasField("headers", _.headers, exists(hasField("key", _.key, equalTo(HeaderNames.setCookie)))))
},
test("login with known account wrong password") {
for
fiber <- handle(Request.post(Body.fromString("""{"password":"wrong","email":"maximebatista18@gmail.com"}"""), url)).fork
_ <- TestClock.adjust(1.seconds)
response <- ZIO.fromFiber(fiber)
json <- getJsonBody(response)
errorType <- parseAttribute(json, JsonCursor.field("error").isString)
yield
assert(response)(hasField("status", _.status, equalTo(Status.Unauthorized)))
&& assert(errorType)(equalTo("unauthorized"))
}
)
}
override def tspec = suite("/login page handler")(
requestsSpec,
loginSpec
)
}

@ -0,0 +1,100 @@
package org.tbasket.test.endpoint.auth
import org.tbasket.endpoint.Endpoint.handle
import org.tbasket.test.TestUtils
import org.tbasket.test.TestUtils.*
import org.tbasket.test.endpoint.TBasketPageSpec
import org.tbasket.test.endpoint.auth.RegisterHandlerTests.test
import zio.*
import zio.http.*
import zio.http.model.{HeaderNames, Headers, Status}
import zio.json.ast.JsonCursor
import zio.test.*
import zio.test.Assertion.*
object RegisterHandlerTests extends TBasketPageSpec("/auth/register") {
private def requestsSpec = suite("bad request tests")(
ZIO.attempt(Map( //TODO test all wrong combinations
"empty packet" -> Body.empty,
"with no mail attribute" -> Body.fromString("""{"password": "123445678"}"""),
"with no password attribute" -> Body.fromString("""{"email":"valid.mail@x.y"}"""),
"with invalid form data" -> Body.fromString("""{this is a corrupted form data}""")
)).map(_.map { case (name, body) =>
test(name) {
for
response <- handle(Request.post(body, url))
json <- getJsonBody(response)
errorType <- parseAttribute(json, JsonCursor.field("error").isString)
yield
assert(response)(hasField("status", _.status, equalTo(Status.BadRequest)))
&& assertTrue(errorType.startsWith("invalid"))
}
})
)
private def registerSpec = suite("register tests")(
test("register then try register again") {
(for
resp <- handle(Request.post(Body.fromString("""{"name":"tuaillon","forename":"leo","email":"leo.tuaillon@etu.uca.fr","password":"bouhours"}"""), url))
yield
assert(resp)(hasField("status", _.status, equalTo(Status.Found)))
&& assert(resp)(hasField("body", _.body, equalTo(Body.empty))) //TODO assert that the cookie name is JWT
&& assert(resp)(hasField("headers", _.headers, exists(hasField("key", _.key, equalTo(HeaderNames.setCookie)))))
&& assert(resp)(hasField("headers", _.headers, contains(Headers.location("/"))))
)
*>
(for
resp <- handle(Request.post(Body.fromString("""{"name":"tuaillon","forename":"leo","email":"leo.tuaillon@etu.uca.fr","password":"bouhours"}"""), url))
json <- getJsonBody(resp)
errorType <- parseAttributeOpt(json, JsonCursor.field("error").isString)
yield
assert(resp)(hasField("status", _.status, equalTo(Status.NotAcceptable)))
&& assert(errorType)(isSome(equalTo("already registered")))
&& assert(resp)(hasField("headers", _.headers, contains(Headers.location("/login")))))
},
test("register bad email") {
for
resp <- handle(Request.post(Body.fromString("""{"name":"tuaillon","forename":"leo","email":"leo.tuaillonbadmail","password":"bouhours"}"""), url))
json <- getJsonBody(resp)
errorType <- parseAttributeOpt(json, JsonCursor.field("error").isString)
yield
assert(resp)(hasField("status", _.status, equalTo(Status.ExpectationFailed)))
&& assert(errorType)(isSome(equalTo("invalid email")))
},
test("register bad password") {
for
resp <- handle(Request.post(Body.fromString("""{"name":"tuaillon","forename":"leo","email":"leo.tuaillon@etu.uca.fr","password":"1234"}"""), url))
json <- getJsonBody(resp)
errorType <- parseAttributeOpt(json, JsonCursor.field("error").isString)
yield
assert(resp)(hasField("status", _.status, equalTo(Status.ExpectationFailed)))
&& assert(errorType)(isSome(equalTo("invalid password")))
},
test("register bad name") {
for
resp <- handle(Request.post(Body.fromString("""{"name":"","forename":"leo","email":"leo.tuaillon@etu.uca.fr","password":"123456"}"""), url))
json <- getJsonBody(resp)
errorType <- parseAttributeOpt(json, JsonCursor.field("error").isString)
yield
assert(resp)(hasField("status", _.status, equalTo(Status.ExpectationFailed)))
&& assert(errorType)(isSome(equalTo("invalid name")))
},
test("register bad forename") {
for
resp <- handle(Request.post(Body.fromString("""{"name":"tuaillon","forename":"","email":"leo.tuaillon@etu.uca.fr","password":"bouhours"}"""), url))
json <- getJsonBody(resp)
errorType <- parseAttributeOpt(json, JsonCursor.field("error").isString)
yield
assert(resp)(hasField("status", _.status, equalTo(Status.ExpectationFailed)))
&& assert(errorType)(isSome(equalTo("invalid forename")))
}
)
override def tspec = suite("/register page handler")(
requestsSpec,
registerSpec
)
}

@ -1,87 +0,0 @@
package org.tbasket.test.pages
import io.getquill.jdbczio.Quill
import io.getquill.{SnakeCase, SqliteZioJdbcContext}
import org.tbasket.auth.Authenticator
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 org.tbasket.test.pages.LoginPageHandlerTests.test
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.*
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":"unknownaccount@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"))))
},
test("login with known 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(response)(hasField("headers", _.headers, exists(hasField("key", _.key, equalTo(HeaderNames.setCookie)))))
}
)
}
override def spec = suite("/login page handler")(
requestsSpec,
loginSpec
).provide(
db.datasourceLayer,
db.contextLayer,
auth,
ConnectionPool.fixed(1),
Scope.default,
ClientConfig.default,
Client.live)
}