feat(project): enforce activation guards

This commit is contained in:
sechmachine
2026-08-15 01:29:53 +07:00
parent 213a889c8f
commit dbf12023c2
8 changed files with 151 additions and 9 deletions
@@ -63,6 +63,12 @@ public class ProjectController {
return "projects/detail"; return "projects/detail";
} }
@PostMapping("/{projectId}/activate")
public String activate(Principal principal, @PathVariable long projectId) {
projects.activate(actorId(principal), projectId);
return "redirect:/projects/" + projectId;
}
@GetMapping("/{projectId}/members") @GetMapping("/{projectId}/members")
public String members(Principal principal, @PathVariable long projectId, Model model) { public String members(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal); long actorId = actorId(principal);
@@ -21,6 +21,7 @@ import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
@Entity @Entity
@Table(name = "projects") @Table(name = "projects")
@@ -157,15 +158,23 @@ public class ProjectEntity {
this, change.replacement(), change.effectiveAt(), actorMentorUserId)); this, change.replacement(), change.effectiveAt(), actorMentorUserId));
} }
public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) { public void activate(
long actorMentorUserId,
Set<Long> activeInternUserIds,
boolean allTaskAssigneesAreCurrent,
Instant at) {
requireOwner(actorMentorUserId); requireOwner(actorMentorUserId);
Objects.requireNonNull(activeInternUserIds, "activeInternUserIds");
Objects.requireNonNull(at, "at"); Objects.requireNonNull(at, "at");
if (status != ProjectStatus.PLANNED) { if (status != ProjectStatus.PLANNED) {
throw new ProjectRuleViolationException("Only a planned Project can be activated"); throw new ProjectRuleViolationException("Only a planned Project can be activated");
} }
if (memberships.stream().noneMatch(ProjectMembershipEntity::isCurrent) if (memberships.stream().noneMatch(membership -> membership.isCurrent()
|| leadershipTerms.stream().noneMatch(ProjectLeadershipTermEntity::isCurrent)) { && activeInternUserIds.contains(membership.internUserId()))) {
throw new ProjectRuleViolationException("Project requires a current member and Leader"); throw new ProjectRuleViolationException("Project requires an active member");
}
if (!activeInternUserIds.contains(currentLeader().internUserId())) {
throw new ProjectRuleViolationException("Project Leader must be an active member");
} }
if (!allTaskAssigneesAreCurrent) { if (!allTaskAssigneesAreCurrent) {
throw new ProjectRuleViolationException("Every current Task assignee must be an active Project member"); throw new ProjectRuleViolationException("Every current Task assignee must be an active Project member");
@@ -7,7 +7,10 @@ import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext; import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository; import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import com.lab.labtimesheet.feature.task.service.TaskQueryService;
import java.time.Clock; import java.time.Clock;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -17,16 +20,19 @@ public class ProjectService {
private final ProjectRepository projects; private final ProjectRepository projects;
private final AccountService accounts; private final AccountService accounts;
private final ProjectQueryService queries; private final ProjectQueryService queries;
private final TaskQueryService taskQueries;
private final Clock clock; private final Clock clock;
public ProjectService( public ProjectService(
ProjectRepository projects, ProjectRepository projects,
AccountService accounts, AccountService accounts,
ProjectQueryService queries, ProjectQueryService queries,
TaskQueryService taskQueries,
Clock clock) { Clock clock) {
this.projects = projects; this.projects = projects;
this.accounts = accounts; this.accounts = accounts;
this.queries = queries; this.queries = queries;
this.taskQueries = taskQueries;
this.clock = clock; this.clock = clock;
} }
@@ -70,6 +76,27 @@ public class ProjectService {
return queries.taskContext(actorUserId, lockedProject(projectId)); return queries.taskContext(actorUserId, lockedProject(projectId));
} }
@Transactional
public void activate(long actorUserId, long projectId) {
var project = lockedProject(projectId);
project.authorizeOwner(actorUserId);
var activeMemberships = project.memberships().stream()
.filter(membership -> membership.isCurrent()
&& accounts.isEligibleIntern(membership.internUserId()))
.toList();
var activeMembershipIds = activeMemberships.stream()
.map(membership -> membership.id())
.collect(Collectors.toUnmodifiableSet());
Set<Long> activeInternUserIds = activeMemberships.stream()
.map(membership -> membership.internUserId())
.collect(Collectors.toUnmodifiableSet());
var allTaskAssigneesAreCurrent = taskQueries.countCurrentTasksAssignedOutside(
projectId, activeMembershipIds) == 0;
project.activate(actorUserId, activeInternUserIds, allTaskAssigneesAreCurrent, clock.instant());
projects.flush();
}
private ProjectEntity lockedProject(long projectId) { private ProjectEntity lockedProject(long projectId) {
return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new); return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new);
} }
@@ -6,6 +6,9 @@
<h1 th:text="${project.name}">Project</h1> <h1 th:text="${project.name}">Project</h1>
<p th:text="${project.description}"></p> <p th:text="${project.description}"></p>
<dl><dt>Status</dt><dd th:text="${project.status}"></dd><dt>Mentor</dt><dd th:text="${project.mentorName}"></dd><dt>Leader</dt><dd th:text="${project.leaderName}"></dd></dl> <dl><dt>Status</dt><dd th:text="${project.status}"></dd><dt>Mentor</dt><dd th:text="${project.mentorName}"></dd><dt>Leader</dt><dd th:text="${project.leaderName}"></dd></dl>
<form th:if="${project.canManage and project.status == 'PLANNED'}" th:action="@{/projects/{id}/activate(id=${project.id})}" method="post">
<button type="submit">Activate</button>
</form>
<nav aria-label="Project sections"><a th:href="@{/projects/{id}/members(id=${project.id})}">Members</a> <a th:href="@{/projects/{id}/leadership(id=${project.id})}">Leadership</a></nav> <nav aria-label="Project sections"><a th:href="@{/projects/{id}/members(id=${project.id})}">Members</a> <a th:href="@{/projects/{id}/leadership(id=${project.id})}">Leadership</a></nav>
</main> </main>
</body> </body>
@@ -150,6 +150,54 @@ class ProjectControllerTest {
.andExpect(redirectedUrl("/projects/30")); .andExpect(redirectedUrl("/projects/30"));
} }
@Test
@WithMockUser(username = "mentor@example.test")
void owningMentorCanActivateAPlannedProject() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
mvc.perform(post("/projects/30/activate").with(csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/projects/30"));
verify(projects).activate(10L, 30L);
}
@Test
@WithMockUser(username = "mentor@example.test")
void plannedProjectDetailShowsActivationOnlyToTheOwningMentor() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(new ProjectDetail(
30L,
"Intern Portal Refresh",
null,
"PLANNED",
LocalDate.of(2026, 8, 15),
LocalDate.of(2026, 9, 30),
"Mentor",
"Leader",
true));
mvc.perform(get("/projects/30"))
.andExpect(status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString(">Activate<")));
when(pages.detail(10L, 30L)).thenReturn(new ProjectDetail(
30L,
"Intern Portal Refresh",
null,
"PLANNED",
LocalDate.of(2026, 8, 15),
LocalDate.of(2026, 9, 30),
"Mentor",
"Leader",
false));
mvc.perform(get("/projects/30"))
.andExpect(status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString(">Activate<"))));
}
@Test @Test
@WithMockUser(username = "mentor@example.test") @WithMockUser(username = "mentor@example.test")
void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception { void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception {
@@ -11,6 +11,7 @@ import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
import com.lab.labtimesheet.feature.project.model.ProjectStatus; import com.lab.labtimesheet.feature.project.model.ProjectStatus;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Set;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class ProjectEntityTest { class ProjectEntityTest {
@@ -114,16 +115,20 @@ class ProjectEntityTest {
var project = plannedProject(); var project = plannedProject();
assertThrows(ProjectAccessDeniedException.class, assertThrows(ProjectAccessDeniedException.class,
() -> project.activate(11L, true, CREATED_AT.plusSeconds(60))); () -> project.activate(11L, Set.of(20L), true, CREATED_AT.plusSeconds(60)));
assertThrows(ProjectRuleViolationException.class, assertThrows(ProjectRuleViolationException.class,
() -> project.activate(10L, false, CREATED_AT.plusSeconds(60))); () -> project.activate(10L, Set.of(), true, CREATED_AT.plusSeconds(60)));
assertThrows(ProjectRuleViolationException.class,
() -> project.activate(10L, Set.of(21L), true, CREATED_AT.plusSeconds(60)));
assertThrows(ProjectRuleViolationException.class,
() -> project.activate(10L, Set.of(20L), false, CREATED_AT.plusSeconds(60)));
project.activate(10L, true, CREATED_AT.plusSeconds(60)); project.activate(10L, Set.of(20L), true, CREATED_AT.plusSeconds(60));
assertEquals(ProjectStatus.ACTIVE, project.status()); assertEquals(ProjectStatus.ACTIVE, project.status());
assertEquals(CREATED_AT.plusSeconds(60), project.activatedAt()); assertEquals(CREATED_AT.plusSeconds(60), project.activatedAt());
assertThrows(ProjectRuleViolationException.class, assertThrows(ProjectRuleViolationException.class,
() -> project.activate(10L, true, CREATED_AT.plusSeconds(120))); () -> project.activate(10L, Set.of(20L), true, CREATED_AT.plusSeconds(120)));
} }
private static ProjectEntity plannedProject() { private static ProjectEntity plannedProject() {
@@ -215,6 +215,46 @@ class ProjectServiceIntegrationTest {
assertTrue(members.stream().noneMatch(member -> member.currentLeader())); assertTrue(members.stream().noneMatch(member -> member.currentLeader()));
} }
@Test
void ownerActivatesAPlannedProjectWhenCurrentMemberAndTaskAssigneeGuardsPass() {
long mentorId = user("mentor-activate@example.test", "MENTOR");
long leaderId = intern("leader-activate@example.test", "I014");
long projectId = createProject(mentorId, leaderId, "Ready to activate");
projectService.activate(mentorId, projectId);
assertEquals("ACTIVE", text("select status from projects where id = ?", projectId));
assertEquals(1, count("select count(*) from projects where id = ? and activated_at is not null", projectId));
}
@Test
void activationRejectsATaskAssignedToAFormerMemberWithoutPartialMutation() {
long mentorId = user("mentor-guard@example.test", "MENTOR");
long leaderId = intern("leader-guard@example.test", "I015");
long formerMemberId = intern("former-assignee@example.test", "I016");
long projectId = createProject(mentorId, leaderId, "Assignee guard");
projectService.addMember(mentorId, projectId, formerMemberId);
long leaderMembershipId = membershipId(projectId, leaderId);
long formerMembershipId = membershipId(projectId, formerMemberId);
jdbc.update("""
insert into tasks (
project_id, assignee_membership_id, title,
created_by_membership_id, assigned_by_membership_id)
values (?, ?, 'Former assignee', ?, ?)
""", projectId, formerMembershipId, leaderMembershipId, leaderMembershipId);
jdbc.update("""
update project_memberships
set left_at = ?, removed_by_mentor_user_id = ?, updated_at = ?
where id = ?
""", dbTime(NOW.plusSeconds(60)), mentorId, dbTime(NOW.plusSeconds(60)), formerMembershipId);
entityManager.clear();
assertThrows(ProjectRuleViolationException.class, () -> projectService.activate(mentorId, projectId));
assertEquals("PLANNED", text("select status from projects where id = ?", projectId));
assertEquals(1, count("select count(*) from tasks where project_id = ? and deleted_at is null", projectId));
}
private long createProject(long mentorId, long leaderId, String name) { private long createProject(long mentorId, long leaderId, String name) {
return projectService.create( return projectService.create(
mentorId, mentorId,
@@ -9,6 +9,7 @@ import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext; import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository; import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import com.lab.labtimesheet.feature.task.service.TaskQueryService;
import java.time.Clock; import java.time.Clock;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
@@ -30,6 +31,9 @@ class ProjectTaskMutationContextTest {
@Mock @Mock
private ProjectQueryService queries; private ProjectQueryService queries;
@Mock
private TaskQueryService taskQueries;
@Mock @Mock
private ProjectEntity project; private ProjectEntity project;
@@ -45,7 +49,7 @@ class ProjectTaskMutationContextTest {
LocalDate.of(2026, 9, 30), LocalDate.of(2026, 9, 30),
40L, 40L,
List.of()); List.of());
var service = new ProjectService(projects, accounts, queries, Clock.systemUTC()); var service = new ProjectService(projects, accounts, queries, taskQueries, Clock.systemUTC());
when(projects.findLockedById(projectId)).thenReturn(Optional.of(project)); when(projects.findLockedById(projectId)).thenReturn(Optional.of(project));
when(queries.taskContext(actorUserId, project)).thenReturn(expected); when(queries.taskContext(actorUserId, project)).thenReturn(expected);