From 1ed23f4de9abdfb06108522f9d6a3257a60eca10 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:48:16 +0700 Subject: [PATCH] feat(tasks): add task pages and request boundaries --- docs/tests/integration/task-workflow.md | 6 +- docs/tests/web/task-pages.md | 78 +++++++++++ .../tasks/TaskAssigneeChoice.java | 3 + .../labtimesheet/tasks/TaskController.java | 108 ++++++++++++++ .../labtimesheet/tasks/TaskCreateForm.java | 13 ++ .../lab/labtimesheet/tasks/TaskService.java | 26 ++++ .../resources/templates/tasks/detail.html | 36 +++++ src/main/resources/templates/tasks/form.html | 37 +++++ src/main/resources/templates/tasks/list.html | 32 +++++ .../TaskCreationIntegrationTest.java | 11 ++ .../tasks/TaskControllerTest.java | 132 ++++++++++++++++++ 11 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 docs/tests/web/task-pages.md create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskController.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java create mode 100644 src/main/resources/templates/tasks/detail.html create mode 100644 src/main/resources/templates/tasks/form.html create mode 100644 src/main/resources/templates/tasks/list.html create mode 100644 src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java diff --git a/docs/tests/integration/task-workflow.md b/docs/tests/integration/task-workflow.md index 0eb7806..0ac76ea 100644 --- a/docs/tests/integration/task-workflow.md +++ b/docs/tests/integration/task-workflow.md @@ -12,7 +12,7 @@ PostgreSQL-backed Task operations preserve generic same-Project membership actor ## Test method -Eight transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. +Nine transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. ## Hand-derived expected result @@ -65,7 +65,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -79,7 +79,7 @@ export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -[INFO] Tests run: 30, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` diff --git a/docs/tests/web/task-pages.md b/docs/tests/web/task-pages.md new file mode 100644 index 0000000..d7db642 --- /dev/null +++ b/docs/tests/web/task-pages.md @@ -0,0 +1,78 @@ +# Test Evidence: Task pages and server-side request boundaries + +- **Test type:** Web +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-007`, `TSK-011`, `TSK-012` +- **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` +- **Test class/method:** `com.lab.labtimesheet.tasks.TaskControllerTest` +- **Implementation commit:** `pending` + +## Protected behavior + +Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, and show `N/A` for an empty Project. + +## Test method + +Six `@WebMvcTest` MockMvc tests render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, and exception-to-status mapping. Only the PostgreSQL-backed Task service is replaced at the controller boundary. + +## Hand-derived expected result + +Unauthenticated list access returns 401 under the current platform security baseline. An authorized empty list returns 200 and contains `N/A`. A denied guessed Task returns 404. A valid create request passes Project 10, assignee membership 7, the supplied fields, and the authenticated email to the service, then redirects to Task 25. Blank title stays on the form with a field error and no write. Valid status/comment posts redirect to Task 25. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskControllerTest test +``` + +**Observed result** + +```text +[ERROR] TaskControllerTest.java:[28,13] cannot find symbol + symbol: class TaskController +[INFO] BUILD FAILURE +``` + +The first sandboxed GREEN attempt then exposed an environment boundary, not an application failure: Mockito could not use Java 25 self-attach inside the restricted sandbox. The exact same command was rerun with approved escalation; one test expectation was corrected from a login redirect to the platform baseline's observed 401 response before the final GREEN run. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskControllerTest test +``` + +Run with approved sandbox escalation for Mockito Java 25 self-attach. + +**Observed result** + +```text +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +The suite ran with approved escalation for OrbStack and Mockito self-attach. + +## External-test boundaries + +This slice test does not prove PostgreSQL state changes; those are covered by `TaskCreationIntegrationTest`. Shared shell styling/navigation remains owned by `work/reports-ui`. Browser journeys, notifications, Iteration 2 workflows, and narrow-screen behavior are outside this Iteration 1 Task evidence. diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java b/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java new file mode 100644 index 0000000..6e09bb1 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java @@ -0,0 +1,3 @@ +package com.lab.labtimesheet.tasks; + +public record TaskAssigneeChoice(long membershipId, String displayName) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskController.java b/src/main/java/com/lab/labtimesheet/tasks/TaskController.java new file mode 100644 index 0000000..0188b1a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskController.java @@ -0,0 +1,108 @@ +package com.lab.labtimesheet.tasks; + +import jakarta.validation.Valid; +import java.util.Locale; +import org.springframework.security.core.Authentication; +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.RequestParam; + +@Controller +public class TaskController { + + private final TaskService taskService; + + public TaskController(TaskService taskService) { + this.taskService = taskService; + } + + @GetMapping("/projects/{projectId}/tasks") + String list(Authentication authentication, @PathVariable long projectId, Model model) { + TaskListView taskList = taskService.list(authentication.getName(), projectId); + model.addAttribute("projectId", projectId); + model.addAttribute("taskList", taskList); + model.addAttribute("progressLabel", progressLabel(taskList.progress())); + return "tasks/list"; + } + + @GetMapping("/projects/{projectId}/tasks/new") + String createForm(Authentication authentication, @PathVariable long projectId, Model model) { + model.addAttribute("taskForm", new TaskCreateForm("", "", null, null)); + populateForm(authentication.getName(), projectId, model); + return "tasks/form"; + } + + @PostMapping("/projects/{projectId}/tasks") + String create( + Authentication authentication, + @PathVariable long projectId, + @Valid @ModelAttribute("taskForm") TaskCreateForm form, + BindingResult bindingResult, + Model model) { + if (bindingResult.hasErrors()) { + populateForm(authentication.getName(), projectId, model); + return "tasks/form"; + } + TaskView task = taskService.create( + authentication.getName(), + new CreateTaskCommand( + projectId, + form.assigneeMembershipId(), + form.title(), + form.description(), + form.dueDate())); + return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id()); + } + + @GetMapping("/projects/{projectId}/tasks/{taskId}") + String details( + Authentication authentication, + @PathVariable long projectId, + @PathVariable long taskId, + Model model) { + model.addAttribute("projectId", projectId); + model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId)); + model.addAttribute("statuses", TaskStatus.values()); + return "tasks/detail"; + } + + @PostMapping("/projects/{projectId}/tasks/{taskId}/status") + String changeStatus( + Authentication authentication, + @PathVariable long projectId, + @PathVariable long taskId, + @RequestParam TaskStatus status) { + taskService.changeStatus(authentication.getName(), projectId, taskId, status); + return detailsRedirect(projectId, taskId); + } + + @PostMapping("/projects/{projectId}/tasks/{taskId}/comments") + String addComment( + Authentication authentication, + @PathVariable long projectId, + @PathVariable long taskId, + @RequestParam String body) { + taskService.addComment(authentication.getName(), projectId, taskId, body); + return detailsRedirect(projectId, taskId); + } + + private void populateForm(String actorEmail, long projectId, Model model) { + model.addAttribute("projectId", projectId); + model.addAttribute("assignees", taskService.assignmentChoices(actorEmail, projectId)); + } + + private static String detailsRedirect(long projectId, long taskId) { + return "redirect:/projects/%d/tasks/%d".formatted(projectId, taskId); + } + + private static String progressLabel(TaskProgress progress) { + return progress.completionPercentage().isEmpty() + ? "N/A" + : String.format(Locale.ROOT, "%.1f%%", progress.completionPercentage().getAsDouble()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java b/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java new file mode 100644 index 0000000..fb0a92d --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java @@ -0,0 +1,13 @@ +package com.lab.labtimesheet.tasks; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +public record TaskCreateForm( + @NotBlank @Size(max = 200) String title, + String description, + @NotNull Long assigneeMembershipId, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueDate) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java index 2765ba8..03fbe68 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java @@ -153,6 +153,32 @@ public class TaskService { return new TaskDetails(task, comments); } + @Transactional(readOnly = true) + public List assignmentChoices(String actorEmail, long projectId) { + Actor actor = requireActiveActor(actorEmail); + requireOpenProject(projectId); + long actorMembershipId = requireActorMembership(projectId, actor.id()); + boolean leader = isCurrentLeader(projectId, actorMembershipId); + return jdbc.sql(""" + select m.id, u.display_name + from project_memberships m + join app_users u on u.id = m.intern_user_id + join intern_profiles i on i.user_id = m.intern_user_id + where m.project_id = :projectId + and m.left_at is null + and u.account_status = 'ACTIVE' + and i.internship_status = 'ACTIVE' + and (:leader or m.id = :actorMembershipId) + order by m.id + """) + .param("projectId", projectId) + .param("leader", leader) + .param("actorMembershipId", actorMembershipId) + .query((rs, rowNum) -> new TaskAssigneeChoice( + rs.getLong("id"), rs.getString("display_name"))) + .list(); + } + private Actor requireActiveActor(String email) { Actor actor = requireReadableActor(email); if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) { diff --git a/src/main/resources/templates/tasks/detail.html b/src/main/resources/templates/tasks/detail.html new file mode 100644 index 0000000..1e9ba7d --- /dev/null +++ b/src/main/resources/templates/tasks/detail.html @@ -0,0 +1,36 @@ + + + + + + Task + + +
+

Task

+

No description

+

Status: TODO

+

Due date:

+ +
+ + + +
+ +
+

Comments

+
    +
  1. Comment
  2. +
+
+ + + +
+
+
+ + diff --git a/src/main/resources/templates/tasks/form.html b/src/main/resources/templates/tasks/form.html new file mode 100644 index 0000000..c604e00 --- /dev/null +++ b/src/main/resources/templates/tasks/form.html @@ -0,0 +1,37 @@ + + + + + + Create Task + + +
+

Create Task

+
+
+ + +

Title error

+
+
+ + +
+
+ + +

Assignee error

+
+
+ + +
+ +
+
+ + diff --git a/src/main/resources/templates/tasks/list.html b/src/main/resources/templates/tasks/list.html new file mode 100644 index 0000000..7b830e0 --- /dev/null +++ b/src/main/resources/templates/tasks/list.html @@ -0,0 +1,32 @@ + + + + + + Project tasks + + +
+

Project tasks

+

Progress: N/A

+
+
TODO
0
+
IN_PROGRESS
0
+
BLOCKED
0
+
DONE
0
+
+

Create Task

+ + + + + + + + + + +
Current non-deleted Tasks
TitleStatusDue date
TaskTODO
+
+ + diff --git a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java b/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java index 57f9634..ff52682 100644 --- a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java @@ -7,6 +7,7 @@ import com.lab.labtimesheet.tasks.CreateTaskCommand; import com.lab.labtimesheet.tasks.TaskCommentView; import com.lab.labtimesheet.tasks.TaskDetails; import com.lab.labtimesheet.tasks.TaskListView; +import com.lab.labtimesheet.tasks.TaskAssigneeChoice; import com.lab.labtimesheet.tasks.TaskNotFoundException; import com.lab.labtimesheet.tasks.TaskService; import com.lab.labtimesheet.tasks.TaskStatus; @@ -233,6 +234,16 @@ class TaskCreationIntegrationTest { .isInstanceOf(TaskNotFoundException.class); } + @Test + void createFormChoicesAreSelfOnlyForMembersAndAllActiveMembersForLeader() { + assertThat(taskService.assignmentChoices("member@example.test", projectId)) + .extracting(TaskAssigneeChoice::membershipId) + .containsExactly(memberMembershipId); + assertThat(taskService.assignmentChoices("leader@example.test", projectId)) + .extracting(TaskAssigneeChoice::membershipId) + .containsExactly(leaderMembershipId, memberMembershipId); + } + private long insertUser(String email, String role) { return jdbc.sql(""" insert into app_users diff --git a/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java b/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java new file mode 100644 index 0000000..6f69df5 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java @@ -0,0 +1,132 @@ +package com.lab.labtimesheet.tasks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +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.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.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(TaskController.class) +class TaskControllerTest { + + private static final String ACTOR_EMAIL = "member@example.test"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private TaskService taskService; + + @Test + void taskListRequiresAuthentication() throws Exception { + mockMvc.perform(get("/projects/10/tasks")) + .andExpect(status().isUnauthorized()); + + verifyNoInteractions(taskService); + } + + @Test + void emptyTaskListRendersNotApplicableProgress() throws Exception { + given(taskService.list(ACTOR_EMAIL, 10L)) + .willReturn(new TaskListView(List.of(), TaskProgress.from(List.of()))); + + mockMvc.perform(get("/projects/10/tasks").with(user(ACTOR_EMAIL))) + .andExpect(status().isOk()) + .andExpect(view().name("tasks/list")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("N/A"))); + } + + @Test + void guessedTaskIdentifierReturnsNotFoundWithoutRenderingDetails() throws Exception { + given(taskService.details(ACTOR_EMAIL, 10L, 999L)).willThrow(new TaskNotFoundException()); + + mockMvc.perform(get("/projects/10/tasks/999").with(user(ACTOR_EMAIL))) + .andExpect(status().isNotFound()); + } + + @Test + void validCreateFormUsesAuthenticatedIdentityAndRedirectsToCreatedTask() throws Exception { + given(taskService.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class))) + .willReturn(task(25L)); + + mockMvc.perform(post("/projects/10/tasks") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("title", "Draft") + .param("description", "Notes") + .param("assigneeMembershipId", "7") + .param("dueDate", "2026-08-20")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/10/tasks/25")); + + ArgumentCaptor command = ArgumentCaptor.forClass(CreateTaskCommand.class); + verify(taskService).create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), command.capture()); + assertThat(command.getValue()).isEqualTo(new CreateTaskCommand( + 10L, 7L, "Draft", "Notes", LocalDate.of(2026, 8, 20))); + } + + @Test + void blankCreateFormRendersValidationErrorWithoutWriting() throws Exception { + given(taskService.assignmentChoices(ACTOR_EMAIL, 10L)) + .willReturn(List.of(new TaskAssigneeChoice(7L, "Member"))); + + mockMvc.perform(post("/projects/10/tasks") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("title", " ") + .param("assigneeMembershipId", "7")) + .andExpect(status().isOk()) + .andExpect(view().name("tasks/form")) + .andExpect(model().attributeHasFieldErrors("taskForm", "title")); + + verify(taskService, org.mockito.Mockito.never()) + .create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class)); + } + + @Test + void statusAndCommentPostsUseAuthenticatedIdentityAndCsrf() throws Exception { + given(taskService.changeStatus(ACTOR_EMAIL, 10L, 25L, TaskStatus.IN_PROGRESS)) + .willReturn(task(25L)); + given(taskService.addComment(ACTOR_EMAIL, 10L, 25L, "Update")) + .willReturn(new TaskCommentView(3L, 25L, 5L, "Update", Instant.parse("2026-08-14T10:00:00Z"))); + + mockMvc.perform(post("/projects/10/tasks/25/status") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("status", "IN_PROGRESS")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/10/tasks/25")); + mockMvc.perform(post("/projects/10/tasks/25/comments") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("body", "Update")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/10/tasks/25")); + } + + private static TaskView task(long id) { + Instant instant = Instant.parse("2026-08-14T10:00:00Z"); + return new TaskView( + id, 10L, 7L, "Draft", "Notes", TaskStatus.TODO, + LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant); + } +}