diff --git a/it/src/main/scala/org/mbari/oni/endpoints/AuthorizationEndpointsSuite.scala b/it/src/main/scala/org/mbari/oni/endpoints/AuthorizationEndpointsSuite.scala index 09cc4e8..40f4ef5 100644 --- a/it/src/main/scala/org/mbari/oni/endpoints/AuthorizationEndpointsSuite.scala +++ b/it/src/main/scala/org/mbari/oni/endpoints/AuthorizationEndpointsSuite.scala @@ -57,7 +57,7 @@ trait AuthorizationEndpointsSuite extends DatabaseFunSuite with EndpointsSuite: val bearerAuth = d.getOrElse(throw new Exception("No bearer auth")) assert(jwtService.verify(bearerAuth.access_token)) - test("login"): + test("login (ADMINISTRATOR)"): val userService = UserAccountService(entityManagerFactory) val userAccount = UserAccount( "test1234", @@ -89,3 +89,29 @@ trait AuthorizationEndpointsSuite extends DatabaseFunSuite with EndpointsSuite: assert(d.isRight) val bearerAuth = d.getOrElse(throw new Exception("No bearer auth")) assert(jwtService.verify(bearerAuth.access_token)) + + test("login (READONLY)"): + val userService = UserAccountService(entityManagerFactory) + val userAccount = UserAccount( + "test12345", + "SuperSecretPassword", + UserAccountRoles.READONLY.getRoleName, + isEncrypted = Some(false) + ) + userService.create(userAccount) match + case Left(e) => fail(e.getMessage) + case Right(ua) => + val backendStub = newBackendStub(authorizationEndpoints.loginEndpointImpl) + + val credentials = Base64.getEncoder.encodeToString(s"${ua.username}:${userAccount.password}".getBytes) + val response = basicRequest + .post(uri"http://test.com/v1/auth/login") + .header("Authorization", s"BASIC $credentials") + .send(backendStub) + .join + + response.body match + case Left(e) => + // this is expected. READONLY users cannot login + case Right(body) => + fail("READONLY user should not be able to login") diff --git a/it/src/main/scala/org/mbari/oni/endpoints/ConceptEndpointsSuite.scala b/it/src/main/scala/org/mbari/oni/endpoints/ConceptEndpointsSuite.scala index 3612728..25b2d38 100644 --- a/it/src/main/scala/org/mbari/oni/endpoints/ConceptEndpointsSuite.scala +++ b/it/src/main/scala/org/mbari/oni/endpoints/ConceptEndpointsSuite.scala @@ -27,6 +27,8 @@ import org.mbari.oni.services.UserAuthMixin import sttp.model.StatusCode import scala.concurrent.ExecutionContext +import org.mbari.oni.jpa.entities.TestEntityFactory +import org.mbari.oni.jpa.entities.TestEntityFactory.randomRankLevelAndName trait ConceptEndpointsSuite extends EndpointsSuite with DataInitializer with UserAuthMixin: @@ -166,11 +168,14 @@ trait ConceptEndpointsSuite extends EndpointsSuite with DataInitializer with Use user => val root = init(2, 0) val name = root.getPrimaryConceptName.getName + val (rankLevel, rankName) = TestEntityFactory.randomRankLevelAndName() + val expectedRank = Some(s"${{rankLevel.getOrElse("")}}${{rankName.getOrElse("")}}") + val conceptCreate = ConceptCreate( "SomeChildConcept", Some(root.getPrimaryConceptName.getName), - rankLevel = Some("yoyoyo"), - rankName = Some("yayaya"), + rankLevel = rankLevel, + rankName = rankName, aphiaId = Some(54321L) ) @@ -182,7 +187,7 @@ trait ConceptEndpointsSuite extends EndpointsSuite with DataInitializer with Use assertEquals(response.code, StatusCode.Ok) val concept = checkResponse[ConceptMetadata](response.body) assertEquals(concept.name, "SomeChildConcept") - assertEquals(concept.rank, Some("yoyoyoyayaya")) + assertEquals(concept.rank, expectedRank) , jwt = jwtService.login(user.username, password, user.toEntity) ) @@ -202,10 +207,12 @@ trait ConceptEndpointsSuite extends EndpointsSuite with DataInitializer with Use user => val root = init(3, 0) val grandChild = root.getChildConcepts.iterator().next().getChildConcepts.iterator().next() + val (rankLevel, rankName) = TestEntityFactory.randomRankLevelAndName() + val expectedRank = Some(s"${{rankLevel.getOrElse("")}}${{rankName.getOrElse("")}}") val conceptUpdate = ConceptUpdate( Some(root.getPrimaryConceptName.getName), - rankLevel = Some("yoyoyo"), - rankName = Some("yayaya"), + rankLevel = rankLevel, + rankName = rankName, aphiaId = Some(543210L) ) @@ -217,7 +224,7 @@ trait ConceptEndpointsSuite extends EndpointsSuite with DataInitializer with Use assertEquals(response.code, StatusCode.Ok) val concept = checkResponse[ConceptMetadata](response.body) assertEquals(concept.name, grandChild.getPrimaryConceptName.getName) - assertEquals(concept.rank, Some("yoyoyoyayaya")) + assertEquals(concept.rank, expectedRank) , jwt = jwtService.login(user.username, password, user.toEntity) ) diff --git a/it/src/main/scala/org/mbari/oni/endpoints/UserAccountsEndpointsSuite.scala b/it/src/main/scala/org/mbari/oni/endpoints/UserAccountsEndpointsSuite.scala index d1af74c..106a0c6 100644 --- a/it/src/main/scala/org/mbari/oni/endpoints/UserAccountsEndpointsSuite.scala +++ b/it/src/main/scala/org/mbari/oni/endpoints/UserAccountsEndpointsSuite.scala @@ -124,6 +124,25 @@ trait UserAccountsEndpointsSuite extends EndpointsSuite with DataInitializer: ) } + test("createEndpoint (ReadOnly from JSON)") { + val entity = TestEntityFactory.createUserAccount(UserAccountRoles.READONLY.getRoleName) + val userAccount = UserAccount.from(entity).copy(password = Strings.random(10)) + + + runPost( + endpoints.createEndpointImpl, + "http://test.com/v1/users", + userAccount.stringify, + response => + assertEquals(response.code, StatusCode.Ok) + val obtained = checkResponse[UserAccount](response.body) + assertEquals(obtained.copy(id = None, password = userAccount.password), userAccount) + , + jwt = jwtService.authorize(jwtService.apiKey) + ) + + } + test("createEndpoint (form camelCase)") { val entity = TestEntityFactory.createUserAccount(UserAccountRoles.ADMINISTRATOR.getRoleName) val userAccount = UserAccount.from(entity).copy(password = Strings.random(10)) diff --git a/it/src/main/scala/org/mbari/oni/jpa/entities/TestEntityFactory.scala b/it/src/main/scala/org/mbari/oni/jpa/entities/TestEntityFactory.scala index 84ca945..5575077 100644 --- a/it/src/main/scala/org/mbari/oni/jpa/entities/TestEntityFactory.scala +++ b/it/src/main/scala/org/mbari/oni/jpa/entities/TestEntityFactory.scala @@ -24,6 +24,7 @@ import java.net.URI import java.time.Instant import java.util.Date import java.util.concurrent.atomic.AtomicLong +import org.mbari.oni.services.RankValidator object TestEntityFactory: @@ -52,7 +53,8 @@ object TestEntityFactory: for _ <- 0 until numberChildren do val entity = buildNode(maxBreadth) if entity.getRankLevel != null then // Add a numbert depth to the rank level - entity.setRankLevel(s"$depth--${entity.getRankLevel}") + // entity.setRankLevel(s"$depth--${entity.getRankLevel}") + entity.setRankLevel(entity.getRankLevel) // println(s"------- Adding " + entity.getPrimaryConceptName.getName + " to " + parent.getPrimaryConceptName.getName) parent.addChildConcept(entity) buildTree(entity, depth - 1, maxBreadth) @@ -155,8 +157,9 @@ object TestEntityFactory: entity.addConceptName(createConceptName()) entity.setConceptMetadata(createConceptMetadata()) if random.nextBoolean() then - entity.setRankLevel(Strings.random(6)) - entity.setRankName(Strings.random(12)) + val (rankLevel, rankName) = randomRankLevelAndName() + entity.setRankLevel(rankLevel.orNull) + entity.setRankName(rankName.orNull) if random.nextBoolean() then entity.setAphiaId(random.nextLong(100000000)) @@ -192,3 +195,8 @@ object TestEntityFactory: // entity.setCitation(s"B. M. Schlining. 1968. $s") entity.setCitation(s) entity + + + def randomRankLevelAndName(): (Option[String], Option[String]) = + val idx = random.nextInt(RankValidator.ValidRanks.size) + RankValidator.ValidRankLevelsAndNames(idx) diff --git a/it/src/main/scala/org/mbari/oni/services/ConceptServiceSuite.scala b/it/src/main/scala/org/mbari/oni/services/ConceptServiceSuite.scala index 15a4261..18bc03d 100644 --- a/it/src/main/scala/org/mbari/oni/services/ConceptServiceSuite.scala +++ b/it/src/main/scala/org/mbari/oni/services/ConceptServiceSuite.scala @@ -267,8 +267,8 @@ trait ConceptServiceSuite extends DatabaseFunSuite with UserAuthMixin: updatedChild <- conceptService.update( child.getPrimaryConceptName.getName, ConceptUpdate( - rankLevel = Some("supersuper"), - rankName = Some("genera"), + rankLevel = Some("sub"), + rankName = Some("genus"), aphiaId = Some(1234) ), user.username @@ -280,7 +280,7 @@ trait ConceptServiceSuite extends DatabaseFunSuite with UserAuthMixin: case Left(e) => fail("Failed to update") case Right(child) => - assertEquals(child.rank, Some("supersupergenera")) + assertEquals(child.rank, Some("subgenus")) assertEquals(child.aphiaId, Some(1234L)) historyService.findByConceptName(child.name) match diff --git a/oni/src/main/scala/org/mbari/oni/endpoints/AuthorizationEndpoints.scala b/oni/src/main/scala/org/mbari/oni/endpoints/AuthorizationEndpoints.scala index b94482c..02b5b04 100644 --- a/oni/src/main/scala/org/mbari/oni/endpoints/AuthorizationEndpoints.scala +++ b/oni/src/main/scala/org/mbari/oni/endpoints/AuthorizationEndpoints.scala @@ -104,7 +104,7 @@ class AuthorizationEndpoints(entityManagerFactory: EntityManagerFactory)(using j entity <- Right(userAccount.toEntity) jwt <- jwtService .login(usernamePassword.username, usernamePassword.password.getOrElse(""), entity) - .toRight(Unauthorized("Invalid username or password")) + .toRight(Unauthorized("Unable to login. Check your username and password and verify that you are an administrator or maintainer")) yield AuthorizationSC.bearer(jwt) } .serverLogic(bearerAuth => Unit => Future(Right(bearerAuth))) diff --git a/oni/src/main/scala/org/mbari/oni/services/ConceptService.scala b/oni/src/main/scala/org/mbari/oni/services/ConceptService.scala index 83f02d0..f611941 100644 --- a/oni/src/main/scala/org/mbari/oni/services/ConceptService.scala +++ b/oni/src/main/scala/org/mbari/oni/services/ConceptService.scala @@ -241,6 +241,7 @@ class ConceptService(entityManagerFactory: EntityManagerFactory): repo.findByName(conceptCreate.name).toScala match case Some(_) => throw ConceptNameAlreadyExists(conceptCreate.name) case None => + RankValidator.throwExceptionIfInvalid(conceptCreate) val parent = conceptCreate.parentName match case Some(parentName) => repo.findByName(parentName).toScala match @@ -365,6 +366,7 @@ class ConceptService(entityManagerFactory: EntityManagerFactory): repo.findByName(name).toScala match case None => throw ConceptNameNotFound(name) case Some(conceptEntity) => + RankValidator.throwExceptionIfInvalid(conceptUpdate) updateParent(userEntity, conceptEntity, conceptUpdate.parentName) updateRankLevel(userEntity, conceptEntity, conceptUpdate.rankLevel) updateRankName(userEntity, conceptEntity, conceptUpdate.rankName) diff --git a/oni/src/main/scala/org/mbari/oni/services/RankValidator.scala b/oni/src/main/scala/org/mbari/oni/services/RankValidator.scala new file mode 100644 index 0000000..ccf4042 --- /dev/null +++ b/oni/src/main/scala/org/mbari/oni/services/RankValidator.scala @@ -0,0 +1,89 @@ +/* + * Copyright (c) Monterey Bay Aquarium Research Institute 2024 + * + * oni code is non-public software. Unauthorized copying of this file, + * via any medium is strictly prohibited. Proprietary and confidential. + */ + +package org.mbari.oni.services + +import org.mbari.oni.domain.ConceptCreate +import org.mbari.oni.domain.ConceptUpdate + +object RankValidator: + + val ValidRankLevelsAndNames: Seq[(Option[String], Option[String])] = Seq( + (None, None), + (None, Some("realm")), + (Some("sub"), Some("realm")), + (None, Some("kingdom")), + (Some("sub"), Some("kingdom")), + (None, Some("phylum")), + (Some("sub"), Some("phylum")), + (None, Some("division")), + (Some("sub"), Some("division")), + (None, Some("class")), + (Some("sub"), Some("class")), + (Some("super"), Some("order")), + (None, Some("order")), + (Some("sub"), Some("order")), + (Some("infra"), Some("order")), + (Some("super"), Some("family")), + (Some("epi"), Some("family")), + (None, Some("family")), + (Some("sub"), Some("family")), + (Some("infra"), Some("family")), + (None, Some("tribe")), + (Some("sub"), Some("tribe")), + (Some("infra"), Some("tribe")), + (None, Some("genus")), + (Some("sub"), Some("genus")), + (None, Some("section")), + (Some("sub"), Some("section")), + (None, Some("species complex")), + (None, Some("species")), + (Some("sub"), Some("species")), + (None, Some("variety")), + (None, Some("form")) + ) + + /** + * A list of commonlhy accepted, valid ranks scraped from Wikipedia + */ + val ValidRanks: Seq[String] = ValidRankLevelsAndNames.map { + (rankLevel, rankName) => s"${rankLevel.getOrElse("")}${rankName.getOrElse("")}".toLowerCase + } + + def validate(rankLevel: Option[String] = None, rankName: Option[String] = None): Boolean = { + val rank = s"${rankLevel.getOrElse("")}${rankName.getOrElse("")}".toLowerCase + ValidRanks.contains(rank) + } + + def validate(conceptCreate: ConceptCreate): Boolean = { + validate(conceptCreate.rankLevel, conceptCreate.rankName) + } + + def validate(conceptUpdate: ConceptUpdate): Boolean = { + validate(conceptUpdate.rankLevel, conceptUpdate.rankName) + } + + def throwExceptionIfInvalid(conceptCreate: ConceptCreate): Unit = { + if (!validate(conceptCreate)) { + val rank = s"${conceptCreate.rankLevel.getOrElse("")}${conceptCreate.rankName.getOrElse("")}" + throw new IllegalArgumentException( + s"Invalid rank level + rank name ($rank). Should be one of ${RankValidator.ValidRanks.mkString(", ")}" + ) + } + } + + def throwExceptionIfInvalid(conceptUpdate: ConceptUpdate): Unit = { + if (!validate(conceptUpdate.rankLevel, conceptUpdate.rankName)) { + val rank = s"${conceptUpdate.rankLevel.getOrElse("")}${conceptUpdate.rankName.getOrElse("")}" + throw new IllegalArgumentException( + s"Invalid rank level + rank name ($rank). Should be one of ${RankValidator.ValidRanks.mkString(", ")}" + ) + } + + } + + diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 17ef4e3..d3be3a3 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -12,7 +12,7 @@ object Dependencies { lazy val circeGeneric = "io.circe" %% "circe-generic" % circeVersion lazy val circeParser = "io.circe" %% "circe-parser" % circeVersion - lazy val commonsCodec = "commons-codec" % "commons-codec" % "1.17.2" + lazy val commonsCodec = "commons-codec" % "commons-codec" % "1.18.0" lazy val gson = "com.google.code.gson" % "gson" % "2.11.0" // THis needs to match the version used by tapirHelidon.