feat(identity): аккаунты преподавателей

This commit is contained in:
2026-10-27 15:46:48 +03:00
parent 6f8add3736
commit a8fa566d3c
5 changed files with 328 additions and 0 deletions

View File

@@ -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>
} }

View File

@@ -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)
}

View File

@@ -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,
)

View File

@@ -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)
}
}

View File

@@ -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,
)
}