From 4e2ab8601bf4fb339c6ca8ecf46964fd41b22e98 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Mon, 13 Jul 2026 18:36:01 +0200 Subject: [PATCH 1/2] feat(admin): Let admins create, archive, and reactivate accounts The admin area under /admin/users (already gated to ROLE_ADMIN) lists every account with roles and status, creates INSTRUCTOR accounts, and archives or reactivates any account. RegistrationService gains a role-parameterized overload so instructor creation reuses the exact uniqueness, hashing, and race-safe constraint mapping of self-registration; the admin sets the initial password and hands it over out of band, and the instructor can change it through the existing password-reset flow (recorded in the service Javadoc). Archiving sets is_active = false, which the login already maps to a disabled account; an admin cannot archive their own account (409), so the platform cannot lose its last admin. Account creation, archiving, and reactivation are audited. The security matrix tightens the admin gate to 200. --- .../learndev/admin/AccountAdminService.java | 91 ++++++++++++ .../learndev/admin/AdminController.java | 139 ++++++++++++++++++ .../learndev/auth/RegistrationService.java | 22 ++- .../resources/templates/admin/user-form.html | 63 ++++++++ src/main/resources/templates/admin/users.html | 64 ++++++++ .../admin/AccountAdminServiceTest.java | 90 ++++++++++++ .../common/config/SecurityMatrixTest.java | 11 +- 7 files changed, 471 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/ericbouchut/learndev/admin/AccountAdminService.java create mode 100644 src/main/java/com/ericbouchut/learndev/admin/AdminController.java create mode 100644 src/main/resources/templates/admin/user-form.html create mode 100644 src/main/resources/templates/admin/users.html create mode 100644 src/test/java/com/ericbouchut/learndev/admin/AccountAdminServiceTest.java 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..4c09c21 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/admin/AdminController.java @@ -0,0 +1,139 @@ +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.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +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 java.security.Principal; +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; + + public AdminController(AccountAdminService accounts, UserRepository users) { + this.accounts = accounts; + this.users = users; + } + + /** + * 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"; + } + + 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/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..eb4b3cc --- /dev/null +++ b/src/main/resources/templates/admin/users.html @@ -0,0 +1,64 @@ + + + + + + +
+

+ 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. +

+ +

Accounts

+ +

+ + Create an instructor account +

+ + + + + + + + + + + + + + + + + + + + + +
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/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 From 6bc1726fcd6c4531522306a300db92ff024bbc99 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Mon, 13 Jul 2026 19:54:08 +0200 Subject: [PATCH 2/2] feat(admin): Let admins archive and restore any course or lesson The moderation view at /admin/courses lists every course, whatever its status or instructor, with its lessons. Archive and restore reuse the Phase 3 lifecycle methods (same transition guards, same audit trail) while bypassing only the ownership rule: the admin loads content directly instead of going through the instructor-owned lookup. AdminFlowTest drives both admin journeys end to end: the created instructor account really unlocks the instructor area through its DB role, an archived account can no longer log in and a reactivated one can again, self-archiving answers 409, moderation archives and restores the course of another instructor, and the ADMIN role does not leak into the instructor area. --- .../learndev/admin/AdminController.java | 100 +++++++++++- .../resources/templates/admin/courses.html | 72 +++++++++ src/main/resources/templates/admin/users.html | 4 + .../learndev/admin/AdminFlowTest.java | 152 ++++++++++++++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/templates/admin/courses.html create mode 100644 src/test/java/com/ericbouchut/learndev/admin/AdminFlowTest.java diff --git a/src/main/java/com/ericbouchut/learndev/admin/AdminController.java b/src/main/java/com/ericbouchut/learndev/admin/AdminController.java index 4c09c21..ed0ec0d 100644 --- a/src/main/java/com/ericbouchut/learndev/admin/AdminController.java +++ b/src/main/java/com/ericbouchut/learndev/admin/AdminController.java @@ -3,10 +3,16 @@ 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; @@ -16,7 +22,12 @@ 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; /** @@ -31,10 +42,19 @@ 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) { + public AdminController( + AccountAdminService accounts, + UserRepository users, + CourseRepository courses, + InstructorCourseService instructorCourses + ) { this.accounts = accounts; this.users = users; + this.courses = courses; + this.instructorCourses = instructorCourses; } /** @@ -131,6 +151,84 @@ public String reactivateAccount( 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( 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/users.html b/src/main/resources/templates/admin/users.html index eb4b3cc..b5847c5 100644 --- a/src/main/resources/templates/admin/users.html +++ b/src/main/resources/templates/admin/users.html @@ -15,12 +15,16 @@

Account reactivated.

+

+ Account unlocked. +

Accounts

Create an instructor account + Course moderation

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()); + } +}