feat(identity): аккаунты преподавателей
This commit is contained in:
@@ -13,4 +13,6 @@ interface AccountRepository : JpaRepository<Account, UUID> {
|
|||||||
fun existsByRole(role: LmsRole): Boolean
|
fun existsByRole(role: LmsRole): Boolean
|
||||||
|
|
||||||
fun countByRole(role: LmsRole): Long
|
fun countByRole(role: LmsRole): Long
|
||||||
|
|
||||||
|
fun findAllByRoleOrderByLoginAsc(role: LmsRole): List<Account>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package ru.ysu.lms.identity.roster
|
||||||
|
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
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.RestController
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/teachers")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class TeacherController(
|
||||||
|
private val teacherService: TeacherService,
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun list(): List<TeacherDto> = teacherService.list()
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
fun create(
|
||||||
|
@RequestBody body: TeacherCreateRequest,
|
||||||
|
): TeacherCredential = teacherService.create(body)
|
||||||
|
|
||||||
|
@PostMapping("/{id}/reset-password")
|
||||||
|
fun resetPassword(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): ResetPasswordResponse = teacherService.resetPassword(id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package ru.ysu.lms.identity.roster
|
||||||
|
|
||||||
|
/** Преподаватель в списке управления: аккаунт роли TEACHER. */
|
||||||
|
data class TeacherDto(
|
||||||
|
val id: String,
|
||||||
|
val login: String,
|
||||||
|
val name: String,
|
||||||
|
val status: String,
|
||||||
|
val mustChangePassword: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TeacherCreateRequest(
|
||||||
|
val login: String,
|
||||||
|
val password: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Выданные преподавателю учётные данные (пароль показывается один раз). */
|
||||||
|
data class TeacherCredential(
|
||||||
|
val id: String,
|
||||||
|
val login: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package ru.ysu.lms.identity.roster
|
||||||
|
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import ru.ysu.lms.common.BadRequestException
|
||||||
|
import ru.ysu.lms.common.ConflictException
|
||||||
|
import ru.ysu.lms.common.NotFoundException
|
||||||
|
import ru.ysu.lms.identity.auth.Passwords
|
||||||
|
import ru.ysu.lms.identity.domain.Account
|
||||||
|
import ru.ysu.lms.identity.domain.AccountStatus
|
||||||
|
import ru.ysu.lms.identity.repo.AccountRepository
|
||||||
|
import ru.ysu.lms.security.LmsRole
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class TeacherService(
|
||||||
|
private val accounts: AccountRepository,
|
||||||
|
private val passwordEncoder: PasswordEncoder,
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun list(): List<TeacherDto> =
|
||||||
|
accounts
|
||||||
|
.findAllByRoleOrderByLoginAsc(LmsRole.TEACHER)
|
||||||
|
.map { account ->
|
||||||
|
TeacherDto(
|
||||||
|
id = account.id.toString(),
|
||||||
|
login = account.login,
|
||||||
|
name = account.login,
|
||||||
|
status = account.status.name,
|
||||||
|
mustChangePassword = account.mustChangePassword,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun create(request: TeacherCreateRequest): TeacherCredential {
|
||||||
|
val login = request.login.trim()
|
||||||
|
if (login.length < 3) {
|
||||||
|
throw BadRequestException("Логин: минимум 3 символа")
|
||||||
|
}
|
||||||
|
if (accounts.existsByLogin(login)) {
|
||||||
|
throw ConflictException("Логин $login уже занят другим аккаунтом")
|
||||||
|
}
|
||||||
|
val password = request.password?.takeIf { it.isNotBlank() } ?: Passwords.generate()
|
||||||
|
val account =
|
||||||
|
accounts.save(
|
||||||
|
Account(
|
||||||
|
login = login,
|
||||||
|
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||||
|
role = LmsRole.TEACHER,
|
||||||
|
mustChangePassword = true,
|
||||||
|
tempPassword = password,
|
||||||
|
status = AccountStatus.ACTIVE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return TeacherCredential(
|
||||||
|
id = account.id.toString(),
|
||||||
|
login = account.login,
|
||||||
|
password = password,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun resetPassword(teacherId: UUID): ResetPasswordResponse {
|
||||||
|
val account =
|
||||||
|
accounts
|
||||||
|
.findById(teacherId)
|
||||||
|
.orElseThrow { NotFoundException("Преподаватель не найден") }
|
||||||
|
if (account.role != LmsRole.TEACHER) {
|
||||||
|
throw BadRequestException("Сбросить пароль можно только преподавателю")
|
||||||
|
}
|
||||||
|
val password = Passwords.generate()
|
||||||
|
account.passwordHash = passwordEncoder.encode(password) ?: account.passwordHash
|
||||||
|
account.mustChangePassword = true
|
||||||
|
account.tempPassword = password
|
||||||
|
account.updatedAt = Instant.now()
|
||||||
|
accounts.save(account)
|
||||||
|
return ResetPasswordResponse(password)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package ru.ysu.lms.identity.roster
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
|
||||||
|
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry
|
||||||
|
import org.springframework.test.context.DynamicPropertySource
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.get
|
||||||
|
import org.springframework.test.web.servlet.post
|
||||||
|
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.context.WebApplicationContext
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer
|
||||||
|
import org.testcontainers.junit.jupiter.Container
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers
|
||||||
|
import ru.ysu.lms.common.BadRequestException
|
||||||
|
import ru.ysu.lms.common.ConflictException
|
||||||
|
import ru.ysu.lms.common.NotFoundException
|
||||||
|
import ru.ysu.lms.identity.auth.AuthService
|
||||||
|
import ru.ysu.lms.identity.auth.LoginRequest
|
||||||
|
import ru.ysu.lms.identity.domain.Account
|
||||||
|
import ru.ysu.lms.identity.domain.AccountStatus
|
||||||
|
import ru.ysu.lms.identity.repo.AccountRepository
|
||||||
|
import ru.ysu.lms.security.LmsRole
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Testcontainers
|
||||||
|
@Transactional
|
||||||
|
class TeacherIntegrationTest {
|
||||||
|
companion object {
|
||||||
|
@Container
|
||||||
|
val pg =
|
||||||
|
PostgreSQLContainer("postgres:18").apply {
|
||||||
|
withDatabaseName("lms")
|
||||||
|
withUsername("lms")
|
||||||
|
withPassword("lms")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@DynamicPropertySource
|
||||||
|
fun datasource(registry: DynamicPropertyRegistry) {
|
||||||
|
registry.add("spring.datasource.url") { pg.jdbcUrl }
|
||||||
|
registry.add("spring.datasource.username") { "lms" }
|
||||||
|
registry.add("spring.datasource.password") { "lms" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired lateinit var teacherService: TeacherService
|
||||||
|
|
||||||
|
@Autowired lateinit var accounts: AccountRepository
|
||||||
|
|
||||||
|
@Autowired lateinit var passwordEncoder: PasswordEncoder
|
||||||
|
|
||||||
|
@Autowired lateinit var authService: AuthService
|
||||||
|
|
||||||
|
@Autowired lateinit var context: WebApplicationContext
|
||||||
|
|
||||||
|
private val mockMvc: MockMvc by lazy {
|
||||||
|
MockMvcBuilders
|
||||||
|
.webAppContextSetup(context)
|
||||||
|
.apply<DefaultMockMvcBuilder>(springSecurity())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `creates teacher with generated password`() {
|
||||||
|
val credential = teacherService.create(TeacherCreateRequest(login = " prepod "))
|
||||||
|
|
||||||
|
val account = accounts.findById(UUID.fromString(credential.id)).orElseThrow()
|
||||||
|
assertEquals("prepod", credential.login)
|
||||||
|
assertEquals("prepod", account.login)
|
||||||
|
assertEquals(LmsRole.TEACHER, account.role)
|
||||||
|
assertEquals(AccountStatus.ACTIVE, account.status)
|
||||||
|
assertTrue(account.mustChangePassword)
|
||||||
|
assertEquals(credential.password, account.tempPassword)
|
||||||
|
assertTrue(passwordEncoder.matches(credential.password, account.passwordHash))
|
||||||
|
|
||||||
|
val auth = authService.login(LoginRequest("prepod", credential.password))
|
||||||
|
assertEquals("TEACHER", auth.role)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `creates teacher with provided password and validates login`() {
|
||||||
|
val credential = teacherService.create(TeacherCreateRequest(login = "teacher2", password = "secret123"))
|
||||||
|
|
||||||
|
assertEquals("secret123", credential.password)
|
||||||
|
assertTrue(passwordEncoder.matches("secret123", accounts.findByLogin("teacher2")!!.passwordHash))
|
||||||
|
|
||||||
|
val duplicate =
|
||||||
|
runCatching { teacherService.create(TeacherCreateRequest(login = " teacher2 ")) }.exceptionOrNull()
|
||||||
|
assertTrue(duplicate is ConflictException)
|
||||||
|
|
||||||
|
val shortLogin =
|
||||||
|
runCatching { teacherService.create(TeacherCreateRequest(login = "ab")) }.exceptionOrNull()
|
||||||
|
assertTrue(shortLogin is BadRequestException)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lists only teachers sorted by login`() {
|
||||||
|
teacherService.create(TeacherCreateRequest(login = "t.zorin"))
|
||||||
|
teacherService.create(TeacherCreateRequest(login = "t.andreev"))
|
||||||
|
accounts.save(student("s.student"))
|
||||||
|
|
||||||
|
val teachers = teacherService.list()
|
||||||
|
|
||||||
|
assertEquals(listOf("t.andreev", "t.zorin"), teachers.map { it.login })
|
||||||
|
assertTrue(teachers.none { it.login == "s.student" })
|
||||||
|
assertEquals("ACTIVE", teachers.first().status)
|
||||||
|
assertTrue(teachers.first().mustChangePassword)
|
||||||
|
assertEquals(teachers.first().login, teachers.first().name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reset password issues new credentials only for teachers`() {
|
||||||
|
val created = teacherService.create(TeacherCreateRequest(login = "t.reset", password = "oldsecret1"))
|
||||||
|
|
||||||
|
val response = teacherService.resetPassword(UUID.fromString(created.id))
|
||||||
|
|
||||||
|
val account = accounts.findById(UUID.fromString(created.id)).orElseThrow()
|
||||||
|
assertTrue(response.password.isNotBlank())
|
||||||
|
assertEquals(response.password, account.tempPassword)
|
||||||
|
assertTrue(account.mustChangePassword)
|
||||||
|
assertTrue(passwordEncoder.matches(response.password, account.passwordHash))
|
||||||
|
authService.login(LoginRequest("t.reset", response.password))
|
||||||
|
|
||||||
|
val student = accounts.save(student("s.reset"))
|
||||||
|
assertTrue(runCatching { teacherService.resetPassword(student.id) }.exceptionOrNull() is BadRequestException)
|
||||||
|
assertTrue(runCatching { teacherService.resetPassword(UUID.randomUUID()) }.exceptionOrNull() is NotFoundException)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `admin endpoints create list and reset teacher`() {
|
||||||
|
val admin = jwt().authorities(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.post("/api/v1/teachers") {
|
||||||
|
with(admin)
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = """{"login":" api.teacher "}"""
|
||||||
|
}.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.login") { value("api.teacher") }
|
||||||
|
jsonPath("$.password") { isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.save(student("api.student"))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.get("/api/v1/teachers") {
|
||||||
|
with(admin)
|
||||||
|
}.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.length()") { value(1) }
|
||||||
|
jsonPath("$[0].login") { value("api.teacher") }
|
||||||
|
}
|
||||||
|
|
||||||
|
val teacher = accounts.findByLogin("api.teacher")!!
|
||||||
|
mockMvc
|
||||||
|
.post("/api/v1/teachers/${teacher.id}/reset-password") {
|
||||||
|
with(admin)
|
||||||
|
}.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.password") { isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non admin cannot list teachers`() {
|
||||||
|
mockMvc
|
||||||
|
.get("/api/v1/teachers") {
|
||||||
|
with(jwt().authorities(SimpleGrantedAuthority("ROLE_STUDENT")))
|
||||||
|
}.andExpect {
|
||||||
|
status { isForbidden() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun student(login: String): Account =
|
||||||
|
Account(
|
||||||
|
login = login,
|
||||||
|
passwordHash = passwordEncoder.encode("secret123") ?: "",
|
||||||
|
role = LmsRole.STUDENT,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user