feat(api): add /api/v2/users and deprecate /api/v1/users

simplify the user DTO classes
make api logout endpoint to version agnostic
This commit is contained in:
Gauthier Roebroeck 2022-03-02 11:20:13 +08:00
parent f1ab136b5e
commit fa04d9511a
14 changed files with 508 additions and 71 deletions

View file

@ -142,7 +142,7 @@ export default Vue.extend({
modalDeleteUser: false,
userToDelete: {} as UserDto,
modalEditSharedLibraries: false,
userToEditSharedLibraries: {} as UserWithSharedLibrariesDto,
userToEditSharedLibraries: {} as UserDto,
modalEditUser: false,
userToEdit: {} as UserDto,
modalChangePassword: false,
@ -150,7 +150,7 @@ export default Vue.extend({
usersLastActivity: {} as any,
}),
computed: {
users(): UserWithSharedLibrariesDto[] {
users(): UserDto[] {
return this.$store.state.komgaUsers.users
},
me(): UserDto {
@ -175,7 +175,7 @@ export default Vue.extend({
this.userToDelete = user
this.modalDeleteUser = true
},
editSharedLibraries(user: UserWithSharedLibrariesDto) {
editSharedLibraries(user: UserDto) {
this.userToEditSharedLibraries = user
this.modalEditSharedLibraries = true
},

View file

@ -104,11 +104,11 @@ export default Vue.extend({
},
async editUser() {
try {
const roles = {
const patch = {
roles: this.roles,
} as RolesUpdateDto
} as UserUpdateDto
await this.$store.dispatch('updateUserRoles', {userId: this.user.id, roles: roles})
await this.$store.dispatch('updateUser', {userId: this.user.id, patch: patch})
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}

View file

@ -91,12 +91,12 @@ export default Vue.extend({
},
},
methods: {
dialogReset(user: UserWithSharedLibrariesDto) {
dialogReset(user: UserDto) {
this.allLibraries = user.sharedAllLibraries
if (user.sharedAllLibraries) {
this.selectedLibraries = this.libraries.map(x => x.id)
} else {
this.selectedLibraries = user.sharedLibraries.map(x => x.id)
this.selectedLibraries = user.sharedLibrariesIds
}
},
dialogCancel() {
@ -110,12 +110,14 @@ export default Vue.extend({
},
async editUser() {
try {
const sharedLibraries = {
all: this.allLibraries,
libraryIds: this.selectedLibraries,
} as SharedLibrariesUpdateDto
const patch = {
sharedLibraries: {
all: this.allLibraries,
libraryIds: this.selectedLibraries,
},
} as UserUpdateDto
await this.$store.dispatch('updateUserSharedLibraries', {user: this.user, sharedLibraries: sharedLibraries})
await this.$store.dispatch('updateUser', {userId: this.user.id, patch: patch})
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}

View file

@ -9,7 +9,7 @@ let service: KomgaUsersService
const vuexModule: Module<any, any> = {
state: {
me: {} as UserDto,
users: [] as UserWithSharedLibrariesDto[],
users: [] as UserDto[],
},
getters: {
meAdmin: state => state.me.hasOwnProperty('roles') && state.me.roles.includes(UserRoles.ADMIN),
@ -21,7 +21,7 @@ const vuexModule: Module<any, any> = {
setMe (state, user: UserDto) {
state.me = user
},
setAllUsers (state, users: UserWithSharedLibrariesDto[]) {
setAllUsers (state, users: UserDto[]) {
state.users = users
},
},
@ -46,18 +46,14 @@ const vuexModule: Module<any, any> = {
await service.postUser(user)
dispatch('getAllUsers')
},
async updateUserRoles ({ dispatch }, { userId, roles }: { userId: string, roles: RolesUpdateDto }) {
await service.patchUserRoles(userId, roles)
async updateUser ({ dispatch }, { userId, patch }: { userId: string, patch: UserUpdateDto }) {
await service.patchUser(userId, patch)
dispatch('getAllUsers')
},
async deleteUser ({ dispatch }, user: UserDto) {
await service.deleteUser(user)
dispatch('getAllUsers')
},
async updateUserSharedLibraries ({ dispatch }, { user, sharedLibraries }: { user: UserDto, sharedLibraries: SharedLibrariesUpdateDto }) {
await service.patchUserSharedLibraries(user, sharedLibraries)
dispatch('getAllUsers')
},
},
}

View file

@ -2,7 +2,7 @@ import {AxiosInstance} from 'axios'
const qs = require('qs')
const API_USERS = '/api/v1/users'
const API_USERS = '/api/v2/users'
export default class KomgaUsersService {
private http: AxiosInstance
@ -50,7 +50,7 @@ export default class KomgaUsersService {
}
}
async getAll(): Promise<UserWithSharedLibrariesDto[]> {
async getAll(): Promise<UserDto[]> {
try {
return (await this.http.get(`${API_USERS}`)).data
} catch (e) {
@ -74,9 +74,9 @@ export default class KomgaUsersService {
}
}
async patchUserRoles(userId: string, roles: RolesUpdateDto): Promise<UserDto> {
async patchUser(userId: string, patch: UserUpdateDto) {
try {
return (await this.http.patch(`${API_USERS}/${userId}`, roles)).data
await this.http.patch(`${API_USERS}/${userId}`, patch)
} catch (e) {
let msg = `An error occurred while trying to patch user '${userId}'`
if (e.response.data.message) {
@ -100,7 +100,7 @@ export default class KomgaUsersService {
async patchUserPassword(user: UserDto, newPassword: PasswordUpdateDto) {
try {
return (await this.http.patch(`${API_USERS}/${user.id}/password`, newPassword)).data
await this.http.patch(`${API_USERS}/${user.id}/password`, newPassword)
} catch (e) {
let msg = `An error occurred while trying to update password for user ${user.email}`
if (e.response.data.message) {
@ -110,21 +110,9 @@ export default class KomgaUsersService {
}
}
async patchUserSharedLibraries(user: UserDto, sharedLibrariesUpdateDto: SharedLibrariesUpdateDto) {
try {
return (await this.http.patch(`${API_USERS}/${user.id}/shared-libraries`, sharedLibrariesUpdateDto)).data
} catch (e) {
let msg = `An error occurred while trying to update shared libraries for user ${user.email}`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async logout() {
try {
await this.http.post(`${API_USERS}/logout`)
await this.http.post('api/logout')
} catch (e) {
let msg = 'An error occurred while trying to logout'
if (e.response.data.message) {

View file

@ -1,19 +1,9 @@
interface UserDto {
id: string,
email: string,
roles: string[]
}
interface UserWithSharedLibrariesDto {
id: string,
email: string,
roles: string[],
sharedAllLibraries: boolean,
sharedLibraries: SharedLibraryDto[]
}
interface SharedLibraryDto {
id: string
sharedLibrariesIds: string[]
}
interface UserCreationDto {
@ -25,13 +15,12 @@ interface PasswordUpdateDto {
password: string
}
interface SharedLibrariesUpdateDto {
all: boolean,
libraryIds: string[]
}
interface RolesUpdateDto {
roles: string[]
interface UserUpdateDto {
roles?: string[],
sharedLibraries?: {
all: boolean,
libraryIds: string[]
},
}
interface AuthenticationActivityDto {

View file

@ -67,7 +67,7 @@ class SecurityConfiguration(
it.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
}
.logout {
it.logoutUrl("/api/v1/users/logout")
it.logoutUrl("/api/logout")
it.deleteCookies(sessionCookieName)
it.invalidateHttpSession(true)
}

View file

@ -2,8 +2,8 @@ package org.gotson.komga.interfaces.api.rest
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.interfaces.api.rest.dto.UserDto
import org.gotson.komga.interfaces.api.rest.dto.toDto
import org.gotson.komga.interfaces.api.rest.dto.UserDtoV2
import org.gotson.komga.interfaces.api.rest.dto.toDtoV2
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.validation.annotation.Validated
@ -30,7 +30,7 @@ class ClaimController(
fun claimAdmin(
@Email(regexp = ".+@.+\\..+") @RequestHeader("X-Komga-Email") email: String,
@NotBlank @RequestHeader("X-Komga-Password") password: String,
): UserDto {
): UserDtoV2 {
if (userDetailsLifecycle.countUsers() > 0)
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "This server has already been claimed")
@ -40,7 +40,7 @@ class ClaimController(
password = password,
roleAdmin = true,
),
).toDto()
).toDtoV2()
}
data class ClaimStatus(

View file

@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")
package org.gotson.komga.interfaces.api.rest
import io.swagger.v3.oas.annotations.Parameter
@ -46,9 +48,10 @@ import javax.validation.Valid
private val logger = KotlinLogging.logger {}
@Deprecated("User api/v2/users instead")
@RestController
@RequestMapping("api/v1/users", produces = [MediaType.APPLICATION_JSON_VALUE])
class UserController(
class UserV1Controller(
private val userLifecycle: KomgaUserLifecycle,
private val userRepository: KomgaUserRepository,
private val libraryRepository: LibraryRepository,
@ -59,10 +62,12 @@ class UserController(
private val demo = env.activeProfiles.contains("demo")
@GetMapping("me")
@Deprecated("User api/v2/users instead")
fun getMe(@AuthenticationPrincipal principal: KomgaPrincipal): UserDto =
principal.user.toDto()
@PatchMapping("me/password")
@Deprecated("User api/v2/users instead")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun updateMyPassword(
@AuthenticationPrincipal principal: KomgaPrincipal,
@ -76,12 +81,14 @@ class UserController(
@GetMapping
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@Deprecated("User api/v2/users instead")
fun getAll(): List<UserWithSharedLibrariesDto> =
userRepository.findAll().map { it.toWithSharedLibrariesDto() }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@Deprecated("User api/v2/users instead")
fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDto =
try {
userLifecycle.createUser(newUser.toDomain()).toDto()
@ -92,6 +99,7 @@ class UserController(
@DeleteMapping("{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id")
@Deprecated("User api/v2/users instead")
fun delete(
@PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
@ -104,6 +112,7 @@ class UserController(
@PatchMapping("{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id")
@Deprecated("User api/v2/users instead")
fun updateUserRoles(
@PathVariable id: String,
@Valid @RequestBody patch: RolesUpdateDto,
@ -123,6 +132,7 @@ class UserController(
@PatchMapping("{id}/password")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id")
@Deprecated("User api/v2/users instead")
fun updatePassword(
@PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
@ -137,6 +147,7 @@ class UserController(
@PatchMapping("{id}/shared-libraries")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@Deprecated("User api/v2/users instead")
fun updateSharesLibraries(
@PathVariable id: String,
@Valid @RequestBody sharedLibrariesUpdateDto: SharedLibrariesUpdateDto,
@ -156,6 +167,7 @@ class UserController(
@GetMapping("me/authentication-activity")
@PageableAsQueryParam
@Deprecated("User api/v2/users instead")
fun getMyAuthenticationActivity(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@ -180,6 +192,7 @@ class UserController(
@GetMapping("authentication-activity")
@PageableAsQueryParam
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@Deprecated("User api/v2/users instead")
fun getAuthenticationActivity(
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) page: Pageable,
@ -201,6 +214,7 @@ class UserController(
@GetMapping("{id}/authentication-activity/latest")
@PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id")
@Deprecated("User api/v2/users instead")
fun getLatestAuthenticationActivityForUser(
@PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal,

View file

@ -0,0 +1,197 @@
package org.gotson.komga.interfaces.api.rest
import io.swagger.v3.oas.annotations.Parameter
import mu.KotlinLogging
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
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.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.interfaces.api.rest.dto.AuthenticationActivityDto
import org.gotson.komga.interfaces.api.rest.dto.PasswordUpdateDto
import org.gotson.komga.interfaces.api.rest.dto.UserCreationDto
import org.gotson.komga.interfaces.api.rest.dto.UserDtoV2
import org.gotson.komga.interfaces.api.rest.dto.UserUpdateDto
import org.gotson.komga.interfaces.api.rest.dto.toDto
import org.gotson.komga.interfaces.api.rest.dto.toDtoV2
import org.springdoc.core.converters.models.PageableAsQueryParam
import org.springframework.core.env.Environment
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import javax.validation.Valid
private val logger = KotlinLogging.logger {}
@RestController
@RequestMapping("api/v2/users", produces = [MediaType.APPLICATION_JSON_VALUE])
class UserV2Controller(
private val userLifecycle: KomgaUserLifecycle,
private val userRepository: KomgaUserRepository,
private val libraryRepository: LibraryRepository,
private val authenticationActivityRepository: AuthenticationActivityRepository,
env: Environment,
) {
private val demo = env.activeProfiles.contains("demo")
@GetMapping("me")
fun getMe(@AuthenticationPrincipal principal: KomgaPrincipal): UserDtoV2 =
principal.toDtoV2()
@PatchMapping("me/password")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun updateMyPassword(
@AuthenticationPrincipal principal: KomgaPrincipal,
@Valid @RequestBody newPasswordDto: PasswordUpdateDto,
) {
if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN)
userRepository.findByEmailIgnoreCaseOrNull(principal.username)?.let { user ->
userLifecycle.updatePassword(user, newPasswordDto.password, false)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@GetMapping
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun getAll(): List<UserDtoV2> =
userRepository.findAll().map { it.toDtoV2() }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDtoV2 =
try {
userLifecycle.createUser(newUser.toDomain()).toDtoV2()
} catch (e: UserEmailAlreadyExistsException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists")
}
@DeleteMapping("{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id")
fun delete(
@PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
) {
userRepository.findByIdOrNull(id)?.let {
userLifecycle.deleteUser(it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PatchMapping("{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id")
fun updateUser(
@PathVariable id: String,
@Valid @RequestBody patch: UserUpdateDto,
@AuthenticationPrincipal principal: KomgaPrincipal,
) {
userRepository.findByIdOrNull(id)?.let { existing ->
val updatedUser = with(patch) {
existing.copy(
roleAdmin = if (isSet("roles")) roles!!.contains(ROLE_ADMIN) else existing.roleAdmin,
roleFileDownload = if (isSet("roles")) roles!!.contains(ROLE_FILE_DOWNLOAD) else existing.roleFileDownload,
rolePageStreaming = if (isSet("roles")) roles!!.contains(ROLE_PAGE_STREAMING) else existing.rolePageStreaming,
sharedAllLibraries = if (isSet("sharedLibraries")) sharedLibraries!!.all else existing.sharedAllLibraries,
sharedLibrariesIds = if (isSet("sharedLibraries")) {
if (sharedLibraries!!.all) emptySet()
else libraryRepository.findAllByIds(sharedLibraries!!.libraryIds).map { it.id }.toSet()
} else existing.sharedLibrariesIds,
)
}
userRepository.update(updatedUser)
logger.info { "Updated user: $updatedUser" }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PatchMapping("{id}/password")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id")
fun updatePassword(
@PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
@Valid @RequestBody newPasswordDto: PasswordUpdateDto,
) {
if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN)
userRepository.findByIdOrNull(id)?.let { user ->
userLifecycle.updatePassword(user, newPasswordDto.password, user.id != principal.user.id)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@GetMapping("me/authentication-activity")
@PageableAsQueryParam
fun getMyAuthenticationActivity(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) page: Pageable,
): Page<AuthenticationActivityDto> {
if (demo && !principal.user.roleAdmin) throw ResponseStatusException(HttpStatus.FORBIDDEN)
val sort =
if (page.sort.isSorted) page.sort
else Sort.by(Sort.Order.desc("dateTime"))
val pageRequest =
if (unpaged) UnpagedSorted(sort)
else PageRequest.of(
page.pageNumber,
page.pageSize,
sort,
)
return authenticationActivityRepository.findAllByUser(principal.user, pageRequest).map { it.toDto() }
}
@GetMapping("authentication-activity")
@PageableAsQueryParam
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun getAuthenticationActivity(
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) page: Pageable,
): Page<AuthenticationActivityDto> {
val sort =
if (page.sort.isSorted) page.sort
else Sort.by(Sort.Order.desc("dateTime"))
val pageRequest =
if (unpaged) UnpagedSorted(sort)
else PageRequest.of(
page.pageNumber,
page.pageSize,
sort,
)
return authenticationActivityRepository.findAll(pageRequest).map { it.toDto() }
}
@GetMapping("{id}/authentication-activity/latest")
@PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id")
fun getLatestAuthenticationActivityForUser(
@PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
): AuthenticationActivityDto =
userRepository.findByIdOrNull(id)?.let { user ->
authenticationActivityRepository.findMostRecentByUser(user)?.toDto()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -8,12 +8,14 @@ import org.gotson.komga.infrastructure.security.KomgaPrincipal
import javax.validation.constraints.Email
import javax.validation.constraints.NotBlank
@Deprecated("Deprecated since 0.153.0. Use UserDtoV2 instead")
data class UserDto(
val id: String,
val email: String,
val roles: List<String>,
)
@Deprecated("Deprecated since 0.153.0. Use toDtoV2() instead")
fun KomgaUser.toDto() =
UserDto(
id = id,
@ -21,8 +23,29 @@ fun KomgaUser.toDto() =
roles = roles().toList(),
)
@Deprecated("Deprecated since 0.153.0. Use toDtoV2() instead")
fun KomgaPrincipal.toDto() = user.toDto()
data class UserDtoV2(
val id: String,
val email: String,
val roles: Set<String>,
val sharedAllLibraries: Boolean,
val sharedLibrariesIds: Set<String>,
)
fun KomgaUser.toDtoV2() =
UserDtoV2(
id = id,
email = email,
roles = roles(),
sharedAllLibraries = sharedAllLibraries,
sharedLibrariesIds = sharedLibrariesIds,
)
fun KomgaPrincipal.toDtoV2() = user.toDtoV2()
@Deprecated("Deprecated since 0.153.0. Use UserDtoV2 instead")
data class UserWithSharedLibrariesDto(
val id: String,
val email: String,
@ -31,10 +54,12 @@ data class UserWithSharedLibrariesDto(
val sharedLibraries: List<SharedLibraryDto>,
)
@Deprecated("Deprecated since 0.153.0. Use UserDtoV2 instead")
data class SharedLibraryDto(
val id: String,
)
@Deprecated("Deprecated since 0.153.0. Use toDtoV2() instead")
fun KomgaUser.toWithSharedLibrariesDto() =
UserWithSharedLibrariesDto(
id = id,
@ -63,11 +88,7 @@ data class PasswordUpdateDto(
@get:NotBlank val password: String,
)
data class SharedLibrariesUpdateDto(
val all: Boolean,
val libraryIds: Set<String>,
)
@Deprecated("Deprecated since 0.153.0")
data class RolesUpdateDto(
val roles: List<String>,
)

View file

@ -0,0 +1,32 @@
package org.gotson.komga.interfaces.api.rest.dto
import kotlin.properties.Delegates
class UserUpdateDto {
private val isSet = mutableMapOf<String, Boolean>()
fun isSet(prop: String) = isSet.getOrDefault(prop, false)
// @get:NullOrNotBlank
// val title: String? = null
// @get:PositiveOrZero
// var ageRating: Int?
// by Delegates.observable(null) { prop, _, _ ->
// isSet[prop.name] = true
// }
var roles: Set<String>?
by Delegates.observable(null) { prop, _, _ ->
isSet[prop.name] = true
}
var sharedLibraries: SharedLibrariesUpdateDto?
by Delegates.observable(null) { prop, _, _ ->
isSet[prop.name] = true
}
}
data class SharedLibrariesUpdateDto(
val all: Boolean,
val libraryIds: Set<String>,
)

View file

@ -9,6 +9,7 @@ import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.patch
@ExtendWith(SpringExtension::class)
@ -24,11 +25,20 @@ class UserControllerDemoTest(
fun `given demo profile is active when a user tries to update its password via api then returns forbidden`() {
val jsonString = """{"password":"new"}"""
mockMvc.patch("/api/v1/users/me/password") {
mockMvc.patch("/api/v2/users/me/password") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isForbidden() }
}
}
@Test
@WithMockCustomUser
fun `given demo profile is active when a user tries to retrieve own authentication activity then returns forbidden`() {
mockMvc.get("/api/v2/users/me/authentication-activity")
.andExpect {
status { isForbidden() }
}
}
}

View file

@ -1,6 +1,20 @@
package org.gotson.komga.interfaces.api.rest
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.makeLibrary
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.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
@ -11,6 +25,7 @@ import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.patch
import org.springframework.test.web.servlet.post
@ExtendWith(SpringExtension::class)
@ -19,19 +34,192 @@ import org.springframework.test.web.servlet.post
@ActiveProfiles("test")
class UserControllerTest(
@Autowired private val mockMvc: MockMvc,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val libraryLifecycle: LibraryLifecycle,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val userLifecycle: KomgaUserLifecycle,
) {
private val admin = KomgaUser("admin@example.org", "", true, id = "admin")
@BeforeAll
fun setup() {
libraryRepository.insert(makeLibrary(id = "1"))
libraryRepository.insert(makeLibrary(id = "2"))
userRepository.insert(admin)
}
@AfterAll
fun teardown() {
userRepository.findAll().forEach {
userLifecycle.deleteUser(it)
}
libraryRepository.findAll().forEach {
libraryLifecycle.deleteLibrary(it)
}
}
@AfterEach
fun deleteUsers() {
userRepository.findAll()
.filterNot { it.email == admin.email }
.forEach { userLifecycle.deleteUser(it) }
}
@ParameterizedTest
@ValueSource(strings = ["user", "user@domain"])
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `when creating a user with invalid email then returns bad request`(email: String) {
val jsonString = """{"email":"$email","password":"password"}"""
mockMvc.post("/api/v1/users") {
mockMvc.post("/api/v2/users") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest() }
}
}
@Nested
inner class Update {
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user without roles when updating roles then roles are updated`() {
val user = KomgaUser("user@example.org", "", false, id = "user", roleFileDownload = false, rolePageStreaming = false)
userLifecycle.createUser(user)
val jsonString = """
{
"roles": ["$ROLE_FILE_DOWNLOAD","$ROLE_PAGE_STREAMING"]
}
""".trimIndent()
mockMvc.patch("/api/v2/users/${user.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent() }
}
with(userRepository.findByIdOrNull(user.id)) {
assertThat(this).isNotNull
assertThat(this!!.roleFileDownload).isTrue
assertThat(this.rolePageStreaming).isTrue
assertThat(this.roleAdmin).isFalse
}
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user with roles when updating roles then roles are updated`() {
val user = KomgaUser("user@example.org", "", true, id = "user", roleFileDownload = true, rolePageStreaming = true)
userLifecycle.createUser(user)
val jsonString = """
{
"roles": []
}
""".trimIndent()
mockMvc.patch("/api/v2/users/${user.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent() }
}
with(userRepository.findByIdOrNull(user.id)) {
assertThat(this).isNotNull
assertThat(this!!.roleFileDownload).isFalse
assertThat(this.rolePageStreaming).isFalse
assertThat(this.roleAdmin).isFalse
}
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user with library restrictions when updating available libraries then they are updated`() {
val user = KomgaUser("user@example.org", "", false, id = "user", sharedAllLibraries = false, sharedLibrariesIds = setOf("1"))
userLifecycle.createUser(user)
val jsonString = """
{
"sharedLibraries": {
"all": "false",
"libraryIds" : ["1", "2"]
}
}
""".trimIndent()
mockMvc.patch("/api/v2/users/${user.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent() }
}
with(userRepository.findByIdOrNull(user.id)) {
assertThat(this).isNotNull
assertThat(this!!.sharedAllLibraries).isFalse
assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("1", "2")
}
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user without library restrictions when restricting libraries then they restrictions are updated`() {
val user = KomgaUser("user@example.org", "", false, id = "user", sharedAllLibraries = true)
userLifecycle.createUser(user)
val jsonString = """
{
"sharedLibraries": {
"all": "false",
"libraryIds" : ["2"]
}
}
""".trimIndent()
mockMvc.patch("/api/v2/users/${user.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent() }
}
with(userRepository.findByIdOrNull(user.id)) {
assertThat(this).isNotNull
assertThat(this!!.sharedAllLibraries).isFalse
assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("2")
}
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user with library restrictions when removing restrictions then they restrictions are updated`() {
val user = KomgaUser("user@example.org", "", false, id = "user", sharedAllLibraries = false, sharedLibrariesIds = setOf("2"))
userLifecycle.createUser(user)
val jsonString = """
{
"sharedLibraries": {
"all": "true",
"libraryIds": []
}
}
""".trimIndent()
mockMvc.patch("/api/v2/users/${user.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent() }
}
with(userRepository.findByIdOrNull(user.id)) {
assertThat(this).isNotNull
assertThat(this!!.sharedAllLibraries).isTrue
assertThat(this.sharedLibrariesIds).isEmpty()
}
}
}
}