diff --git a/src/main/java/com/ericbouchut/learndev/admin/AccountAdminService.java b/src/main/java/com/ericbouchut/learndev/admin/AccountAdminService.java new file mode 100644 index 0000000..2944358 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/admin/AccountAdminService.java @@ -0,0 +1,91 @@ +package com.ericbouchut.learndev.admin; + +import com.ericbouchut.learndev.audit.AuditService; +import com.ericbouchut.learndev.auth.RegistrationService; +import com.ericbouchut.learndev.auth.dto.RegisterForm; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.UUID; + +/** + * Account administration: create instructor accounts and archive or + * reactivate any account. Archiving means {@code is_active = false}, which + * Spring Security already maps to a disabled login (v1 has no destructive + * delete); an admin cannot archive their own account (409), so the platform + * cannot lock every admin out. The initial instructor password is set by the + * admin and handed over out of band; the instructor can change it through + * the existing password-reset flow. Every action lands in the audit trail. + */ +@Service +public class AccountAdminService { + + private final UserRepository users; + private final RegistrationService registration; + private final AuditService audit; + + public AccountAdminService( + UserRepository users, + RegistrationService registration, + AuditService audit + ) { + this.users = users; + this.registration = registration; + this.audit = audit; + } + + /** Every account, for the admin user list. */ + public List allUsers() { + return users.findAll(Sort.by("username")); + } + + /** One account, or 404. */ + public User account(UUID userId) { + return users.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + } + + /** + * Create an {@code INSTRUCTOR} account (same uniqueness and hashing + * rules as self-registration, different seeded role). + */ + @Transactional + public User createInstructor(RegisterForm form, User actor, String ipAddress) { + User created = registration.register(form, "INSTRUCTOR"); + audit.record("ACCOUNT_CREATED", actor, ipAddress, true, + "Instructor account " + created.getUserId() + " created"); + return created; + } + + /** + * Archive an account: the user can no longer log in. + * + * @throws ResponseStatusException 409 when the admin targets themselves + */ + @Transactional + public void archive(User target, User actor, String ipAddress) { + if (target.getUserId().equals(actor.getUserId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "An admin cannot archive their own account"); + } + target.setActive(false); + users.save(target); + audit.record("ACCOUNT_ARCHIVED", actor, ipAddress, true, + "Account " + target.getUserId() + " archived"); + } + + /** Reactivate an archived account: the user can log in again. */ + @Transactional + public void reactivate(User target, User actor, String ipAddress) { + target.setActive(true); + users.save(target); + audit.record("ACCOUNT_REACTIVATED", actor, ipAddress, true, + "Account " + target.getUserId() + " reactivated"); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/admin/AdminController.java b/src/main/java/com/ericbouchut/learndev/admin/AdminController.java new file mode 100644 index 0000000..ed0ec0d --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/admin/AdminController.java @@ -0,0 +1,237 @@ +package com.ericbouchut.learndev.admin; + +import com.ericbouchut.learndev.auth.dto.RegisterForm; +import com.ericbouchut.learndev.auth.exception.DuplicateEmailException; +import com.ericbouchut.learndev.auth.exception.DuplicateUsernameException; +import com.ericbouchut.learndev.course.InstructorCourseService; +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.Lesson; +import com.ericbouchut.learndev.course.repository.CourseRepository; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import org.springframework.web.server.ResponseStatusException; + +import java.security.Principal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Web endpoints of the administration area under {@code /admin/**} + * (gated to {@code ROLE_ADMIN} by the security rules): the account list, + * instructor account creation, and account archiving. Rules and the audit + * trail live in {@link AccountAdminService}. + */ +@Controller +@RequestMapping("/admin") +public class AdminController { + + private final AccountAdminService accounts; + private final UserRepository users; + private final CourseRepository courses; + private final InstructorCourseService instructorCourses; + + public AdminController( + AccountAdminService accounts, + UserRepository users, + CourseRepository courses, + InstructorCourseService instructorCourses + ) { + this.accounts = accounts; + this.users = users; + this.courses = courses; + this.instructorCourses = instructorCourses; + } + + /** + * Display every account with its roles and active flag. + * + * @param model receives the accounts + * @return the account list view name + */ + @GetMapping("/users") + public String userList(Model model) { + model.addAttribute("accounts", accounts.allUsers()); + return "admin/users"; + } + + /** + * Display the instructor account creation form. + * + * @param model receives an empty form + * @return the account form view name + */ + @GetMapping("/users/new-instructor") + public String newInstructorForm(Model model) { + model.addAttribute("form", new RegisterForm("", "", "")); + return "admin/user-form"; + } + + /** + * Create an instructor account. Duplicate username/email surface as + * field errors, like self-registration. + * + * @param form the submitted form, validated by {@code @Valid} + * @param binding collects validation and duplicate-field errors + * @param principal the logged-in admin + * @param request provides the client IP for the audit trail + * @return a redirect to the account list, or the re-rendered form + */ + @PostMapping("/users/new-instructor") + public String createInstructor( + @Valid @ModelAttribute("form") RegisterForm form, + BindingResult binding, + Principal principal, + HttpServletRequest request + ) { + if (binding.hasErrors()) { + return "admin/user-form"; + } + try { + accounts.createInstructor(form, currentUser(principal), request.getRemoteAddr()); + } catch (DuplicateUsernameException e) { + binding.rejectValue("username", "duplicate", "Username already taken"); + return "admin/user-form"; + } catch (DuplicateEmailException e) { + binding.rejectValue("email", "duplicate", "Email already registered"); + return "admin/user-form"; + } + return "redirect:/admin/users?created"; + } + + /** + * Archive an account (PRG with an {@code archived} flag). + * + * @param userId the account to archive + * @param principal the logged-in admin (self-archive guard) + * @param request provides the client IP for the audit trail + * @return a redirect to the account list + */ + @PostMapping("/users/{userId}/archive") + public String archiveAccount( + @PathVariable UUID userId, + Principal principal, + HttpServletRequest request + ) { + accounts.archive(accounts.account(userId), + currentUser(principal), request.getRemoteAddr()); + return "redirect:/admin/users?archived"; + } + + /** + * Reactivate an account (PRG with a {@code reactivated} flag). + * + * @param userId the account to reactivate + * @param principal the logged-in admin + * @param request provides the client IP for the audit trail + * @return a redirect to the account list + */ + @PostMapping("/users/{userId}/reactivate") + public String reactivateAccount( + @PathVariable UUID userId, + Principal principal, + HttpServletRequest request + ) { + accounts.reactivate(accounts.account(userId), + currentUser(principal), request.getRemoteAddr()); + return "redirect:/admin/users?reactivated"; + } + + /** + * Display every course, any status, any instructor, with its lessons + * (content moderation view). + * + * @param model receives the courses and their lessons + * @return the course moderation view name + */ + @GetMapping("/courses") + public String courseList(Model model) { + List allCourses = courses.findAll(Sort.by("title")); + Map> lessonsByCourseId = new HashMap<>(); + for (Course course : allCourses) { + lessonsByCourseId.put(course.getCourseId(), instructorCourses.lessonsOf(course)); + } + model.addAttribute("courses", allCourses); + model.addAttribute("lessonsByCourseId", lessonsByCourseId); + return "admin/courses"; + } + + /** + * Archive or restore any course, bypassing the instructor ownership + * rule (the lifecycle guards still apply). PRG back to the list. + * + * @param courseId the course to transition + * @param action {@code archive} or {@code restore} + * @param principal the logged-in admin + * @param request provides the client IP for the audit trail + * @return a redirect to the course moderation view + */ + @PostMapping("/courses/{courseId}/{action:archive|restore}") + public String transitionCourse( + @PathVariable Long courseId, + @PathVariable String action, + Principal principal, + HttpServletRequest request + ) { + User admin = currentUser(principal); + Course course = courses.findById(courseId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if ("archive".equals(action)) { + instructorCourses.archive(course, admin, request.getRemoteAddr()); + } else { + instructorCourses.restore(course, admin, request.getRemoteAddr()); + } + return "redirect:/admin/courses?" + action + "d"; + } + + /** + * Archive or restore any lesson of a course, bypassing the instructor + * ownership rule. PRG back to the list. + * + * @param courseId the course the lesson belongs to + * @param lessonId the lesson to transition + * @param action {@code archive} or {@code restore} + * @param principal the logged-in admin + * @param request provides the client IP for the audit trail + * @return a redirect to the course moderation view + */ + @PostMapping("/courses/{courseId}/lessons/{lessonId}/{action:archive|restore}") + public String transitionLesson( + @PathVariable Long courseId, + @PathVariable Long lessonId, + @PathVariable String action, + Principal principal, + HttpServletRequest request + ) { + User admin = currentUser(principal); + Course course = courses.findById(courseId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Lesson lesson = instructorCourses.ownedLesson(course, lessonId); + if ("archive".equals(action)) { + instructorCourses.archiveLesson(lesson, admin, request.getRemoteAddr()); + } else { + instructorCourses.restoreLesson(lesson, admin, request.getRemoteAddr()); + } + return "redirect:/admin/courses?lesson-" + action + "d"; + } + + private User currentUser(Principal principal) { + return users.findByUsername(principal.getName()) + .orElseThrow(() -> new IllegalStateException( + "Authenticated user not found: " + principal.getName())); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java b/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java index 07d6801..f377432 100644 --- a/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java +++ b/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java @@ -41,20 +41,36 @@ public RegistrationService(UserRepository users, RoleRepository roles, PasswordE */ @Transactional public User register(RegisterForm form) { + return register(form, "STUDENT"); + } + + /** + * Registers a new account with the given seeded role (self-registration + * uses {@code STUDENT}; the admin area creates {@code INSTRUCTOR} + * accounts). Same uniqueness and hashing rules as {@link #register}. + * + * @param form the validated registration form + * @param roleName the seeded role to assign + * @return the saved user, including its generated id + * @throws DuplicateUsernameException if the username is already taken + * @throws DuplicateEmailException if the email is already registered + */ + @Transactional + public User register(RegisterForm form, String roleName) { if (users.existsByUsername(form.username())) { throw new DuplicateUsernameException(form.username()); } if (users.existsByEmail(form.email())) { throw new DuplicateEmailException(form.email()); } - Role student = roles.findByRoleName("STUDENT") - .orElseThrow(() -> new IllegalStateException("STUDENT role not seeded")); + Role role = roles.findByRoleName(roleName) + .orElseThrow(() -> new IllegalStateException(roleName + " role not seeded")); User user = new User(); user.setUsername(form.username()); user.setEmail(form.email()); user.setPassword(encoder.encode(form.password())); - user.getRoles().add(student); + user.getRoles().add(role); // The existsBy* pre-checks above race under concurrency: two requests can // both pass them, and the loser hits the users_username_key/users_email_key diff --git a/src/main/resources/templates/admin/courses.html b/src/main/resources/templates/admin/courses.html new file mode 100644 index 0000000..7ab5bb1 --- /dev/null +++ b/src/main/resources/templates/admin/courses.html @@ -0,0 +1,72 @@ + + + + + + +
+

+ Course archived. It is no longer listed in the catalogue. +

+

+ Course restored as a draft. +

+

+ Lesson archived. +

+

+ Lesson restored as a draft. +

+ +

Course moderation

+ +

There is no course yet.

+ +
    +
  • +

    Course title

    +

    + By instructor + · Draft +

    + +
    +
    + +
    +
    + +
    +
    + +
      +
    1. + Lesson title + (Draft) +
      + +
      +
      + +
      +
    2. +
    +
  • +
+
+ + + + diff --git a/src/main/resources/templates/admin/user-form.html b/src/main/resources/templates/admin/user-form.html new file mode 100644 index 0000000..05a0bce --- /dev/null +++ b/src/main/resources/templates/admin/user-form.html @@ -0,0 +1,63 @@ + + + + + + + +
+ + +
+

Create an instructor account

+ +
+
+ + 3 to 50 characters. + + +
+ +
+ + + +
+ +
+ + + 8 to 100 characters. Hand it over securely; the instructor can + change it via the password reset. + + + +
+ + +
+
+ +

Back to the accounts

+
+ + + + diff --git a/src/main/resources/templates/admin/users.html b/src/main/resources/templates/admin/users.html new file mode 100644 index 0000000..b5847c5 --- /dev/null +++ b/src/main/resources/templates/admin/users.html @@ -0,0 +1,68 @@ + + + + + + +
+

+ Instructor account created. Hand the initial password over securely; + the instructor can change it via "Forgot your password?". +

+

+ Account archived. The user can no longer log in. +

+

+ Account reactivated. +

+

+ Account unlocked. +

+ +

Accounts

+ +

+ + Create an instructor account + Course moderation +

+ + + + + + + + + + + + + + + + + + + + + +
Every account on the platform
UsernameEmailRolesStatusAction
usernameemailSTUDENTActive + +
+ +
+
+ +
+
+
+ + + + diff --git a/src/test/java/com/ericbouchut/learndev/admin/AccountAdminServiceTest.java b/src/test/java/com/ericbouchut/learndev/admin/AccountAdminServiceTest.java new file mode 100644 index 0000000..11ef9c9 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/admin/AccountAdminServiceTest.java @@ -0,0 +1,90 @@ +package com.ericbouchut.learndev.admin; + +import com.ericbouchut.learndev.audit.AuditService; +import com.ericbouchut.learndev.auth.RegistrationService; +import com.ericbouchut.learndev.auth.dto.RegisterForm; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class AccountAdminServiceTest { + + private final UserRepository users = mock(UserRepository.class); + private final RegistrationService registration = mock(RegistrationService.class); + private final AuditService audit = mock(AuditService.class); + private final AccountAdminService service = + new AccountAdminService(users, registration, audit); + + private final User admin = userWithId("admin"); + private final User target = userWithId("someone"); + + private static User userWithId(String username) { + User user = new User(); + user.setUserId(UUID.randomUUID()); + user.setUsername(username); + return user; + } + + @Test + void creates_an_instructor_account_and_audits_it() { + // Arrange (Given): registration succeeds with the INSTRUCTOR role + RegisterForm form = new RegisterForm("teacher", "teacher@example.com", "secret12"); + when(registration.register(form, "INSTRUCTOR")).thenReturn(target); + + // Act (When) + User created = service.createInstructor(form, admin, "127.0.0.1"); + + // Assert (Then): delegated with the right role, audited + assertThat(created).isSameAs(target); + verify(registration).register(form, "INSTRUCTOR"); + verify(audit).record(eq("ACCOUNT_CREATED"), eq(admin), anyString(), + anyBoolean(), anyString()); + } + + @Test + void archiving_disables_the_account_and_audits_it() { + // Act (When) + service.archive(target, admin, "127.0.0.1"); + + // Assert (Then) + assertThat(target.isActive()).isFalse(); + verify(users).save(target); + verify(audit).record(eq("ACCOUNT_ARCHIVED"), eq(admin), anyString(), + anyBoolean(), anyString()); + } + + @Test + void an_admin_cannot_archive_their_own_account() { + // Act + Assert (When/Then): 409, nothing written + assertThatThrownBy(() -> service.archive(admin, admin, "127.0.0.1")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409"); + assertThat(admin.isActive()).isTrue(); + verify(users, never()).save(any()); + } + + @Test + void reactivating_enables_the_account_again() { + // Arrange (Given): an archived account + target.setActive(false); + + // Act (When) + service.reactivate(target, admin, "127.0.0.1"); + + // Assert (Then) + assertThat(target.isActive()).isTrue(); + verify(audit).record(eq("ACCOUNT_REACTIVATED"), eq(admin), anyString(), + anyBoolean(), anyString()); + } +} diff --git a/src/test/java/com/ericbouchut/learndev/admin/AdminFlowTest.java b/src/test/java/com/ericbouchut/learndev/admin/AdminFlowTest.java new file mode 100644 index 0000000..4325778 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/admin/AdminFlowTest.java @@ -0,0 +1,152 @@ +package com.ericbouchut.learndev.admin; + +import com.ericbouchut.learndev.audit.repository.AuditLogRepository; +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.PublicationStatus; +import com.ericbouchut.learndev.course.repository.CourseRepository; +import com.ericbouchut.learndev.support.AbstractPostgresIT; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * End-to-end journey of an admin: create an instructor account (which then + * really unlocks the instructor area through its DB role), archive an + * account (whose login dies) and reactivate it (login works again), refuse + * self-archiving, and moderate another instructor's content. Boots the full + * context against the shared Postgres container. + * + *

Named with the {@code Test} suffix (not {@code IT}) so Surefire runs it + * as part of {@code mvn test}; this project does not use the Failsafe plugin. + */ +@SpringBootTest(properties = { + // This feature does not use MongoDB; keep the test context Postgres-only. + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration" +}) +@AutoConfigureMockMvc +class AdminFlowTest extends AbstractPostgresIT { + + @Autowired + MockMvc mvc; + + @Autowired + UserRepository userRepository; + + @Autowired + CourseRepository courseRepository; + + @Autowired + AuditLogRepository auditLogRepository; + + private User registeredUser(String username) throws Exception { + if (userRepository.findByUsername(username).isEmpty()) { + mvc.perform(post("/auth/register").with(csrf()) + .param("username", username) + .param("email", username + "@example.com") + .param("password", "secret12")) + .andExpect(status().is3xxRedirection()); + } + return userRepository.findByUsername(username).orElseThrow(); + } + + @Test + void admin_creates_instructors_and_manages_accounts() throws Exception { + User admin = registeredUser("chief"); + + // Create an instructor account through the admin form. + mvc.perform(post("/admin/users/new-instructor") + .with(user("chief").roles("ADMIN")).with(csrf()) + .param("username", "new-teacher") + .param("email", "new-teacher@example.com") + .param("password", "secret12")) + .andExpect(redirectedUrl("/admin/users?created")); + + // The DB role is real: logging in as the new instructor unlocks the + // instructor area with no mock authorities involved. + mvc.perform(formLogin("/auth/login").user("new-teacher").password("secret12")) + .andExpect(authenticated().withUsername("new-teacher").withRoles("INSTRUCTOR")); + User teacher = userRepository.findByUsername("new-teacher").orElseThrow(); + assertThat(auditLogRepository.findAll()) + .anyMatch(log -> "ACCOUNT_CREATED".equals(log.getActionType())); + + // A duplicate username re-renders the form with a field error. + mvc.perform(post("/admin/users/new-instructor") + .with(user("chief").roles("ADMIN")).with(csrf()) + .param("username", "new-teacher") + .param("email", "other@example.com") + .param("password", "secret12")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Username already taken"))); + + // Archive the instructor: their login dies. + mvc.perform(post("/admin/users/{id}/archive", teacher.getUserId()) + .with(user("chief").roles("ADMIN")).with(csrf())) + .andExpect(redirectedUrl("/admin/users?archived")); + mvc.perform(formLogin("/auth/login").user("new-teacher").password("secret12")) + .andExpect(unauthenticated()); + + // Reactivate: the login works again. + mvc.perform(post("/admin/users/{id}/reactivate", teacher.getUserId()) + .with(user("chief").roles("ADMIN")).with(csrf())) + .andExpect(redirectedUrl("/admin/users?reactivated")); + mvc.perform(formLogin("/auth/login").user("new-teacher").password("secret12")) + .andExpect(authenticated().withUsername("new-teacher")); + + // Self-archiving is refused. + mvc.perform(post("/admin/users/{id}/archive", admin.getUserId()) + .with(user("chief").roles("ADMIN")).with(csrf())) + .andExpect(status().isConflict()); + } + + @Test + void admin_moderates_any_instructors_content() throws Exception { + registeredUser("moderator"); + User owner = registeredUser("course-owner"); + + Course course = new Course(); + course.setTitle("Course to moderate"); + course.setStatus(PublicationStatus.PUBLISHED); + course.setInstructor(owner); + courseRepository.save(course); + + // The moderation view lists the course; archiving it needs no + // ownership (the admin is not the instructor). + mvc.perform(get("/admin/courses").with(user("moderator").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Course to moderate"))); + mvc.perform(post("/admin/courses/{id}/archive", course.getCourseId()) + .with(user("moderator").roles("ADMIN")).with(csrf())) + .andExpect(redirectedUrl("/admin/courses?archived")); + assertThat(courseRepository.findById(course.getCourseId()).orElseThrow().getStatus()) + .isEqualTo(PublicationStatus.ARCHIVED); + + // Restore brings it back as a draft. + mvc.perform(post("/admin/courses/{id}/restore", course.getCourseId()) + .with(user("moderator").roles("ADMIN")).with(csrf())) + .andExpect(redirectedUrl("/admin/courses?restored")); + assertThat(courseRepository.findById(course.getCourseId()).orElseThrow().getStatus()) + .isEqualTo(PublicationStatus.DRAFT); + + // The admin role does not leak into the instructor area. + mvc.perform(get("/instructor/courses").with(user("moderator").roles("ADMIN"))) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/ericbouchut/learndev/common/config/SecurityMatrixTest.java b/src/test/java/com/ericbouchut/learndev/common/config/SecurityMatrixTest.java index 5cb206a..5349930 100644 --- a/src/test/java/com/ericbouchut/learndev/common/config/SecurityMatrixTest.java +++ b/src/test/java/com/ericbouchut/learndev/common/config/SecurityMatrixTest.java @@ -18,10 +18,8 @@ /** * Access matrix for the role-gated URL prefixes: anonymous users are sent to - * the login page, the wrong role is denied (403), and the right role passes - * the gate. The admin controller does not exist yet, so its "passes the - * gate" is asserted as 404 (the request reached MVC dispatch); tighten it to - * 200 when the admin phase lands. + * the login page, the wrong role is denied (403), and the right role reaches + * its area (200). * *

Named with the {@code Test} suffix (not {@code IT}) so Surefire runs it * as part of {@code mvn test}; this project does not use the Failsafe plugin. @@ -102,9 +100,10 @@ void instructor_is_denied_admin() throws Exception { // Admin: may pass the /admin gate; not an instructor, so denied there. @Test - @WithMockUser(roles = "ADMIN") void admin_passes_admin_gate() throws Exception { - mvc.perform(get("/admin/users")).andExpect(status().isNotFound()); + mvc.perform(get("/admin/users") + .with(user("matrix-admin").roles("ADMIN"))) + .andExpect(status().isOk()); } @Test