From a8fa566d3cce3452b541ee9bc981201a6b881b2e Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Tue, 27 Oct 2026 15:46:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(identity):=20=D0=B0=D0=BA=D0=BA=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D1=82=D1=8B=20=D0=BF=D1=80=D0=B5=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lms/identity/repo/AccountRepository.kt | 2 + .../lms/identity/roster/TeacherController.kt | 30 +++ .../ru/ysu/lms/identity/roster/TeacherDtos.kt | 22 ++ .../ysu/lms/identity/roster/TeacherService.kt | 81 ++++++++ .../identity/roster/TeacherIntegrationTest.kt | 193 ++++++++++++++++++ 5 files changed, 328 insertions(+) create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherController.kt create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherDtos.kt create mode 100644 services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherService.kt create mode 100644 services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/TeacherIntegrationTest.kt diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/AccountRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/AccountRepository.kt index 0a7d022..2e64174 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/AccountRepository.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/AccountRepository.kt @@ -13,4 +13,6 @@ interface AccountRepository : JpaRepository { fun existsByRole(role: LmsRole): Boolean fun countByRole(role: LmsRole): Long + + fun findAllByRoleOrderByLoginAsc(role: LmsRole): List } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherController.kt new file mode 100644 index 0000000..59227a5 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherController.kt @@ -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 = 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) +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherDtos.kt new file mode 100644 index 0000000..22521d7 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherDtos.kt @@ -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, +) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherService.kt new file mode 100644 index 0000000..499062e --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherService.kt @@ -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 = + 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) + } +} diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/TeacherIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/TeacherIntegrationTest.kt new file mode 100644 index 0000000..93cabeb --- /dev/null +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/TeacherIntegrationTest.kt @@ -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(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, + ) +}