diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt index 216d0d80b..124d263f1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt @@ -7,6 +7,7 @@ import jakarta.validation.Valid import org.gotson.komga.domain.model.AgeRestriction import org.gotson.komga.domain.model.ContentRestrictions import org.gotson.komga.domain.model.DuplicateNameException +import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.UserEmailAlreadyExistsException import org.gotson.komga.domain.model.UserRoles import org.gotson.komga.domain.persistence.AuthenticationActivityRepository @@ -16,6 +17,7 @@ import org.gotson.komga.domain.service.KomgaUserLifecycle import org.gotson.komga.infrastructure.jooq.UnpagedSorted import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.rest.dto.AllowExcludeDto import org.gotson.komga.interfaces.api.rest.dto.ApiKeyDto import org.gotson.komga.interfaces.api.rest.dto.ApiKeyRequestDto import org.gotson.komga.interfaces.api.rest.dto.AuthenticationActivityDto @@ -95,8 +97,35 @@ class UserController( newUser: UserCreationDto, ): UserDto = try { - userLifecycle.createUser(newUser.toDomain()).toDto() - } catch (e: UserEmailAlreadyExistsException) { + userLifecycle + .createUser( + with(newUser) { + KomgaUser( + email, + password, + roles = UserRoles.Companion.valuesOf(roles), + // keep existing behaviour before those properties were added, by default new user has access to all libraries + sharedAllLibraries = sharedLibraries == null || sharedLibraries.all, + sharedLibrariesIds = + if (sharedLibraries == null || sharedLibraries.all) + emptySet() + else + libraryRepository.findAllByIds(sharedLibraries.libraryIds).map { it.id }.toSet(), + // keep existing behaviour before those properties were added, by default no restrictions are applied + restrictions = + ContentRestrictions( + ageRestriction = + if (ageRestriction == null || ageRestriction.restriction == AllowExcludeDto.NONE) + null + else + AgeRestriction(ageRestriction.age, ageRestriction.restriction.toDomain()), + labelsAllow = labelsAllow ?: emptySet(), + labelsExclude = labelsExclude ?: emptySet(), + ), + ) + }, + ).toDto() + } catch (_: UserEmailAlreadyExistsException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists") } @@ -142,10 +171,10 @@ class UserController( ContentRestrictions( ageRestriction = if (isSet("ageRestriction")) { - if (ageRestriction == null) + if (ageRestriction == null || ageRestriction?.restriction == AllowExcludeDto.NONE) null else - AgeRestriction(ageRestriction!!.age, ageRestriction!!.restriction) + AgeRestriction(ageRestriction!!.age, ageRestriction!!.restriction.toDomain()) } else { existing.restrictions.ageRestriction }, diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PasswordUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PasswordUpdateDto.kt new file mode 100644 index 000000000..e15c0d86b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/PasswordUpdateDto.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.constraints.NotBlank + +data class PasswordUpdateDto( + @get:NotBlank val password: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserCreationDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserCreationDto.kt new file mode 100644 index 000000000..99e6ae433 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserCreationDto.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import jakarta.validation.Valid +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank + +data class UserCreationDto( + @get:Email(regexp = ".+@.+\\..+") val email: String, + @get:NotBlank val password: String, + val roles: List = emptyList(), + // new fields supported + @get:Valid val ageRestriction: AgeRestrictionUpdateDto?, + val labelsAllow: Set?, + val labelsExclude: Set?, + val sharedLibraries: SharedLibrariesUpdateDto?, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt index daf9b1da6..ee425a30c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt @@ -1,11 +1,8 @@ package org.gotson.komga.interfaces.api.rest.dto -import jakarta.validation.constraints.Email -import jakarta.validation.constraints.NotBlank import org.gotson.komga.domain.model.AgeRestriction import org.gotson.komga.domain.model.AllowExclude import org.gotson.komga.domain.model.KomgaUser -import org.gotson.komga.domain.model.UserRoles import org.gotson.komga.infrastructure.security.KomgaPrincipal data class UserDto( @@ -39,20 +36,3 @@ fun KomgaUser.toDto() = ) fun KomgaPrincipal.toDto() = user.toDto() - -data class UserCreationDto( - @get:Email(regexp = ".+@.+\\..+") val email: String, - @get:NotBlank val password: String, - val roles: List = emptyList(), -) { - fun toDomain(): KomgaUser = - KomgaUser( - email, - password, - roles = UserRoles.valuesOf(roles), - ) -} - -data class PasswordUpdateDto( - @get:NotBlank val password: String, -) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt index 8f02d08f0..8c26b3a0a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt @@ -40,10 +40,24 @@ class UserUpdateDto { data class AgeRestrictionUpdateDto( @get:PositiveOrZero val age: Int, - val restriction: AllowExclude, + val restriction: AllowExcludeDto, ) data class SharedLibrariesUpdateDto( val all: Boolean, val libraryIds: Set, ) + +enum class AllowExcludeDto { + ALLOW_ONLY, + EXCLUDE, + NONE, + ; + + fun toDomain() = + when (this) { + ALLOW_ONLY -> AllowExclude.ALLOW_ONLY + EXCLUDE -> AllowExclude.EXCLUDE + NONE -> throw IllegalArgumentException() + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt index 91237f739..3e39faf04 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt @@ -11,6 +11,9 @@ import org.gotson.komga.domain.persistence.KomgaUserRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.service.KomgaUserLifecycle import org.gotson.komga.domain.service.LibraryLifecycle +import org.gotson.komga.interfaces.api.rest.dto.AllowExcludeDto +import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.hasItem import org.hamcrest.text.MatchesPattern import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterEach @@ -69,19 +72,126 @@ class UserControllerTest( .forEach { userLifecycle.deleteUser(it) } } - @ParameterizedTest - @ValueSource(strings = ["user", "user@domain"]) + @Nested + inner class Create { + @ParameterizedTest + @ValueSource(strings = ["user", "user@domain"]) + @WithMockCustomUser(roles = ["ADMIN"]) + fun `when creating a user with invalid email then returns bad request`(email: String) { + // language=JSON + val jsonString = """{"email":"$email","password":"password"}""" + + mockMvc + .post("/api/v2/users") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `when creating a user with limited fields then returns created user`() { + // language=JSON + val jsonString = + """ + { + "email":"newuser@example.org", + "password":"password" + } + """.trimIndent() + + mockMvc + .post("/api/v2/users") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isCreated() } + jsonPath("$.roles") { value(hasItem("USER")) } + jsonPath("$.sharedAllLibraries") { value(true) } + jsonPath("$.sharedLibrariesId") { doesNotExist() } + jsonPath("$.labelsAllow") { isEmpty() } + jsonPath("$.labelsExclude") { isEmpty() } + jsonPath("$.ageRestriction") { isEmpty() } + } + } + } + + @Test @WithMockCustomUser(roles = ["ADMIN"]) - fun `when creating a user with invalid email then returns bad request`(email: String) { + fun `when creating a user with all fields then returns created user`() { // language=JSON - val jsonString = """{"email":"$email","password":"password"}""" + val jsonString = + """ + { + "email":"newuser@example.org", + "password":"password", + "roles": ["${UserRoles.FILE_DOWNLOAD.name}"], + "ageRestriction": { + "age": 5, + "restriction": "${AllowExcludeDto.NONE}" + }, + "labelsAllow": ["allowTag"], + "labelsExclude": ["excludeTag"], + "sharedLibraries": { + "all": "false", + "libraryIds" : ["1", "157"] + } + } + """.trimIndent() mockMvc .post("/api/v2/users") { contentType = MediaType.APPLICATION_JSON content = jsonString }.andExpect { - status { isBadRequest() } + status { isCreated() } + jsonPath("$.roles") { value(allOf(hasItem("USER"), hasItem(UserRoles.FILE_DOWNLOAD.name))) } + jsonPath("$.labelsAllow") { value(hasItem("allowtag")) } + jsonPath("$.labelsExclude") { value(hasItem("excludetag")) } + jsonPath("$.ageRestriction") { doesNotExist() } + jsonPath("$.sharedAllLibraries") { value(false) } + jsonPath("$.sharedLibrariesIds") { value(hasItem("1")) } + } + } + + @Test + @WithMockCustomUser(roles = ["ADMIN"]) + fun `when creating a user with some fields then returns created user`() { + // language=JSON + val jsonString = + """ + { + "email":"newuser@example.org", + "password":"password", + "roles": ["${UserRoles.FILE_DOWNLOAD.name}"], + "ageRestriction": { + "age": 5, + "restriction": "${AllowExcludeDto.ALLOW_ONLY}" + }, + "labelsAllow": ["allowTag"], + "labelsExclude": ["excludeTag"], + "sharedLibraries": { + "all": "false", + "libraryIds" : ["1", "157"] + } + } + """.trimIndent() + + mockMvc + .post("/api/v2/users") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isCreated() } + jsonPath("$.roles") { value(allOf(hasItem("USER"), hasItem(UserRoles.FILE_DOWNLOAD.name))) } + jsonPath("$.labelsAllow") { value(hasItem("allowtag")) } + jsonPath("$.labelsExclude") { value(hasItem("excludetag")) } + jsonPath("$.ageRestriction.age") { value(5) } + jsonPath("$.ageRestriction.restriction") { value(AllowExclude.ALLOW_ONLY.name) } + jsonPath("$.sharedAllLibraries") { value(false) } + jsonPath("$.sharedLibrariesIds") { value(hasItem("1")) } } }