From 48592ade1694bb26db8e7876e7f4b880e854dc67 Mon Sep 17 00:00:00 2001 From: irinaschubert Date: Mon, 4 Jul 2022 17:22:20 +0200 Subject: [PATCH] refactor(v3): finish user slice (DEV-671) (#2078) * start with C4 documentation * refactor UserId * add imports for UserId * separate username and email checks * add update methods * refactor id * use password hash instead of password * add negative tests * refactor UserHandlerSpec * add shared test data * use shared test data in tests * finish user slice * add all user projects to root * remove unnecessary log output * add architecture documentation * add sequence diagram with mermaid * cleanup code * bump ZioLoggingVersion * refactor tests * implement feedback from review * fix failing test * Update Id.scala * rename test data * add password strength as value object * move taps to end of method * simplify test * refactor user to return a validation * remove throwables from shared test data * refactor tests * remove duplicated line * correct typo * remove sha-1 support * remove unsafeMake as we don't need them * add placeholder to empty files to remove warnings * return error instead of throw * update docs * improve handling of value objects * add unsafeMake again * Update UsersRouteADM.scala * refactor UserDomainSpec * Update Makefile * add slf4j dependency Co-authored-by: Ivan Subotic <400790+subotic@users.noreply.github.com> --- Makefile | 5 - build.sbt | 90 +-- .../docs/http-request-flow-with-events.md | 27 + .../{flows => docs}/http-request-flow.md | 2 +- docs/architecture/users.dsl | 8 + docs/architecture/workspace.dsl | 169 ++++- .../src/main/scala/dsp/valueobjects/Id.scala | 65 ++ .../src/main/scala/dsp/valueobjects/Iri.scala | 10 +- .../main/scala/dsp/valueobjects/User.scala | 144 ++-- .../scala/dsp/valueobjects/GroupSpec.scala | 8 +- .../test/scala/dsp/valueobjects/IriSpec.scala | 49 +- .../scala/dsp/valueobjects/ListSpec.scala | 14 +- .../scala/dsp/valueobjects/ProjectSpec.scala | 16 +- .../scala/dsp/valueobjects/UserSpec.scala | 198 ++--- .../main/scala/dsp/user/api/UserRepo.scala | 30 +- .../scala/dsp/user/domain/UserDomain.scala | 233 +++--- .../test/scala/dsp/user/api/UserApiSpec.scala | 4 + .../dsp/user/domain/UserDomainSpec.scala | 157 ++++ .../user/sharedtestdata/SharedTestData.scala | 97 +++ .../scala/dsp/user/handler/UserHandler.scala | 207 +++-- .../dsp/user/handler/UserHandlerSpec.scala | 704 ++++++++++++++++-- .../external/UserListenerExternal.scala | 4 + .../internal/UserListenerInternal.scala | 4 + .../main/scala/dsp/user/route/UserRoute.scala | 4 + .../external/UserListenerExternalSpec.scala | 4 + .../internal/UserListenerInternalSpec.scala | 4 + .../scala/dsp/user/route/UserRouteSpec.scala | 4 + .../dsp/user/repo/impl/UserRepoLive.scala | 96 ++- .../dsp/user/repo/impl/UserRepoImplSpec.scala | 126 ++-- .../dsp/user/repo/impl/UserRepoMock.scala | 89 ++- project/Dependencies.scala | 41 +- .../webapi/routing/admin/UsersRouteADM.scala | 39 +- 32 files changed, 2003 insertions(+), 649 deletions(-) create mode 100644 docs/architecture/docs/http-request-flow-with-events.md rename docs/architecture/{flows => docs}/http-request-flow.md (97%) create mode 100644 docs/architecture/users.dsl create mode 100644 dsp-shared/src/main/scala/dsp/valueobjects/Id.scala create mode 100644 dsp-user/core/src/test/scala/dsp/user/sharedtestdata/SharedTestData.scala diff --git a/Makefile b/Makefile index d41b20fff1..b27846e5f9 100644 --- a/Makefile +++ b/Makefile @@ -203,11 +203,6 @@ test-repository-upgrade: build init-db-test-minimal ## runs DB upgrade integrati .PHONY: test test: build ## runs all tests - sbt -v "schemaApi/test" - sbt -v "schemaCore/test" - sbt -v "schemaRepo/test" - sbt -v "schemaRepoEventStoreService/test" - sbt -v "schemaRepoSearchService/test" sbt -v "shared/test" sbt -v "sipi/test" sbt -v "userCore/test" diff --git a/build.sbt b/build.sbt index ca96ad750f..94c6c35ec2 100644 --- a/build.sbt +++ b/build.sbt @@ -28,7 +28,7 @@ lazy val buildSettings = Seq( lazy val rootBaseDir = ThisBuild / baseDirectory lazy val root: Project = Project(id = "root", file(".")) - .aggregate(webapi, apiMain) + .aggregate(webapi, sipi, shared, valueObjects, userCore, userHandler, userRepo, userInterface) .enablePlugins(GitVersioning, GitBranchPrompt) .settings( // values set for all sub-projects @@ -232,16 +232,6 @@ lazy val webapiJavaTestOptions = Seq( // DSP's new codebase ////////////////////////////////////// -lazy val apiMain = project - .in(file("dsp-api-main")) - .settings( - name := "dsp-api-main", - libraryDependencies ++= Dependencies.dspApiMainLibraryDependencies, - resolvers += "Sonatype" at "https://oss.sonatype.org/content/repositories/snapshots", - testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) - ) - .dependsOn(schemaCore, schemaRepo, schemaApi) - // Value Objects project lazy val valueObjects = project @@ -252,57 +242,6 @@ lazy val valueObjects = project testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) ) -// Schema projects - -lazy val schemaApi = project - .in(file("dsp-schema/api")) - .settings( - name := "schemaApi", - libraryDependencies ++= Dependencies.schemaApiLibraryDependencies, - resolvers += "Sonatype" at "https://oss.sonatype.org/content/repositories/snapshots", - testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) - ) - .dependsOn(schemaCore) - -lazy val schemaCore = project - .in(file("dsp-schema/core")) - .settings( - name := "schemaCore", - libraryDependencies ++= Dependencies.schemaCoreLibraryDependencies, - resolvers += "Sonatype" at "https://oss.sonatype.org/content/repositories/snapshots", - testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) - ) - -lazy val schemaRepo = project - .in(file("dsp-schema/repo")) - .settings( - name := "schemaRepo", - libraryDependencies ++= Dependencies.schemaRepoLibraryDependencies, - resolvers += "Sonatype" at "https://oss.sonatype.org/content/repositories/snapshots", - testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) - ) - .dependsOn(schemaCore) - -lazy val schemaRepoEventStoreService = project - .in(file("dsp-schema/repo-eventstore-service")) - .settings( - name := "schemaRepoEventstoreService", - libraryDependencies ++= Dependencies.schemaRepoEventStoreServiceLibraryDependencies, - resolvers += "Sonatype" at "https://oss.sonatype.org/content/repositories/snapshots", - testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) - ) - .dependsOn(schemaRepo) - -lazy val schemaRepoSearchService = project - .in(file("dsp-schema/repo-search-service")) - .settings( - name := "dsp-schema-repo-search-service", - libraryDependencies ++= Dependencies.schemaRepoSearchServiceLibraryDependencies, - resolvers += "Sonatype" at "https://oss.sonatype.org/content/repositories/snapshots", - testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) - ) - .dependsOn(schemaRepo) - // User projects lazy val userInterface = project @@ -335,10 +274,14 @@ lazy val userHandler = project libraryDependencies ++= Dependencies.userHandlerLibraryDependencies, testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) ) - .dependsOn(shared, userCore, userRepo % "test->test") //userHandler tests need mock implementation of UserRepo + .dependsOn( + shared, + userCore % "compile->compile;test->test", + userRepo % "test->test" //userHandler tests need mock implementation of UserRepo + ) -lazy val userCore = project - .in(file("dsp-user/core")) +lazy val userRepo = project + .in(file("dsp-user/repo")) .settings( scalacOptions ++= Seq( "-feature", @@ -347,14 +290,14 @@ lazy val userCore = project "-Yresolve-term-conflict:package", "-Ymacro-annotations" ), - name := "userCore", - libraryDependencies ++= Dependencies.userCoreLibraryDependencies, + name := "userRepo", + libraryDependencies ++= Dependencies.userRepoLibraryDependencies, testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) ) - .dependsOn(shared) + .dependsOn(shared, userCore % "compile->compile;test->test") -lazy val userRepo = project - .in(file("dsp-user/repo")) +lazy val userCore = project + .in(file("dsp-user/core")) .settings( scalacOptions ++= Seq( "-feature", @@ -363,12 +306,11 @@ lazy val userRepo = project "-Yresolve-term-conflict:package", "-Ymacro-annotations" ), - name := "userRepo", - libraryDependencies ++= Dependencies.userRepoLibraryDependencies, + name := "userCore", + libraryDependencies ++= Dependencies.userCoreLibraryDependencies, testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) ) - .dependsOn(shared, userCore) -//.dependsOn(userHandler % "compile->compile;test->test", userDomain) + .dependsOn(shared) lazy val shared = project .in(file("dsp-shared")) diff --git a/docs/architecture/docs/http-request-flow-with-events.md b/docs/architecture/docs/http-request-flow-with-events.md new file mode 100644 index 0000000000..e8c697ae1b --- /dev/null +++ b/docs/architecture/docs/http-request-flow-with-events.md @@ -0,0 +1,27 @@ +## Example for an HTTP Request Flow with Events + +### Create a User +```mermaid +sequenceDiagram + autonumber + user ->> userRoute: "sends HTTP request" + userRoute ->> userRoute: "validates input (payload) and creates value objects" + userRoute ->> userHandler: "sends value objects" + userHandler ->> userRepo: "reserves username" + userRepo ->> eventStoreService: "reserves username" + eventStoreService ->> eventStoreService: "checks if username exists" + eventStoreService ->> eventStoreService: "reserves username" + userHandler ->> userDomain: "calls User.make() with value objects" + userDomain ->> userDomain: "creates userDomainEntity + userCreatedEvent(who, what)" + userDomain ->> userHandler: "returns (userDomainEntity + userCreatedEvent)" + userHandler ->> userRepo: "storeUser(userDomainEntity + userCreatedEvent)" + userRepo ->> eventStoreService: "storeUser(userDomainEntity + userCreatedEvent)" + eventStoreService ->> eventStoreService: "store event(s), userCreatedEvent(who, what, when(!))" + eventStoreService ->> eventListener: "publishEvent(userCreatedEvent)" + eventListener ->> triplestoreService: "writeToTsService(E)" + triplestoreService ->> triplestoreService: "SPARQL update - write user to triplestore" + eventListener ->> arkService: "writeToArkService(E)" + arkService ->> arkService: "create ARK(URL)" + eventListener ->> elasticSearchService: "writeToEsService(E)" + elasticSearchService ->> elasticSearchService: "write" +``` diff --git a/docs/architecture/flows/http-request-flow.md b/docs/architecture/docs/http-request-flow.md similarity index 97% rename from docs/architecture/flows/http-request-flow.md rename to docs/architecture/docs/http-request-flow.md index 1d5a35605d..0a0aa0a049 100644 --- a/docs/architecture/flows/http-request-flow.md +++ b/docs/architecture/docs/http-request-flow.md @@ -1,4 +1,4 @@ -HTTP Request Flow +## HTTP Request Flow V2 vs. V3 V1 / V2 / admin: ```mermaid diff --git a/docs/architecture/users.dsl b/docs/architecture/users.dsl new file mode 100644 index 0000000000..b0f51a16b0 --- /dev/null +++ b/docs/architecture/users.dsl @@ -0,0 +1,8 @@ +# person [description] [tags] +user = person "A user / client" "A user of DSP, regardless if known/unknow or its role" +#userUnknown = person "Unknown user" "An unknown user of DSP" +#userUnknown = person "Unknown user" "An unknown user of DSP" +#userKnown = person "Known user" "A logged-in user of DSP" +#userSysAdmin = person "System Admin" "A system admin of DSP" +#userProjectAdmin = person "Project Admin" "A project administrator" +#userProjectMember = person "Project Member" "A project member" diff --git a/docs/architecture/workspace.dsl b/docs/architecture/workspace.dsl index f1f6b5afa2..e758b1c091 100644 --- a/docs/architecture/workspace.dsl +++ b/docs/architecture/workspace.dsl @@ -1,21 +1,178 @@ -workspace { +workspace "Architecture Diagrams for DSP" "This is a collection of diagrams for the DSP architecture" { + # https://github.com/structurizr/dsl/blob/master/docs/language-reference.md + # for an example, see https://structurizr.com/dsl?example=big-bank-plc + + + # static model model { - user = person "User" - softwareSystem = softwareSystem "Software System" + !include users.dsl + + enterprise "DSP - DaSCH Service Platform" { + # softwareSystem [description] [tags] + dspJsLib = softwaresystem "JS-LIB" "Layer between DSP-API and DSP-APP" + dspApp = softwaresystem "DSP-APP" "admin.dasch.swiss" + dspTools = softwaresystem "DSP-TOOLS" "CLI for DSP-API" + fuseki = softwaresystem "Fuseki Triplestore" "RDF database" "Database" + sipi = softwaresystem "SIPI" "IIIF image server" + arkResolver = softwaresystem "ARK Resolver" "Forwards ARK URLs to DSP-APP URLs" + dspApi = softwareSystem "DSP-API" "api.dasch.swiss" { + # container [description] [technology] [tags] + + eventStoreService = container "Event Store Service" + eventListener = container "Event Listener" + triplestoreService = container "Triplestore Service" + arkService = container "ARK Service" + elasticSearchService = container "Elastic Search Service" + + sharedProject = container "Shared Project" "The project that handles shared entities" { + valueObjectsPackage = component "ValueObjects Package" "The package that provides value objects" + errorPackage = component "Error Package" "The package that provides errors" + } + + webapiProject = container "Webapi" "The project that wraps webapi V2" + + projectSlice = container "Project Slice" "The slice that handles projects" + roleSlice = container "Role Slice" "The slice that handles roles" + schemaSlice = container "Schema Slice" "The slice that handles schemas" + resourceSlice = container "Resource Slice" "The slice that handles resources" + listSlice = container "List Slice" "The slice that handles lists" + routes = container "Routes" "The slice that provides all routes" + + userSlice = container "User Slice" "The slice that handles users" { + userCore = component "User Core" + userDomain = component "User Domain" + userHandler = component "User Handler" + + userRepo = component "User Repo (API)" + userRepoLive = component "User Repo Live (Implementation)" + userRepoMock = component "User Repo Mock (Implementation for Tests)" + + userRoute = component "User Route" + } + + } + } + + # relationships between users and software systems + user -> dspApp "Uses [Browser]" + user -> arkResolver "Uses [Browser]" + user -> dspTools "Uses [CLI]" + + # relationships to/from software systems + dspApp -> dspJsLib + dspJsLib -> dspApi + dspTools -> dspApi + dspApi -> fuseki + dspApi -> sipi + dspTools -> sipi + + # relationships to/from containers + webapiProject -> sharedProject "depends on" + projectSlice -> sharedProject "depends on" + roleSlice -> sharedProject "depends on" + schemaSlice -> sharedProject "depends on" + resourceSlice -> sharedProject "depends on" + listSlice -> sharedProject "depends on" + routes -> sharedProject "depends on" + userSlice -> sharedProject "depends on" + + # relationships to/from components + userRepo -> userCore "depends on" + userRepoLive -> userCore "depends on" + userRepoMock -> userCore "depends on" + userRoute -> userCore "depends on" + + userRepoLive -> userRepo "implements" + userRepoMock -> userRepo "implements" + + userCore -> userDomain "contains" + userCore -> userHandler "contains" - user -> softwareSystem "Uses" } views { - systemContext softwareSystem "Diagram1" { + systemlandscape "SystemLandscape" "System Landscape of DSP-API" { + include * + autoLayout + } + + # systemContext [key] [description] + systemContext dspApi "SystemContextDspApi" "DSP-API System Context" { + include * + autoLayout + } + + # container [key] [description] + container dspApi "ContainerDspApi" "Containers of DSP-API" { + include * + autoLayout + } + + # component [key] [description] + component userSlice "ComponentsOfUserSlice" "Components of the User Slice" { + include * + + } + + component SharedProject "ComponentsOfSharedProject" "Components of the Shared Project" { include * autoLayout } + # dynamic <*|software system identifier|container identifier> [key] [description] + dynamic userSlice "HttpRequestWithEventsCreateUser" "Example workflow for a HTTP request with events (create user)" { + user -> userRoute "sends HTTP request to" + userRoute -> userRoute "validates input and creates value objects" + userRoute -> userHandler "createUser(vo)" + userHandler -> userRepo "reserve username" + userRepo -> eventStoreService "reserve username" + eventStoreService -> eventStoreService "check if username exists" + eventStoreService -> eventStoreService "reserve username" + userHandler -> userDomain ".make(vo)" + userDomain -> userDomain "create user domain entity + userCreatedEvent(who, what)" + userDomain -> userHandler "return (userDomainEntity + userCreatedEvent)" + userHandler -> userRepo "storeUser(userDomainEntity + userCreatedEvent)" + userRepo -> eventStoreService "storeUser(userDomainEntity + userCreatedEvent)" + eventStoreService -> eventStoreService "store event(s), userCreatedEvent(who, what, when(!))" + eventStoreService -> eventListener "publishEvent(userCreatedEvent)" + eventListener -> triplestoreService "writeToTsService(E)" + triplestoreService -> triplestoreService "SPARQL Update" + eventListener -> arkService "writeToArkService(E)" + arkService -> arkService "create ARK(URL)" + eventListener -> elasticSearchService "writeToEsService(E)" + + } + + dynamic userSlice "HttpRequestWithEventsUpdateUser" "Example workflow for a HTTP request with events (update username)" { + user -> userRoute "sends HTTP request to" + userRoute -> userRoute "validates input and creates value objects" + userRoute -> userHandler "updateUsername(vo)" + userHandler -> userRepo "getUser(userId)" + userRepo -> eventStoreService "getUser(userId)" + eventStoreService -> eventStoreService "get all events for this user" + eventStoreService -> userDomain "createUserFromEvents(E,E,E,...)" + userDomain -> userHandler "return User" + userHandler -> userDomain "updateUsername(vo)" + userDomain -> userDomain "updateUser(userDomainEntity + userUpdatedEvent(who, what))" + userDomain -> userHandler "return userDomainEntity + userUpdatedEvent(who, what)" + userHandler -> userRepo "storeUser(userDomainEntity + userUpdatedEvent(who, what, when(!))" + + userRepo -> eventStoreService "storeUser(userDomainEntity + userCreatedEvent)" + eventStoreService -> eventStoreService "store event(s), userCreatedEvent(who, what, when(!))" + eventStoreService -> eventListener "publishEvent(userCreatedEvent)" + eventListener -> triplestoreService "writeToTsService(E)" + triplestoreService -> triplestoreService "SPARQL Update" + eventListener -> arkService "writeToArkService(E)" + arkService -> arkService "create ARK(URL)" + eventListener -> elasticSearchService "writeToEsService(E)" + + autoLayout + } + theme default } !adrs decisions - !docs flows + !docs docs } diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/Id.scala b/dsp-shared/src/main/scala/dsp/valueobjects/Id.scala new file mode 100644 index 0000000000..1e9e6da3f5 --- /dev/null +++ b/dsp-shared/src/main/scala/dsp/valueobjects/Id.scala @@ -0,0 +1,65 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.valueobjects + +import dsp.errors.BadRequestException +import zio.prelude.Validation + +import java.util.UUID + +sealed trait Id +object Id { + + /** + * Stores the user ID, i.e. UUID and IRI of the user + * + * @param uuid the UUID of the user + * @param iri the IRI of the user + */ + sealed abstract case class UserId private ( + uuid: UUID, + iri: Iri.UserIri + ) extends Id + + /** + * Companion object for UserId. Contains factory methods for creating UserId instances. + */ + object UserId { + + private val userIriPrefix = "http://rdfh.ch/users/" + + /** + * Generates a UserId instance with a new (random) UUID and a given IRI which is created from a prefix and the UUID. + * + * @return a new UserId instance + */ + def fromIri(iri: Iri.UserIri): Validation[Throwable, UserId] = { + val uuid: UUID = UUID.fromString(iri.value.split("/").last) + Validation.succeed(new UserId(uuid, iri) {}) + } + + /** + * Generates a UserId instance from a given UUID and an IRI which is created from a prefix and the UUID. + * + * @return a new UserId instance + */ + def fromUuid(uuid: UUID): Validation[Throwable, UserId] = { + val iri = Iri.UserIri.make(userIriPrefix + uuid.toString).fold(e => throw e.head, v => v) + Validation.succeed(new UserId(uuid, iri) {}) + } + + /** + * Generates a UserId instance with a new (random) UUID and an IRI which is created from a prefix and the UUID. + * + * @return a new UserId instance + */ + def make(): Validation[Throwable, UserId] = { + val uuid: UUID = UUID.randomUUID() + val iri = Iri.UserIri.make(userIriPrefix + uuid.toString).fold(e => throw e.head, v => v) + Validation.succeed(new UserId(uuid, iri) {}) + } + } +} diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala b/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala index 60323c1c59..996035c235 100644 --- a/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala +++ b/dsp-shared/src/main/scala/dsp/valueobjects/Iri.scala @@ -5,8 +5,8 @@ package dsp.valueobjects -import zio.prelude.Validation import dsp.errors.BadRequestException +import zio.prelude.Validation sealed trait Iri object Iri { @@ -114,7 +114,7 @@ object Iri { * UserIri value object. */ sealed abstract case class UserIri private (value: String) extends Iri - object UserIri { self => + object UserIri { def make(value: String): Validation[Throwable, UserIri] = if (value.isEmpty) { Validation.fail(BadRequestException(IriErrorMessages.UserIriMissing)) @@ -136,12 +136,6 @@ object Iri { validatedValue.map(new UserIri(_) {}) } } - - def make(value: Option[String]): Validation[Throwable, Option[UserIri]] = - value match { - case Some(v) => self.make(v).map(Some(_)) - case None => Validation.succeed(None) - } } } diff --git a/dsp-shared/src/main/scala/dsp/valueobjects/User.scala b/dsp-shared/src/main/scala/dsp/valueobjects/User.scala index 1b97924f50..d987a03713 100644 --- a/dsp-shared/src/main/scala/dsp/valueobjects/User.scala +++ b/dsp-shared/src/main/scala/dsp/valueobjects/User.scala @@ -5,9 +5,14 @@ package dsp.valueobjects +import dsp.errors.BadRequestException +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder +import zio._ import zio.prelude.Validation + import scala.util.matching.Regex -import dsp.errors.BadRequestException +import java.security.SecureRandom object User { @@ -40,11 +45,24 @@ object User { } } - def make(value: Option[String]): Validation[Throwable, Option[Username]] = - value match { - case Some(v) => self.make(v).map(Some(_)) - case None => Validation.succeed(None) - } + /** + * Makes a Username value object even if the input is not valid. Instead of returning an Error, it + * just logs the Error message and returns the Username. This is needed when the input value + * was created at a time where the validation was different and couldn't be updated. Only use + * this method in the repo layer or in tests! + * + * @param value The value the value object is created from + */ + def unsafeMake(value: String): Validation[Throwable, Username] = + Username + .make(value) + .fold( + e => { + ZIO.logError(e.head.getMessage()) + Validation.succeed(new Username(value) {}) + }, + v => Validation.succeed(v) + ) } /** @@ -63,12 +81,6 @@ object User { case None => Validation.fail(BadRequestException(UserErrorMessages.EmailInvalid)) } } - - def make(value: Option[String]): Validation[Throwable, Option[Email]] = - value match { - case Some(v) => self.make(v).map(Some(_)) - case None => Validation.succeed(None) - } } /** @@ -82,12 +94,6 @@ object User { } else { Validation.succeed(new GivenName(value) {}) } - - def make(value: Option[String]): Validation[Throwable, Option[GivenName]] = - value match { - case Some(v) => self.make(v).map(Some(_)) - case None => Validation.succeed(None) - } } /** @@ -101,12 +107,6 @@ object User { } else { Validation.succeed(new FamilyName(value) {}) } - - def make(value: Option[String]): Validation[Throwable, Option[FamilyName]] = - value match { - case Some(v) => self.make(v).map(Some(_)) - case None => Validation.succeed(None) - } } /** @@ -125,11 +125,67 @@ object User { case None => Validation.fail(BadRequestException(UserErrorMessages.PasswordInvalid)) } } + } - def make(value: Option[String]): Validation[Throwable, Option[Password]] = - value match { - case Some(v) => self.make(v).map(Some(_)) - case None => Validation.succeed(None) + /** + * PasswordHash value object. Takes a string as input and hashes it. + */ + sealed abstract case class PasswordHash private (value: String, passwordStrength: PasswordStrength) { self => + + /** + * Check password (in clear text). The password supplied in clear text is compared against the + * stored hash. + * + * @param passwordString Password (clear text) to be checked + * @return true if password matches, false otherwise + */ + def matches(passwordString: String): Boolean = + // check which type of hash we have + if (self.value.startsWith("$e0801$")) { + // SCrypt + val encoder = new SCryptPasswordEncoder() + encoder.matches(passwordString, self.value) + } else if (self.value.startsWith("$2a$")) { + // BCrypt + val encoder = new BCryptPasswordEncoder() + encoder.matches(passwordString, self.value) + } else { + ZIO.logError(UserErrorMessages.PasswordHashUnknown) + false + } + + } + object PasswordHash { + private val PasswordRegex: Regex = """^[\s\S]*$""".r + + def make(value: String, passwordStrength: PasswordStrength): Validation[Throwable, PasswordHash] = + if (value.isEmpty) { + Validation.fail(BadRequestException(UserErrorMessages.PasswordMissing)) + } else { + PasswordRegex.findFirstIn(value) match { + case Some(value) => + val encoder = + new BCryptPasswordEncoder( + passwordStrength.value, + new SecureRandom() + ) + val hashedValue = encoder.encode(value) + Validation.succeed(new PasswordHash(hashedValue, passwordStrength) {}) + case None => Validation.fail(BadRequestException(UserErrorMessages.PasswordInvalid)) + } + } + } + + /** + * PasswordStrength value object. + */ + sealed abstract case class PasswordStrength private (value: Int) + object PasswordStrength { self => + def make(value: Int): Validation[Throwable, PasswordStrength] = + if (value < 4 || value > 31) { + Validation.fail(BadRequestException(UserErrorMessages.PasswordStrengthInvalid)) + } else { + Validation.succeed(new PasswordStrength(value) {}) } } @@ -155,12 +211,6 @@ object User { } else { Validation.succeed(new LanguageCode(value) {}) } - - def make(value: Option[String]): Validation[Throwable, Option[LanguageCode]] = - value match { - case Some(v) => self.make(v).map(Some(_)) - case None => Validation.succeed(None) - } } /** @@ -174,16 +224,18 @@ object User { } object UserErrorMessages { - val UsernameMissing = "Username cannot be empty." - val UsernameInvalid = "Username is invalid." - val EmailMissing = "Email cannot be empty." - val EmailInvalid = "Email is invalid." - val PasswordMissing = "Password cannot be empty." - val PasswordInvalid = "Password is invalid." - val GivenNameMissing = "GivenName cannot be empty." - val GivenNameInvalid = "GivenName is invalid." - val FamilyNameMissing = "FamilyName cannot be empty." - val FamilyNameInvalid = "FamilyName is invalid." - val LanguageCodeMissing = "LanguageCode cannot be empty." - val LanguageCodeInvalid = "LanguageCode is invalid." + val UsernameMissing = "Username cannot be empty." + val UsernameInvalid = "Username is invalid." + val EmailMissing = "Email cannot be empty." + val EmailInvalid = "Email is invalid." + val PasswordMissing = "Password cannot be empty." + val PasswordInvalid = "Password is invalid." + val PasswordStrengthInvalid = "PasswordStrength is invalid." + val PasswordHashUnknown = "The provided PasswordHash has an unknown format." + val GivenNameMissing = "GivenName cannot be empty." + val GivenNameInvalid = "GivenName is invalid." + val FamilyNameMissing = "FamilyName cannot be empty." + val FamilyNameInvalid = "FamilyName is invalid." + val LanguageCodeMissing = "LanguageCode cannot be empty." + val LanguageCodeInvalid = "LanguageCode is invalid." } diff --git a/dsp-shared/src/test/scala/dsp/valueobjects/GroupSpec.scala b/dsp-shared/src/test/scala/dsp/valueobjects/GroupSpec.scala index 4d11bf5cf7..2a906226e2 100644 --- a/dsp-shared/src/test/scala/dsp/valueobjects/GroupSpec.scala +++ b/dsp-shared/src/test/scala/dsp/valueobjects/GroupSpec.scala @@ -24,13 +24,13 @@ object GroupSpec extends ZIOSpecDefault { def spec = (groupNameTest + groupDescriptionsTest + groupStatusTest + groupSelfJoinTest) private val groupNameTest = suite("GroupSpec - GroupName")( - test("pass an empty value and throw an error") { + test("pass an empty value and return an error") { assertTrue(GroupName.make("") == Validation.fail(BadRequestException(GroupErrorMessages.GroupNameMissing))) && assertTrue( GroupName.make(Some("")) == Validation.fail(BadRequestException(GroupErrorMessages.GroupNameMissing)) ) }, - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( GroupName.make(invalidName) == Validation.fail( BadRequestException(GroupErrorMessages.GroupNameInvalid) @@ -54,7 +54,7 @@ object GroupSpec extends ZIOSpecDefault { ) private val groupDescriptionsTest = suite("GroupSpec - GroupDescriptions")( - test("pass an empty object and throw an error") { + test("pass an empty object and return an error") { assertTrue( GroupDescriptions.make(Seq.empty) == Validation.fail( BadRequestException(GroupErrorMessages.GroupDescriptionsMissing) @@ -66,7 +66,7 @@ object GroupSpec extends ZIOSpecDefault { ) ) }, - test("pass an invalid object and throw an error") { + test("pass an invalid object and return an error") { assertTrue( GroupDescriptions.make(invalidDescription) == Validation.fail( BadRequestException(GroupErrorMessages.GroupDescriptionsInvalid) diff --git a/dsp-shared/src/test/scala/dsp/valueobjects/IriSpec.scala b/dsp-shared/src/test/scala/dsp/valueobjects/IriSpec.scala index 9bdac05a26..530ec4c60c 100644 --- a/dsp-shared/src/test/scala/dsp/valueobjects/IriSpec.scala +++ b/dsp-shared/src/test/scala/dsp/valueobjects/IriSpec.scala @@ -27,13 +27,13 @@ object IriSpec extends ZIOSpecDefault { def spec = (groupIriTest + listIriTest + projectIriTest) private val groupIriTest = suite("IriSpec - GroupIri")( - test("pass an empty value and throw an error") { + test("pass an empty value and return an error") { assertTrue(GroupIri.make("") == Validation.fail(BadRequestException(IriErrorMessages.GroupIriMissing))) && assertTrue( GroupIri.make(Some("")) == Validation.fail(BadRequestException(IriErrorMessages.GroupIriMissing)) ) }, - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( GroupIri.make(invalidIri) == Validation.fail( BadRequestException(IriErrorMessages.GroupIriInvalid) @@ -45,7 +45,7 @@ object IriSpec extends ZIOSpecDefault { ) ) }, - test("pass an invalid IRI containing unsupported UUID version and throw an error") { + test("pass an invalid IRI containing unsupported UUID version and return an error") { assertTrue( GroupIri.make(groupIriWithUUIDVersion3) == Validation.fail( BadRequestException(IriErrorMessages.UuidVersionInvalid) @@ -69,13 +69,13 @@ object IriSpec extends ZIOSpecDefault { ) private val listIriTest = suite("IriSpec - ListIri")( - test("pass an empty value and throw an error") { + test("pass an empty value and return an error") { assertTrue(ListIri.make("") == Validation.fail(BadRequestException(IriErrorMessages.ListIriMissing))) && assertTrue( ListIri.make(Some("")) == Validation.fail(BadRequestException(IriErrorMessages.ListIriMissing)) ) }, - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( ListIri.make(invalidIri) == Validation.fail( BadRequestException(IriErrorMessages.ListIriInvalid) @@ -87,7 +87,7 @@ object IriSpec extends ZIOSpecDefault { ) ) }, - test("pass an invalid IRI containing unsupported UUID version and throw an error") { + test("pass an invalid IRI containing unsupported UUID version and return an error") { assertTrue( ListIri.make(listIriWithUUIDVersion3) == Validation.fail( BadRequestException(IriErrorMessages.UuidVersionInvalid) @@ -111,13 +111,13 @@ object IriSpec extends ZIOSpecDefault { ) private val projectIriTest = suite("IriSpec - ProjectIri")( - test("pass an empty value and throw an error") { + test("pass an empty value and return an error") { assertTrue(ProjectIri.make("") == Validation.fail(BadRequestException(IriErrorMessages.ProjectIriMissing))) && assertTrue( ProjectIri.make(Some("")) == Validation.fail(BadRequestException(IriErrorMessages.ProjectIriMissing)) ) }, - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( ProjectIri.make(invalidIri) == Validation.fail( BadRequestException(IriErrorMessages.ProjectIriInvalid) @@ -129,7 +129,7 @@ object IriSpec extends ZIOSpecDefault { ) ) }, - test("pass an invalid IRI containing unsupported UUID version and throw an error") { + test("pass an invalid IRI containing unsupported UUID version and return an error") { assertTrue( ProjectIri.make(projectIriWithUUIDVersion3) == Validation.fail( BadRequestException(IriErrorMessages.UuidVersionInvalid) @@ -152,45 +152,26 @@ object IriSpec extends ZIOSpecDefault { } ) - private val UserIriTest = suite("IriSpec - ProjectIri")( - test("pass an empty value and throw an error") { - assertTrue(UserIri.make("") == Validation.fail(BadRequestException(IriErrorMessages.UserIriMissing))) && - assertTrue( - UserIri.make(Some("")) == Validation.fail(BadRequestException(IriErrorMessages.UserIriMissing)) - ) + private val UserIriTest = suite("IriSpec - UserIri")( + test("pass an empty value and return an error") { + assertTrue(UserIri.make("") == Validation.fail(BadRequestException(IriErrorMessages.UserIriMissing))) }, - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( UserIri.make(invalidIri) == Validation.fail( BadRequestException(IriErrorMessages.UserIriInvalid) ) - ) && - assertTrue( - UserIri.make(Some(invalidIri)) == Validation.fail( - BadRequestException(IriErrorMessages.UserIriInvalid) - ) ) }, - test("pass an invalid IRI containing unsupported UUID version and throw an error") { + test("pass an invalid IRI containing unsupported UUID version and return an error") { assertTrue( UserIri.make(userIriWithUUIDVersion3) == Validation.fail( BadRequestException(IriErrorMessages.UuidVersionInvalid) ) - ) && - assertTrue( - UserIri.make(Some(userIriWithUUIDVersion3)) == Validation.fail( - BadRequestException(IriErrorMessages.UuidVersionInvalid) - ) ) }, test("pass a valid value and successfully create value object") { - assertTrue(UserIri.make(validUserIri).toOption.get.value == validUserIri) && - assertTrue(UserIri.make(Option(validUserIri)).getOrElse(null).get.value == validUserIri) - }, - test("successfully validate passing None") { - assertTrue( - UserIri.make(None) == Validation.succeed(None) - ) + assertTrue(UserIri.make(validUserIri).toOption.get.value == validUserIri) } ) } diff --git a/dsp-shared/src/test/scala/dsp/valueobjects/ListSpec.scala b/dsp-shared/src/test/scala/dsp/valueobjects/ListSpec.scala index 211b077406..68e4204a8b 100644 --- a/dsp-shared/src/test/scala/dsp/valueobjects/ListSpec.scala +++ b/dsp-shared/src/test/scala/dsp/valueobjects/ListSpec.scala @@ -26,13 +26,13 @@ object ListSpec extends ZIOSpecDefault { def spec = (listNameTest + positionTest + labelsTest + commentsTest) private val listNameTest = suite("ListSpec - ListName")( - test("pass an empty value and throw an error") { + test("pass an empty value and return an error") { assertTrue(ListName.make("") == Validation.fail(BadRequestException(ListErrorMessages.ListNameMissing))) && assertTrue( ListName.make(Some("")) == Validation.fail(BadRequestException(ListErrorMessages.ListNameMissing)) ) }, - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( ListName.make(invalidName) == Validation.fail( BadRequestException(ListErrorMessages.ListNameInvalid) @@ -56,7 +56,7 @@ object ListSpec extends ZIOSpecDefault { ) private val positionTest = suite("ListSpec - Position")( - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( Position.make(invalidPosition) == Validation.fail( BadRequestException(ListErrorMessages.InvalidPosition) @@ -80,7 +80,7 @@ object ListSpec extends ZIOSpecDefault { ) private val labelsTest = suite("ListSpec - Labels")( - test("pass an empty object and throw an error") { + test("pass an empty object and return an error") { assertTrue( Labels.make(Seq.empty) == Validation.fail( BadRequestException(ListErrorMessages.LabelsMissing) @@ -92,7 +92,7 @@ object ListSpec extends ZIOSpecDefault { ) ) }, - test("pass an invalid object and throw an error") { + test("pass an invalid object and return an error") { assertTrue( Labels.make(invalidLabel) == Validation.fail( BadRequestException(ListErrorMessages.LabelsInvalid) @@ -116,7 +116,7 @@ object ListSpec extends ZIOSpecDefault { ) private val commentsTest = suite("ListSpec - Comments")( - test("pass an empty object and throw an error") { + test("pass an empty object and return an error") { assertTrue( Comments.make(Seq.empty) == Validation.fail( BadRequestException(ListErrorMessages.CommentsMissing) @@ -128,7 +128,7 @@ object ListSpec extends ZIOSpecDefault { ) ) }, - test("pass an invalid object and throw an error") { + test("pass an invalid object and return an error") { assertTrue( Comments.make(invalidComment) == Validation.fail( BadRequestException(ListErrorMessages.CommentsInvalid) diff --git a/dsp-shared/src/test/scala/dsp/valueobjects/ProjectSpec.scala b/dsp-shared/src/test/scala/dsp/valueobjects/ProjectSpec.scala index d73b6a6ea4..01fb80699d 100644 --- a/dsp-shared/src/test/scala/dsp/valueobjects/ProjectSpec.scala +++ b/dsp-shared/src/test/scala/dsp/valueobjects/ProjectSpec.scala @@ -29,7 +29,7 @@ object ProjectSpec extends ZIOSpecDefault { (shortcodeTest + shortnameTest + longnameTest + projectDescriptionsTest + keywordsTest + logoTest + projectStatusTest + projectSelfJoinTest) private val shortcodeTest = suite("ProjectSpec - Shortcode")( - test("pass an empty value and throw an error") { + test("pass an empty value and return an error") { assertTrue( Shortcode.make("") == Validation.fail(BadRequestException(ProjectErrorMessages.ShortcodeMissing)) ) && @@ -37,7 +37,7 @@ object ProjectSpec extends ZIOSpecDefault { Shortcode.make(Some("")) == Validation.fail(BadRequestException(ProjectErrorMessages.ShortcodeMissing)) ) }, - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( Shortcode.make(invalidShortcode) == Validation.fail( BadRequestException(ProjectErrorMessages.ShortcodeInvalid) @@ -61,7 +61,7 @@ object ProjectSpec extends ZIOSpecDefault { ) private val shortnameTest = suite("ProjectSpec - Shortname")( - test("pass an empty value and throw an error") { + test("pass an empty value and return an error") { assertTrue( Shortname.make("") == Validation.fail(BadRequestException(ProjectErrorMessages.ShortnameMissing)) ) && @@ -69,7 +69,7 @@ object ProjectSpec extends ZIOSpecDefault { Shortname.make(Some("")) == Validation.fail(BadRequestException(ProjectErrorMessages.ShortnameMissing)) ) }, - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( Shortname.make(invalidShortname) == Validation.fail( BadRequestException(ProjectErrorMessages.ShortnameInvalid) @@ -93,7 +93,7 @@ object ProjectSpec extends ZIOSpecDefault { ) private val longnameTest = suite("ProjectSpec - Longname")( - test("pass an empty value and throw an error") { + test("pass an empty value and return an error") { assertTrue(Longname.make("") == Validation.fail(BadRequestException(ProjectErrorMessages.LongnameMissing))) && assertTrue( Longname.make(Some("")) == Validation.fail(BadRequestException(ProjectErrorMessages.LongnameMissing)) @@ -111,7 +111,7 @@ object ProjectSpec extends ZIOSpecDefault { ) private val projectDescriptionsTest = suite("ProjectSpec - ProjectDescriptions")( - test("pass an empty object and throw an error") { + test("pass an empty object and return an error") { assertTrue( ProjectDescription.make(Seq.empty) == Validation.fail( BadRequestException(ProjectErrorMessages.ProjectDescriptionsMissing) @@ -135,7 +135,7 @@ object ProjectSpec extends ZIOSpecDefault { ) private val keywordsTest = suite("ProjectSpec - Keywords")( - test("pass an empty object and throw an error") { + test("pass an empty object and return an error") { assertTrue( Keywords.make(Seq.empty) == Validation.fail( BadRequestException(ProjectErrorMessages.KeywordsMissing) @@ -159,7 +159,7 @@ object ProjectSpec extends ZIOSpecDefault { ) private val logoTest = suite("ProjectSpec - Logo")( - test("pass an empty object and throw an error") { + test("pass an empty object and return an error") { assertTrue( Logo.make("") == Validation.fail( BadRequestException(ProjectErrorMessages.LogoMissing) diff --git a/dsp-shared/src/test/scala/dsp/valueobjects/UserSpec.scala b/dsp-shared/src/test/scala/dsp/valueobjects/UserSpec.scala index 0ba489cf9e..82da3529a7 100644 --- a/dsp-shared/src/test/scala/dsp/valueobjects/UserSpec.scala +++ b/dsp-shared/src/test/scala/dsp/valueobjects/UserSpec.scala @@ -11,7 +11,7 @@ import zio.test._ import dsp.errors.BadRequestException /** - * This spec is used to test the [[User]] value objects creation. + * This spec is used to test the [[dsp.valueobjects.User]] value objects creation. */ object UserSpec extends ZIOSpecDefault { private val validUsername = "user008" @@ -33,251 +33,179 @@ object UserSpec extends ZIOSpecDefault { private val invalidLanguageCode = "00" def spec = - (usernameTest + emailTest + givenNameTest + familyNameTest + passwordTest + languageCodeTest + systemAdminTest) + (usernameTest + emailTest + givenNameTest + familyNameTest + passwordTest + passwordHashTest + languageCodeTest + systemAdminTest) - private val usernameTest = suite("UserSpec - Username")( - test("pass an empty value and throw an error") { - assertTrue(Username.make("") == Validation.fail(BadRequestException(UserErrorMessages.UsernameMissing))) && + private val usernameTest = suite("Username")( + test("pass an empty value and return an error") { assertTrue( - Username.make(Some("")) == Validation.fail(BadRequestException(UserErrorMessages.UsernameMissing)) + Username.make("") == Validation.fail(BadRequestException(UserErrorMessages.UsernameMissing)) ) }, - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( Username.make(invalidUsername) == Validation.fail( BadRequestException(UserErrorMessages.UsernameInvalid) ) - ) && - assertTrue( - Username.make(Some(invalidUsername)) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) - ) ) }, - test("pass too short value and throw an error") { + test("pass too short value and return an error") { assertTrue( Username.make(tooShortUsername) == Validation.fail( BadRequestException(UserErrorMessages.UsernameInvalid) ) - ) && - assertTrue( - Username.make(Some(tooShortUsername)) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) - ) ) }, - test("pass too long value and throw an error") { + test("pass too long value and return an error") { assertTrue( Username.make(tooLongUsername) == Validation.fail( BadRequestException(UserErrorMessages.UsernameInvalid) ) - ) && - assertTrue( - Username.make(Some(tooLongUsername)) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) - ) ) }, - test("pass an invalid value with '_' as the first char and throw an error") { + test("pass an invalid value with '_' as the first char and return an error") { assertTrue( Username.make(invalidUsernameWithUnderscoreAsFirstChar) == Validation.fail( BadRequestException(UserErrorMessages.UsernameInvalid) ) - ) && - assertTrue( - Username.make(Some(invalidUsernameWithUnderscoreAsFirstChar)) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) - ) ) }, - test("pass an invalid value with '_' as the last char and throw an error") { + test("pass an invalid value with '_' as the last char and return an error") { assertTrue( Username.make(invalidUsernameWithUnderscoreAsLastChar) == Validation.fail( BadRequestException(UserErrorMessages.UsernameInvalid) ) - ) && - assertTrue( - Username.make(Some(invalidUsernameWithUnderscoreAsLastChar)) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) - ) ) }, - test("pass an invalid value with '_' used multiple times in a row and throw an error") { + test("pass an invalid value with '_' used multiple times in a row and return an error") { assertTrue( Username.make(invalidUsernameWithMultipleUnderscoresInRow) == Validation.fail( BadRequestException(UserErrorMessages.UsernameInvalid) ) - ) && - assertTrue( - Username.make(Some(invalidUsernameWithMultipleUnderscoresInRow)) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) - ) ) }, - test("pass an invalid value with '.' as the first char and throw an error") { + test("pass an invalid value with '.' as the first char and return an error") { assertTrue( Username.make(invalidUsernameWithDotAsFirstChar) == Validation.fail( BadRequestException(UserErrorMessages.UsernameInvalid) ) - ) && - assertTrue( - Username.make(Some(invalidUsernameWithDotAsFirstChar)) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) - ) ) }, - test("pass an invalid value with '.' as the last char and throw an error") { + test("pass an invalid value with '.' as the last char and return an error") { assertTrue( Username.make(invalidUsernameWithDotAsLastChar) == Validation.fail( BadRequestException(UserErrorMessages.UsernameInvalid) ) - ) && - assertTrue( - Username.make(Some(invalidUsernameWithDotAsLastChar)) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) - ) ) }, - test("pass an invalid value with '.' used multiple times in a row and throw an error") { + test("pass an invalid value with '.' used multiple times in a row and return an error") { assertTrue( Username.make(invalidUsernameWithMultipleDotsInRow) == Validation.fail( BadRequestException(UserErrorMessages.UsernameInvalid) ) - ) && - assertTrue( - Username.make(Some(invalidUsernameWithMultipleDotsInRow)) == Validation.fail( - BadRequestException(UserErrorMessages.UsernameInvalid) - ) ) }, test("pass a valid value and successfully create value object") { assertTrue(Username.make(validUsername).toOption.get.value == validUsername) - assertTrue(Username.make(Option(validUsername)).getOrElse(null).get.value == validUsername) - } + - test("successfully validate passing None") { - assertTrue( - Username.make(None) == Validation.succeed(None) - ) - } + } ) - private val emailTest = suite("UserSpec - Email")( - test("pass an empty value and throw an error") { - assertTrue(Email.make("") == Validation.fail(BadRequestException(UserErrorMessages.EmailMissing))) && - assertTrue( - Email.make(Some("")) == Validation.fail(BadRequestException(UserErrorMessages.EmailMissing)) - ) + private val emailTest = suite("Email")( + test("pass an empty value and return an error") { + assertTrue(Email.make("") == Validation.fail(BadRequestException(UserErrorMessages.EmailMissing))) }, - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( Email.make(invalidEmailAddress) == Validation.fail( BadRequestException(UserErrorMessages.EmailInvalid) ) - ) && - assertTrue( - Email.make(Some(invalidEmailAddress)) == Validation.fail( - BadRequestException(UserErrorMessages.EmailInvalid) - ) ) }, test("pass a valid value and successfully create value object") { - assertTrue(Email.make(validEmailAddress).toOption.get.value == validEmailAddress) && - assertTrue(Email.make(Option(validEmailAddress)).getOrElse(null).get.value == validEmailAddress) - }, - test("successfully validate passing None") { - assertTrue( - Email.make(None) == Validation.succeed(None) - ) + assertTrue(Email.make(validEmailAddress).toOption.get.value == validEmailAddress) } ) - private val givenNameTest = suite("UserSpec - GivenName")( - test("pass an empty value and throw an error") { - assertTrue(GivenName.make("") == Validation.fail(BadRequestException(UserErrorMessages.GivenNameMissing))) && + private val givenNameTest = suite("GivenName")( + test("pass an empty value and return an error") { assertTrue( - GivenName.make(Some("")) == Validation.fail(BadRequestException(UserErrorMessages.GivenNameMissing)) + GivenName.make("") == Validation.fail(BadRequestException(UserErrorMessages.GivenNameMissing)) ) }, test("pass a valid value and successfully create value object") { - assertTrue(GivenName.make(validGivenName).toOption.get.value == validGivenName) && - assertTrue(GivenName.make(Option(validGivenName)).getOrElse(null).get.value == validGivenName) - }, - test("successfully validate passing None") { - assertTrue( - GivenName.make(None) == Validation.succeed(None) - ) + assertTrue(GivenName.make(validGivenName).toOption.get.value == validGivenName) } ) - private val familyNameTest = suite("UserSpec - FamilyName")( - test("pass an empty value and throw an error") { - assertTrue(FamilyName.make("") == Validation.fail(BadRequestException(UserErrorMessages.FamilyNameMissing))) && + private val familyNameTest = suite("FamilyName")( + test("pass an empty value and return an error") { assertTrue( - FamilyName.make(Some("")) == Validation.fail(BadRequestException(UserErrorMessages.FamilyNameMissing)) + FamilyName.make("") == Validation.fail(BadRequestException(UserErrorMessages.FamilyNameMissing)) ) }, test("pass a valid value and successfully create value object") { - assertTrue(FamilyName.make(validFamilyName).toOption.get.value == validFamilyName) && - assertTrue(FamilyName.make(Option(validFamilyName)).getOrElse(null).get.value == validFamilyName) - }, - test("successfully validate passing None") { + assertTrue(FamilyName.make(validFamilyName).toOption.get.value == validFamilyName) + } + ) + + private val passwordTest = suite("Password")( + test("pass an empty value and return an error") { assertTrue( - FamilyName.make(None) == Validation.succeed(None) + Password.make("") == Validation.fail(BadRequestException(UserErrorMessages.PasswordMissing)) ) + }, + test("pass a valid value and successfully create value object") { + assertTrue(Password.make(validPassword).toOption.get.value == validPassword) } ) - private val passwordTest = suite("UserSpec - Password")( - test("pass an empty value and throw an error") { - assertTrue(Password.make("") == Validation.fail(BadRequestException(UserErrorMessages.PasswordMissing))) && + private val passwordHashTest = suite("PasswordHash")( + test("pass an empty value and return an error") { + val passwordStrength = PasswordStrength.make(12).fold(e => throw e.head, v => v) assertTrue( - Password.make(Some("")) == Validation.fail(BadRequestException(UserErrorMessages.PasswordMissing)) + PasswordHash.make("", passwordStrength) == Validation.fail( + BadRequestException(UserErrorMessages.PasswordMissing) + ) ) }, test("pass a valid value and successfully create value object") { - assertTrue(Password.make(validPassword).toOption.get.value == validPassword) && - assertTrue(Password.make(Option(validPassword)).getOrElse(null).get.value == validPassword) + val passwordString = "password1" + val passwordStrength = PasswordStrength.make(12).fold(e => throw e.head, v => v) + val password = PasswordHash.make(passwordString, passwordStrength).fold(e => throw e.head, v => v) + + assertTrue(password.matches(passwordString)) }, - test("successfully validate passing None") { - assertTrue( - Password.make(None) == Validation.succeed(None) - ) + test("test if a password matches it hashed value") { + val passwordString = "password1" + val passwordEqualString = "password1" + val passwordNotEqualString = "password2" + + val passwordStrength = PasswordStrength.make(12).fold(e => throw e.head, v => v) + val password = PasswordHash.make(passwordString, passwordStrength).fold(e => throw e.head, v => v) + + assertTrue(password.matches(passwordEqualString)) && + assertTrue(!password.matches(passwordNotEqualString)) } ) - private val languageCodeTest = suite("UserSpec - LanguageCode")( - test("pass an empty value and throw an error") { + private val languageCodeTest = suite("LanguageCode")( + test("pass an empty value and return an error") { assertTrue( LanguageCode.make("") == Validation.fail(BadRequestException(UserErrorMessages.LanguageCodeMissing)) - ) && - assertTrue( - LanguageCode.make(Some("")) == Validation.fail(BadRequestException(UserErrorMessages.LanguageCodeMissing)) ) }, - test("pass an invalid value and throw an error") { + test("pass an invalid value and return an error") { assertTrue( LanguageCode.make(invalidLanguageCode) == Validation.fail( BadRequestException(UserErrorMessages.LanguageCodeInvalid) ) - ) && - assertTrue( - LanguageCode.make(Some(invalidLanguageCode)) == Validation.fail( - BadRequestException(UserErrorMessages.LanguageCodeInvalid) - ) ) }, test("pass a valid value and successfully create value object") { - assertTrue(LanguageCode.make(validLanguageCode).toOption.get.value == validLanguageCode) && - assertTrue(LanguageCode.make(Option(validLanguageCode)).getOrElse(null).get.value == validLanguageCode) - }, - test("successfully validate passing None") { - assertTrue( - LanguageCode.make(None) == Validation.succeed(None) - ) + assertTrue(LanguageCode.make(validLanguageCode).toOption.get.value == validLanguageCode) } ) - private val systemAdminTest = suite("GroupSpec - SystemAdmin")( + private val systemAdminTest = suite("SystemAdmin")( test("pass a valid object and successfully create value object") { assertTrue(SystemAdmin.make(true).toOption.get.value == true) } diff --git a/dsp-user/core/src/main/scala/dsp/user/api/UserRepo.scala b/dsp-user/core/src/main/scala/dsp/user/api/UserRepo.scala index 1f63806081..44eb4aca8c 100644 --- a/dsp-user/core/src/main/scala/dsp/user/api/UserRepo.scala +++ b/dsp-user/core/src/main/scala/dsp/user/api/UserRepo.scala @@ -7,6 +7,8 @@ package dsp.user.api import dsp.errors._ import dsp.user.domain._ +import dsp.valueobjects.Id.UserId +import dsp.valueobjects.User._ import zio._ import zio.macros.accessible @@ -45,20 +47,36 @@ trait UserRepo { def getUserById(id: UserId): IO[Option[Nothing], User] /** - * Retrieves the user from the repository by username or email. + * Retrieves the user from the repository by username. * - * @param usernameOrEmail username or email of the user. + * @param username username of the user. * @return an optional [[User]]. */ - def getUserByUsernameOrEmail(usernameOrEmail: String): IO[Option[Nothing], User] + def getUserByUsername(username: Username): IO[Option[Nothing], User] /** - * Checks if a username or email exists in the repo. + * Retrieves the user from the repository by email. * - * @param usernameOrEmail username or email of the user. + * @param email email of the user. + * @return an optional [[User]]. + */ + def getUserByEmail(email: Email): IO[Option[Nothing], User] + + /** + * Checks if a username exists in the repo. + * + * @param username username of the user. + * @return Unit in case of success + */ + def checkIfUsernameExists(username: Username): IO[Option[Nothing], Unit] + + /** + * Checks if an email exists in the repo. + * + * @param email email of the user. * @return Unit in case of success */ - def checkUsernameOrEmailExists(usernameOrEmail: String): IO[Option[Nothing], Unit] + def checkIfEmailExists(email: Email): IO[Option[Nothing], Unit] /** * Deletes a [[User]] from the repository by its [[UserId]]. diff --git a/dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala b/dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala index 1d66dfea1e..f77a19ec38 100644 --- a/dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala +++ b/dsp-user/core/src/main/scala/dsp/user/domain/UserDomain.scala @@ -5,138 +5,183 @@ package dsp.user.domain +import dsp.valueobjects.Id.UserId import dsp.valueobjects.User._ import zio.prelude.Validation import java.util.UUID - -// move this to shared value objects project once we have it -sealed trait Iri -object Iri { - - /** - * UserIri value object. - */ - sealed abstract case class UserIri private (value: String) extends Iri - object UserIri { self => - - def make(value: String): UserIri = new UserIri(value) {} - } - // ... - -} +import dsp.errors.BadRequestException /** - * Stores the user ID, i.e. UUID and IRI of the user + * Represents the user domain object. * - * @param uuid the UUID of the user - * @param iri the IRI of the user + * @param id the ID of the user + * @param givenName the given name of the user + * @param familyName the family name of the user + * @param username the username of the user + * @param email the email of the user + * @param password the password of the user + * @param language the preferred language of the user + * @param status the status of the user + * @param role the role of the user */ -abstract case class UserId private ( - uuid: UUID, - iri: Iri.UserIri -) +sealed abstract case class User private ( + id: UserId, + givenName: GivenName, + familyName: FamilyName, + username: Username, + email: Email, + password: PasswordHash, + language: LanguageCode, + status: UserStatus + //role: Role +) extends Ordered[User] { self => -/** - * Companion object for UserId. Contains factory methods for creating UserId instances. - */ -object UserId { + /** + * Allows to sort collections of [[User]]s. Sorting is done by the IRI. + */ + def compare(that: User): Int = self.id.iri.toString().compareTo(that.id.iri.toString()) /** - * Generates a UserId instance from a given string (either UUID or IRI). + * Update the username of a user * - * @param value the string to parse (either UUID or IRI) - * @return a new UserId instance + * @param newValue the new username + * @return the updated [[User]] */ - // TODO not sure if we need this - // def fromString(value: String): UserId = { - // val uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$".r - // val iriPattern = "^http*".r + def updateUsername(newValue: Username): Validation[BadRequestException, User] = + User.make( + self.id, + self.givenName, + self.familyName, + newValue, + self.email, + self.password, + self.language, + self.status + ) - // value match { - // case uuidPattern(value) => new UserId(UUID.fromString(value), Iri.UserIri.make(value)) {} - // case iriPattern(value) => - // new UserId(UUID.fromString(value.substring(value.lastIndexOf("/") + 1)), Iri.UserIri.make(value)) {} - // //case _ => ??? - // } - // } + /** + * Update the email of a user + * + * @param newValue the new email + * @return the updated [[User]] + */ + def updateEmail(newValue: Email): Validation[BadRequestException, User] = + User.make( + self.id, + self.givenName, + self.familyName, + self.username, + newValue, + self.password, + self.language, + self.status + ) /** - * Generates a UserId instance with a new (random) UUID and an IRI which is created from a prefix and the UUID. + * Update the given name of a user * - * @return a new UserId instance + * @param newValue the new given name + * @return the updated [[User]] */ - def fromIri(iri: Iri.UserIri): UserId = { - val uuid: UUID = UUID.fromString(iri.value.split("/").last) - new UserId(uuid, iri) {} - } + def updateGivenName(newValue: GivenName): Validation[BadRequestException, User] = + User.make( + self.id, + newValue, + self.familyName, + self.username, + self.email, + self.password, + self.language, + self.status + ) /** - * Generates a UserId instance with a new (random) UUID and an IRI which is created from a prefix and the UUID. + * Update the family name of a user * - * @return a new UserId instance + * @param newValue the new family name + * @return the updated [[User]] */ - def fromUuid(uuid: UUID): UserId = { - val iri: Iri.UserIri = Iri.UserIri.make("http://rdfh.ch/users/" + uuid.toString) - new UserId(uuid, iri) {} - } + def updateFamilyName(newValue: FamilyName): Validation[BadRequestException, User] = + User.make( + self.id, + self.givenName, + newValue, + self.username, + self.email, + self.password, + self.language, + self.status + ) /** - * Generates a UserId instance with a new (random) UUID and an IRI which is created from a prefix and the UUID. + * Update the password of a user * - * @return a new UserId instance + * @param newValue the new password + * @return the updated [[User]] */ - // TODO should this return a Validation[Throwable, UserId] - def make(): UserId = { - val uuid: UUID = UUID.randomUUID() - val iri: Iri.UserIri = Iri.UserIri.make("http://rdfh.ch/users/" + uuid.toString) - new UserId(uuid, iri) {} - } -} + def updatePassword(newValue: PasswordHash): Validation[BadRequestException, User] = + User.make( + self.id, + self.givenName, + self.familyName, + self.username, + self.email, + newValue, + self.language, + self.status + ) -/** - * Represents the user domain object. - * - * @param id the ID of the user - * @param givenName the given name of the user - * @param familyName the family name of the user - * @param username the username of the user - * @param email the email of the user - * @param password the password of the user - * @param language the user's preferred language - * @param role the user's role - */ -sealed abstract case class User private ( - id: UserId, - givenName: GivenName, - familyName: FamilyName, - username: Username, - email: Email, - password: Option[Password], - language: LanguageCode - //role: Role -) extends Ordered[User] { self => + /** + * Update the language of a user + * + * @param newValue the new language + * @return the updated [[User]] + */ + def updateLanguage(newValue: LanguageCode): Validation[BadRequestException, User] = + User.make( + self.id, + self.givenName, + self.familyName, + self.username, + self.email, + self.password, + newValue, + self.status + ) /** - * Allows to sort collections of [[User]]s. Sorting is done by the IRI. + * Update the status of a user + * + * @param id the user's ID + * @param newValue the new status + * @return the updated [[User]] */ - def compare(that: User): Int = self.id.iri.toString().compareTo(that.id.iri.toString()) + def updateStatus(newValue: UserStatus): Validation[BadRequestException, User] = + User.make( + self.id, + self.givenName, + self.familyName, + self.username, + self.email, + self.password, + self.language, + newValue + ) - def updateUsername(value: Username): User = - new User(self.id, self.givenName, self.familyName, value, self.email, self.password, self.language) {} } object User { def make( + id: UserId, givenName: GivenName, familyName: FamilyName, username: Username, email: Email, - password: Password, - language: LanguageCode + password: PasswordHash, + language: LanguageCode, + status: UserStatus //role: Role - ): User = { - val id = UserId.make() - new User(id, givenName, familyName, username, email, Some(password), language) {} - } + ): Validation[BadRequestException, User] = + Validation.succeed(new User(id, givenName, familyName, username, email, password, language, status) {}) } diff --git a/dsp-user/core/src/test/scala/dsp/user/api/UserApiSpec.scala b/dsp-user/core/src/test/scala/dsp/user/api/UserApiSpec.scala index f01f75cf8f..752c5a7093 100644 --- a/dsp-user/core/src/test/scala/dsp/user/api/UserApiSpec.scala +++ b/dsp-user/core/src/test/scala/dsp/user/api/UserApiSpec.scala @@ -4,3 +4,7 @@ */ package dsp.user.api + +// this placeholder can be removed as soon as the code are there +// without it there is a warning +object placeholder {} diff --git a/dsp-user/core/src/test/scala/dsp/user/domain/UserDomainSpec.scala b/dsp-user/core/src/test/scala/dsp/user/domain/UserDomainSpec.scala index 6c1f2391b3..92fd0d42c5 100644 --- a/dsp-user/core/src/test/scala/dsp/user/domain/UserDomainSpec.scala +++ b/dsp-user/core/src/test/scala/dsp/user/domain/UserDomainSpec.scala @@ -4,3 +4,160 @@ */ package dsp.user.domain + +import dsp.user.domain.User +import dsp.user.domain._ +import dsp.valueobjects.User._ +import dsp.user.sharedtestdata.SharedTestData +import zio.ZLayer +import zio._ +import zio.test._ + +/** + * This spec is used to test [[dsp.user.domain.UserDomain]]. + */ +object UserDomainSpec extends ZIOSpecDefault { + + def spec = (compareUsersTest + createUserTest + updateUserTest) + + private val compareUsersTest = suite("compareUsers")( + test("compare two users") { + val user = SharedTestData.user1 + val userEqual = SharedTestData.user1 // same as user, i.e. has same UserId + val userNotEqual = SharedTestData.user2 + + assertTrue(user.equals(userEqual)) && + assertTrue(!user.equals(userNotEqual)) && + assertTrue(user == userEqual) && + assertTrue(user != userNotEqual) + } + ) + + private val createUserTest = suite("createUser")( + test("create a user") { + (for { + id <- SharedTestData.userId1 + givenName <- SharedTestData.givenName1 + familyName <- SharedTestData.familyName1 + username <- SharedTestData.username1 + email <- SharedTestData.email1 + password <- SharedTestData.password1 + language <- SharedTestData.languageEn + status <- SharedTestData.statusTrue + user <- User + .make( + id, + givenName, + familyName, + username, + email, + password, + language, + status + ) + } yield assertTrue(user.username == username) && + assertTrue(user.email == email) && + assertTrue(user.givenName == givenName) && + assertTrue(user.familyName == familyName) && + assertTrue(user.password == password) && + assertTrue(user.language == language) && + assertTrue(user.status == status)).toZIO + } + ) + + private val updateUserTest = suite("updateUser")( + test("update the username") { + (for { + user <- SharedTestData.user1 + newValue <- Username.make("newUsername") + updatedUser <- user.updateUsername(newValue) + } yield assertTrue(updatedUser.username == newValue) && + assertTrue(updatedUser.username != user.username) && + assertTrue(updatedUser.email == user.email) && + assertTrue(updatedUser.givenName == user.givenName) && + assertTrue(updatedUser.familyName == user.familyName) && + assertTrue(updatedUser.password == user.password) && + assertTrue(updatedUser.language == user.language) && + assertTrue(updatedUser.status == user.status)).toZIO + }, + test("update the email") { + (for { + user <- SharedTestData.user1 + newValue <- Email.make("newEmail@mail.com") + updatedUser <- user.updateEmail(newValue) + } yield assertTrue(updatedUser.email == newValue) && + assertTrue(updatedUser.email != user.email) && + assertTrue(updatedUser.givenName == user.givenName) && + assertTrue(updatedUser.familyName == user.familyName) && + assertTrue(updatedUser.password == user.password) && + assertTrue(updatedUser.language == user.language) && + assertTrue(updatedUser.status == user.status)).toZIO + }, + test("update the givenName") { + (for { + user <- SharedTestData.user1 + newValue <- GivenName.make("newGivenName") + updatedUser <- user.updateGivenName(newValue) + } yield assertTrue(updatedUser.givenName == newValue) && + assertTrue(updatedUser.email == user.email) && + assertTrue(updatedUser.givenName != user.givenName) && + assertTrue(updatedUser.familyName == user.familyName) && + assertTrue(updatedUser.password == user.password) && + assertTrue(updatedUser.language == user.language) && + assertTrue(updatedUser.status == user.status)).toZIO + }, + test("update the familyName") { + (for { + user <- SharedTestData.user1 + newValue <- FamilyName.make("newFamilyName") + updatedUser <- user.updateFamilyName(newValue) + } yield assertTrue(updatedUser.familyName == newValue) && + assertTrue(updatedUser.email == user.email) && + assertTrue(updatedUser.givenName == user.givenName) && + assertTrue(updatedUser.familyName != user.familyName) && + assertTrue(updatedUser.password == user.password) && + assertTrue(updatedUser.language == user.language) && + assertTrue(updatedUser.status == user.status)).toZIO + }, + test("update the password") { + (for { + user <- SharedTestData.user1 + passwordStrength <- SharedTestData.passwordStrength + newValue <- PasswordHash.make("newPassword1", passwordStrength) + updatedUser <- user.updatePassword(newValue) + } yield assertTrue(updatedUser.password == newValue) && + assertTrue(updatedUser.email == user.email) && + assertTrue(updatedUser.givenName == user.givenName) && + assertTrue(updatedUser.familyName == user.familyName) && + assertTrue(updatedUser.password != user.password) && + assertTrue(updatedUser.language == user.language) && + assertTrue(updatedUser.status == user.status)).toZIO + }, + test("update the language") { + (for { + user <- SharedTestData.user1 + newValue <- LanguageCode.make("fr") + updatedUser <- user.updateLanguage(newValue) + } yield assertTrue(updatedUser.language == newValue) && + assertTrue(updatedUser.email == user.email) && + assertTrue(updatedUser.givenName == user.givenName) && + assertTrue(updatedUser.familyName == user.familyName) && + assertTrue(updatedUser.password == user.password) && + assertTrue(updatedUser.language != user.language) && + assertTrue(updatedUser.status == user.status)).toZIO + }, + test("update the status") { + (for { + user <- SharedTestData.user1 + newValue <- UserStatus.make(false) + updatedUser <- user.updateStatus(newValue) + } yield assertTrue(updatedUser.status == newValue) && + assertTrue(updatedUser.email == user.email) && + assertTrue(updatedUser.givenName == user.givenName) && + assertTrue(updatedUser.familyName == user.familyName) && + assertTrue(updatedUser.password == user.password) && + assertTrue(updatedUser.language == user.language) && + assertTrue(updatedUser.status != user.status)).toZIO + } + ) +} diff --git a/dsp-user/core/src/test/scala/dsp/user/sharedtestdata/SharedTestData.scala b/dsp-user/core/src/test/scala/dsp/user/sharedtestdata/SharedTestData.scala new file mode 100644 index 0000000000..4f8083115b --- /dev/null +++ b/dsp-user/core/src/test/scala/dsp/user/sharedtestdata/SharedTestData.scala @@ -0,0 +1,97 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dsp.user.sharedtestdata + +import dsp.valueobjects.User._ +import dsp.user.domain.User +import dsp.errors.BadRequestException +import zio.prelude.Validation +import dsp.valueobjects.Id + +object SharedTestData { + val passwordStrength = PasswordStrength.make(12) + + val userId1 = Id.UserId.make() + val givenName1 = GivenName.make("GivenName1") + val familyName1 = FamilyName.make("FamilyName1") + val username1 = Username.make("username1") + val email1 = Email.make("email1@email.com") + val password1 = for { + passwordStrength <- PasswordStrength.make(12) + password <- PasswordHash.make("password1", passwordStrength) + } yield password + + val userId2 = Id.UserId.make() + val givenName2 = GivenName.make("GivenName2") + val familyName2 = FamilyName.make("FamilyName2") + val username2 = Username.make("username2") + val email2 = Email.make("email2@email.com") + val password2 = for { + passwordStrength <- PasswordStrength.make(12) + password <- PasswordHash.make("password2", passwordStrength) + } yield password + + val userId3 = Id.UserId.make() + val givenName3 = GivenName.make("GivenName3") + val familyName3 = FamilyName.make("FamilyName3") + val username3 = Username.make("username3") + val email3 = Email.make("email3@email.com") + val password3 = for { + passwordStrength <- PasswordStrength.make(12) + password <- PasswordHash.make("password3", passwordStrength) + } yield password + + val languageEn = LanguageCode.make("en") + val languageFr = LanguageCode.make("fr") + val languageDe = LanguageCode.make("de") + + val statusTrue = UserStatus.make(true) + val statusFalse = UserStatus.make(false) + + val user1 = for { + id <- userId1 + givenName <- givenName1 + familyName <- familyName1 + username <- username1 + email <- email1 + password <- password1 + language <- languageEn + status <- statusTrue + user <- User + .make( + id, + givenName, + familyName, + username, + email, + password, + language, + status + ) + } yield user + + val user2 = for { + id <- userId2 + givenName <- givenName2 + familyName <- familyName2 + username <- username2 + email <- email2 + password <- password2 + language <- languageEn + status <- statusTrue + user <- User + .make( + id, + givenName, + familyName, + username, + email, + password, + language, + status + ) + } yield user +} diff --git a/dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala b/dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala index 650193d2d2..7407be7f71 100644 --- a/dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala +++ b/dsp-user/handler/src/main/scala/dsp/user/handler/UserHandler.scala @@ -7,15 +7,17 @@ package dsp.user.handler import dsp.errors.BadRequestException import dsp.errors.DuplicateValueException +import dsp.errors.ForbiddenException import dsp.errors.NotFoundException import dsp.errors.RequestRejectedException import dsp.user.api.UserRepo import dsp.user.domain.User -import dsp.user.domain.UserId +import dsp.valueobjects.Id.UserId import dsp.valueobjects.User._ import zio._ import java.util.UUID +import dsp.errors.KnoraException /** * The user handler. @@ -23,107 +25,222 @@ import java.util.UUID * @param repo the user repository */ final case class UserHandler(repo: UserRepo) { - // implement all possible requests from V2, but divide things up into smaller functions to keep it cleaner than before /** - * Retrieve all users (sorted by IRI). + * Retrieves all users (sorted by IRI). */ def getUsers(): UIO[List[User]] = - repo.getUsers().map(_.sorted) - - // getSingleUserADM should be inspected in the route. According to the user identifier, the - // right method from the userHandler should be called. + repo.getUsers().map(_.sorted).tap(_ => ZIO.logInfo(s"Got all users")) /** - * Retrieve the user by ID. + * Retrieves the user by ID. * * @param id the user's ID */ def getUserById(id: UserId): IO[NotFoundException, User] = for { - user <- repo.getUserById(id).mapError(_ => NotFoundException("User not found")) + user <- repo + .getUserById(id) + .mapError(_ => NotFoundException(s"User with ID '${id}' not found")) + .tap(_ => ZIO.logInfo(s"Looked up user by ID '${id}'")) } yield user /** - * Retrieve the user by username. + * Retrieves the user by username. * * @param username the user's username */ def getUserByUsername(username: Username): IO[NotFoundException, User] = repo - .getUserByUsernameOrEmail(username.value) - .mapError(_ => NotFoundException(s"User with Username ${username.value} not found")) + .getUserByUsername(username) + .mapError(_ => NotFoundException(s"User with Username '${username.value}' not found")) + .tap(_ => ZIO.logInfo(s"Looked up user by username '${username.value}'")) /** - * Retrieve the user by email. + * Retrieves the user by email. * * @param email the user's email */ def getUserByEmail(email: Email): IO[NotFoundException, User] = repo - .getUserByUsernameOrEmail(email.value) - .mapError(_ => NotFoundException(s"User with Email ${email.value} not found")) + .getUserByEmail(email) + .mapError(_ => NotFoundException(s"User with Email '${email.value}' not found")) + .tap(_ => ZIO.logInfo(s"Looked up user by email '${email.value}'")) /** - * Check if username is already taken + * Checks if username is already taken * * @param username the user's username */ - private def checkUsernameTaken(username: Username): IO[DuplicateValueException, Unit] = + private def checkIfUsernameTaken(username: Username): IO[DuplicateValueException, Unit] = for { _ <- repo - .checkUsernameOrEmailExists(username.value) - .mapError(_ => DuplicateValueException(s"Username ${username.value} already exists")) + .checkIfUsernameExists(username) + .mapError(_ => DuplicateValueException(s"Username '${username.value}' already taken")) + .tap(_ => ZIO.logInfo(s"Checked if username '${username.value}' is already taken")) } yield () /** - * Check if email is already taken + * Checks if email is already taken * * @param email the user's email */ - private def checkEmailTaken(email: Email): IO[DuplicateValueException, Unit] = + private def checkIfEmailTaken(email: Email): IO[DuplicateValueException, Unit] = for { _ <- repo - .checkUsernameOrEmailExists(email.value) - .mapError(_ => DuplicateValueException(s"Email ${email.value} already exists")) + .checkIfEmailExists(email) + .mapError(_ => DuplicateValueException(s"Email '${email.value}' already taken")) + .tap(_ => ZIO.logInfo(s"Checked if email '${email.value}' is already taken")) } yield () + /** + * Creates a new user + * + * @param username the user's username + * @param email the user's email + * @param givenName the user's givenName + * @param familyName the user's familyName + * @param password the user's password (hashed) + * @param language the user's language + * @param role the user's role + */ def createUser( username: Username, email: Email, givenName: GivenName, familyName: FamilyName, - password: Password, - language: LanguageCode + password: PasswordHash, + language: LanguageCode, + status: UserStatus //role: Role - ): IO[DuplicateValueException, UserId] = - for { - _ <- checkUsernameTaken(username) // also lock/reserve username - _ <- checkEmailTaken(email) // also lock/reserve email - user <- ZIO.succeed(User.make(givenName, familyName, username, email, password, language)) - userId <- repo.storeUser(user) // we assume that this can't fail - } yield userId + ): IO[Throwable, UserId] = + (for { + _ <- checkIfUsernameTaken(username) // TODO reserve username + _ <- checkIfEmailTaken(email) // TODO reserve email + id <- UserId.make().toZIO + user <- User.make(id, givenName, familyName, username, email, password, language, status).toZIO + userId <- repo.storeUser(user) + } yield userId).tap(userId => ZIO.logInfo(s"Created user with ID '${userId}'")) - def updateUsername(id: UserId, value: Username): IO[RequestRejectedException, UserId] = - for { - _ <- checkUsernameTaken(value) - // lock/reserve username + /** + * Updates the username of a user + * + * @param id the user's ID + * @param newValue the new username + */ + def updateUsername(id: UserId, newValue: Username): IO[RequestRejectedException, UserId] = + (for { + _ <- checkIfUsernameTaken(newValue) + // TODO reserve new username because it has to be unique + // check if user exists and get him + user <- getUserById(id) + userUpdated <- user.updateUsername(newValue).toZIO + _ <- repo.storeUser(userUpdated) + } yield id).tap(_ => ZIO.logInfo(s"Updated username with new value '${newValue.value}'")) + + /** + * Updates the email of a user + * + * @param id the user's ID + * @param newValue the new email + */ + def updateEmail(id: UserId, newValue: Email): IO[RequestRejectedException, UserId] = + (for { + // TODO reserve new email because it has to be unique + _ <- checkIfEmailTaken(newValue) + // check if user exists and get him + user <- getUserById(id) + userUpdated <- user.updateEmail(newValue).toZIO + _ <- repo.storeUser(userUpdated) + } yield id).tap(_ => ZIO.logInfo(s"Updated email with new value '${newValue.value}'")) + + /** + * Updates the given name of a user + * + * @param id the user's ID + * @param newValue the new given name + */ + def updateGivenName(id: UserId, newValue: GivenName): IO[RequestRejectedException, UserId] = + (for { + // check if user exists and get him + user <- getUserById(id) + userUpdated <- user.updateGivenName(newValue).toZIO + _ <- repo.storeUser(userUpdated) + } yield id).tap(_ => ZIO.logInfo(s"Updated givenName with new value '${newValue.value}'")) + + /** + * Updates the family name of a user + * + * @param id the user's ID + * @param newValue the new family name + */ + def updateFamilyName(id: UserId, newValue: FamilyName): IO[RequestRejectedException, UserId] = + (for { // check if user exists and get him, lock user user <- getUserById(id) - userUpdated <- ZIO.succeed(user.updateUsername(value)) + userUpdated <- user.updateFamilyName(newValue).toZIO _ <- repo.storeUser(userUpdated) - } yield id + } yield id).tap(_ => ZIO.logInfo(s"Updated familyName with new value '${newValue.value}'")) + + /** + * Updates the password of a user + * + * @param id the user's ID + * @param newPassword the new password + * @param currentPassword the user's current password + * @param requestingUser the requesting user + */ + def updatePassword( + id: UserId, + newPassword: PasswordHash, + currentPassword: PasswordHash, + requestingUser: User + ): IO[RequestRejectedException, UserId] = { + // either the user himself or a sysadmin can change a user's password + if (!requestingUser.id.equals(id)) { // TODO check role, user needs to be himself or sysadmin, i.e. sth like requestingUser.role.equals(SysAdmin) + return ZIO.fail( + ForbiddenException("User's password can only be changed by the user itself or a system administrator") + ) + } + + // check if the provided current password (of either the user or the sysadmin) is correct + if (!requestingUser.password.equals(currentPassword)) { + return ZIO.fail(ForbiddenException("The supplied password does not match the requesting user's password")) + } - def updatePassword(id: UserId, newPassword: Password, currentPassword: Password, requestingUser: User) = ??? - // either the user himself or a sysadmin can change a user's password - // in both cases we need the current password of either the user itself or the sysadmin + (for { + // check if user exists and get him, lock user + user <- getUserById(id) + userUpdated <- user.updatePassword(newPassword).toZIO + _ <- repo.storeUser(userUpdated) + } yield id).tap(_ => ZIO.logInfo(s"Updated password")) + } + /** + * Updates the username of a user + * + * @param id the user's ID + * @param newValue the new language + */ + def updateLanguage(id: UserId, newValue: LanguageCode): IO[RequestRejectedException, UserId] = + (for { + // check if user exists and get him, lock user + user <- getUserById(id) + userUpdated <- user.updateLanguage(newValue).toZIO + _ <- repo.storeUser(userUpdated) + } yield id).tap(_ => ZIO.logInfo(s"Updated language with new value '${newValue.value}'")) + + /** + * Deletes the user which means that it is marked as deleted. + * + * @param id the user's ID + */ def deleteUser(id: UserId): IO[NotFoundException, UserId] = - for { + (for { _ <- repo .deleteUser(id) - .mapError(_ => NotFoundException(s"User with ID ${id} not found")) - } yield id + .mapError(_ => NotFoundException(s"User with ID '${id}' not found")) + } yield id).tap(_ => ZIO.logInfo(s"Deleted user with ID '${id}'")) } @@ -136,6 +253,6 @@ object UserHandler { for { repo <- ZIO.service[UserRepo] } yield UserHandler(repo) - }.tap(_ => ZIO.debug(">>> User handler initialized <<<")) + }.tap(_ => ZIO.logInfo(">>> User handler initialized <<<")) } } diff --git a/dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala b/dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala index 82b7082c5b..080706b242 100644 --- a/dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala +++ b/dsp-user/handler/src/test/scala/dsp/user/handler/UserHandlerSpec.scala @@ -5,92 +5,702 @@ package dsp.user.handler +import dsp.errors.NotFoundException import dsp.user.domain.User import dsp.user.domain._ import dsp.user.repo.impl.UserRepoMock +import dsp.valueobjects.Id.UserId import dsp.valueobjects.User._ import zio.ZLayer import zio._ +import zio.test.Assertion._ import zio.test._ +import dsp.valueobjects.UserErrorMessages +import dsp.errors.DuplicateValueException +import dsp.user.sharedtestdata.SharedTestData +import dsp.errors.ForbiddenException /** * This spec is used to test [[dsp.user.handler.UserHandler]]. */ object UserHandlerSpec extends ZIOSpecDefault { - def spec = (userTests) + def spec = (getAllUsersTests + createUserTest + getUserByTest + updateUserTest + deleteUserTest) - private val givenName = GivenName.make("GivenName1").fold(e => throw e.head, v => v) - private val familyName = FamilyName.make("familyName1").fold(e => throw e.head, v => v) - private val username = Username.make("username1").fold(e => throw e.head, v => v) - private val email = Email.make("email1@email.com").fold(e => throw e.head, v => v) - private val password = Password.make("password1").fold(e => throw e.head, v => v) - private val language = LanguageCode.make("en").fold(e => throw e.head, v => v) + private val getAllUsersTests = suite("getAllUsers")( + test("return an empty map when trying to get all users but there are none") { + for { + userHandler <- ZIO.service[UserHandler] + + retrievedUsers <- userHandler.getUsers() + } yield assertTrue(retrievedUsers.size == 0) + }, + test("store several users and retrieve all") { + for { + userHandler <- ZIO.service[UserHandler] + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + + username2 <- SharedTestData.username2.toZIO + email2 <- SharedTestData.email2.toZIO + givenName2 <- SharedTestData.givenName2.toZIO + familyName2 <- SharedTestData.familyName2.toZIO + password2 <- SharedTestData.password2.toZIO + + username3 <- SharedTestData.username3.toZIO + email3 <- SharedTestData.email3.toZIO + givenName3 <- SharedTestData.givenName3.toZIO + familyName3 <- SharedTestData.familyName3.toZIO + password3 <- SharedTestData.password3.toZIO + + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + _ <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + + _ <- userHandler.createUser( + username2, + email2, + givenName2, + familyName2, + password2, + language, + status + ) + + _ <- userHandler.createUser( + username3, + email3, + givenName3, + familyName3, + password3, + language, + status + ) + + retrievedUsers <- userHandler.getUsers() + } yield assertTrue(retrievedUsers.size == 3) + } + ).provide(UserRepoMock.layer, UserHandler.layer) + + private val createUserTest = suite("createUser")( + test("return an Error when creating a user if a username is already taken") { + for { + userHandler <- ZIO.service[UserHandler] - val userTests = suite("UserHandler")( + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + + username2 <- SharedTestData.username2.toZIO + email2 <- SharedTestData.email2.toZIO + givenName2 <- SharedTestData.givenName2.toZIO + familyName2 <- SharedTestData.familyName2.toZIO + password2 <- SharedTestData.password2.toZIO + + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + _ <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + + error <- userHandler + .createUser( + username = username1, + email = email2, + givenName = givenName2, + familyName = familyName2, + password = password2, + language = language, + status = status + ) + .exit + + } yield assert(error)( + fails(equalTo(DuplicateValueException(s"Username '${username1.value}' already taken"))) + ) + }, + test("return an Error when creating a user if a email is already taken") { + for { + userHandler <- ZIO.service[UserHandler] + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + + username2 <- SharedTestData.username2.toZIO + email2 <- SharedTestData.email2.toZIO + givenName2 <- SharedTestData.givenName2.toZIO + familyName2 <- SharedTestData.familyName2.toZIO + password2 <- SharedTestData.password2.toZIO + + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + _ <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + + error <- userHandler + .createUser( + username = username2, + email = email1, + givenName = givenName2, + familyName = familyName2, + password = password2, + language = language, + status = status + ) + .exit + + } yield assert(error)( + fails(equalTo(DuplicateValueException(s"Email '${email1.value}' already taken"))) + ) + } + ).provide(UserRepoMock.layer, UserHandler.layer) + + private val getUserByTest = suite("getUserBy")( test("store a user and retrieve by ID") { for { userHandler <- ZIO.service[UserHandler] + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + userId <- userHandler.createUser( - username = username, - email = email, - givenName = givenName, - familyName = familyName, - password = password, - language = language + username1, + email1, + givenName1, + familyName1, + password1, + language, + status ) retrievedUser <- userHandler.getUserById(userId) - _ <- ZIO.debug(retrievedUser) - } yield assertTrue(retrievedUser.username == username) && - assertTrue(retrievedUser.email == email) && - assertTrue(retrievedUser.givenName == givenName) && - assertTrue(retrievedUser.familyName == familyName) && - assertTrue(retrievedUser.password == Some(password)) && - assertTrue(retrievedUser.language == language) + } yield assertTrue(retrievedUser.username == username1) && + assertTrue(retrievedUser.email == email1) && + assertTrue(retrievedUser.givenName == givenName1) && + assertTrue(retrievedUser.familyName == familyName1) && + assertTrue(retrievedUser.password == password1) && + assertTrue(retrievedUser.language == language) && + assertTrue(retrievedUser.status == status) + }, + test("return an Error if user not found by ID") { + for { + userHandler <- ZIO.service[UserHandler] + newUserId <- UserId.make().toZIO + error <- userHandler.getUserById(newUserId).exit + } yield assert(error)(fails(equalTo(NotFoundException(s"User with ID '${newUserId}' not found")))) }, test("store a user and retrieve by username") { for { userHandler <- ZIO.service[UserHandler] + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + userId <- userHandler.createUser( - username = username, - email = email, - givenName = givenName, - familyName = familyName, - password = password, - language = language + username1, + email1, + givenName1, + familyName1, + password1, + language, + status ) - retrievedUser <- userHandler.getUserByUsername(username) - } yield assertTrue(retrievedUser.username == username) && - assertTrue(retrievedUser.email == email) && - assertTrue(retrievedUser.givenName == givenName) && - assertTrue(retrievedUser.familyName == familyName) && - assertTrue(retrievedUser.password == Some(password)) && - assertTrue(retrievedUser.language == language) + retrievedUser <- userHandler.getUserByUsername(username1) + } yield assertTrue(retrievedUser.username == username1) && + assertTrue(retrievedUser.email == email1) && + assertTrue(retrievedUser.givenName == givenName1) && + assertTrue(retrievedUser.familyName == familyName1) && + assertTrue(retrievedUser.password == password1) && + assertTrue(retrievedUser.language == language) && + assertTrue(retrievedUser.status == status) + }, + test("return an Error if user not found by username") { + val username = Username.make("usernameThatDoesNotExist").fold(e => throw e.head, v => v) + for { + userHandler <- ZIO.service[UserHandler] + error <- userHandler.getUserByUsername(username).exit + } yield assert(error)(fails(equalTo(NotFoundException(s"User with Username '${username.value}' not found")))) }, test("store a user and retrieve by email") { for { userHandler <- ZIO.service[UserHandler] + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + userId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + + retrievedUser <- userHandler.getUserByEmail(email1) + } yield assertTrue(retrievedUser.username == username1) && + assertTrue(retrievedUser.email == email1) && + assertTrue(retrievedUser.givenName == givenName1) && + assertTrue(retrievedUser.familyName == familyName1) && + assertTrue(retrievedUser.password == password1) && + assertTrue(retrievedUser.language == language) && + assertTrue(retrievedUser.status == status) + }, + test("return an Error if user not found by email") { + + val email = Email.make("emailThat@DoesNotExi.st").fold(e => throw e.head, v => v) + for { + userHandler <- ZIO.service[UserHandler] + error <- userHandler.getUserByEmail(email).exit + } yield assert(error)(fails(equalTo(NotFoundException(s"User with Email '${email.value}' not found")))) + } + ).provide(UserRepoMock.layer, UserHandler.layer) + + private val updateUserTest = suite("updateUser")( + test("store a user and update the username") { + for { + userHandler <- ZIO.service[UserHandler] + + newValue <- Username.make("newUsername").toZIO + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + userId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + idOfUpdatedUser <- userHandler.updateUsername(userId, newValue) + retrievedUser <- userHandler.getUserById(userId) + } yield assertTrue(retrievedUser.username == newValue) && + assertTrue(retrievedUser.username != username1) && + assertTrue(retrievedUser.email == email1) && + assertTrue(retrievedUser.givenName == givenName1) && + assertTrue(retrievedUser.familyName == familyName1) && + assertTrue(retrievedUser.password == password1) && + assertTrue(retrievedUser.language == language) && + assertTrue(retrievedUser.status == status) + }, + test("return an error if the username is taken when trying to update the username") { + for { + userHandler <- ZIO.service[UserHandler] + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + userId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + + error <- userHandler.updateUsername(userId, username1).exit + } yield assert(error)( + fails(equalTo(DuplicateValueException(s"Username '${username1.value}' already taken"))) + ) + }, + test("store a user and update the email") { + for { + userHandler <- ZIO.service[UserHandler] + + newValue <- Email.make("new.mail1@email.com").toZIO + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + userId <- userHandler.createUser( - username = username, - email = email, - givenName = givenName, - familyName = familyName, - password = password, - language = language + username1, + email1, + givenName1, + familyName1, + password1, + language, + status ) - retrievedUser <- userHandler.getUserByEmail(email) - } yield assertTrue(retrievedUser.username == username) && - assertTrue(retrievedUser.email == email) && - assertTrue(retrievedUser.givenName == givenName) && - assertTrue(retrievedUser.familyName == familyName) && - assertTrue(retrievedUser.password == Some(password)) && - assertTrue(retrievedUser.language == language) + idOfUpdatedUser <- userHandler.updateEmail(userId, newValue) + retrievedUser <- userHandler.getUserById(userId) + } yield assertTrue(retrievedUser.email == newValue) && + assertTrue(retrievedUser.username == username1) && + assertTrue(retrievedUser.email != email1) && + assertTrue(retrievedUser.givenName == givenName1) && + assertTrue(retrievedUser.familyName == familyName1) && + assertTrue(retrievedUser.password == password1) && + assertTrue(retrievedUser.language == language) && + assertTrue(retrievedUser.status == status) + }, + test("return an error if the email is taken when trying to update the email") { + for { + userHandler <- ZIO.service[UserHandler] + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + userId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + + error <- userHandler.updateEmail(userId, email1).exit + } yield assert(error)( + fails(equalTo(DuplicateValueException(s"Email '${email1.value}' already taken"))) + ) + }, + test("store a user and update the givenName") { + for { + userHandler <- ZIO.service[UserHandler] + + newValue <- GivenName.make("newGivenName").toZIO + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + userId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + + idOfUpdatedUser <- userHandler.updateGivenName(userId, newValue) + retrievedUser <- userHandler.getUserById(userId) + } yield assertTrue(retrievedUser.givenName == newValue) && + assertTrue(retrievedUser.username == username1) && + assertTrue(retrievedUser.email == email1) && + assertTrue(retrievedUser.givenName != givenName1) && + assertTrue(retrievedUser.familyName == familyName1) && + assertTrue(retrievedUser.password == password1) && + assertTrue(retrievedUser.language == language) && + assertTrue(retrievedUser.status == status) + }, + test("store a user and update the familyName") { + for { + userHandler <- ZIO.service[UserHandler] + + newValue <- FamilyName.make("newFamilyName").toZIO + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + userId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + + idOfUpdatedUser <- userHandler.updateFamilyName(userId, newValue) + retrievedUser <- userHandler.getUserById(userId) + } yield assertTrue(retrievedUser.familyName == newValue) && + assertTrue(retrievedUser.username == username1) && + assertTrue(retrievedUser.email == email1) && + assertTrue(retrievedUser.givenName == givenName1) && + assertTrue(retrievedUser.familyName != familyName1) && + assertTrue(retrievedUser.password == password1) && + assertTrue(retrievedUser.language == language) && + assertTrue(retrievedUser.status == status) + }, + test("store a user and update the password") { + for { + userHandler <- ZIO.service[UserHandler] + + passwordStrength <- PasswordStrength.make(12).toZIO + newValue <- PasswordHash.make("newPassword1", passwordStrength).toZIO + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + userId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + storedUser <- userHandler.getUserById(userId) + idOfUpdatedUser <- userHandler.updatePassword(userId, newValue, storedUser.password, storedUser) + retrievedUser <- userHandler.getUserById(userId) + } yield assertTrue(retrievedUser.password == newValue) && + assertTrue(retrievedUser.username == username1) && + assertTrue(retrievedUser.email == email1) && + assertTrue(retrievedUser.givenName == givenName1) && + assertTrue(retrievedUser.familyName == familyName1) && + assertTrue(retrievedUser.password != password1) && + assertTrue(retrievedUser.language == language) && + assertTrue(retrievedUser.status == status) + }, + test( + "return an error when the supplied password does not match the requesting user's password when trying to update the password" + ) { + for { + userHandler <- ZIO.service[UserHandler] + + passwordStrength <- PasswordStrength.make(12).toZIO + newValue <- PasswordHash.make("newPassword1", passwordStrength).toZIO + wrongPassword <- PasswordHash.make("wrongPassword1", passwordStrength).toZIO + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + userId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + storedUser <- userHandler.getUserById(userId) + error <- userHandler.updatePassword(userId, newValue, wrongPassword, storedUser).exit + } yield assert(error)( + fails(equalTo(ForbiddenException("The supplied password does not match the requesting user's password"))) + ) + }, + test( + "return an error when the requesting user is not the user whose password is asked to be changed when trying to update the password" + ) { + for { + userHandler <- ZIO.service[UserHandler] + + passwordStrength <- PasswordStrength.make(12).toZIO + newValue <- PasswordHash.make("newPassword1", passwordStrength).toZIO + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + userId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + + storedUser <- userHandler.getUserById(userId) + otherUser <- SharedTestData.user2.toZIO + error <- userHandler.updatePassword(userId, newValue, storedUser.password, otherUser).exit + } yield assert(error)( + fails( + equalTo( + ForbiddenException("User's password can only be changed by the user itself or a system administrator") + ) + ) + ) + }, + test("store a user and update the language") { + for { + userHandler <- ZIO.service[UserHandler] + + newValue <- LanguageCode.make("fr").toZIO + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + userId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + + idOfUpdatedUser <- userHandler.updateLanguage(userId, newValue) + retrievedUser <- userHandler.getUserById(userId) + } yield assertTrue(retrievedUser.language == newValue) && + assertTrue(retrievedUser.username == username1) && + assertTrue(retrievedUser.email == email1) && + assertTrue(retrievedUser.givenName == givenName1) && + assertTrue(retrievedUser.familyName == familyName1) && + assertTrue(retrievedUser.password == password1) && + assertTrue(retrievedUser.language != language) && + assertTrue(retrievedUser.status == status) + } + ).provide(UserRepoMock.layer, UserHandler.layer) + + private val deleteUserTest = suite("deleteUser")( + test("delete a user") { + for { + userHandler <- ZIO.service[UserHandler] + + username1 <- SharedTestData.username1.toZIO + email1 <- SharedTestData.email1.toZIO + givenName1 <- SharedTestData.givenName1.toZIO + familyName1 <- SharedTestData.familyName1.toZIO + password1 <- SharedTestData.password1.toZIO + language <- SharedTestData.languageEn.toZIO + status <- SharedTestData.statusTrue.toZIO + + userId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + + id <- userHandler.deleteUser(userId) + idNotFound <- userHandler.getUserById(userId).exit + usernameNotFound <- userHandler.getUserByUsername(username1).exit + emailNotFound <- userHandler.getUserByEmail(email1).exit + + // create new user with same values + + newUserId <- userHandler.createUser( + username1, + email1, + givenName1, + familyName1, + password1, + language, + status + ) + } yield assertTrue(id == userId) && + assertTrue(userId != newUserId) && + assert(idNotFound)(fails(equalTo(NotFoundException(s"User with ID '${userId}' not found")))) && + assert(usernameNotFound)( + fails(equalTo(NotFoundException(s"User with Username '${username1.value}' not found"))) + ) && + assert(emailNotFound)( + fails(equalTo(NotFoundException(s"User with Email '${email1.value}' not found"))) + ) + }, + test("return an error if the ID of a user is not found when trying to delete the user") { + for { + userHandler <- ZIO.service[UserHandler] + userId <- UserId.make().toZIO + error <- userHandler.deleteUser(userId).exit + } yield assert(error)( + fails(equalTo(NotFoundException(s"User with ID '${userId}' not found"))) + ) } ).provide(UserRepoMock.layer, UserHandler.layer) } diff --git a/dsp-user/interface/src/main/scala/dsp/user/listener/external/UserListenerExternal.scala b/dsp-user/interface/src/main/scala/dsp/user/listener/external/UserListenerExternal.scala index 55d99ee7c8..707ab682b1 100644 --- a/dsp-user/interface/src/main/scala/dsp/user/listener/external/UserListenerExternal.scala +++ b/dsp-user/interface/src/main/scala/dsp/user/listener/external/UserListenerExternal.scala @@ -4,3 +4,7 @@ */ package dsp.user.listener.external + +// this placeholder can be removed as soon as the code are there +// without it there is a warning +object placeholder {} diff --git a/dsp-user/interface/src/main/scala/dsp/user/listener/internal/UserListenerInternal.scala b/dsp-user/interface/src/main/scala/dsp/user/listener/internal/UserListenerInternal.scala index 9aa3296287..6459b362ab 100644 --- a/dsp-user/interface/src/main/scala/dsp/user/listener/internal/UserListenerInternal.scala +++ b/dsp-user/interface/src/main/scala/dsp/user/listener/internal/UserListenerInternal.scala @@ -4,3 +4,7 @@ */ package dsp.user.listener.internal + +// this placeholder can be removed as soon as the code are there +// without it there is a warning +object placeholder {} diff --git a/dsp-user/interface/src/main/scala/dsp/user/route/UserRoute.scala b/dsp-user/interface/src/main/scala/dsp/user/route/UserRoute.scala index 631ae35b69..3db4a717f2 100644 --- a/dsp-user/interface/src/main/scala/dsp/user/route/UserRoute.scala +++ b/dsp-user/interface/src/main/scala/dsp/user/route/UserRoute.scala @@ -4,3 +4,7 @@ */ package dsp.user.route + +// this placeholder can be removed as soon as the code are there +// without it there is a warning +object placeholder {} diff --git a/dsp-user/interface/src/test/scala/dsp/user/listener/external/UserListenerExternalSpec.scala b/dsp-user/interface/src/test/scala/dsp/user/listener/external/UserListenerExternalSpec.scala index 55d99ee7c8..707ab682b1 100644 --- a/dsp-user/interface/src/test/scala/dsp/user/listener/external/UserListenerExternalSpec.scala +++ b/dsp-user/interface/src/test/scala/dsp/user/listener/external/UserListenerExternalSpec.scala @@ -4,3 +4,7 @@ */ package dsp.user.listener.external + +// this placeholder can be removed as soon as the code are there +// without it there is a warning +object placeholder {} diff --git a/dsp-user/interface/src/test/scala/dsp/user/listener/internal/UserListenerInternalSpec.scala b/dsp-user/interface/src/test/scala/dsp/user/listener/internal/UserListenerInternalSpec.scala index 9aa3296287..6459b362ab 100644 --- a/dsp-user/interface/src/test/scala/dsp/user/listener/internal/UserListenerInternalSpec.scala +++ b/dsp-user/interface/src/test/scala/dsp/user/listener/internal/UserListenerInternalSpec.scala @@ -4,3 +4,7 @@ */ package dsp.user.listener.internal + +// this placeholder can be removed as soon as the code are there +// without it there is a warning +object placeholder {} diff --git a/dsp-user/interface/src/test/scala/dsp/user/route/UserRouteSpec.scala b/dsp-user/interface/src/test/scala/dsp/user/route/UserRouteSpec.scala index 631ae35b69..3db4a717f2 100644 --- a/dsp-user/interface/src/test/scala/dsp/user/route/UserRouteSpec.scala +++ b/dsp-user/interface/src/test/scala/dsp/user/route/UserRouteSpec.scala @@ -4,3 +4,7 @@ */ package dsp.user.route + +// this placeholder can be removed as soon as the code are there +// without it there is a warning +object placeholder {} diff --git a/dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala b/dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala index d64ed9e860..2b1042f846 100644 --- a/dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala +++ b/dsp-user/repo/src/main/scala/dsp/user/repo/impl/UserRepoLive.scala @@ -5,83 +5,118 @@ package dsp.user.repo.impl -import dsp.errors.NotFoundException import dsp.user.api.UserRepo -import dsp.user.domain.Iri import dsp.user.domain.User -import dsp.user.domain.UserId import zio._ import zio.stm.TMap import java.util.UUID +import dsp.valueobjects.Id.UserId +import dsp.valueobjects.User._ /** * User repository live implementation * * @param users a map of users (UUID -> User). - * @param lookupTable a map of username/email to UUID. + * @param lookupTableUsernameToUuid a map of users (Username -> UUID). + * @param lookupTableEmailToUuid a map of users (Email -> UUID). */ final case class UserRepoLive( users: TMap[UUID, User], - lookupTable: TMap[String, UUID] // sealed trait for key type + lookupTableUsernameToUuid: TMap[Username, UUID], + lookupTableEmailToUuid: TMap[Email, UUID] ) extends UserRepo { /** * @inheritDoc * * Stores the user with key UUID in the users map. - * Stores the username and email with the associated UUID in the lookup table. + * Stores the username and email with the associated UUID in the lookup tables. */ def storeUser(user: User): UIO[UserId] = (for { _ <- users.put(user.id.uuid, user) - _ <- lookupTable.put(user.username.value, user.id.uuid) - _ <- lookupTable.put(user.email.value, user.id.uuid) - } yield user.id).commit.tap(_ => ZIO.logDebug(s"Stored user: ${user.id}")) + _ <- lookupTableUsernameToUuid.put(user.username, user.id.uuid) + _ <- lookupTableEmailToUuid.put(user.email, user.id.uuid) + } yield user.id).commit.tap(_ => ZIO.logInfo(s"Stored user: ${user.id.uuid}")) /** * @inheritDoc */ - def getUsers(): UIO[List[User]] = users.values.commit + def getUsers(): UIO[List[User]] = + users.values.commit.tap(userList => ZIO.logInfo(s"Looked up all users, found ${userList.size}")) /** * @inheritDoc */ def getUserById(id: UserId): IO[Option[Nothing], User] = + users + .get(id.uuid) + .commit + .some + .tapBoth( + _ => ZIO.logInfo(s"Couldn't find user with UUID '${id.uuid}'"), + _ => ZIO.logInfo(s"Looked up user by UUID '${id.uuid}'") + ) + + /** + * @inheritDoc + */ + def getUserByUsername(username: Username): IO[Option[Nothing], User] = + (for { + iri: UUID <- lookupTableUsernameToUuid.get(username).some + user: User <- users.get(iri).some + } yield user).commit.tapBoth( + _ => ZIO.logInfo(s"Couldn't find user with username '${username.value}'"), + _ => ZIO.logInfo(s"Looked up user by username '${username.value}'") + ) + + /** + * @inheritDoc + */ + def getUserByEmail(email: Email): IO[Option[Nothing], User] = (for { - user <- users.get(id.uuid).some - } yield user).commit.tap(_ => ZIO.logDebug(s"Found user by ID: ${id}")) + iri: UUID <- lookupTableEmailToUuid.get(email).some + user: User <- users.get(iri).some + } yield user).commit.tapBoth( + _ => ZIO.logInfo(s"Couldn't find user with email '${email.value}'"), + _ => ZIO.logInfo(s"Looked up user by email '${email.value}'") + ) /** * @inheritDoc */ - def getUserByUsernameOrEmail(usernameOrEmail: String): IO[Option[Nothing], User] = + def checkIfUsernameExists(username: Username): IO[Option[Nothing], Unit] = (for { - iri <- lookupTable.get(usernameOrEmail).some - user <- users.get(iri).some - } yield user).commit.tap(_ => ZIO.logDebug(s"Found user by username or email: ${usernameOrEmail}")) + usernameExists <- lookupTableUsernameToUuid.contains(username).commit + _ <- usernameExists match { + case false => ZIO.succeed(()) // username does not exist + case true => ZIO.fail(None) // username does exist + } + } yield ()).tap(_ => ZIO.logInfo(s"Username '${username.value}' was checked")) /** * @inheritDoc */ - def checkUsernameOrEmailExists(usernameOrEmail: String): IO[Option[Nothing], Unit] = + def checkIfEmailExists(email: Email): IO[Option[Nothing], Unit] = (for { - iriOption: Option[UUID] <- lookupTable.get(usernameOrEmail) - _ = iriOption match { - case None => ZIO.succeed(()) // username or email does not exist - case Some(_) => ZIO.fail(None) // username or email does exist - } - } yield ()).commit.tap(_ => ZIO.logInfo(s"Username/email '${usernameOrEmail}' was checked")) + emailExists <- lookupTableEmailToUuid.contains(email).commit + _ <- emailExists match { + case false => ZIO.succeed(()) // email does not exist + case true => ZIO.fail(None) // email does exist + } + } yield ()).tap(_ => ZIO.logInfo(s"Email '${email.value}' was checked")) /** * @inheritDoc */ def deleteUser(id: UserId): IO[Option[Nothing], UserId] = (for { - user <- users.get(id.uuid).some - _ <- users.delete(id.uuid) // removes the values (User) for the key (UUID) - _ <- lookupTable.delete(user.username.value) // remove the user also from the lookup table - } yield id).commit.tap(_ => ZIO.logDebug(s"Deleted user: ${id}")) + user: User <- users.get(id.uuid).some + _ <- users.delete(id.uuid) // removes the values (User) for the key (UUID) + _ <- lookupTableUsernameToUuid.delete(user.username) // remove the user also from the lookup table + _ <- lookupTableEmailToUuid.delete(user.email) // remove the user also from the lookup table + } yield id).commit.tap(_ => ZIO.logInfo(s"Deleted user: ${id}")) } /** @@ -92,8 +127,9 @@ object UserRepoLive { ZLayer { for { users <- TMap.empty[UUID, User].commit - lookupTable <- TMap.empty[String, UUID].commit - } yield UserRepoLive(users, lookupTable) - }.tap(_ => ZIO.debug(">>> User repository initialized <<<")) + lutUsername <- TMap.empty[Username, UUID].commit + lutEmail <- TMap.empty[Email, UUID].commit + } yield UserRepoLive(users, lutUsername, lutEmail) + }.tap(_ => ZIO.logInfo(">>> User repository initialized <<<")) } } diff --git a/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoImplSpec.scala b/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoImplSpec.scala index b4e43dbe59..870791a948 100644 --- a/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoImplSpec.scala +++ b/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoImplSpec.scala @@ -15,6 +15,9 @@ import zio._ import zio.prelude.Validation import zio.prelude.ZValidation import zio.test._ +import zio.test.Assertion._ +import dsp.errors.DuplicateValueException +import dsp.user.sharedtestdata.SharedTestData /** * This spec is used to test all [[dsp.user.repo.UserRepo]] implementations. @@ -23,65 +26,94 @@ object UserRepoImplSpec extends ZIOSpecDefault { def spec = (userRepoMockTests + userRepoLiveTests) - private val testUser1 = (for { - givenName <- GivenName.make("GivenName1") - familyName <- FamilyName.make("familyName1") - username <- Username.make("username1") - email <- Email.make("email1@email.com") - password <- Password.make("password1") - language <- LanguageCode.make("en") - user = User.make( - givenName, - familyName, - username, - email, - password, - language - ) - } yield (user)).toZIO - - private val testUser2 = (for { - givenName <- GivenName.make("GivenName2") - familyName <- FamilyName.make("familyName2") - username <- Username.make("username2") - email <- Email.make("email2@email.com") - password <- Password.make("password2") - language <- LanguageCode.make("en") - user = User.make( - givenName, - familyName, - username, - email, - password, - language - ) - } yield (user)).toZIO + private val user1 = SharedTestData.user1 + private val user2 = SharedTestData.user2 val userTests = - test("store a user and retrieve by ID") { + test("store several users and retrieve all") { for { - user <- testUser1 - _ <- UserRepo.storeUser(user) - retrievedUser <- UserRepo.getUserById(user.id) - } yield assertTrue(retrievedUser == user) + user1 <- user1.toZIO + user2 <- user2.toZIO + _ <- UserRepo.storeUser(user1) + _ <- UserRepo.storeUser(user2) + retrievedUsers <- UserRepo.getUsers() + } yield assertTrue(retrievedUsers.size == 2) } + - test("retrieve the user by username") { + test("store a user and retrieve by ID") { + for { + user1 <- user1.toZIO + _ <- UserRepo.storeUser(user1) + retrievedUser <- UserRepo.getUserById(user1.id) + } yield assertTrue(retrievedUser == user1) + } + + test("store a user and retrieve the user by username") { for { - user <- testUser1 - _ <- UserRepo.storeUser(user) - retrievedUser <- UserRepo.getUserByUsernameOrEmail(user.username.value) - } yield assertTrue(retrievedUser == user) + user1 <- user1.toZIO + _ <- UserRepo.storeUser(user1) + retrievedUser <- UserRepo.getUserByUsername(user1.username) + } yield assertTrue(retrievedUser == user1) } + - test("retrieve the user by email") { + test("store a user and retrieve the user by email") { for { - user1 <- testUser1 - user2 <- testUser2 + user1 <- user1.toZIO + user2 <- user2.toZIO _ <- UserRepo.storeUser(user1) - retrievedUser <- UserRepo.getUserByUsernameOrEmail(user1.email.value) + retrievedUser <- UserRepo.getUserByEmail(user1.email) } yield { assertTrue(retrievedUser == user1) && assertTrue(retrievedUser != user2) } + } + + test("return failure (None) if a username already exists") { + for { + user1 <- user1.toZIO + _ <- UserRepo.storeUser(user1) + error <- UserRepo.checkIfUsernameExists(user1.username).exit + } yield assert(error)( + fails(equalTo(None)) + ) + } + + test("return success (Unit) if a username is unique") { + for { + user1 <- user1.toZIO + user2 <- user2.toZIO + _ <- UserRepo.storeUser(user1) + result <- UserRepo.checkIfUsernameExists(user2.username) + } yield assertTrue(result == ()) + } + + test("return failure (None) if an email already exists") { + for { + user1 <- user1.toZIO + _ <- UserRepo.storeUser(user1) + error <- UserRepo.checkIfEmailExists(user1.email).exit + } yield assert(error)( + fails(equalTo(None)) + ) + } + + test("return success (Unit) if an email is unique") { + for { + user1 <- user1.toZIO + user2 <- user2.toZIO + _ <- UserRepo.storeUser(user1) + result <- UserRepo.checkIfEmailExists(user2.email) + } yield assertTrue(result == ()) + } + + test("store and delete a user") { + for { + user1 <- user1.toZIO + userId <- UserRepo.storeUser(user1) + userIdOfDeletedUser <- UserRepo.deleteUser(userId) + idIsDeleted <- UserRepo.getUserById(userIdOfDeletedUser).exit + usernameIsDeleted <- UserRepo.getUserByUsername(user1.username).exit + emailIsDeleted <- UserRepo.getUserByEmail(user1.email).exit + usernameIsDeletedFromLookupTable <- UserRepo.checkIfUsernameExists(user1.username) + emailIsDeletedFromLookupTable <- UserRepo.checkIfEmailExists(user1.email) + } yield assertTrue(userId == user1.id) && + assert(idIsDeleted)(fails(equalTo(None))) && + assert(usernameIsDeleted)(fails(equalTo(None))) && + assert(emailIsDeleted)(fails(equalTo(None))) && + assertTrue(usernameIsDeletedFromLookupTable == ()) && + assertTrue(emailIsDeletedFromLookupTable == ()) } val userRepoMockTests = suite("UserRepoMock")( diff --git a/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala b/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala index c095f373af..a3b6ee5b39 100644 --- a/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala +++ b/dsp-user/repo/src/test/scala/dsp/user/repo/impl/UserRepoMock.scala @@ -6,43 +6,45 @@ package dsp.user.repo.impl import dsp.user.api.UserRepo -import dsp.user.domain.Iri import dsp.user.domain.User -import dsp.user.domain.UserId import zio._ import zio.stm.TMap import java.util.UUID +import dsp.valueobjects.Id.UserId +import dsp.valueobjects.User._ /** * User repo test implementation. Mocks the user repo for tests. * * @param users a map of users (UUID -> User). - * @param lookupTable a map of users (username/email -> UUID). + * @param lookupTableUsernameUuid a map of users (Username -> UUID). + * @param lookupTableEmailUuid a map of users (Email -> UUID). */ final case class UserRepoMock( users: TMap[UUID, User], - lookupTable: TMap[String, UUID] // sealed trait for key type + lookupTableUsernameToUuid: TMap[Username, UUID], + lookupTableEmailToUuid: TMap[Email, UUID] ) extends UserRepo { /** * @inheritDoc * * Stores the user with key UUID in the users map. - * Stores the username and email with the associated UUID in the lookup table. + * Stores the username and email with the associated UUID in the lookup tables. */ def storeUser(user: User): UIO[UserId] = (for { _ <- users.put(user.id.uuid, user) - _ <- lookupTable.put(user.username.value, user.id.uuid) - _ <- lookupTable.put(user.email.value, user.id.uuid) - } yield user.id).commit.tap(_ => ZIO.logInfo(s"Stored user: ${user.id.uuid}")) + _ <- lookupTableUsernameToUuid.put(user.username, user.id.uuid) + _ <- lookupTableEmailToUuid.put(user.email, user.id.uuid) + } yield user.id).commit.tap(_ => ZIO.logInfo(s"Stored user with ID '${user.id.uuid}'")) /** * @inheritDoc */ def getUsers(): UIO[List[User]] = - users.values.commit.tap(userList => ZIO.logInfo(s"Looked up all users, found ${userList.size}")) + users.values.commit.tap(userList => ZIO.logInfo(s"Looked up all users, found ${userList.size} users")) /** * @inheritDoc @@ -60,36 +62,66 @@ final case class UserRepoMock( /** * @inheritDoc */ - def getUserByUsernameOrEmail(usernameOrEmail: String): IO[Option[Nothing], User] = + def getUserByUsername(username: Username): IO[Option[Nothing], User] = (for { - iri: UUID <- lookupTable.get(usernameOrEmail).some + iri: UUID <- lookupTableUsernameToUuid.get(username).some user: User <- users.get(iri).some } yield user).commit.tapBoth( - _ => ZIO.logInfo(s"Couldn't find user with username/email '${usernameOrEmail}'"), - _ => ZIO.logInfo(s"Looked up user by username/email '${usernameOrEmail}'") + _ => ZIO.logInfo(s"Couldn't find user with username '${username.value}'"), + _ => ZIO.logInfo(s"Looked up user by username '${username.value}'") ) /** * @inheritDoc */ - def checkUsernameOrEmailExists(usernameOrEmail: String): IO[Option[Nothing], Unit] = + def getUserByEmail(email: Email): IO[Option[Nothing], User] = (for { - iriOption: Option[UUID] <- lookupTable.get(usernameOrEmail) - _ = iriOption match { - case None => ZIO.succeed(()) // username or email does not exist - case Some(_) => ZIO.fail(None) // username or email does exist - } - } yield ()).commit.tap(_ => ZIO.logInfo(s"Username/email '${usernameOrEmail}' was checked")) + iri: UUID <- lookupTableEmailToUuid.get(email).some + user: User <- users.get(iri).some + } yield user).commit.tapBoth( + _ => ZIO.logInfo(s"Couldn't find user with email '${email.value}'"), + _ => ZIO.logInfo(s"Looked up user by email '${email.value}'") + ) + + /** + * @inheritDoc + */ + def checkIfUsernameExists(username: Username): IO[Option[Nothing], Unit] = + (for { + usernameExists <- lookupTableUsernameToUuid.contains(username).commit + _ <- usernameExists match { + case false => ZIO.succeed(()) // username does not exist + case true => ZIO.fail(None) // username does exist + } + } yield ()).tap(_ => ZIO.logInfo(s"Username '${username.value}' was checked")) /** * @inheritDoc */ - def deleteUser(id: UserId): IO[Option[Nothing], UserId] = + def checkIfEmailExists(email: Email): IO[Option[Nothing], Unit] = + (for { + emailExists <- lookupTableEmailToUuid.contains(email).commit + _ <- emailExists match { + case false => ZIO.succeed(()) // email does not exist + case true => ZIO.fail(None) // email does exist + } + } yield ()).tap(_ => ZIO.logInfo(s"Email '${email.value}' was checked")) + + /** + * @inheritDoc + */ + def deleteUser(id: UserId): IO[Option[Nothing], UserId] = { + val userStatusFalse = UserStatus.make(false).fold(e => throw e.head, v => v) + (for { user: User <- users.get(id.uuid).some - _ <- users.delete(id.uuid) // removes the values (User) for the key (UUID) - _ <- lookupTable.delete(user.username.value) // remove the user also from the lookup table - } yield id).commit.tap(_ => ZIO.logDebug(s"Deleted user: ${id}")) + //_ <- ZIO.succeed(user.updateStatus(userStatusFalse)) TODO how do we deal deleted users? + _ <- users.delete(id.uuid) // removes the values (User) for the key (UUID) + _ <- lookupTableUsernameToUuid.delete(user.username) // remove the user also from the lookup table + _ <- lookupTableEmailToUuid.delete(user.email) // remove the user also from the lookup table + } yield id).commit.tap(_ => ZIO.logInfo(s"Deleted user: ${id}")) + } + } /** @@ -99,9 +131,10 @@ object UserRepoMock { val layer: ZLayer[Any, Nothing, UserRepo] = { ZLayer { for { - users <- TMap.empty[UUID, User].commit - lut <- TMap.empty[String, UUID].commit - } yield UserRepoMock(users, lut) - }.tap(_ => ZIO.debug(">>> In-memory user repository initialized <<<")) + users <- TMap.empty[UUID, User].commit + lutUsername <- TMap.empty[Username, UUID].commit + lutEmail <- TMap.empty[Email, UUID].commit + } yield UserRepoMock(users, lutUsername, lutEmail) + }.tap(_ => ZIO.logInfo(">>> In-memory user repository initialized <<<")) } } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index a9af56d0bf..0e5a6e9f5d 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -57,6 +57,7 @@ object Dependencies { // logging val logbackClassic = "ch.qos.logback" % "logback-classic" % "1.2.11" val scalaLogging = "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4" // Scala 3 compatible + val slf4j = "org.slf4j" % "slf4j-simple" % "1.7.36" // Metrics val aspectjweaver = "org.aspectj" % "aspectjweaver" % "1.9.4" @@ -187,39 +188,49 @@ object Dependencies { val schemaRepoEventStoreServiceLibraryDependencies = Seq() val schemaRepoSearchServiceLibraryDependencies = Seq() - // user project dependencies + // user slice dependencies val userInterfaceLibraryDependencies = Seq( zio, zioMacros, zioTest % Test, - zioTestSbt % Test + zioTestSbt % Test, + slf4j % Test ) val userHandlerLibraryDependencies = Seq( + springSecurityCore, + bouncyCastle, zio, zioMacros, zioTest % Test, - zioTestSbt % Test + zioTestSbt % Test, + slf4j % Test ) val userCoreLibraryDependencies = Seq( + springSecurityCore, + bouncyCastle, zio, zioMacros, zioTest % Test, - zioTestSbt % Test + zioTestSbt % Test, + slf4j % Test ) val userRepoLibraryDependencies = Seq( zio, zioMacros, zioTest % Test, - zioTestSbt % Test + zioTestSbt % Test, + slf4j % Test + ) + val sharedLibraryDependencies = Seq( + springSecurityCore, + bouncyCastle, + commonsLang3, + commonsValidator, + gwtServlet, + zioPrelude, + scalaLogging, + zioTest % Test, + zioTestSbt % Test, + slf4j % Test ) - val sharedLibraryDependencies = - Seq( - commonsLang3, - commonsValidator, - gwtServlet, - zioPrelude, - scalaLogging, - zioTest % Test, - zioTestSbt % Test - ) } diff --git a/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala b/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala index 811f7a7b53..84ae4d2280 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala @@ -117,7 +117,10 @@ class UsersRouteADM(routeData: KnoraRouteData) extends KnoraRoute(routeData) wit post { entity(as[CreateUserApiRequestADM]) { apiRequest => requestContext => // get all values from request and make value objects from it - val id: Validation[Throwable, Option[UserIri]] = UserIri.make(apiRequest.id) + val iri: Validation[Throwable, Option[UserIri]] = apiRequest.id match { + case Some(iri) => UserIri.make(iri).map(Some(_)) + case None => Validation.succeed(None) + } val username: Validation[Throwable, Username] = Username.make(apiRequest.username) val email: Validation[Throwable, Email] = Email.make(apiRequest.email) val givenName: Validation[Throwable, GivenName] = GivenName.make(apiRequest.givenName) @@ -127,9 +130,10 @@ class UsersRouteADM(routeData: KnoraRouteData) extends KnoraRoute(routeData) wit val languageCode: Validation[Throwable, LanguageCode] = LanguageCode.make(apiRequest.lang) val systemAdmin: Validation[Throwable, SystemAdmin] = SystemAdmin.make(apiRequest.systemAdmin) + // TODO try this out with ZIO.collectAllPar (https://zio.github.io/zio-prelude/docs/functionaldatatypes/validation#accumulating-errors) val validatedUserCreatePayload: Validation[Throwable, UserCreatePayloadADM] = Validation.validateWith( - id, + iri, username, email, givenName, @@ -258,13 +262,30 @@ class UsersRouteADM(routeData: KnoraRouteData) extends KnoraRoute(routeData) wit throw BadRequestException("Changes to built-in users are not allowed.") } - val maybeUsername: Option[Username] = Username.make(apiRequest.username).fold(e => throw e.head, v => v) - val maybeEmail: Option[Email] = Email.make(apiRequest.email).fold(e => throw e.head, v => v) - val maybeGivenName: Option[GivenName] = GivenName.make(apiRequest.givenName).fold(e => throw e.head, v => v) - val maybeFamilyName: Option[FamilyName] = - FamilyName.make(apiRequest.familyName).fold(e => throw e.head, v => v) - val maybeLanguageCode: Option[LanguageCode] = - LanguageCode.make(apiRequest.lang).fold(e => throw e.head, v => v) + val maybeUsername: Option[Username] = apiRequest.username match { + case Some(username) => Username.make(username).fold(e => throw e.head, v => Some(v)) + case None => None + } + + val maybeEmail: Option[Email] = apiRequest.email match { + case Some(email) => Email.make(email).fold(e => throw e.head, v => Some(v)) + case None => None + } + + val maybeGivenName: Option[GivenName] = apiRequest.givenName match { + case Some(givenName) => GivenName.make(givenName).fold(e => throw e.head, v => Some(v)) + case None => None + } + + val maybeFamilyName: Option[FamilyName] = apiRequest.familyName match { + case Some(familyName) => FamilyName.make(familyName).fold(e => throw e.head, v => Some(v)) + case None => None + } + + val maybeLanguageCode: Option[LanguageCode] = apiRequest.lang match { + case Some(lang) => LanguageCode.make(lang).fold(e => throw e.head, v => Some(v)) + case None => None + } val userUpdatePayload: UserUpdateBasicInformationPayloadADM = UserUpdateBasicInformationPayloadADM(