diff --git a/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changelog.xml b/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changelog.xml index 578a27770..17575edf0 100644 --- a/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changelog.xml +++ b/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changelog.xml @@ -31,4 +31,5 @@ + diff --git a/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changesets/20240809_sam_user_favorite_resources_table.xml b/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changesets/20240809_sam_user_favorite_resources_table.xml new file mode 100644 index 000000000..24c3e4ddf --- /dev/null +++ b/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changesets/20240809_sam_user_favorite_resources_table.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/swagger/api-docs.yaml b/src/main/resources/swagger/api-docs.yaml index e7ef83d59..3f60bd5e0 100755 --- a/src/main/resources/swagger/api-docs.yaml +++ b/src/main/resources/swagger/api-docs.yaml @@ -3239,6 +3239,106 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorReport' + /api/users/v2/self/favoriteResources: + get: + tags: + - Users + summary: Gets a user's favorite resources. + operationId: getFavoriteResources + responses: + 200: + description: list of the user's favorite resources + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/FullyQualifiedResourceId' + 403: + description: user not found or is not allowed to use Terra + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReport' + /api/users/v2/self/favoriteResources/{resourceTypeName}: + get: + tags: + - Users + summary: Gets a user's favorite resources of a given resource type. + operationId: getFavoriteResourcesOfType + parameters: + - name: resourceTypeName + in: path + description: Resource type to narrow favorite resources to + required: true + schema: + type: string + responses: + 200: + description: list of the user's favorite resources of a specific resource type + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/FullyQualifiedResourceId' + 404: + description: resource type not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReport' + /api/users/v2/self/favoriteResources/{resourceTypeName}/{resourceId}: + put: + tags: + - Users + summary: Add a resource to the calling user's favorites + operationId: addFavoriteResource + parameters: + - name: resourceTypeName + in: path + description: Resource type of the resource to favorite + required: true + schema: + type: string + - name: resourceId + in: path + description: Resource ID of the resource to favorite + required: true + schema: + type: string + responses: + 202: + description: Favorite resource added + content: { } + 404: + description: resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReport' + delete: + tags: + - Users + summary: Remove a resource from the calling user's favorites + operationId: removeFavoriteResource + parameters: + - name: resourceTypeName + in: path + description: Resource type of the resource to favorite + required: true + schema: + type: string + - name: resourceId + in: path + description: Resource ID of the resource to favorite + required: true + schema: + type: string + responses: + 202: + description: Favorite resource removed + content: { } /api/users/v2/{sam_user_id}: get: tags: diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/SecurityDirectives.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/SecurityDirectives.scala index 3f5e337f2..662bf31c9 100644 --- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/SecurityDirectives.scala +++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/SecurityDirectives.scala @@ -145,6 +145,25 @@ trait SecurityDirectives { } } + def requireAnyAction( + resource: FullyQualifiedResourceId, + userId: WorkbenchUserId, + samRequestContext: SamRequestContext + ): Directive0 = + Directives.mapInnerRoute { innerRoute => + onSuccess(policyEvaluatorService.listUserResourceActions(resource, userId, samRequestContext)) { actions => + if (actions.nonEmpty) { + innerRoute + } else { + Directives.failWith( + new WorkbenchExceptionWithErrorReport( + ErrorReport(StatusCodes.Forbidden, s"You do not have access to ${resource.resourceTypeName.value}/${resource.resourceId.value}") + ) + ) + } + } + } + /** in the case where we don't have the required action, we need to figure out if we should return a Not Found (you have no access) vs a Forbidden (you have * access, just not the right kind) */ diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2.scala index ba8fc057f..d631d3aba 100644 --- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2.scala +++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2.scala @@ -8,15 +8,17 @@ import akka.http.scaladsl.server.{Directive0, ExceptionHandler, Route} import cats.effect.IO import org.broadinstitute.dsde.workbench.model._ import org.broadinstitute.dsde.workbench.sam.model.api.SamUserResponse._ +import org.broadinstitute.dsde.workbench.sam.model.api.SamJsonSupport._ import org.broadinstitute.dsde.workbench.sam.model.api._ -import org.broadinstitute.dsde.workbench.sam.model.{ResourceRoleName, ResourceTypeName, TermsOfServiceDetails} +import org.broadinstitute.dsde.workbench.sam.model.{FullyQualifiedResourceId, ResourceId, ResourceRoleName, ResourceTypeName, TermsOfServiceDetails} import org.broadinstitute.dsde.workbench.sam.service.{ResourceService, TosService, UserService} import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext import spray.json.enrichAny +import spray.json.DefaultJsonProtocol._ /** Created by tlangs on 10/12/2023. */ -trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives { +trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives with SamModelDirectives with SecurityDirectives { val userService: UserService val tosService: TosService val resourceService: ResourceService @@ -81,6 +83,27 @@ trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives { getSamUserCombinedState(samUser, samRequestContext) } } + } ~ + // api/user/v2/self/favoriteResources + pathPrefix("favoriteResources") { + withActiveUser(samRequestContextWithoutUser) { samUser: SamUser => + val samRequestContext = samRequestContextWithoutUser.copy(samUser = Some(samUser)) + pathEndOrSingleSlash { + getFavoriteResources(samUser, samRequestContext) + } ~ + // api/user/v2/self/favoriteResources/{resourceTypeName} + pathPrefix(Segment) { resourceTypeName => + withNonAdminResourceType(ResourceTypeName(resourceTypeName)) { resourceType => + getFavoriteResourcesOfType(samUser, resourceType.name, samRequestContext) ~ + // api/user/v2/self/favoriteResources/{resourceTypeName}/{resourceId} + pathPrefix(Segment) { resourceId => + val resource = FullyQualifiedResourceId(resourceType.name, ResourceId(resourceId)) + addFavoriteResource(samUser, resource, samRequestContext) ~ + removeFavoriteResource(samUser, resource, samRequestContext) + } + } + } + } } } ~ pathPrefix(Segment) { samUserId => @@ -194,12 +217,14 @@ trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives { includePublic = false, samRequestContext ) + favoriteResources <- resourceService.getUserFavoriteResources(samUser.id, samRequestContext) } yield SamUserCombinedStateResponse( samUser, allowances, maybeAttributes, termsOfServiceDetails.getOrElse(TermsOfServiceDetails(None, None, permitsSystemUsage = false, isCurrentVersion = false)), - Map("enterpriseFeatures" -> enterpriseFeatures.toJson) + Map("enterpriseFeatures" -> enterpriseFeatures.toJson), + favoriteResources ) } } @@ -217,4 +242,39 @@ trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives { } } } + + private def getFavoriteResources(samUser: SamUser, samRequestContext: SamRequestContext): Route = + getWithTelemetry(samRequestContext) { + complete { + resourceService.getUserFavoriteResources(samUser.id, samRequestContext).map(StatusCodes.OK -> _) + } + } + + private def getFavoriteResourcesOfType(samUser: SamUser, resourceTypeName: ResourceTypeName, samRequestContext: SamRequestContext): Route = + getWithTelemetry(samRequestContext) { + complete { + resourceService.getUserFavoriteResourcesOfType(samUser.id, resourceTypeName, samRequestContext).map(StatusCodes.OK -> _) + } + } + + private def addFavoriteResource(samUser: SamUser, resource: FullyQualifiedResourceId, samRequestContext: SamRequestContext): Route = + putWithTelemetry(samRequestContext) { + changeForbiddenToNotFound { + requireAnyAction(resource, samUser.id, samRequestContext) { + complete { + resourceService.addUserFavoriteResource(samUser.id, resource, samRequestContext).map { + case true => StatusCodes.Accepted + case false => StatusCodes.NotFound + } + } + } + } + } + + private def removeFavoriteResource(samUser: SamUser, resource: FullyQualifiedResourceId, samRequestContext: SamRequestContext): Route = + deleteWithTelemetry(samRequestContext) { + complete { + resourceService.removeUserFavoriteResource(samUser.id, resource, samRequestContext).map(_ => StatusCodes.Accepted) + } + } } diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/DirectoryDAO.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/DirectoryDAO.scala index 3d03f77da..eeca37f18 100644 --- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/DirectoryDAO.scala +++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/DirectoryDAO.scala @@ -12,7 +12,7 @@ import org.broadinstitute.dsde.workbench.sam.azure.{ PetManagedIdentityId } import org.broadinstitute.dsde.workbench.sam.model.api.{AdminUpdateUserRequest, SamUser, SamUserAttributes} -import org.broadinstitute.dsde.workbench.sam.model.{BasicWorkbenchGroup, FullyQualifiedResourceId, ResourceAction, SamUserTos} +import org.broadinstitute.dsde.workbench.sam.model.{BasicWorkbenchGroup, FullyQualifiedResourceId, ResourceAction, ResourceTypeName, SamUserTos} import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext import java.time.Instant @@ -178,4 +178,16 @@ trait DirectoryDAO { def setUserAttributes(samUserAttributes: SamUserAttributes, samRequestContext: SamRequestContext): IO[Unit] def listParentGroups(groupName: WorkbenchGroupName, samRequestContext: SamRequestContext): IO[Set[WorkbenchGroupName]] + + def addUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Boolean] + + def removeUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Unit] + + def getUserFavoriteResources(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]] + + def getUserFavoriteResourcesOfType( + userId: WorkbenchUserId, + resourceTypeName: ResourceTypeName, + samRequestContext: SamRequestContext + ): IO[Set[FullyQualifiedResourceId]] } diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAO.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAO.scala index 140d2a501..8018692e9 100644 --- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAO.scala +++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAO.scala @@ -1378,4 +1378,91 @@ class PostgresDirectoryDAO(protected val writeDbRef: DbReference, protected val loadParentGroupsQuery.map(rs => WorkbenchGroupName(rs.string(parent.resultName.name))).list().apply().toSet } + + override def addUserFavoriteResource( + userId: WorkbenchUserId, + fullyQualifiedResourceId: FullyQualifiedResourceId, + samRequestContext: SamRequestContext + ): IO[Boolean] = + serializableWriteTransaction("addUserFavoriteResource", samRequestContext) { implicit session => + val resourceTable = ResourceTable.syntax + val resourceTypeTable = ResourceTypeTable.syntax + val userFavoriteResourcesTable = UserFavoriteResourcesTable.syntax + val userFavoriteResourcesColumns = UserFavoriteResourcesTable.column + Try { + samsql""" + insert into ${UserFavoriteResourcesTable as userFavoriteResourcesTable} (${userFavoriteResourcesColumns.samUserId}, ${userFavoriteResourcesColumns.resourceId}) + values ( + $userId, + (select ${resourceTable.result.id} from ${ResourceTable as resourceTable} left join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id} where ${resourceTable.name} = ${fullyQualifiedResourceId.resourceId} and ${resourceTypeTable.name} = ${fullyQualifiedResourceId.resourceTypeName}) + ) + on conflict do nothing + """.update().apply() > 0 + }.getOrElse(false) + } + + override def removeUserFavoriteResource( + userId: WorkbenchUserId, + fullyQualifiedResourceId: FullyQualifiedResourceId, + samRequestContext: SamRequestContext + ): IO[Unit] = + serializableWriteTransaction("removeUserFavoriteResource", samRequestContext) { implicit session => + val resourceTable = ResourceTable.syntax + val resourceTypeTable = ResourceTypeTable.syntax + val userFavoriteResourcesColumns = UserFavoriteResourcesTable.column + samsql""" + delete from ${UserFavoriteResourcesTable.table} + where ${userFavoriteResourcesColumns.samUserId} = $userId + and ${userFavoriteResourcesColumns.resourceId} = (select ${resourceTable.result.id} from ${ResourceTable as resourceTable} left join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id} where ${resourceTable.name} = ${fullyQualifiedResourceId.resourceId} and ${resourceTypeTable.name} = ${fullyQualifiedResourceId.resourceTypeName}) + """.update().apply() + } + + override def getUserFavoriteResources(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]] = + readOnlyTransaction("getUserFavoriteResources", samRequestContext) { implicit session => + val resourceTable = ResourceTable.syntax + val resourceTypeTable = ResourceTypeTable.syntax + val userFavoriteResourcesTable = UserFavoriteResourcesTable.syntax + + val loadUserFavoriteResourcesQuery = + samsql"""select ${resourceTable.result.name}, ${resourceTypeTable.result.name} + from ${ResourceTable as resourceTable} + join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id} + join ${UserFavoriteResourcesTable as userFavoriteResourcesTable} on ${resourceTable.id} = ${userFavoriteResourcesTable.resourceId} + where ${userFavoriteResourcesTable.samUserId} = $userId""" + + loadUserFavoriteResourcesQuery + .map(rs => + FullyQualifiedResourceId(ResourceTypeName(rs.string(resourceTypeTable.resultName.name)), ResourceId(rs.string(resourceTable.resultName.name))) + ) + .list() + .apply() + .toSet + } + + def getUserFavoriteResourcesOfType( + userId: WorkbenchUserId, + resourceTypeName: ResourceTypeName, + samRequestContext: SamRequestContext + ): IO[Set[FullyQualifiedResourceId]] = + readOnlyTransaction("getUserFavoriteResourcesOfType", samRequestContext) { implicit session => + val resourceTable = ResourceTable.syntax + val resourceTypeTable = ResourceTypeTable.syntax + val userFavoriteResourcesTable = UserFavoriteResourcesTable.syntax + + val loadUserFavoriteResourcesQuery = + samsql"""select ${resourceTable.result.name} + from ${ResourceTable as resourceTable} + join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id} + join ${UserFavoriteResourcesTable as userFavoriteResourcesTable} on ${resourceTable.id} = ${userFavoriteResourcesTable.resourceId} + where ${userFavoriteResourcesTable.samUserId} = $userId + and ${resourceTypeTable.name} = $resourceTypeName + """ + + loadUserFavoriteResourcesQuery + .map(rs => FullyQualifiedResourceId(resourceTypeName, ResourceId(rs.string(resourceTable.resultName.name)))) + .list() + .apply() + .toSet + } + } diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/db/tables/UserFavoriteResourcesTable.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/db/tables/UserFavoriteResourcesTable.scala new file mode 100644 index 000000000..a13da4373 --- /dev/null +++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/db/tables/UserFavoriteResourcesTable.scala @@ -0,0 +1,26 @@ +package org.broadinstitute.dsde.workbench.sam.db.tables + +import org.broadinstitute.dsde.workbench.model._ +import org.broadinstitute.dsde.workbench.sam.db.SamTypeBinders +import scalikejdbc._ + +import java.time.Instant + +final case class UserFavoriteResourcesRecord( + samUserId: WorkbenchUserId, + resourceId: ResourcePK, + createdAt: Instant +) + +object UserFavoriteResourcesTable extends SQLSyntaxSupportWithDefaultSamDB[UserFavoriteResourcesRecord] { + override def tableName: String = "SAM_USER_FAVORITE_RESOURCES" + + import SamTypeBinders._ + def apply(e: ResultName[UserFavoriteResourcesRecord])(rs: WrappedResultSet): UserFavoriteResourcesRecord = UserFavoriteResourcesRecord( + rs.get(e.samUserId), + rs.get(e.resourceId), + rs.get(e.createdAt) + ) + + def apply(o: SyntaxProvider[UserFavoriteResourcesRecord])(rs: WrappedResultSet): UserFavoriteResourcesRecord = apply(o.resultName)(rs) +} diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/model/api/SamUserCombinedStateResponse.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/model/api/SamUserCombinedStateResponse.scala index c34d53c84..a0e1e309e 100644 --- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/model/api/SamUserCombinedStateResponse.scala +++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/model/api/SamUserCombinedStateResponse.scala @@ -1,6 +1,6 @@ package org.broadinstitute.dsde.workbench.sam.model.api -import org.broadinstitute.dsde.workbench.sam.model.TermsOfServiceDetails +import org.broadinstitute.dsde.workbench.sam.model.{FullyQualifiedResourceId, TermsOfServiceDetails} import spray.json.DefaultJsonProtocol._ import spray.json._ import org.broadinstitute.dsde.workbench.sam.model.api.SamUserAllowances.SamUserAllowedResponseFormat @@ -8,12 +8,13 @@ import org.broadinstitute.dsde.workbench.sam.model.api.SamUserAttributes.SamUser import org.broadinstitute.dsde.workbench.sam.model.api.SamJsonSupport._ object SamUserCombinedStateResponse { - implicit val SamUserResponseFormat: RootJsonFormat[SamUserCombinedStateResponse] = jsonFormat5(SamUserCombinedStateResponse.apply) + implicit val SamUserResponseFormat: RootJsonFormat[SamUserCombinedStateResponse] = jsonFormat6(SamUserCombinedStateResponse.apply) } final case class SamUserCombinedStateResponse( samUser: SamUser, allowances: SamUserAllowances, attributes: Option[SamUserAttributes], termsOfServiceDetails: TermsOfServiceDetails, - additionalDetails: Map[String, JsValue] + additionalDetails: Map[String, JsValue], + favoriteResources: Set[FullyQualifiedResourceId] ) diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceService.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceService.scala index 707d63cff..39ff9b3a5 100644 --- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceService.scala +++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceService.scala @@ -958,6 +958,22 @@ class ResourceService( def listResourceChildren(resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]] = accessPolicyDAO.listResourceChildren(resourceId, samRequestContext) + def addUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Boolean] = + directoryDAO.addUserFavoriteResource(userId, resourceId, samRequestContext) + + def removeUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Unit] = + directoryDAO.removeUserFavoriteResource(userId, resourceId, samRequestContext) + + def getUserFavoriteResources(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]] = + directoryDAO.getUserFavoriteResources(userId, samRequestContext) + + def getUserFavoriteResourcesOfType( + userId: WorkbenchUserId, + resourceType: ResourceTypeName, + samRequestContext: SamRequestContext + ): IO[Set[FullyQualifiedResourceId]] = + directoryDAO.getUserFavoriteResourcesOfType(userId, resourceType, samRequestContext) + private[service] def createAccessChangeEvents( resource: FullyQualifiedResourceId, beforePolicies: Iterable[AccessPolicy], diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/MockSamRoutesBuilder.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/MockSamRoutesBuilder.scala index 74486f798..072fdb58d 100644 --- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/MockSamRoutesBuilder.scala +++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/MockSamRoutesBuilder.scala @@ -137,6 +137,7 @@ class MockSamRoutesBuilder(allUsersGroup: WorkbenchGroup)(implicit system: Actor val mockUserService = userServiceBuilder.build val mockCloudExtensions = cloudExtensionsBuilder.build val mockTosService = mockTosServiceBuilder.build + val mockPolicyEvaluatorService = mock[PolicyEvaluatorService] new SamRoutes( mockResourceService, @@ -144,7 +145,7 @@ class MockSamRoutesBuilder(allUsersGroup: WorkbenchGroup)(implicit system: Actor null, null, null, - null, + mockPolicyEvaluatorService, mockTosService, null, null, diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2Spec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2Spec.scala index f166e3839..1a76d2521 100644 --- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2Spec.scala +++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2Spec.scala @@ -8,6 +8,7 @@ import org.broadinstitute.dsde.workbench.model.{ErrorReport, WorkbenchEmail, Wor import org.broadinstitute.dsde.workbench.model.ErrorReportJsonSupport._ import org.broadinstitute.dsde.workbench.sam.matchers.BeForSamUserResponseMatcher.beForUser import org.broadinstitute.dsde.workbench.sam.model._ +import org.broadinstitute.dsde.workbench.sam.model.api.SamJsonSupport._ import org.broadinstitute.dsde.workbench.sam.model.api.{ FilteredResourceFlat, FilteredResourcesFlat, @@ -28,8 +29,10 @@ import org.scalatest.matchers.should.Matchers import java.time.Instant import org.broadinstitute.dsde.workbench.sam.matchers.TimeMatchers import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext +import org.mockito.ArgumentMatchers import org.mockito.Mockito.lenient import spray.json.enrichAny +import spray.json.DefaultJsonProtocol._ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with ScalatestRouteTest with MockitoSugar with TestSupport { val defaultUser: SamUser = Generator.genWorkbenchUserGoogle.sample.get @@ -260,6 +263,7 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with "GET /api/users/v2/self/combinedState" should "get the user combined state of the calling user" in { // Arrange val userAttributes = SamUserAttributes(defaultUser.id, marketingConsent = true) + val favoriteResources = Set(FullyQualifiedResourceId(ResourceTypeName("workspaceType"), ResourceId("workspaceName"))) val enterpriseFeature = FilteredResourceFlat( resourceType = ResourceTypeName("enterprise-feature"), resourceId = ResourceId("enterprise-feature"), @@ -275,7 +279,8 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with SamUserAllowances(enabled = true, termsOfService = true), Option(SamUserAttributes(defaultUser.id, marketingConsent = true)), TermsOfServiceDetails(Option("v1"), Option(Instant.now()), permitsSystemUsage = true, isCurrentVersion = true), - Map("enterpriseFeatures" -> FilteredResourcesFlat(Set(enterpriseFeature)).toJson) + Map("enterpriseFeatures" -> FilteredResourcesFlat(Set(enterpriseFeature)).toJson), + favoriteResources ) val samRoutes = new MockSamRoutesBuilder(allUsersGroup) @@ -298,6 +303,11 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with any[SamRequestContext] ) + lenient() + .doReturn(IO.pure(favoriteResources)) + .when(samRoutes.resourceService) + .getUserFavoriteResources(any[WorkbenchUserId], any[SamRequestContext]) + // Act and Assert Get(s"/api/users/v2/self/combinedState") ~> samRoutes.route ~> check { status shouldEqual StatusCodes.OK @@ -310,6 +320,7 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with response.termsOfServiceDetails.permitsSystemUsage should be(userCombinedStateResponse.termsOfServiceDetails.permitsSystemUsage) response.termsOfServiceDetails.latestAcceptedVersion should be(userCombinedStateResponse.termsOfServiceDetails.latestAcceptedVersion) response.additionalDetails should be(Map("enterpriseFeatures" -> filteresResourcesFlat.toJson)) + response.favoriteResources should be(favoriteResources) } } @@ -330,7 +341,8 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with SamUserAllowances(enabled = true, termsOfService = true), Option(SamUserAttributes(defaultUser.id, marketingConsent = true)), TermsOfServiceDetails(Option("v1"), Option(Instant.now()), permitsSystemUsage = true, isCurrentVersion = true), - Map("enterpriseFeatures" -> FilteredResourcesFlat(Set(enterpriseFeature)).toJson) + Map("enterpriseFeatures" -> FilteredResourcesFlat(Set(enterpriseFeature)).toJson), + Set.empty ) val samRoutes = new MockSamRoutesBuilder(allUsersGroup) @@ -352,6 +364,11 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with any[SamRequestContext] ) + lenient() + .doReturn(IO.pure(Set.empty)) + .when(samRoutes.resourceService) + .getUserFavoriteResources(any[WorkbenchUserId], any[SamRequestContext]) + // Act and Assert Get(s"/api/users/v2/self/combinedState") ~> samRoutes.route ~> check { status shouldEqual StatusCodes.OK @@ -364,18 +381,20 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with response.termsOfServiceDetails.permitsSystemUsage should be(userCombinedStateResponse.termsOfServiceDetails.permitsSystemUsage) response.termsOfServiceDetails.latestAcceptedVersion should be(userCombinedStateResponse.termsOfServiceDetails.latestAcceptedVersion) response.additionalDetails should be(Map("enterpriseFeatures" -> filteresResourcesFlat.toJson)) + } } it should "return falsy terms of service if the user has no tos history" in { // Arrange - val filteresResourcesFlat = FilteredResourcesFlat(Set.empty) + val filteredResourcesFlat = FilteredResourcesFlat(Set.empty) val userCombinedStateResponse = SamUserCombinedStateResponse( defaultUser, SamUserAllowances(enabled = false, termsOfService = false), Option(SamUserAttributes(defaultUser.id, marketingConsent = true)), TermsOfServiceDetails(None, None, permitsSystemUsage = false, isCurrentVersion = false), - Map("enterpriseFeatures" -> filteresResourcesFlat.toJson) + Map("enterpriseFeatures" -> filteredResourcesFlat.toJson), + Set.empty ) val samRoutes = new MockSamRoutesBuilder(allUsersGroup) @@ -397,6 +416,11 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with any[SamRequestContext] ) + lenient() + .doReturn(IO.pure(Set.empty)) + .when(samRoutes.resourceService) + .getUserFavoriteResources(any[WorkbenchUserId], any[SamRequestContext]) + // Act and Assert Get(s"/api/users/v2/self/combinedState") ~> samRoutes.route ~> check { status shouldEqual StatusCodes.OK @@ -408,7 +432,184 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with response.termsOfServiceDetails.isCurrentVersion should be(userCombinedStateResponse.termsOfServiceDetails.isCurrentVersion) response.termsOfServiceDetails.permitsSystemUsage should be(userCombinedStateResponse.termsOfServiceDetails.permitsSystemUsage) response.termsOfServiceDetails.latestAcceptedVersion shouldBe None - response.additionalDetails should be(Map("enterpriseFeatures" -> filteresResourcesFlat.toJson)) + response.additionalDetails should be(Map("enterpriseFeatures" -> filteredResourcesFlat.toJson)) + response.favoriteResources should be(Set.empty) + + } + } + + "GET /api/user/v2/self/favoriteResources" should "return the user's favorite resources" in { + // Arrange + val user = Generator.genWorkbenchUserGoogle.sample.get + val resourceType = ResourceTypeName("workspace") + val resourceId = ResourceId("workspace") + val resource = FullyQualifiedResourceId(resourceType, resourceId) + val favoriteResources = Set(resource) + val samRoutes = new MockSamRoutesBuilder(allUsersGroup) + .withEnabledUser(user) + .withAllowedUser(user) + .callAsNonAdminUser(Some(user)) + .build + + when(samRoutes.resourceService.getUserFavoriteResources(ArgumentMatchers.eq(user.id), any[SamRequestContext])) + .thenReturn(IO.pure(favoriteResources)) + + // Act and Assert + Get(s"/api/users/v2/self/favoriteResources") ~> samRoutes.route ~> check { + status shouldEqual StatusCodes.OK + responseAs[Set[FullyQualifiedResourceId]] shouldEqual favoriteResources + } + } + + "GET /api/user/v2/self/favoriteResources/{resourceTypeName}" should "return the user's favorite resources of a given type" in { + // Arrange + val user = Generator.genWorkbenchUserGoogle.sample.get + val resourceType = ResourceTypeName("workspace") + val resourceId = ResourceId("workspace") + val resource = FullyQualifiedResourceId(resourceType, resourceId) + val favoriteResources = Set(resource) + val samRoutes = new MockSamRoutesBuilder(allUsersGroup) + .withEnabledUser(user) + .withAllowedUser(user) + .callAsNonAdminUser(Some(user)) + .build + + when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType))) + .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner"))))) + + when(samRoutes.resourceService.getUserFavoriteResourcesOfType(ArgumentMatchers.eq(user.id), ArgumentMatchers.eq(resourceType), any[SamRequestContext])) + .thenReturn(IO.pure(favoriteResources)) + + // Act and Assert + Get(s"/api/users/v2/self/favoriteResources/$resourceType") ~> samRoutes.route ~> check { + status shouldEqual StatusCodes.OK + responseAs[Set[FullyQualifiedResourceId]] shouldEqual favoriteResources + } + } + + "PUT /api/user/v2/self/favoriteResources/{resourceTypeName}/{resourceId}" should "add a resource to a users favorites" in { + // Arrange + val user = Generator.genWorkbenchUserGoogle.sample.get + val resourceType = ResourceTypeName("workspace") + val resourceId = ResourceId("workspace") + val resource = FullyQualifiedResourceId(resourceType, resourceId) + val samRoutes = new MockSamRoutesBuilder(allUsersGroup) + .withEnabledUser(user) + .withAllowedUser(user) + .callAsNonAdminUser(Some(user)) + .build + + when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType))) + .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner"))))) + + when(samRoutes.policyEvaluatorService.listUserResourceActions(ArgumentMatchers.eq(resource), ArgumentMatchers.eq(user.id), any[SamRequestContext])) + .thenReturn(IO.pure(Set(ResourceAction("read")))) + + when(samRoutes.resourceService.addUserFavoriteResource(ArgumentMatchers.eq(user.id), ArgumentMatchers.eq(resource), any[SamRequestContext])) + .thenReturn(IO.pure(true)) + + // Act and Assert + Put(s"/api/users/v2/self/favoriteResources/$resourceType/$resourceId") ~> samRoutes.route ~> check { + status shouldEqual StatusCodes.Accepted + } + } + + it should "forbid adding a resource to a users favorites if they have no access to it, but return Not Found" in { + // Arrange + val user = Generator.genWorkbenchUserGoogle.sample.get + val resourceType = ResourceTypeName("workspace") + val resourceId = ResourceId("workspace") + val resource = FullyQualifiedResourceId(resourceType, resourceId) + val samRoutes = new MockSamRoutesBuilder(allUsersGroup) + .withEnabledUser(user) + .withAllowedUser(user) + .callAsNonAdminUser(Some(user)) + .build + + when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType))) + .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner"))))) + + when(samRoutes.policyEvaluatorService.listUserResourceActions(ArgumentMatchers.eq(resource), ArgumentMatchers.eq(user.id), any[SamRequestContext])) + .thenReturn(IO.pure(Set(ResourceAction("read")))) + + when(samRoutes.resourceService.addUserFavoriteResource(ArgumentMatchers.eq(user.id), ArgumentMatchers.eq(resource), any[SamRequestContext])) + .thenReturn(IO.pure(false)) + + // Act and Assert + Put(s"/api/users/v2/self/favoriteResources/$resourceType/$resourceId") ~> samRoutes.route ~> check { + status shouldEqual StatusCodes.NotFound + } + } + + it should "return Not Found if adding a resource that doesn't exist" in { + // Arrange + val user = Generator.genWorkbenchUserGoogle.sample.get + val resourceType = ResourceTypeName("workspace") + val resourceId = ResourceId("workspace") + val resource = FullyQualifiedResourceId(resourceType, resourceId) + val samRoutes = new MockSamRoutesBuilder(allUsersGroup) + .withEnabledUser(user) + .withAllowedUser(user) + .callAsNonAdminUser(Some(user)) + .build + + when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType))) + .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner"))))) + + when(samRoutes.policyEvaluatorService.listUserResourceActions(ArgumentMatchers.eq(resource), ArgumentMatchers.eq(user.id), any[SamRequestContext])) + .thenReturn(IO.pure(Set.empty)) + + // Act and Assert + Put(s"/api/users/v2/self/favoriteResources/$resourceType/$resourceId") ~> samRoutes.route ~> check { + status shouldEqual StatusCodes.NotFound + } + } + + "DELETE /api/user/v2/self/favoriteResources/{resourceTypeName}/{resourceId}" should "remove a resource to a users favorites" in { + // Arrange + val user = Generator.genWorkbenchUserGoogle.sample.get + val resourceType = ResourceTypeName("workspace") + val resourceId = ResourceId("workspace") + val resource = FullyQualifiedResourceId(resourceType, resourceId) + val samRoutes = new MockSamRoutesBuilder(allUsersGroup) + .withEnabledUser(user) + .withAllowedUser(user) + .callAsNonAdminUser(Some(user)) + .build + + when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType))) + .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner"))))) + + when(samRoutes.resourceService.removeUserFavoriteResource(ArgumentMatchers.eq(user.id), ArgumentMatchers.eq(resource), any[SamRequestContext])) + .thenReturn(IO.pure(())) + + // Act and Assert + Delete(s"/api/users/v2/self/favoriteResources/$resourceType/$resourceId") ~> samRoutes.route ~> check { + status shouldEqual StatusCodes.Accepted + } + } + + it should "allow removing a resource to a users favorites if they have no access to it" in { + // Arrange + val user = Generator.genWorkbenchUserGoogle.sample.get + val resourceType = ResourceTypeName("workspace") + val resourceId = ResourceId("workspace") + val resource = FullyQualifiedResourceId(resourceType, resourceId) + val samRoutes = new MockSamRoutesBuilder(allUsersGroup) + .withEnabledUser(user) + .withAllowedUser(user) + .callAsNonAdminUser(Some(user)) + .build + + when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType))) + .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner"))))) + + when(samRoutes.resourceService.removeUserFavoriteResource(ArgumentMatchers.eq(user.id), ArgumentMatchers.eq(resource), any[SamRequestContext])) + .thenReturn(IO.pure(())) + + // Act and Assert + Delete(s"/api/users/v2/self/favoriteResources/$resourceType/$resourceId") ~> samRoutes.route ~> check { + status shouldEqual StatusCodes.Accepted } } } diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/MockDirectoryDAO.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/MockDirectoryDAO.scala index 835bdc6fb..ed417cd39 100644 --- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/MockDirectoryDAO.scala +++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/MockDirectoryDAO.scala @@ -16,7 +16,7 @@ import org.broadinstitute.dsde.workbench.sam.azure.{ } import org.broadinstitute.dsde.workbench.sam.db.tables.TosTable import org.broadinstitute.dsde.workbench.sam.model.api.{AdminUpdateUserRequest, SamUser, SamUserAttributes} -import org.broadinstitute.dsde.workbench.sam.model.{AccessPolicy, BasicWorkbenchGroup, FullyQualifiedResourceId, ResourceAction, SamUserTos} +import org.broadinstitute.dsde.workbench.sam.model.{AccessPolicy, BasicWorkbenchGroup, FullyQualifiedResourceId, ResourceAction, ResourceTypeName, SamUserTos} import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext import java.time.Instant @@ -43,6 +43,8 @@ class MockDirectoryDAO(val groups: mutable.Map[WorkbenchGroupIdentity, Workbench private val petManagedIdentitiesByUser: mutable.Map[PetManagedIdentityId, PetManagedIdentity] = new TrieMap() + private val userFavoriteResources: mutable.Map[WorkbenchUserId, Set[FullyQualifiedResourceId]] = new TrieMap() + override def createGroup( group: BasicWorkbenchGroup, accessInstruction: Option[String] = None, @@ -480,4 +482,33 @@ class MockDirectoryDAO(val groups: mutable.Map[WorkbenchGroupIdentity, Workbench } override def listParentGroups(groupName: WorkbenchGroupName, samRequestContext: SamRequestContext): IO[Set[WorkbenchGroupName]] = IO.pure(Set.empty) + + override def addUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Boolean] = { + if (userFavoriteResources.keySet.contains(userId)) { + val updatedResources = userFavoriteResources(userId) + resourceId + userFavoriteResources += userId -> updatedResources + } else { + userFavoriteResources += userId -> Set(resourceId) + } + IO.pure(true) + } + + override def removeUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Unit] = { + if (userFavoriteResources.keySet.contains(userId)) { + val updatedResources = userFavoriteResources(userId) - resourceId + userFavoriteResources += userId -> updatedResources + } + IO.unit + } + + override def getUserFavoriteResources(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]] = IO { + userFavoriteResources.getOrElse(userId, Set.empty) + } + + override def getUserFavoriteResourcesOfType( + userId: WorkbenchUserId, + resourceTypeName: ResourceTypeName, + samRequestContext: SamRequestContext + ): IO[Set[FullyQualifiedResourceId]] = + IO.pure(userFavoriteResources.getOrElse(userId, Set.empty).filter(_.resourceTypeName == resourceTypeName)) } diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAOSpec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAOSpec.scala index 7c04091fa..fcb0d910a 100644 --- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAOSpec.scala +++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAOSpec.scala @@ -2043,5 +2043,83 @@ class PostgresDirectoryDAOSpec extends RetryableAnyFreeSpec with Matchers with B dao.listParentGroups(WorkbenchGroupName("nonexistentGroup"), samRequestContext).unsafeRunSync() shouldBe empty } } + + "UserFavoriteResources" - { + "add a favorite resource for a user" in { + assume(databaseEnabled, databaseEnabledClue) + val user = Generator.genWorkbenchUserGoogle.sample.get + dao.createUser(user, samRequestContext).unsafeRunSync() + policyDAO.createResourceType(resourceType, samRequestContext).unsafeRunSync() + policyDAO.createResource(defaultResource, samRequestContext).unsafeRunSync() + val result = dao.addUserFavoriteResource(user.id, defaultResource.fullyQualifiedId, samRequestContext).unsafeRunSync() + result should be(true) + + val loadedFavoriteResources = dao.getUserFavoriteResources(user.id, samRequestContext).unsafeRunSync() + loadedFavoriteResources should contain theSameElementsAs Set(defaultResource.fullyQualifiedId) + } + + "return false if adding a favorite resource for a user that doesn't exist" in { + assume(databaseEnabled, databaseEnabledClue) + val user = Generator.genWorkbenchUserGoogle.sample.get + dao.createUser(user, samRequestContext).unsafeRunSync() + policyDAO.createResourceType(resourceType, samRequestContext).unsafeRunSync() + policyDAO.createResource(defaultResource, samRequestContext).unsafeRunSync() + + val otherResource = Resource(resourceTypeName, ResourceId("otherResource"), Set.empty) + val result = dao.addUserFavoriteResource(user.id, otherResource.fullyQualifiedId, samRequestContext).unsafeRunSync() + result should be(false) + } + + "remove a favorite resource for a user" in { + assume(databaseEnabled, databaseEnabledClue) + val user = Generator.genWorkbenchUserGoogle.sample.get + dao.createUser(user, samRequestContext).unsafeRunSync() + val otherResource = Resource(resourceType.name, ResourceId("otherResource"), Set.empty) + policyDAO.createResourceType(resourceType, samRequestContext).unsafeRunSync() + policyDAO.createResource(defaultResource, samRequestContext).unsafeRunSync() + policyDAO.createResource(otherResource, samRequestContext).unsafeRunSync() + + dao.addUserFavoriteResource(user.id, defaultResource.fullyQualifiedId, samRequestContext).unsafeRunSync() + dao.addUserFavoriteResource(user.id, otherResource.fullyQualifiedId, samRequestContext).unsafeRunSync() + dao.removeUserFavoriteResource(user.id, defaultResource.fullyQualifiedId, samRequestContext).unsafeRunSync() + + val loadedFavoriteResources = dao.getUserFavoriteResources(user.id, samRequestContext).unsafeRunSync() + loadedFavoriteResources should contain theSameElementsAs Set(otherResource.fullyQualifiedId) + } + + "remove a favorite resource for a user when the resource doesn't exist" in { + assume(databaseEnabled, databaseEnabledClue) + val user = Generator.genWorkbenchUserGoogle.sample.get + dao.createUser(user, samRequestContext).unsafeRunSync() + val otherResource = Resource(resourceType.name, ResourceId("otherResource"), Set.empty) + policyDAO.createResourceType(resourceType, samRequestContext).unsafeRunSync() + policyDAO.createResource(defaultResource, samRequestContext).unsafeRunSync() + + dao.removeUserFavoriteResource(user.id, otherResource.fullyQualifiedId, samRequestContext).unsafeRunSync() + + val loadedFavoriteResources = dao.getUserFavoriteResources(user.id, samRequestContext).unsafeRunSync() + loadedFavoriteResources should contain theSameElementsAs Set.empty + } + + "get the favorite resources of a specific resource type for a user" in { + assume(databaseEnabled, databaseEnabledClue) + val user = Generator.genWorkbenchUserGoogle.sample.get + dao.createUser(user, samRequestContext).unsafeRunSync() + policyDAO.createResourceType(resourceType, samRequestContext).unsafeRunSync() + policyDAO.createResource(defaultResource, samRequestContext).unsafeRunSync() + + val otherResourceTypeName: ResourceTypeName = ResourceTypeName("awesomeType2") + val otherResourceType: ResourceType = ResourceType(otherResourceTypeName, actionPatterns, roles, ownerRoleName) + val otherResource = Resource(otherResourceTypeName, ResourceId("otherResource"), Set.empty) + policyDAO.createResourceType(otherResourceType, samRequestContext).unsafeRunSync() + policyDAO.createResource(otherResource, samRequestContext).unsafeRunSync() + + dao.addUserFavoriteResource(user.id, defaultResource.fullyQualifiedId, samRequestContext).unsafeRunSync() + dao.addUserFavoriteResource(user.id, otherResource.fullyQualifiedId, samRequestContext).unsafeRunSync() + + val loadedFavoriteResources = dao.getUserFavoriteResourcesOfType(user.id, otherResourceTypeName, samRequestContext).unsafeRunSync() + loadedFavoriteResources should contain theSameElementsAs Set(otherResource.fullyQualifiedId) + } + } } } diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceServiceSpec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceServiceSpec.scala index f7536dd7b..a40b51f3d 100644 --- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceServiceSpec.scala +++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceServiceSpec.scala @@ -3311,6 +3311,55 @@ class ResourceServiceSpec returnedPolicies.head._2.isLeft shouldBe true } + "UserFavoriteResource" should "add, remove, and list favorite resources for a user" in { + assume(databaseEnabled, databaseEnabledClue) + + val resourceName = ResourceId("resource") + val resource2Name = ResourceId("resource2") + val resource = FullyQualifiedResourceId(defaultResourceType.name, resourceName) + val resource2 = FullyQualifiedResourceId(otherResourceType.name, resource2Name) + + service.createResourceType(defaultResourceType, samRequestContext).unsafeRunSync() + service.createResourceType(otherResourceType, samRequestContext).unsafeRunSync() + + service.createResource(defaultResourceType, resourceName, dummyUser, samRequestContext).unsafeRunSync() + service.createResource(otherResourceType, resource2Name, dummyUser, samRequestContext).unsafeRunSync() + + service.addUserFavoriteResource(dummyUser.id, resource, samRequestContext).unsafeRunSync() + + service.getUserFavoriteResources(dummyUser.id, samRequestContext).unsafeRunSync() should contain theSameElementsAs Set(resource) + + service.addUserFavoriteResource(dummyUser.id, resource2, samRequestContext).unsafeRunSync() + + service.getUserFavoriteResources(dummyUser.id, samRequestContext).unsafeRunSync() should contain theSameElementsAs Set(resource, resource2) + + service.removeUserFavoriteResource(dummyUser.id, resource, samRequestContext).unsafeRunSync() + + service.getUserFavoriteResources(dummyUser.id, samRequestContext).unsafeRunSync() should contain theSameElementsAs Set(resource2) + + service.removeUserFavoriteResource(dummyUser.id, resource2, samRequestContext).unsafeRunSync() + + service.getUserFavoriteResources(dummyUser.id, samRequestContext).unsafeRunSync() shouldBe empty + } + + it should "not return favorite resources for another user" in { + assume(databaseEnabled, databaseEnabledClue) + + def otherUser = Generator.genWorkbenchUserBoth.sample.get + dirDAO.createUser(otherUser, samRequestContext).unsafeRunSync() + + val resourceName = ResourceId("resource") + val resource = FullyQualifiedResourceId(defaultResourceType.name, resourceName) + + service.createResourceType(defaultResourceType, samRequestContext).unsafeRunSync() + + service.createResource(defaultResourceType, resourceName, dummyUser, samRequestContext).unsafeRunSync() + + service.addUserFavoriteResource(dummyUser.id, resource, samRequestContext).unsafeRunSync() + + service.getUserFavoriteResources(otherUser.id, samRequestContext).unsafeRunSync() shouldBe empty + } + /** Sets up a test log appender attached to the audit logger, runs the `test` IO, ensures that `events` were appended. If tryTwice` run `test` again to make * sure subsequent calls to no log more messages. Ends by tearing down the log appender. */