add more tests

* also make auth provider more powerful
This commit is contained in:
Evgenii Alekseev 2022-01-23 04:34:39 +03:00
parent 0ab9162cb5
commit ccbf581332
15 changed files with 310 additions and 45 deletions

View File

@ -37,23 +37,16 @@ trait Authorization {
def authAdmin(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext
): Future[Option[User]] =
authenticator(Permission.admin, partyId)(username, password)
auth.authenticator(Permission.admin, partyId)(username, password)
def authGet(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext
): Future[Option[User]] =
authenticator(Permission.get, partyId)(username, password)
auth.authenticator(Permission.get, partyId)(username, password)
def authPost(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext
): Future[Option[User]] =
authenticator(Permission.post, partyId)(username, password)
auth.authenticator(Permission.post, partyId)(username, password)
private def authenticator(scope: Permission.Value, partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext
): Future[Option[User]] =
auth.get(partyId, username).map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(user)
case _ => None
}
}

View File

@ -14,19 +14,31 @@ import akka.util.Timeout
import com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache}
import com.typesafe.config.Config
import me.arcanis.ffxivbis.messages.{GetUser, Message}
import me.arcanis.ffxivbis.models.User
import me.arcanis.ffxivbis.models.{Permission, User}
import java.util.concurrent.TimeUnit
import scala.concurrent.Future
import scala.concurrent.{ExecutionContext, Future}
trait AuthorizationProvider {
def get(partyId: String, username: String): Future[Option[User]]
def authenticator[T](scope: Permission.Value, partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext,
extractor: User => T
): Future[Option[T]] =
get(partyId, username).map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(extractor(user))
case _ => None
}
}
object AuthorizationProvider {
def apply(config: Config, storage: ActorRef[Message], timeout: Timeout, scheduler: Scheduler): AuthorizationProvider =
def apply(config: Config, storage: ActorRef[Message])(implicit
timeout: Timeout,
scheduler: Scheduler
): AuthorizationProvider =
new AuthorizationProvider {
private val cacheSize = config.getInt("me.arcanis.ffxivbis.web.authorization-cache.cache-size")
private val cacheTimeout =

View File

@ -28,7 +28,7 @@ class RootEndpoint(system: ActorSystem[Nothing], storage: ActorRef[Message], pro
implicit val scheduler: Scheduler = system.scheduler
implicit val timeout: Timeout = config.getTimeout("me.arcanis.ffxivbis.settings.request-timeout")
private val auth = AuthorizationProvider(config, storage, timeout, scheduler)
private val auth = AuthorizationProvider(config, storage)
private val rootApiV1Endpoint = new RootApiV1Endpoint(storage, auth, provider, config)
private val rootView = new RootView(auth)

View File

@ -8,36 +8,38 @@
*/
package me.arcanis.ffxivbis.messages
import akka.actor.typed.{ActorRef, Behavior}
import akka.actor.typed.ActorRef
import me.arcanis.ffxivbis.models._
import me.arcanis.ffxivbis.service.LootSelector
sealed trait DatabaseMessage extends Message {
def partyId: String
}
object DatabaseMessage {
type Handler = PartialFunction[DatabaseMessage, Behavior[DatabaseMessage]]
def isReadOnly: Boolean
}
// bis handler
trait BisDatabaseMessage extends DatabaseMessage
case class AddPieceToBis(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
override def partyId: String = playerId.partyId
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class GetBiS(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]])
extends BisDatabaseMessage
extends BisDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
override def partyId: String = playerId.partyId
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class RemovePiecesFromBiS(playerId: PlayerId, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
override def partyId: String = playerId.partyId
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
// loot handler
@ -45,54 +47,77 @@ trait LootDatabaseMessage extends DatabaseMessage
case class AddPieceTo(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit])
extends LootDatabaseMessage {
override def partyId: String = playerId.partyId
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class GetLoot(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]])
extends LootDatabaseMessage
extends LootDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class RemovePieceFrom(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit])
extends LootDatabaseMessage {
override def partyId: String = playerId.partyId
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class SuggestLoot(partyId: String, piece: Piece, replyTo: ActorRef[LootSelector.LootSelectorResult])
extends LootDatabaseMessage
extends LootDatabaseMessage {
override val isReadOnly: Boolean = true
}
// party handler
trait PartyDatabaseMessage extends DatabaseMessage
case class AddPlayer(player: Player, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override def partyId: String = player.partyId
override val partyId: String = player.partyId
override val isReadOnly: Boolean = false
}
case class GetParty(partyId: String, replyTo: ActorRef[Party]) extends PartyDatabaseMessage
case class GetParty(partyId: String, replyTo: ActorRef[Party]) extends PartyDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetPartyDescription(partyId: String, replyTo: ActorRef[PartyDescription]) extends PartyDatabaseMessage
case class GetPartyDescription(partyId: String, replyTo: ActorRef[PartyDescription]) extends PartyDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetPlayer(playerId: PlayerId, replyTo: ActorRef[Option[Player]]) extends PartyDatabaseMessage {
override def partyId: String = playerId.partyId
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = true
}
case class RemovePlayer(playerId: PlayerId, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override def partyId: String = playerId.partyId
override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
}
case class UpdateParty(partyDescription: PartyDescription, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override def partyId: String = partyDescription.partyId
override val partyId: String = partyDescription.partyId
override val isReadOnly: Boolean = false
}
// user handler
trait UserDatabaseMessage extends DatabaseMessage
case class AddUser(user: User, isHashedPassword: Boolean, replyTo: ActorRef[Unit]) extends UserDatabaseMessage {
override def partyId: String = user.partyId
override val partyId: String = user.partyId
override val isReadOnly: Boolean = false
}
case class DeleteUser(partyId: String, username: String, replyTo: ActorRef[Unit]) extends UserDatabaseMessage
case class DeleteUser(partyId: String, username: String, replyTo: ActorRef[Unit]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class Exists(partyId: String, replyTo: ActorRef[Boolean]) extends UserDatabaseMessage
case class Exists(partyId: String, replyTo: ActorRef[Boolean]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetUser(partyId: String, username: String, replyTo: ActorRef[Option[User]]) extends UserDatabaseMessage
case class GetUser(partyId: String, username: String, replyTo: ActorRef[Option[User]]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetUsers(partyId: String, replyTo: ActorRef[Seq[User]]) extends UserDatabaseMessage
case class GetUsers(partyId: String, replyTo: ActorRef[Seq[User]]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}

View File

@ -16,7 +16,6 @@ import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.messages._
import me.arcanis.ffxivbis.models.Party
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.{ExecutionContext, Future}
class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMessage])
@ -62,7 +61,8 @@ class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMes
case req: DatabaseMessage =>
storage ! req
Behaviors.receiveMessage(handle(cache - req.partyId))
val result = if (req.isReadOnly) cache else cache - req.partyId
Behaviors.receiveMessage(handle(result))
}
private def getPartyId: Future[String] = {

View File

@ -84,4 +84,5 @@ object Fixtures {
lazy val users: Seq[User] = Seq(userAdmin, userGet)
lazy val authProvider: AuthorizationProvider = (_: String, _: String) => Future.successful(Some(userAdmin))
lazy val rejectingProvider: AuthorizationProvider = (_: String, _: String) => Future.successful(None)
}

View File

@ -0,0 +1,44 @@
package me.arcanis.ffxivbis.http
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.model.headers.{Authorization, BasicHttpCredentials}
import akka.http.scaladsl.server.Route
import akka.http.scaladsl.testkit.ScalatestRouteTest
import me.arcanis.ffxivbis.Fixtures
import me.arcanis.ffxivbis.http.view.RootView
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class AuthorizationTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val auth =
Authorization(BasicHttpCredentials(Fixtures.userAdmin.username, Fixtures.userPassword))
"authorization directive" must {
"accept credentials" in {
val route = new RootView(Fixtures.authProvider).routes
Get(Uri(s"/party/${Fixtures.partyId}")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
}
}
"reject credentials" in {
val route = new RootView(Fixtures.rejectingProvider).routes
Get(Uri(s"/party/${Fixtures.partyId}")).withHeaders(auth) ~> Route.seal(route) ~> check {
status shouldEqual StatusCodes.Unauthorized
}
}
"reject with empty credentials" in {
val route = new RootView(Fixtures.authProvider).routes
Get(Uri(s"/party/${Fixtures.partyId}")) ~> Route.seal(route) ~> check {
status shouldEqual StatusCodes.Unauthorized
}
}
}
}

View File

@ -0,0 +1,22 @@
package me.arcanis.ffxivbis.http
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.server.Directives.complete
import akka.http.scaladsl.testkit.ScalatestRouteTest
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class HttpLogTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val log = new HttpLog {}
"log directive" must {
"work with empty request" in {
Get(Uri("/")) ~> log.withHttpLog(complete(StatusCodes.OK)) ~> check {
status shouldEqual StatusCodes.OK
}
}
}
}

View File

@ -0,0 +1,39 @@
package me.arcanis.ffxivbis.http
import akka.actor.testkit.typed.scaladsl.ActorTestKit
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.testkit.ScalatestRouteTest
import com.typesafe.config.Config
import me.arcanis.ffxivbis.Settings
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class RootEndpointTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val testKit = ActorTestKit(Settings.withRandomDatabase)
override val testConfig: Config = testKit.system.settings.config
private val storage = testKit.spawn(Database())
private val provider = testKit.spawn(BisProvider())
private val party = testKit.spawn(PartyService(storage))
private val route = new RootEndpoint(testKit.system, party, provider).routes
"root route" must {
"return swagger ui" in {
Get(Uri("/api-docs")) ~> route ~> check {
status shouldEqual StatusCodes.OK
}
}
"return static routes" in {
Get(Uri("/static/favicon.ico")) ~> route ~> check {
status shouldEqual StatusCodes.OK
}
}
}
}

View File

@ -0,0 +1,28 @@
package me.arcanis.ffxivbis.http
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.testkit.ScalatestRouteTest
import me.arcanis.ffxivbis.Settings
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class SwaggerTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val swagger = new Swagger(Settings.withRandomDatabase)
"swagger guard" must {
"generate json" in {
Get(Uri("/api-docs/swagger.json")) ~> swagger.routes ~> check {
status shouldEqual StatusCodes.OK
}
}
"generate yml" in {
Get(Uri("/api-docs/swagger.yaml")) ~> swagger.routes ~> check {
status shouldEqual StatusCodes.OK
}
}
}
}

View File

@ -95,5 +95,15 @@ class LootEndpointTest extends AnyWordSpecLike with Matchers with ScalatestRoute
}
}
"suggest loot" in {
val entity = PieceModel.fromPiece(Fixtures.lootBody)
val response = Seq(Fixtures.playerEmpty.withCounters(Some(Fixtures.lootBody))).map(PlayerIdWithCountersModel.fromPlayerId)
Put(endpoint, entity).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[Seq[PlayerIdWithCountersModel]] shouldEqual response
}
}
}
}

View File

@ -7,6 +7,7 @@ import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.testkit.{RouteTestTimeout, ScalatestRouteTest}
import akka.testkit.TestKit
import com.typesafe.config.Config
import me.arcanis.ffxivbis
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.messages.{AddPlayer, AddUser}
import me.arcanis.ffxivbis.service.PartyService
@ -52,7 +53,7 @@ class PlayerEndpointTest extends AnyWordSpecLike with Matchers with ScalatestRou
"api v1 player endpoint" must {
"get users" in {
"get users belonging to the party" in {
val response = Seq(PlayerModel.fromPlayer(Fixtures.playerEmpty))
Get(endpoint).withHeaders(auth) ~> route ~> check {
@ -61,5 +62,42 @@ class PlayerEndpointTest extends AnyWordSpecLike with Matchers with ScalatestRou
}
}
"get party stats" in {
val response = Seq(PlayerIdWithCountersModel.fromPlayerId(Fixtures.playerEmpty.withCounters(None)))
Get(endpoint.withPath(endpoint.path / "stats")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[Seq[PlayerIdWithCountersModel]] shouldEqual response
}
}
"add new player to the party" in {
val entity = PlayerActionModel(ApiAction.add, PlayerModel.fromPlayer(Fixtures.playerWithBiS))
Post(endpoint, entity).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.Accepted
responseAs[String] shouldEqual ""
}
Get(endpoint).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[Seq[PlayerModel]].map(_.toPlayer.playerId) should contain(Fixtures.playerWithBiS.playerId)
}
}
"remove player from the party" in {
val entity = PlayerActionModel(ApiAction.remove, PlayerModel.fromPlayer(Fixtures.playerEmpty))
Post(endpoint, entity).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.Accepted
responseAs[String] shouldEqual ""
}
Get(endpoint).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[Seq[PlayerModel]].map(_.toPlayer.playerId) should not contain(Fixtures.playerEmpty.playerId)
}
}
}
}

View File

@ -2,8 +2,6 @@ package me.arcanis.ffxivbis.http.api.v1
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.testkit.ScalatestRouteTest
import com.typesafe.config.Config
import me.arcanis.ffxivbis.Settings
import me.arcanis.ffxivbis.http.api.v1.json._
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
@ -13,8 +11,6 @@ import scala.language.postfixOps
class StatusEndpointTest extends AnyWordSpecLike
with Matchers with ScalatestRouteTest with JsonSupport {
override val testConfig: Config = Settings.withRandomDatabase
private val route = new StatusEndpoint().routes
"api v1 status endpoint" must {

View File

@ -0,0 +1,55 @@
package me.arcanis.ffxivbis.http.view
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.model.headers.{Authorization, BasicHttpCredentials}
import akka.http.scaladsl.testkit.ScalatestRouteTest
import me.arcanis.ffxivbis.Fixtures
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class RootViewTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val auth =
Authorization(BasicHttpCredentials(Fixtures.userAdmin.username, Fixtures.userPassword))
private val route = new RootView(Fixtures.authProvider).routes
"html view endpoint" must {
"return root view" in {
Get(Uri("/")) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[String] should not be empty
}
}
"return root party view" in {
Get(Uri(s"/party/${Fixtures.partyId}")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[String] should not be empty
}
}
"return bis view" in {
Get(Uri(s"/party/${Fixtures.partyId}/bis")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[String] should not be empty
}
}
"return loot view" in {
Get(Uri(s"/party/${Fixtures.partyId}/loot")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[String] should not be empty
}
}
"return users view" in {
Get(Uri(s"/party/${Fixtures.partyId}/users")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[String] should not be empty
}
}
}
}

View File

@ -6,5 +6,7 @@ import java.time.Instant
import scala.language.implicitConversions
object Converters {
implicit def pieceToLoot(piece: Piece): Loot = Loot(-1, piece, Instant.ofEpochMilli(0), isFreeLoot = false)
}