diff --git a/jacoco-reporter/application.yml b/jacoco-reporter/application.yml new file mode 100644 index 0000000..e69de29 diff --git a/modules/account/build.gradle.kts b/modules/account/build.gradle.kts new file mode 100644 index 0000000..9e7e3e4 --- /dev/null +++ b/modules/account/build.gradle.kts @@ -0,0 +1,110 @@ +plugins { + id("scala") + id("org.scoverage") version "8.1" + id("io.quarkus") +} + +group = "de.nowchess" +version = "1.0-SNAPSHOT" + +@Suppress("UNCHECKED_CAST") +val versions = rootProject.extra["VERSIONS"] as Map + +repositories { + mavenCentral() +} + +scala { + scalaVersion = versions["SCALA3"]!! +} + +scoverage { + scoverageVersion.set(versions["SCOVERAGE"]!!) +} + +tasks.withType { + scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8") +} + +val quarkusPlatformGroupId: String by project +val quarkusPlatformArtifactId: String by project +val quarkusPlatformVersion: String by project + +dependencies { + + compileOnly("org.scala-lang:scala3-compiler_3") { + version { + strictly(versions["SCALA3"]!!) + } + } + implementation("org.scala-lang:scala3-library_3") { + version { + strictly(versions["SCALA3"]!!) + } + } + + implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) + implementation("io.quarkus:quarkus-rest") + implementation("io.quarkus:quarkus-rest-jackson") + implementation("io.quarkus:quarkus-config-yaml") + implementation("io.quarkus:quarkus-arc") + implementation("io.quarkus:quarkus-hibernate-orm-panache") + implementation("io.quarkus:quarkus-jdbc-postgresql") + implementation("io.quarkus:quarkus-smallrye-jwt") + implementation("io.quarkus:quarkus-smallrye-jwt-build") + implementation("io.quarkus:quarkus-elytron-security-common") + implementation("io.quarkus:quarkus-smallrye-health") + implementation("io.quarkus:quarkus-micrometer") + implementation("io.quarkus:quarkus-smallrye-openapi") + implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") + + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") + testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") + testImplementation("io.quarkus:quarkus-junit") + testImplementation("io.rest-assured:rest-assured") + testImplementation("io.quarkus:quarkus-jdbc-h2") + testImplementation("io.quarkus:quarkus-test-security") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +configurations.matching { !it.name.startsWith("scoverage") }.configureEach { + resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}") +} +configurations.scoverage { + resolutionStrategy.eachDependency { + if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) { + useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0") + } + } +} + +tasks.withType { + options.encoding = "UTF-8" + options.compilerArgs.add("-parameters") +} + +tasks.withType().configureEach { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +tasks.test { + useJUnitPlatform { + includeEngines("scalatest", "junit-jupiter") + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + } + finalizedBy(tasks.reportScoverage) +} +tasks.reportScoverage { + dependsOn(tasks.test) +} + +tasks.jar { + duplicatesStrategy = DuplicatesStrategy.INCLUDE +} diff --git a/modules/account/src/main/scala/de/nowchess/account/config/JacksonConfig.scala b/modules/account/src/main/scala/de/nowchess/account/config/JacksonConfig.scala new file mode 100644 index 0000000..ad00ee6 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/config/JacksonConfig.scala @@ -0,0 +1,17 @@ +package de.nowchess.account.config + +import com.fasterxml.jackson.core.Version +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.DefaultScalaModule +import io.quarkus.jackson.ObjectMapperCustomizer +import jakarta.inject.Singleton + +@Singleton +class JacksonConfig extends ObjectMapperCustomizer: + def customize(mapper: ObjectMapper): Unit = + mapper.registerModule(new DefaultScalaModule() { + override def version(): Version = + // scalafix:off DisableSyntax.null + new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala") + // scalafix:on DisableSyntax.null + }) diff --git a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala new file mode 100644 index 0000000..a51274d --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala @@ -0,0 +1,25 @@ +package de.nowchess.account.config + +import de.nowchess.account.domain.{ChallengeColor, ChallengeStatus, DeclineReason} +import de.nowchess.account.dto.* +import io.quarkus.runtime.annotations.RegisterForReflection + +@RegisterForReflection( + targets = Array( + classOf[RegisterRequest], + classOf[LoginRequest], + classOf[TokenResponse], + classOf[PlayerInfo], + classOf[PublicAccountDto], + classOf[TimeControlDto], + classOf[ChallengeRequest], + classOf[ChallengeDto], + classOf[DeclineRequest], + classOf[ChallengeListDto], + classOf[ErrorDto], + classOf[ChallengeStatus], + classOf[ChallengeColor], + classOf[DeclineReason], + ), +) +class NativeReflectionConfig diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/Account.scala b/modules/account/src/main/scala/de/nowchess/account/domain/Account.scala new file mode 100644 index 0000000..7b4ebe1 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/domain/Account.scala @@ -0,0 +1,27 @@ +package de.nowchess.account.domain + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase +import jakarta.persistence.* +import scala.compiletime.uninitialized + +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "accounts") +class Account extends PanacheEntityBase: + @Id + @GeneratedValue(strategy = GenerationType.UUID) + var id: UUID = uninitialized + + @Column(unique = true, nullable = false) + var username: String = uninitialized + + @Column(unique = true, nullable = false) + var email: String = uninitialized + + var passwordHash: String = uninitialized + + var rating: Int = 1500 + + var createdAt: Instant = uninitialized \ No newline at end of file diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala b/modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala new file mode 100644 index 0000000..11d218f --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala @@ -0,0 +1,50 @@ +package de.nowchess.account.domain + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase +import jakarta.persistence.* +import scala.compiletime.uninitialized + +import java.time.Instant +import java.util.UUID +import scala.Conversion + +@Entity +@Table(name = "challenges") +class Challenge extends PanacheEntityBase: + @Id + @GeneratedValue(strategy = GenerationType.UUID) + var id: UUID = uninitialized + + @ManyToOne + var challenger: Account = uninitialized + + @ManyToOne + var destUser: Account = uninitialized + + @Convert(converter = classOf[ChallengeColorConverter]) + @Column(columnDefinition = "varchar(255)") + var color: ChallengeColor = uninitialized + + @Convert(converter = classOf[ChallengeStatusConverter]) + @Column(columnDefinition = "varchar(255)") + var status: ChallengeStatus = uninitialized + + @Convert(converter = classOf[DeclineReasonConverter]) + @Column(nullable = true, columnDefinition = "varchar(255)") + var declineReason: DeclineReason = uninitialized + + var timeControlType: String = uninitialized + + @Column(nullable = true) + var timeControlLimit: java.lang.Integer = uninitialized + + @Column(nullable = true) + var timeControlIncrement: java.lang.Integer = uninitialized + + var createdAt: Instant = uninitialized + + var expiresAt: Instant = uninitialized + + def declineReasonOpt: Option[DeclineReason] = Option(declineReason) + def timeControlLimitOpt: Option[Int] = Option(timeControlLimit).map(_.intValue()) + def timeControlIncrementOpt: Option[Int] = Option(timeControlIncrement).map(_.intValue()) diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColor.scala b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColor.scala new file mode 100644 index 0000000..9f5fb13 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColor.scala @@ -0,0 +1,4 @@ +package de.nowchess.account.domain + +enum ChallengeColor: + case White, Black, Random diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColorConverter.scala b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColorConverter.scala new file mode 100644 index 0000000..4ca6b58 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColorConverter.scala @@ -0,0 +1,14 @@ +package de.nowchess.account.domain + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter(autoApply = true) +class ChallengeColorConverter extends AttributeConverter[ChallengeColor, String]: + override def convertToDatabaseColumn(attribute: ChallengeColor): String = + if attribute == null then null else attribute.toString + + override def convertToEntityAttribute(dbData: String): ChallengeColor = + if dbData == null then null + else + ChallengeColor.valueOf(dbData) diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatus.scala b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatus.scala new file mode 100644 index 0000000..4883cf8 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatus.scala @@ -0,0 +1,4 @@ +package de.nowchess.account.domain + +enum ChallengeStatus: + case Created, Canceled, Declined, Accepted diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatusConverter.scala b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatusConverter.scala new file mode 100644 index 0000000..3cf23b7 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatusConverter.scala @@ -0,0 +1,14 @@ +package de.nowchess.account.domain + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter(autoApply = true) +class ChallengeStatusConverter extends AttributeConverter[ChallengeStatus, String]: + override def convertToDatabaseColumn(attribute: ChallengeStatus): String = + if attribute == null then null else attribute.toString + + override def convertToEntityAttribute(dbData: String): ChallengeStatus = + if dbData == null then null + else + ChallengeStatus.valueOf(dbData) diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReason.scala b/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReason.scala new file mode 100644 index 0000000..8f7c7fd --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReason.scala @@ -0,0 +1,4 @@ +package de.nowchess.account.domain + +enum DeclineReason: + case Generic, Later, TooFast, TooSlow, TimeControl, Rated, Casual, Standard, Variant, NoBot, OnlyBot diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReasonConverter.scala b/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReasonConverter.scala new file mode 100644 index 0000000..103ab17 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReasonConverter.scala @@ -0,0 +1,14 @@ +package de.nowchess.account.domain + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter(autoApply = true) +class DeclineReasonConverter extends AttributeConverter[DeclineReason, String]: + override def convertToDatabaseColumn(attribute: DeclineReason): String = + if attribute == null then null else attribute.toString + + override def convertToEntityAttribute(dbData: String): DeclineReason = + if dbData == null then null + else + DeclineReason.valueOf(dbData) diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala b/modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala new file mode 100644 index 0000000..eceaef8 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala @@ -0,0 +1,7 @@ +package de.nowchess.account.domain + +sealed trait TimeControl + +object TimeControl: + case class Clock(limit: Int, increment: Int) extends TimeControl + case object Unlimited extends TimeControl diff --git a/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala new file mode 100644 index 0000000..e9a406d --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala @@ -0,0 +1,34 @@ +package de.nowchess.account.dto + +case class RegisterRequest(username: String, email: String, password: String) + +case class LoginRequest(username: String, password: String) + +case class TokenResponse(token: String) + +case class PlayerInfo(id: String, name: String, rating: Int) + +case class PublicAccountDto(id: String, username: String, rating: Int, createdAt: String) + +case class TimeControlDto(`type`: String, limit: Option[Int], increment: Option[Int]) + +case class ChallengeRequest(color: String, timeControl: TimeControlDto) + +case class ChallengeDto( + id: String, + challenger: PlayerInfo, + destUser: PlayerInfo, + variant: String, + color: String, + timeControl: TimeControlDto, + status: String, + declineReason: Option[String], + createdAt: String, + expiresAt: String, +) + +case class DeclineRequest(reason: Option[String]) + +case class ChallengeListDto(in: List[ChallengeDto], out: List[ChallengeDto]) + +case class ErrorDto(error: String) diff --git a/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala b/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala new file mode 100644 index 0000000..fe7f3d7 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala @@ -0,0 +1,44 @@ +package de.nowchess.account.repository + +import de.nowchess.account.domain.Account +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.persistence.EntityManager + +import java.util.UUID +import scala.jdk.CollectionConverters.* + +@ApplicationScoped +class AccountRepository: + + @Inject + var em: EntityManager = scala.compiletime.uninitialized + + def findByUsername(username: String): Option[Account] = + em.createQuery("FROM Account WHERE username = :username", classOf[Account]) + .setParameter("username", username) + .getResultList + .stream() + .findFirst() + .map(Option(_)) + .orElse(None) + + def findById(id: UUID): Option[Account] = + Option(em.find(classOf[Account], id)) + + def persist(account: Account): Account = + em.persist(account) + account + + def findByEmail(email: String): Option[Account] = + em.createQuery("FROM Account WHERE email = :email", classOf[Account]) + .setParameter("email", email) + .getResultList + .asScala + .headOption + + def findAll(): List[Account] = + em.createQuery("FROM Account", classOf[Account]) + .getResultList + .asScala + .toList diff --git a/modules/account/src/main/scala/de/nowchess/account/repository/ChallengeRepository.scala b/modules/account/src/main/scala/de/nowchess/account/repository/ChallengeRepository.scala new file mode 100644 index 0000000..2cb437d --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/repository/ChallengeRepository.scala @@ -0,0 +1,60 @@ +package de.nowchess.account.repository + +import de.nowchess.account.domain.{Challenge, ChallengeStatus} +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.persistence.EntityManager + +import java.time.Instant +import java.util.UUID +import scala.jdk.CollectionConverters.* + +@ApplicationScoped +class ChallengeRepository: + + @Inject + var em: EntityManager = scala.compiletime.uninitialized + + def findActiveByChallengerId(challengerId: UUID): List[Challenge] = + em.createQuery( + "FROM Challenge WHERE challenger.id = :cid AND status = :status AND expiresAt > :now", + classOf[Challenge], + ).setParameter("cid", challengerId) + .setParameter("status", ChallengeStatus.Created) + .setParameter("now", Instant.now()) + .getResultList + .asScala + .toList + + def findActiveByDestUserId(destUserId: UUID): List[Challenge] = + em.createQuery( + "FROM Challenge WHERE destUser.id = :uid AND status = :status AND expiresAt > :now", + classOf[Challenge], + ).setParameter("uid", destUserId) + .setParameter("status", ChallengeStatus.Created) + .setParameter("now", Instant.now()) + .getResultList + .asScala + .toList + + def findDuplicateChallenge(challengerId: UUID, destUserId: UUID): Option[Challenge] = + em.createQuery( + "FROM Challenge WHERE challenger.id = :cid AND destUser.id = :uid AND status = :status AND expiresAt > :now", + classOf[Challenge], + ).setParameter("cid", challengerId) + .setParameter("uid", destUserId) + .setParameter("status", ChallengeStatus.Created) + .setParameter("now", Instant.now()) + .getResultList + .asScala + .headOption + + def findById(id: UUID): Option[Challenge] = + Option(em.find(classOf[Challenge], id)) + + def persist(challenge: Challenge): Challenge = + em.persist(challenge) + challenge + + def merge(challenge: Challenge): Challenge = + em.merge(challenge) diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala new file mode 100644 index 0000000..6d1a963 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala @@ -0,0 +1,67 @@ +package de.nowchess.account.resource + +import de.nowchess.account.domain.Account +import de.nowchess.account.dto.* +import de.nowchess.account.service.AccountService +import jakarta.annotation.security.RolesAllowed +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.ws.rs.* +import jakarta.ws.rs.core.{MediaType, Response} +import org.eclipse.microprofile.jwt.JsonWebToken +import scala.compiletime.uninitialized + +import java.util.UUID + +@Path("/api/account") +@ApplicationScoped +@Consumes(Array(MediaType.APPLICATION_JSON)) +@Produces(Array(MediaType.APPLICATION_JSON)) +class AccountResource: + + @Inject + var accountService: AccountService = uninitialized + + @Inject + var jwt: JsonWebToken = uninitialized + + @POST + def register(req: RegisterRequest): Response = + accountService.register(req) match + case Right(account) => + Response.ok(toPublicDto(account)).build() + case Left(error) => + Response.status(Response.Status.CONFLICT).entity(ErrorDto(error)).build() + + @POST + @Path("/login") + def login(req: LoginRequest): Response = + accountService.login(req) match + case Right(token) => + Response.ok(TokenResponse(token)).build() + case Left(error) => + Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error)).build() + + @GET + @Path("/me") + @RolesAllowed(Array("**")) + def me(): Response = + val id = UUID.fromString(jwt.getSubject) + accountService.findById(id) match + case Some(account) => Response.ok(toPublicDto(account)).build() + case None => Response.status(Response.Status.NOT_FOUND).build() + + @GET + @Path("/{username}") + def publicProfile(@PathParam("username") username: String): Response = + accountService.findByUsername(username) match + case Some(account) => Response.ok(toPublicDto(account)).build() + case None => Response.status(Response.Status.NOT_FOUND).build() + + private def toPublicDto(account: Account): PublicAccountDto = + PublicAccountDto( + id = account.id.toString, + username = account.username, + rating = account.rating, + createdAt = account.createdAt.toString, + ) diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala new file mode 100644 index 0000000..0142d64 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala @@ -0,0 +1,75 @@ +package de.nowchess.account.resource + +import de.nowchess.account.dto.* +import de.nowchess.account.service.ChallengeService +import jakarta.annotation.security.RolesAllowed +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.ws.rs.* +import jakarta.ws.rs.core.{MediaType, Response} +import org.eclipse.microprofile.jwt.JsonWebToken +import scala.compiletime.uninitialized + +import java.util.UUID + +@Path("/api/challenge") +@ApplicationScoped +@RolesAllowed(Array("**")) +@Consumes(Array(MediaType.APPLICATION_JSON)) +@Produces(Array(MediaType.APPLICATION_JSON)) +class ChallengeResource: + + @Inject + var challengeService: ChallengeService = uninitialized + + @Inject + var jwt: JsonWebToken = uninitialized + + @POST + @Path("/{username}") + def create(@PathParam("username") username: String, req: ChallengeRequest): Response = + val userId = UUID.fromString(jwt.getSubject) + challengeService.create(userId, username, req) match + case Right(challenge) => + Response.status(Response.Status.CREATED).entity(challengeService.toDto(challenge)).build() + case Left(error) => + val status = if error.contains("not found") then Response.Status.NOT_FOUND + else if error.contains("yourself") then Response.Status.BAD_REQUEST + else Response.Status.CONFLICT + Response.status(status).entity(ErrorDto(error)).build() + + @GET + def list(): Response = + val userId = UUID.fromString(jwt.getSubject) + Response.ok(challengeService.listForUser(userId)).build() + + @POST + @Path("/{id}/accept") + def accept(@PathParam("id") id: UUID): Response = + val userId = UUID.fromString(jwt.getSubject) + challengeService.accept(id, userId) match + case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build() + case Left(error) => errorResponse(error) + + @POST + @Path("/{id}/decline") + def decline(@PathParam("id") id: UUID, req: DeclineRequest): Response = + val userId = UUID.fromString(jwt.getSubject) + challengeService.decline(id, userId, req) match + case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build() + case Left(error) => errorResponse(error) + + @POST + @Path("/{id}/cancel") + def cancel(@PathParam("id") id: UUID): Response = + val userId = UUID.fromString(jwt.getSubject) + challengeService.cancel(id, userId) match + case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build() + case Left(error) => errorResponse(error) + + private def errorResponse(error: String): Response = + val status = + if error.contains("not found") then Response.Status.NOT_FOUND + else if error.contains("authorized") then Response.Status.FORBIDDEN + else Response.Status.BAD_REQUEST + Response.status(status).entity(ErrorDto(error)).build() diff --git a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala new file mode 100644 index 0000000..f5fae4b --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala @@ -0,0 +1,54 @@ +package de.nowchess.account.service + +import de.nowchess.account.domain.Account +import de.nowchess.account.dto.{LoginRequest, RegisterRequest} +import de.nowchess.account.repository.AccountRepository +import io.quarkus.elytron.security.common.BcryptUtil +import io.smallrye.jwt.build.Jwt +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.transaction.Transactional +import scala.compiletime.uninitialized + +import java.time.Instant +import java.util.UUID + +@ApplicationScoped +class AccountService: + + @Inject + var accountRepository: AccountRepository = uninitialized + + @Transactional + def register(req: RegisterRequest): Either[String, Account] = + if accountRepository.findByUsername(req.username).isDefined then + Left(s"Username '${req.username}' is already taken") + else if accountRepository.findByEmail(req.email).isDefined then + Left(s"Email '${req.email}' is already registered") + else + val account = new Account() + account.username = req.username + account.email = req.email + account.passwordHash = BcryptUtil.bcryptHash(req.password) + account.createdAt = Instant.now() + accountRepository.persist(account) + Right(account) + + def login(req: LoginRequest): Either[String, String] = + accountRepository + .findByUsername(req.username) + .filter(a => BcryptUtil.matches(req.password, a.passwordHash)) + .map { account => + Jwt + .issuer("nowchess") + .subject(account.id.toString) + .claim("username", account.username) + .sign() + } + .toRight("Invalid credentials") + + def findByUsername(username: String): Option[Account] = + accountRepository.findByUsername(username) + + def findById(id: UUID): Option[Account] = + accountRepository.findById(id) diff --git a/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala new file mode 100644 index 0000000..d1d42be --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala @@ -0,0 +1,119 @@ +package de.nowchess.account.service + +import de.nowchess.account.domain.{Challenge, ChallengeColor, ChallengeStatus, DeclineReason} +import de.nowchess.account.dto.{ChallengeDto, ChallengeListDto, ChallengeRequest, DeclineRequest, PlayerInfo, TimeControlDto} +import de.nowchess.account.repository.{AccountRepository, ChallengeRepository} +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.transaction.Transactional +import scala.compiletime.uninitialized + +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.UUID + +@ApplicationScoped +class ChallengeService: + + @Inject + var accountRepository: AccountRepository = uninitialized + + @Inject + var challengeRepository: ChallengeRepository = uninitialized + + @Transactional + def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[String, Challenge] = + for + destUser <- accountRepository.findByUsername(destUsername).toRight(s"User '$destUsername' not found") + challenger <- accountRepository.findById(challengerId).toRight("Challenger not found") + _ <- Either.cond(challenger.id != destUser.id, (), "Cannot challenge yourself") + _ <- Either.cond( + challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty, + (), + "Active challenge to this user already exists", + ) + color <- parseColor(req.color) + yield + val challenge = new Challenge() + challenge.challenger = challenger + challenge.destUser = destUser + challenge.color = color + challenge.status = ChallengeStatus.Created + challenge.timeControlType = req.timeControl.`type` + challenge.timeControlLimit = + req.timeControl.limit.map(java.lang.Integer.valueOf).orNull + challenge.timeControlIncrement = + req.timeControl.increment.map(java.lang.Integer.valueOf).orNull + challenge.createdAt = Instant.now() + challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS) + challengeRepository.persist(challenge) + challenge + + @Transactional + def accept(challengeId: UUID, userId: UUID): Either[String, Challenge] = + for + challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found") + _ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active") + _ <- Either.cond(challenge.destUser.id == userId, (), "Not authorized to accept this challenge") + yield + challenge.status = ChallengeStatus.Accepted + challengeRepository.merge(challenge) + challenge + + @Transactional + def decline(challengeId: UUID, userId: UUID, req: DeclineRequest): Either[String, Challenge] = + for + challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found") + _ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active") + _ <- Either.cond(challenge.destUser.id == userId, (), "Not authorized to decline this challenge") + reason <- parseDeclineReason(req.reason) + yield + challenge.status = ChallengeStatus.Declined + challenge.declineReason = reason.orNull + challengeRepository.merge(challenge) + challenge + + @Transactional + def cancel(challengeId: UUID, userId: UUID): Either[String, Challenge] = + for + challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found") + _ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active") + _ <- Either.cond(challenge.challenger.id == userId, (), "Not authorized to cancel this challenge") + yield + challenge.status = ChallengeStatus.Canceled + challengeRepository.merge(challenge) + challenge + + def listForUser(userId: UUID): ChallengeListDto = + val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto) + val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto) + ChallengeListDto(in = incoming, out = outgoing) + + private def parseColor(raw: String): Either[String, ChallengeColor] = + raw.toLowerCase match + case "white" => Right(ChallengeColor.White) + case "black" => Right(ChallengeColor.Black) + case "random" => Right(ChallengeColor.Random) + case _ => Left(s"Unknown color: $raw") + + private def parseDeclineReason(raw: Option[String]): Either[String, Option[DeclineReason]] = + raw match + case None => Right(None) + case Some(r) => + DeclineReason.values.find(_.toString.equalsIgnoreCase(r)) match + case Some(reason) => Right(Some(reason)) + case None => Left(s"Unknown decline reason: $r") + + def toDto(c: Challenge): ChallengeDto = + ChallengeDto( + id = c.id.toString, + challenger = PlayerInfo(c.challenger.id.toString, c.challenger.username, c.challenger.rating), + destUser = PlayerInfo(c.destUser.id.toString, c.destUser.username, c.destUser.rating), + variant = "standard", + color = c.color.toString.toLowerCase, + timeControl = TimeControlDto(c.timeControlType, c.timeControlLimitOpt, c.timeControlIncrementOpt), + status = c.status.toString.toLowerCase, + declineReason = c.declineReasonOpt.map(_.toString.toLowerCase), + createdAt = c.createdAt.toString, + expiresAt = c.expiresAt.toString, + ) diff --git a/modules/account/src/test/resources/keys/test-private.pem b/modules/account/src/test/resources/keys/test-private.pem new file mode 100644 index 0000000..ad772e8 --- /dev/null +++ b/modules/account/src/test/resources/keys/test-private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4zBHgRLMez2b6 +wfdvvTJVR8xxbr/kJUMiq4ot14KhtTaGikFW+77ezjoqabFWH7CNjDvASWCM2n7X +PxL4fhUwzvTbhRZ2XNM80lKB+OIjP3hoNLvgeSNHbS4CztOfk2JVtQFLQdYJ/gvB +oFPgBtZYO/SZVML28d5U92JrWRIC1e1Ht1oKwKJoOqtTJrs/RuOlKQ/du4kwY8m0 +jPw05wFA1YRMUC78xKklCVYCufYewIUTdKxATK0ZKWBoPCJnxDg8gwgpnV1wHQrH +GcbZvhcVg3GWpDcYdnogV4rlssws57+uAhGRyQBkmmhVb+zT+LT7WXDPB46MnHkK +FIZaxEkHAgMBAAECggEAAvu4Zih1w8+RWAb9mZ4yS9Im6MXi7yny1YJzbp4GC9pD +ERT2TRMvV6V4puqh5EQKs55J8Ka+mkeEuLDZ+4z9hpYwucKCRFLnThoPHu4HqI4D +wZroVY1fFm4aygzQucjFU6DibnaXn/2r7upJsFor56zAHCGULCxnbHO58QW1Frqa +UrTndSkrxavBD9LL1ohPEy3saXlRCVAEM5l7jZbg52dPauIYAOv0e+EE3RETw/Xz +3EWukIZ7PKyoyuQm8Sv2u7lyISljDGlvrW5IjVRPMPqOKNOa/pV3qU4mbUY6GjbC +B4xt8kEKjVSkTeMXA+W0gnZddnQOtcQYSrYWWes+AQKBgQDzjmt1ZJktZG96M8+f +Ov9JznfzSLYxN7EboDhqjTVBOkb6flRSYrd9E6gReIIrq5Sjs9Z+toA/u8BmjQ/P +GTrrLVh6bLBicUGKcmQFKw/0D9lOlbxaMg8VO9rqSb/AslumJwjucU7DA+WAN52j +cyiLiw+EmWjL/DV51fHHI18SgQKBgQDCPRzpeP8Qox83/+tGR/6fSSRi5ec3ZVPy +aCCCZM6qqhLv3hJkV0djRruVVfe136PwUi20BW6aF0PXmxDIGRWqDLQGkvDNEhjw +ZLBv/dYtW2HBZhq4E0w8DiaNZCOWvpLQ3QCEtzmuhyHhNqYHzvmuerk+w4c/8fY6 +DFyPyiAHhwKBgDrpO/zNNG/SV1SLq7CsKIvFsSXbdJY7Dk/MVVkQhs0cN4bnf6Xd +0twiIQj4ySOfAPkHyt4jbqn70/H6NNS3GZVBBqG2IIPvORcvzBmj7Nvv6XQkq8Z1 +TUipja4V4JfPjHOIBZUHOzHYg26cBTk/5ZK7NCmyobKVcqnhofW1DI4BAoGAaRu4 +8X5QSCh9VEhggH+lAX0K+5l9LTTf4GUIcocqbp/p73M0cKfqMYatK3qBuSF0DS/r +G2d1Gl1MkPeQdTddyc9l+8i4FcCdTjiuYWvy4kh49bbS7plCv5zIr+pod8JYoD13 +clnUFOV7J+vynHccFZbDd3tHTQsaOv9Fd2nhOzECgYEA8SWBEmTuaBh+0vr6zS+E +wD+cwB3iaGo+7fP7TZ+v1kxoDlcDjPYM4ikiOB+OPGNkAfqc3MGsbhfgcxqD0+5r +kpCFyiyieyoT+7hkMpMsJCNwFO+29fc3DDqPX4Keqp26tMxtRzYea3GtVShiRXew +5i4ReFwm3/IWDn9kLmHT6Fg= +-----END PRIVATE KEY----- diff --git a/modules/account/src/test/resources/keys/test-public.pem b/modules/account/src/test/resources/keys/test-public.pem new file mode 100644 index 0000000..a5b79c0 --- /dev/null +++ b/modules/account/src/test/resources/keys/test-public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMwR4ESzHs9m+sH3b70y +VUfMcW6/5CVDIquKLdeCobU2hopBVvu+3s46KmmxVh+wjYw7wElgjNp+1z8S+H4V +MM7024UWdlzTPNJSgfjiIz94aDS74HkjR20uAs7Tn5NiVbUBS0HWCf4LwaBT4AbW +WDv0mVTC9vHeVPdia1kSAtXtR7daCsCiaDqrUya7P0bjpSkP3buJMGPJtIz8NOcB +QNWETFAu/MSpJQlWArn2HsCFE3SsQEytGSlgaDwiZ8Q4PIMIKZ1dcB0KxxnG2b4X +FYNxlqQ3GHZ6IFeK5bLMLOe/rgIRkckAZJpoVW/s0/i0+1lwzweOjJx5ChSGWsRJ +BwIDAQAB +-----END PUBLIC KEY----- diff --git a/modules/account/src/test/scala/de/nowchess/account/resource/AccountResourceTest.scala b/modules/account/src/test/scala/de/nowchess/account/resource/AccountResourceTest.scala new file mode 100644 index 0000000..7ea3f1c --- /dev/null +++ b/modules/account/src/test/scala/de/nowchess/account/resource/AccountResourceTest.scala @@ -0,0 +1,107 @@ +package de.nowchess.account.resource + +import io.quarkus.test.junit.QuarkusTest +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.hamcrest.Matchers.* +import org.junit.jupiter.api.Test + +@QuarkusTest +class AccountResourceTest: + + private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON) + + private def registerBody(username: String, email: String = "", password: String = "secret") = + val resolvedEmail = if email.isEmpty then s"$username@example.com" else email + s"""{"username":"$username","email":"$resolvedEmail","password":"$password"}""" + + private def loginBody(username: String, password: String = "secret") = + s"""{"username":"$username","password":"$password"}""" + + private def registerAndLogin(username: String): String = + givenRequest() + .body(registerBody(username)) + .when() + .post("/api/account") + .`then`() + .statusCode(200) + givenRequest() + .body(loginBody(username)) + .when() + .post("/api/account/login") + .`then`() + .statusCode(200) + .extract() + .path[String]("token") + + @Test + def registerReturns200(): Unit = + givenRequest() + .body(registerBody("alice")) + .when() + .post("/api/account") + .`then`() + .statusCode(200) + .body("username", is("alice")) + .body("rating", is(1500)) + + @Test + def registerConflictOnDuplicateUsername(): Unit = + givenRequest().body(registerBody("bob")).when().post("/api/account") + givenRequest() + .body(registerBody("bob")) + .when() + .post("/api/account") + .`then`() + .statusCode(409) + .body("error", containsString("bob")) + + @Test + def loginReturns200WithToken(): Unit = + givenRequest().body(registerBody("charlie")).when().post("/api/account") + givenRequest() + .body(loginBody("charlie")) + .when() + .post("/api/account/login") + .`then`() + .statusCode(200) + .body("token", notNullValue()) + + @Test + def loginUnauthorizedOnWrongPassword(): Unit = + givenRequest().body(registerBody("dave")).when().post("/api/account") + givenRequest() + .body(loginBody("dave", "wrongpassword")) + .when() + .post("/api/account/login") + .`then`() + .statusCode(401) + + @Test + def getMeReturns200(): Unit = + val token = registerAndLogin("eve") + givenRequest() + .header("Authorization", s"Bearer $token") + .when() + .get("/api/account/me") + .`then`() + .statusCode(200) + .body("username", is("eve")) + + @Test + def getPublicProfileReturns200(): Unit = + givenRequest().body(registerBody("frank")).when().post("/api/account") + givenRequest() + .when() + .get("/api/account/frank") + .`then`() + .statusCode(200) + .body("username", is("frank")) + + @Test + def getPublicProfileNotFound(): Unit = + givenRequest() + .when() + .get("/api/account/doesnotexist") + .`then`() + .statusCode(404) diff --git a/modules/account/src/test/scala/de/nowchess/account/resource/ChallengeResourceTest.scala b/modules/account/src/test/scala/de/nowchess/account/resource/ChallengeResourceTest.scala new file mode 100644 index 0000000..061bfae --- /dev/null +++ b/modules/account/src/test/scala/de/nowchess/account/resource/ChallengeResourceTest.scala @@ -0,0 +1,152 @@ +package de.nowchess.account.resource + +import io.quarkus.test.junit.QuarkusTest +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.hamcrest.Matchers.* +import org.junit.jupiter.api.Test + +@QuarkusTest +class ChallengeResourceTest: + + private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON) + + private def registerBody(username: String, suffix: String = "") = + val email = s"$username$suffix@test.com" + s"""{"username":"$username$suffix","email":"$email","password":"secret"}""" + + private def loginBody(username: String, suffix: String = "") = + s"""{"username":"$username$suffix","password":"secret"}""" + + private def registerAndLogin(username: String, suffix: String = ""): String = + givenRequest().body(registerBody(username, suffix)).when().post("/api/account") + givenRequest() + .body(loginBody(username, suffix)) + .when() + .post("/api/account/login") + .`then`() + .statusCode(200) + .extract() + .path[String]("token") + + private val clockBody = + """{"color":"random","timeControl":{"type":"clock","limit":300,"increment":5}}""" + + private def authed(token: String) = + givenRequest().header("Authorization", s"Bearer $token") + + @Test + def createChallengeReturns201(): Unit = + val t1 = registerAndLogin("user1c") + registerAndLogin("user2c") + authed(t1) + .contentType(ContentType.JSON) + .body(clockBody) + .when() + .post("/api/challenge/user2c") + .`then`() + .statusCode(201) + .body("status", is("created")) + .body("color", is("random")) + + @Test + def createChallengeConflictOnDuplicate(): Unit = + val t1 = registerAndLogin("user1dup") + registerAndLogin("user2dup") + authed(t1).contentType(ContentType.JSON).body(clockBody).when().post("/api/challenge/user2dup") + authed(t1) + .contentType(ContentType.JSON) + .body(clockBody) + .when() + .post("/api/challenge/user2dup") + .`then`() + .statusCode(409) + + @Test + def createChallengeSelfForbidden(): Unit = + val token = registerAndLogin("selfuser") + authed(token) + .contentType(ContentType.JSON) + .body(clockBody) + .when() + .post("/api/challenge/selfuser") + .`then`() + .statusCode(400) + + @Test + def acceptChallengeReturns200(): Unit = + val t1 = registerAndLogin("accUser1") + val t2 = registerAndLogin("accUser2") + val challengeId = authed(t1) + .contentType(ContentType.JSON) + .body(clockBody) + .when() + .post("/api/challenge/accUser2") + .`then`() + .statusCode(201) + .extract() + .path[String]("id") + authed(t2) + .when() + .post(s"/api/challenge/$challengeId/accept") + .`then`() + .statusCode(200) + .body("status", is("accepted")) + + @Test + def declineChallengeReturns200(): Unit = + val t1 = registerAndLogin("decUser1") + val t2 = registerAndLogin("decUser2") + val challengeId = authed(t1) + .contentType(ContentType.JSON) + .body(clockBody) + .when() + .post("/api/challenge/decUser2") + .`then`() + .statusCode(201) + .extract() + .path[String]("id") + authed(t2) + .contentType(ContentType.JSON) + .body("""{"reason":"later"}""") + .when() + .post(s"/api/challenge/$challengeId/decline") + .`then`() + .statusCode(200) + .body("status", is("declined")) + .body("declineReason", is("later")) + + @Test + def cancelChallengeReturns200(): Unit = + val t1 = registerAndLogin("canUser1") + registerAndLogin("canUser2") + val challengeId = authed(t1) + .contentType(ContentType.JSON) + .body(clockBody) + .when() + .post("/api/challenge/canUser2") + .`then`() + .statusCode(201) + .extract() + .path[String]("id") + authed(t1) + .when() + .post(s"/api/challenge/$challengeId/cancel") + .`then`() + .statusCode(200) + .body("status", is("canceled")) + + @Test + def listChallengesReturnsInAndOut(): Unit = + val t1 = registerAndLogin("listUser1") + registerAndLogin("listUser2") + registerAndLogin("listUser3") + authed(t1).contentType(ContentType.JSON).body(clockBody).when().post("/api/challenge/listUser2").`then`().statusCode(201) + authed(t1).contentType(ContentType.JSON).body(clockBody).when().post("/api/challenge/listUser3").`then`().statusCode(201) + authed(t1) + .when() + .get("/api/challenge") + .`then`() + .statusCode(200) + .body("out.size()", is(2)) + .body("in.size()", is(0))