feat(identity): внутренние API для профиля студента
This commit is contained in:
@@ -16,3 +16,7 @@ LETSENCRYPT_EMAIL=admin@our-place.ru
|
|||||||
# Резервное копирование (см. deploy/BACKUP.md).
|
# Резервное копирование (см. deploy/BACKUP.md).
|
||||||
LMS_BACKUP_DIR=/opt/lms/backups
|
LMS_BACKUP_DIR=/opt/lms/backups
|
||||||
LMS_BACKUP_KEEP_DAYS=14
|
LMS_BACKUP_KEEP_DAYS=14
|
||||||
|
|
||||||
|
# Внутренний токен между identity и progress (профиль студента).
|
||||||
|
# Обязателен в production, задайте длинную случайную строку.
|
||||||
|
LMS_INTERNAL_TOKEN=change-me-to-a-long-random-string
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ x-db-env: &db-env
|
|||||||
x-jwt-env: &jwt-env
|
x-jwt-env: &jwt-env
|
||||||
LMS_JWT_JWK_SET_URI: http://identity:8081/api/v1/auth/jwks.json
|
LMS_JWT_JWK_SET_URI: http://identity:8081/api/v1/auth/jwks.json
|
||||||
|
|
||||||
|
x-internal-env: &internal-env
|
||||||
|
LMS_INTERNAL_TOKEN: ${LMS_INTERNAL_TOKEN:?Задайте LMS_INTERNAL_TOKEN в .env.prod}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18
|
image: postgres:18
|
||||||
@@ -40,7 +43,7 @@ services:
|
|||||||
image: lms-identity:latest
|
image: lms-identity:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
<<: *db-env
|
<<: [*db-env, *internal-env]
|
||||||
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity
|
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
@@ -66,7 +69,7 @@ services:
|
|||||||
image: lms-progress:latest
|
image: lms-progress:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
<<: [*db-env, *jwt-env]
|
<<: [*db-env, *jwt-env, *internal-env]
|
||||||
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=progress
|
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=progress
|
||||||
LMS_SUBMISSIONS_DIR: /data/submissions
|
LMS_SUBMISSIONS_DIR: /data/submissions
|
||||||
LMS_IDENTITY_URI: http://identity:8081
|
LMS_IDENTITY_URI: http://identity:8081
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package ru.ysu.lms.identity.internal
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
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.RequestHeader
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import ru.ysu.lms.common.ForbiddenException
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Внутренние эндпоинты identity. Gateway их не маршрутизирует, поэтому вместо
|
||||||
|
* пользовательского JWT используется общий сервисный токен.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/internal")
|
||||||
|
class InternalController(
|
||||||
|
private val service: InternalService,
|
||||||
|
@Value("\${lms.internal.token}") private val internalToken: String,
|
||||||
|
) {
|
||||||
|
@GetMapping("/students/{studentId}/context")
|
||||||
|
fun studentContext(
|
||||||
|
@RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?,
|
||||||
|
@PathVariable studentId: UUID,
|
||||||
|
): StudentContextDto {
|
||||||
|
ensureToken(token)
|
||||||
|
return service.studentContext(studentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/accounts/lookup")
|
||||||
|
fun lookupAccounts(
|
||||||
|
@RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?,
|
||||||
|
@RequestBody request: AccountLookupRequest,
|
||||||
|
): List<AccountInfoDto> {
|
||||||
|
ensureToken(token)
|
||||||
|
return service.lookupAccounts(request.ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/groups/{groupId}")
|
||||||
|
fun group(
|
||||||
|
@RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?,
|
||||||
|
@PathVariable groupId: UUID,
|
||||||
|
): InternalGroupDto {
|
||||||
|
ensureToken(token)
|
||||||
|
return service.group(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureToken(token: String?) {
|
||||||
|
if (token == null ||
|
||||||
|
!MessageDigest.isEqual(token.toByteArray(StandardCharsets.UTF_8), internalToken.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
) {
|
||||||
|
throw ForbiddenException(INVALID_TOKEN_MESSAGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
|
||||||
|
const val INVALID_TOKEN_MESSAGE = "Неверный внутренний токен"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package ru.ysu.lms.identity.internal
|
||||||
|
|
||||||
|
/** Контекст студента для внутренних агрегаторов (профиль преподавателя). */
|
||||||
|
data class StudentContextDto(
|
||||||
|
val id: String,
|
||||||
|
val login: String,
|
||||||
|
val fullName: String,
|
||||||
|
val studentNo: String,
|
||||||
|
val status: String,
|
||||||
|
val enrollmentYear: Short? = null,
|
||||||
|
val groupId: String? = null,
|
||||||
|
val groupName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Пакетный запрос логинов по идентификаторам аккаунтов. */
|
||||||
|
data class AccountLookupRequest(
|
||||||
|
val ids: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AccountInfoDto(
|
||||||
|
val id: String,
|
||||||
|
val login: String,
|
||||||
|
val role: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Группа с текущим составом — без учётных данных. */
|
||||||
|
data class InternalGroupDto(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val students: List<InternalGroupStudentDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InternalGroupStudentDto(
|
||||||
|
val id: String,
|
||||||
|
val studentNo: String,
|
||||||
|
val fullName: String,
|
||||||
|
val status: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package ru.ysu.lms.identity.internal
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import ru.ysu.lms.common.BadRequestException
|
||||||
|
import ru.ysu.lms.common.NotFoundException
|
||||||
|
import ru.ysu.lms.identity.repo.AccountRepository
|
||||||
|
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||||
|
import ru.ysu.lms.identity.repo.GroupRepository
|
||||||
|
import ru.ysu.lms.identity.repo.StudentRepository
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-model для внутренних вызовов: отдаёт данные без админского JWT.
|
||||||
|
* Доступ ограничен сервисным токеном на уровне [InternalController].
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class InternalService(
|
||||||
|
private val accounts: AccountRepository,
|
||||||
|
private val students: StudentRepository,
|
||||||
|
private val enrollments: EnrollmentRepository,
|
||||||
|
private val groups: GroupRepository,
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun studentContext(studentId: UUID): StudentContextDto {
|
||||||
|
val student =
|
||||||
|
students.findById(studentId).orElseThrow {
|
||||||
|
NotFoundException("Студент не найден")
|
||||||
|
}
|
||||||
|
val account = student.account ?: accounts.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
|
||||||
|
val enrollment = enrollments.findByStudentAccountId(studentId)
|
||||||
|
return StudentContextDto(
|
||||||
|
id = account.id.toString(),
|
||||||
|
login = account.login,
|
||||||
|
fullName = student.fullName,
|
||||||
|
studentNo = student.studentNo,
|
||||||
|
status = student.status.name,
|
||||||
|
enrollmentYear = student.enrollmentYear,
|
||||||
|
groupId = enrollment?.group?.id?.toString(),
|
||||||
|
groupName = enrollment?.group?.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Группа и её текущий состав. Пароли и временные пароли сюда не попадают. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun group(groupId: UUID): InternalGroupDto {
|
||||||
|
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||||
|
val members =
|
||||||
|
enrollments
|
||||||
|
.findAllByGroupId(groupId)
|
||||||
|
.mapNotNull { enrollment ->
|
||||||
|
val student = enrollment.student ?: return@mapNotNull null
|
||||||
|
student.accountId?.let { accountId ->
|
||||||
|
InternalGroupStudentDto(
|
||||||
|
id = accountId.toString(),
|
||||||
|
studentNo = student.studentNo,
|
||||||
|
fullName = student.fullName,
|
||||||
|
status = student.status.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
||||||
|
return InternalGroupDto(id = group.id.toString(), name = group.name, students = members)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Логины авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun lookupAccounts(ids: List<String>): List<AccountInfoDto> {
|
||||||
|
val requested = ids.map(::parseId).distinct()
|
||||||
|
if (requested.isEmpty()) return emptyList()
|
||||||
|
val found = accounts.findAllById(requested).associateBy { it.id }
|
||||||
|
return requested.mapNotNull { id ->
|
||||||
|
found[id]?.let { account ->
|
||||||
|
AccountInfoDto(id = account.id.toString(), login = account.login, role = account.role.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseId(raw: String): UUID =
|
||||||
|
runCatching { UUID.fromString(raw.trim()) }
|
||||||
|
.getOrElse { throw BadRequestException("Некорректный идентификатор аккаунта") }
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ class SecurityConfig {
|
|||||||
"/api/v1/auth/setup",
|
"/api/v1/auth/setup",
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
"/api/v1/auth/jwks.json",
|
"/api/v1/auth/jwks.json",
|
||||||
|
// Внутренние эндпоинты защищены сервисным токеном в самом контроллере.
|
||||||
|
"/api/v1/internal/**",
|
||||||
"/actuator/**",
|
"/actuator/**",
|
||||||
"/v3/api-docs/**",
|
"/v3/api-docs/**",
|
||||||
"/swagger-ui/**",
|
"/swagger-ui/**",
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ springdoc:
|
|||||||
path: /v3/api-docs
|
path: /v3/api-docs
|
||||||
|
|
||||||
lms:
|
lms:
|
||||||
|
internal:
|
||||||
|
token: ${LMS_INTERNAL_TOKEN:dev-internal-token}
|
||||||
jwt:
|
jwt:
|
||||||
# Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml).
|
# Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml).
|
||||||
access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d}
|
access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d}
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
package ru.ysu.lms.identity.internal
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
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.crypto.password.PasswordEncoder
|
||||||
|
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.identity.domain.Account
|
||||||
|
import ru.ysu.lms.identity.domain.Enrollment
|
||||||
|
import ru.ysu.lms.identity.domain.GroupEntity
|
||||||
|
import ru.ysu.lms.identity.domain.Student
|
||||||
|
import ru.ysu.lms.identity.domain.StudentStatus
|
||||||
|
import ru.ysu.lms.identity.repo.AccountRepository
|
||||||
|
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||||
|
import ru.ysu.lms.identity.repo.GroupRepository
|
||||||
|
import ru.ysu.lms.identity.repo.StudentRepository
|
||||||
|
import ru.ysu.lms.security.LmsRole
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Testcontainers
|
||||||
|
@Transactional
|
||||||
|
class InternalControllerIntegrationTest {
|
||||||
|
companion object {
|
||||||
|
private const val TOKEN = "test-internal-token"
|
||||||
|
|
||||||
|
@Container
|
||||||
|
val pg =
|
||||||
|
PostgreSQLContainer("postgres:18").apply {
|
||||||
|
withDatabaseName("lms")
|
||||||
|
withUsername("lms")
|
||||||
|
withPassword("lms")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@DynamicPropertySource
|
||||||
|
fun properties(registry: DynamicPropertyRegistry) {
|
||||||
|
registry.add("spring.datasource.url") { pg.jdbcUrl }
|
||||||
|
registry.add("spring.datasource.username") { "lms" }
|
||||||
|
registry.add("spring.datasource.password") { "lms" }
|
||||||
|
registry.add("lms.internal.token") { TOKEN }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired lateinit var accounts: AccountRepository
|
||||||
|
|
||||||
|
@Autowired lateinit var students: StudentRepository
|
||||||
|
|
||||||
|
@Autowired lateinit var enrollments: EnrollmentRepository
|
||||||
|
|
||||||
|
@Autowired lateinit var groups: GroupRepository
|
||||||
|
|
||||||
|
@Autowired lateinit var passwordEncoder: PasswordEncoder
|
||||||
|
|
||||||
|
@Autowired lateinit var context: WebApplicationContext
|
||||||
|
|
||||||
|
private val mockMvc: MockMvc by lazy {
|
||||||
|
MockMvcBuilders
|
||||||
|
.webAppContextSetup(context)
|
||||||
|
.apply<DefaultMockMvcBuilder>(springSecurity())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid token returns student context with current group`() {
|
||||||
|
val student = student(login = "ivanov", studentNo = "123456", groupName = "ПИ-21БО")
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.get("/api/v1/internal/students/${student.accountId}/context") {
|
||||||
|
header("X-Internal-Token", TOKEN)
|
||||||
|
}.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.id") { value(student.accountId.toString()) }
|
||||||
|
jsonPath("$.login") { value("ivanov") }
|
||||||
|
jsonPath("$.fullName") { value("Иванов Иван Иванович") }
|
||||||
|
jsonPath("$.studentNo") { value("123456") }
|
||||||
|
jsonPath("$.status") { value("ACTIVE") }
|
||||||
|
jsonPath("$.enrollmentYear") { value(2025) }
|
||||||
|
jsonPath("$.groupId") { isNotEmpty() }
|
||||||
|
jsonPath("$.groupName") { value("ПИ-21БО") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `context without group returns nulls`() {
|
||||||
|
val student = student(login = "petrov", studentNo = "654321")
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.get("/api/v1/internal/students/${student.accountId}/context") {
|
||||||
|
header("X-Internal-Token", TOKEN)
|
||||||
|
}.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.groupId") { doesNotExist() }
|
||||||
|
jsonPath("$.groupName") { doesNotExist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid token returns account logins for lookup`() {
|
||||||
|
val first = account("a.ivanov")
|
||||||
|
val second = account("p.petrov")
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.post("/api/v1/internal/accounts/lookup") {
|
||||||
|
header("X-Internal-Token", TOKEN)
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = """{"ids":["${first.id}","${second.id}"]}"""
|
||||||
|
}.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.length()") { value(2) }
|
||||||
|
jsonPath("$[0].id") { value(first.id.toString()) }
|
||||||
|
jsonPath("$[0].login") { value("a.ivanov") }
|
||||||
|
jsonPath("$[0].role") { value("STUDENT") }
|
||||||
|
jsonPath("$[1].login") { value("p.petrov") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lookup ignores missing accounts and duplicates`() {
|
||||||
|
val existing = account("a.ivanov")
|
||||||
|
val missing = UUID.randomUUID()
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.post("/api/v1/internal/accounts/lookup") {
|
||||||
|
header("X-Internal-Token", TOKEN)
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = """{"ids":["${existing.id}","$missing","${existing.id}"]}"""
|
||||||
|
}.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.length()") { value(1) }
|
||||||
|
jsonPath("$[0].id") { value(existing.id.toString()) }
|
||||||
|
jsonPath("$[0].login") { value("a.ivanov") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty lookup returns empty list`() {
|
||||||
|
mockMvc
|
||||||
|
.post("/api/v1/internal/accounts/lookup") {
|
||||||
|
header("X-Internal-Token", TOKEN)
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = """{"ids":[]}"""
|
||||||
|
}.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.length()") { value(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid lookup id is rejected`() {
|
||||||
|
mockMvc
|
||||||
|
.post("/api/v1/internal/accounts/lookup") {
|
||||||
|
header("X-Internal-Token", TOKEN)
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = """{"ids":["not-a-uuid"]}"""
|
||||||
|
}.andExpect {
|
||||||
|
status { isBadRequest() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid token is forbidden`() {
|
||||||
|
val student = student(login = "sidorov", studentNo = "111111", groupName = "ПИ-21БО")
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.get("/api/v1/internal/students/${student.accountId}/context") {
|
||||||
|
header("X-Internal-Token", "wrong-token")
|
||||||
|
}.andExpect {
|
||||||
|
status { isForbidden() }
|
||||||
|
jsonPath("$.detail") { value("Неверный внутренний токен") }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.post("/api/v1/internal/accounts/lookup") {
|
||||||
|
header("X-Internal-Token", "wrong-token")
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = """{"ids":[]}"""
|
||||||
|
}.andExpect {
|
||||||
|
status { isForbidden() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing token is forbidden`() {
|
||||||
|
val student = student(login = "sidorov", studentNo = "111111")
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.get("/api/v1/internal/students/${student.accountId}/context") {
|
||||||
|
}.andExpect {
|
||||||
|
status { isForbidden() }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.post("/api/v1/internal/accounts/lookup") {
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = """{"ids":[]}"""
|
||||||
|
}.andExpect {
|
||||||
|
status { isForbidden() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown student is not found`() {
|
||||||
|
mockMvc
|
||||||
|
.get("/api/v1/internal/students/${UUID.randomUUID()}/context") {
|
||||||
|
header("X-Internal-Token", TOKEN)
|
||||||
|
}.andExpect {
|
||||||
|
status { isNotFound() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid token returns group with students and no credentials`() {
|
||||||
|
val group = group("ПИ-21БО")
|
||||||
|
val petrov = enroll(group, login = "p.petrov", studentNo = "111111", lastName = "Петров")
|
||||||
|
val ivanov = enroll(group, login = "a.ivanov", studentNo = "222222", lastName = "Иванов")
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.get("/api/v1/internal/groups/${group.id}") {
|
||||||
|
header("X-Internal-Token", TOKEN)
|
||||||
|
}.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.id") { value(group.id.toString()) }
|
||||||
|
jsonPath("$.name") { value("ПИ-21БО") }
|
||||||
|
jsonPath("$.students.length()") { value(2) }
|
||||||
|
jsonPath("$.students[0].id") { value(ivanov.accountId.toString()) }
|
||||||
|
jsonPath("$.students[0].studentNo") { value("222222") }
|
||||||
|
jsonPath("$.students[0].fullName") { value("Иванов Иван Иванович") }
|
||||||
|
jsonPath("$.students[0].status") { value("ACTIVE") }
|
||||||
|
jsonPath("$.students[1].id") { value(petrov.accountId.toString()) }
|
||||||
|
jsonPath("$.students[1].studentNo") { value("111111") }
|
||||||
|
jsonPath("$.students[0].tempPassword") { doesNotExist() }
|
||||||
|
jsonPath("$.students[0].login") { doesNotExist() }
|
||||||
|
jsonPath("$.students[0].passwordHash") { doesNotExist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `group with invalid token is forbidden`() {
|
||||||
|
val group = group("ПИ-21БО")
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.get("/api/v1/internal/groups/${group.id}") {
|
||||||
|
header("X-Internal-Token", "wrong-token")
|
||||||
|
}.andExpect {
|
||||||
|
status { isForbidden() }
|
||||||
|
jsonPath("$.detail") { value("Неверный внутренний токен") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown group is not found`() {
|
||||||
|
mockMvc
|
||||||
|
.get("/api/v1/internal/groups/${UUID.randomUUID()}") {
|
||||||
|
header("X-Internal-Token", TOKEN)
|
||||||
|
}.andExpect {
|
||||||
|
status { isNotFound() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun account(login: String): Account =
|
||||||
|
accounts.save(
|
||||||
|
Account(
|
||||||
|
login = login,
|
||||||
|
passwordHash = passwordEncoder.encode("secret123") ?: "",
|
||||||
|
role = LmsRole.STUDENT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun group(name: String): GroupEntity = groups.save(GroupEntity(name = name, enrollmentYear = 2025))
|
||||||
|
|
||||||
|
private fun enroll(
|
||||||
|
group: GroupEntity,
|
||||||
|
login: String,
|
||||||
|
studentNo: String,
|
||||||
|
lastName: String,
|
||||||
|
): Student {
|
||||||
|
val student = createStudent(login = login, studentNo = studentNo, lastName = lastName)
|
||||||
|
enrollments.save(Enrollment(group = group, student = student))
|
||||||
|
return student
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun student(
|
||||||
|
login: String,
|
||||||
|
studentNo: String,
|
||||||
|
groupName: String? = null,
|
||||||
|
): Student {
|
||||||
|
val student = createStudent(login = login, studentNo = studentNo, lastName = "Иванов")
|
||||||
|
if (groupName != null) {
|
||||||
|
enrollments.save(Enrollment(group = group(groupName), student = student))
|
||||||
|
}
|
||||||
|
return student
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createStudent(
|
||||||
|
login: String,
|
||||||
|
studentNo: String,
|
||||||
|
lastName: String,
|
||||||
|
): Student {
|
||||||
|
val account = account(login)
|
||||||
|
val student =
|
||||||
|
students.save(
|
||||||
|
Student(
|
||||||
|
studentNo = studentNo,
|
||||||
|
lastName = lastName,
|
||||||
|
firstName = "Иван",
|
||||||
|
middleName = "Иванович",
|
||||||
|
basis = "бюджет",
|
||||||
|
status = StudentStatus.ACTIVE,
|
||||||
|
enrollmentYear = 2025,
|
||||||
|
account = account,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(account.id, student.accountId)
|
||||||
|
return student
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user