feat(ldd): property-based, mutation и браузерный E2E

This commit is contained in:
2026-10-28 00:01:56 +03:00
parent 80ccf26b61
commit 905feb440b
18 changed files with 1432 additions and 2 deletions

View File

@@ -0,0 +1,257 @@
package ru.ysu.lms.academic.course
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatCode
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.Test
import org.springframework.security.oauth2.jwt.Jwt
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.CourseGroup
import ru.ysu.lms.academic.domain.CourseTeacher
import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.identity.IdentityClient
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.academic.repo.CourseTeacherRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.common.ForbiddenException
import java.util.Optional
import java.util.UUID
/**
* JUnit 4 harness для PIT: JUnit5-плагин PIT несовместим с JUnit Platform 6,
* а встроенный JUnit 4 runner — совместим. Обычный `./gradlew test` этот класс
* не запускает; он нужен только задаче `pitestCourseAccess`.
*/
class CourseAccessServiceMutationTest {
private val courses = mockk<CourseRepository>()
private val teachers = mockk<CourseTeacherRepository>()
private val groups = mockk<CourseGroupRepository>()
private val labs = mockk<LabRepository>()
private val identity = mockk<IdentityClient>()
private val courseService = mockk<CourseService>()
private val service =
CourseAccessService(
courses,
teachers,
groups,
labs,
identity,
courseService,
)
@Test
fun `admin bypasses archived course`() {
val archived = course(Course.STATUS_ARCHIVED)
assertThatCode {
service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_ADMIN))
}.doesNotThrowAnyException()
}
@Test
fun `teacher assigned active course allowed`() {
val active = course(Course.STATUS_ACTIVE)
val teacherId = UUID.randomUUID()
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(CourseTeacher(course = active, accountId = teacherId))
every { courses.findAllById(any()) } returns listOf(active)
assertThatCode {
service.requireCourseAccess(active.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
}.doesNotThrowAnyException()
}
@Test
fun `teacher foreign and archived courses denied`() {
val assigned = course(Course.STATUS_ACTIVE)
val foreign = course(Course.STATUS_ACTIVE)
val archived = course(Course.STATUS_ARCHIVED)
val teacherId = UUID.randomUUID()
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(CourseTeacher(course = assigned, accountId = teacherId))
every { courses.findAllById(any()) } returns listOf(assigned, archived)
assertThatThrownBy {
service.requireCourseAccess(foreign.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
}.isInstanceOf(ForbiddenException::class.java)
assertThatThrownBy {
service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `teacher without active assignment denied`() {
val teacherId = UUID.randomUUID()
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns emptyList()
assertThatThrownBy {
service.requireCourseAccess(course(Course.STATUS_ACTIVE).id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `student current active group allowed and foreign denied`() {
val active = course(Course.STATUS_ACTIVE)
val foreign = course(Course.STATUS_ACTIVE)
val groupId = UUID.randomUUID()
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
listOf(CourseGroup(course = active, groupId = groupId))
every { courses.findAllById(any()) } returns listOf(active)
assertThatCode {
service.requireCourseAccess(active.id, jwt(CourseAccessService.ROLE_STUDENT))
}.doesNotThrowAnyException()
assertThatThrownBy {
service.requireCourseAccess(foreign.id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `student without group and archived course denied`() {
every { identity.currentGroupIds(TOKEN) } returns emptyList()
assertThatThrownBy {
service.requireCourseAccess(course(Course.STATUS_ACTIVE).id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(ForbiddenException::class.java)
val archived = course(Course.STATUS_ARCHIVED)
val groupId = UUID.randomUUID()
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
listOf(CourseGroup(course = archived, groupId = groupId))
every { courses.findAllById(any()) } returns listOf(archived)
assertThatThrownBy {
service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `lab without course is admin only`() {
val lab = Lab(number = 1, title = "Без курса")
every { labs.findById(lab.id) } returns Optional.of(lab)
assertThatCode {
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_ADMIN))
}.doesNotThrowAnyException()
assertThatThrownBy {
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `lab access follows course`() {
val active = course(Course.STATUS_ACTIVE)
val lab = Lab(number = 1, title = "Лабораторная", course = active)
val groupId = UUID.randomUUID()
every { labs.findById(lab.id) } returns Optional.of(lab)
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
listOf(CourseGroup(course = active, groupId = groupId))
every { courses.findAllById(any()) } returns listOf(active)
assertThatCode {
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
}.doesNotThrowAnyException()
assertThat(service.accessibleCourseIds(jwt(CourseAccessService.ROLE_ADMIN))).isNull()
}
@Test
fun `my courses admin teacher and student are filtered`() {
val active = course(Course.STATUS_ACTIVE)
val archived = course(Course.STATUS_ARCHIVED)
every { courseService.list() } returns listOf(summary(active), summary(archived))
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_ADMIN)).map { it.id })
.containsExactly(active.id.toString(), archived.id.toString())
val teacherId = UUID.randomUUID()
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(
CourseTeacher(course = active, accountId = teacherId),
CourseTeacher(course = archived, accountId = teacherId),
)
every { courses.findAllById(any()) } returns listOf(active, archived)
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_TEACHER, teacherId)).map { it.id })
.containsExactly(active.id.toString())
val groupId = UUID.randomUUID()
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
listOf(CourseGroup(course = active, groupId = groupId))
every { courses.findAllById(any()) } returns listOf(active)
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_STUDENT)).map { it.id })
.containsExactly(active.id.toString())
}
@Test
fun `empty course list and unknown role are safe`() {
every { teachers.findByAccountIdAndRemovedAtIsNull(any()) } returns emptyList()
every { courseService.list() } returns emptyList()
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_TEACHER, UUID.randomUUID()))).isEmpty()
assertThat(service.accessibleCourseIds(jwt("UNKNOWN"))).isEmpty()
assertThat(service.isAdmin(jwt(CourseAccessService.ROLE_ADMIN))).isTrue()
assertThat(service.isAdmin(jwt(CourseAccessService.ROLE_TEACHER))).isFalse()
}
@Test
fun `invalid subject and foreign lab are denied`() {
val invalid =
Jwt
.withTokenValue(TOKEN)
.header("alg", "none")
.subject("not-a-uuid")
.claim("role", "TEACHER")
.build()
assertThatThrownBy { service.accessibleCourseIds(invalid) }
.isInstanceOf(ForbiddenException::class.java)
val active = course(Course.STATUS_ACTIVE)
val foreign = course(Course.STATUS_ACTIVE)
val teacherId = UUID.randomUUID()
val lab = Lab(number = 1, title = "Лабораторная", course = foreign)
every { labs.findById(lab.id) } returns Optional.of(lab)
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(CourseTeacher(course = active, accountId = teacherId))
every { courses.findAllById(any()) } returns listOf(active)
assertThatThrownBy { service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId)) }
.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `missing lab is not found`() {
every { labs.findById(any()) } returns Optional.empty()
assertThatThrownBy { service.requireLabAccess(UUID.randomUUID(), jwt(CourseAccessService.ROLE_ADMIN)) }
.isInstanceOf(ru.ysu.lms.common.NotFoundException::class.java)
}
private fun course(status: String): Course = Course(title = "Курс", status = status)
private fun summary(course: Course): CourseSummary =
CourseSummary(
id = course.id.toString(),
courseNo = 1,
title = course.title,
ownerAccountId = course.ownerAccountId.toString(),
status = course.status,
teacherCount = 0,
groupCount = 0,
labCount = 0,
)
private fun jwt(
role: String,
subject: UUID = UUID.randomUUID(),
): Jwt =
Jwt
.withTokenValue(TOKEN)
.header("alg", "none")
.subject(subject.toString())
.claim("role", role)
.build()
private companion object {
const val TOKEN = "token"
}
}

View File

@@ -0,0 +1,153 @@
package ru.ysu.lms.academic.course
import io.mockk.every
import io.mockk.mockk
import net.jqwik.api.ForAll
import net.jqwik.api.Property
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.springframework.security.oauth2.jwt.Jwt
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.CourseGroup
import ru.ysu.lms.academic.domain.CourseTeacher
import ru.ysu.lms.academic.identity.IdentityClient
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.academic.repo.CourseTeacherRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.common.ForbiddenException
import java.util.UUID
/**
* Property-based проверка матрицы доступа: не отдельные примеры, а
* утверждение «доступ есть ровно тогда, когда выполнены все условия».
* jqwik перебирает комбинации флагов; PIT дополнительно проверяет, что
* тест падает при мутации логики.
*/
class CourseAccessServicePropertyTest {
@Suppress("ktlint:standard:function-naming")
@Property(tries = 80)
fun `teacher access equals assigned and active course`(
@ForAll hasAssignment: Boolean,
@ForAll targetAssigned: Boolean,
@ForAll active: Boolean,
) {
val courses = mockk<CourseRepository>()
val teachers = mockk<CourseTeacherRepository>()
val groups = mockk<CourseGroupRepository>()
val labs = mockk<LabRepository>()
val identity = mockk<IdentityClient>()
val service =
CourseAccessService(
courses,
teachers,
groups,
labs,
identity,
mockk<CourseService>(),
)
val teacherId = UUID.randomUUID()
val assigned = course(if (active) Course.STATUS_ACTIVE else Course.STATUS_ARCHIVED)
val target = if (targetAssigned) assigned else course(Course.STATUS_ACTIVE)
if (hasAssignment) {
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(CourseTeacher(course = assigned, accountId = teacherId))
every { courses.findAllById(any()) } returns listOf(assigned)
} else {
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns emptyList()
}
val thrown =
catchThrowable {
service.requireCourseAccess(target.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
}
val expected = hasAssignment && targetAssigned && active
if (expected) {
assertThat(thrown).isNull()
} else {
assertThat(thrown).isInstanceOf(ForbiddenException::class.java)
}
}
@Suppress("ktlint:standard:function-naming")
@Property(tries = 80)
fun `student access equals current attached active course`(
@ForAll hasGroup: Boolean,
@ForAll attached: Boolean,
@ForAll active: Boolean,
) {
val courses = mockk<CourseRepository>()
val teachers = mockk<CourseTeacherRepository>()
val groups = mockk<CourseGroupRepository>()
val labs = mockk<LabRepository>()
val identity = mockk<IdentityClient>()
val service =
CourseAccessService(
courses,
teachers,
groups,
labs,
identity,
mockk<CourseService>(),
)
val groupId = UUID.randomUUID()
val course = course(if (active) Course.STATUS_ACTIVE else Course.STATUS_ARCHIVED)
if (hasGroup) {
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
if (attached) listOf(CourseGroup(course = course, groupId = groupId)) else emptyList()
every { courses.findAllById(any()) } returns listOf(course)
} else {
every { identity.currentGroupIds(TOKEN) } returns emptyList()
}
val thrown =
catchThrowable {
service.requireCourseAccess(course.id, jwt(CourseAccessService.ROLE_STUDENT))
}
val expected = hasGroup && attached && active
if (expected) {
assertThat(thrown).isNull()
} else {
assertThat(thrown).isInstanceOf(ForbiddenException::class.java)
}
}
@Suppress("ktlint:standard:function-naming")
@Property(tries = 20)
fun `admin always bypasses course checks`(
@ForAll active: Boolean,
) {
val service =
CourseAccessService(
mockk(),
mockk(),
mockk(),
mockk(),
mockk(),
mockk(),
)
val course = course(if (active) Course.STATUS_ACTIVE else Course.STATUS_ARCHIVED)
assertThat(catchThrowable { service.requireCourseAccess(course.id, jwt(CourseAccessService.ROLE_ADMIN)) }).isNull()
}
private fun course(status: String): Course = Course(title = "Курс", status = status)
private fun jwt(
role: String,
subject: UUID = UUID.randomUUID(),
): Jwt =
Jwt
.withTokenValue(TOKEN)
.header("alg", "none")
.subject(subject.toString())
.claim("role", role)
.build()
private companion object {
const val TOKEN = "token"
}
}