feat(tasks): add task pages and request boundaries

This commit is contained in:
sechmachine
2026-08-14 23:48:16 +07:00
parent 597ebf1b49
commit 1ed23f4de9
11 changed files with 479 additions and 3 deletions
+3 -3
View File
@@ -12,7 +12,7 @@ PostgreSQL-backed Task operations preserve generic same-Project membership actor
## Test method ## 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 ## Hand-derived expected result
@@ -65,7 +65,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
**Observed result** **Observed result**
```text ```text
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 [INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS [INFO] BUILD SUCCESS
``` ```
@@ -79,7 +79,7 @@ export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test ./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 [INFO] BUILD SUCCESS
``` ```
+78
View File
@@ -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.
@@ -0,0 +1,3 @@
package com.lab.labtimesheet.tasks;
public record TaskAssigneeChoice(long membershipId, String displayName) {}
@@ -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());
}
}
@@ -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) {}
@@ -153,6 +153,32 @@ public class TaskService {
return new TaskDetails(task, comments); return new TaskDetails(task, comments);
} }
@Transactional(readOnly = true)
public List<TaskAssigneeChoice> 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) { private Actor requireActiveActor(String email) {
Actor actor = requireReadableActor(email); Actor actor = requireReadableActor(email);
if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) { if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) {
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${details.task.title}">Task</title>
</head>
<body>
<main>
<h1 th:text="${details.task.title}">Task</h1>
<p th:text="${details.task.description ?: 'No description'}">No description</p>
<p>Status: <strong th:text="${details.task.status}">TODO</strong></p>
<p>Due date: <span th:text="${details.task.dueDate ?: '—'}"></span></p>
<form method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/status(projectId=${projectId},taskId=${details.task.id})}">
<label for="status">New status</label>
<select id="status" name="status" required>
<option th:each="status : ${statuses}" th:value="${status}" th:text="${status}">TODO</option>
</select>
<button type="submit">Change status</button>
</form>
<section aria-labelledby="comments-heading">
<h2 id="comments-heading">Comments</h2>
<ol>
<li th:each="comment : ${details.comments}" th:text="${comment.body}">Comment</li>
</ol>
<form method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/comments(projectId=${projectId},taskId=${details.task.id})}">
<label for="body">Comment</label>
<textarea id="body" name="body" required></textarea>
<button type="submit">Add comment</button>
</form>
</section>
</main>
</body>
</html>
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create Task</title>
</head>
<body>
<main>
<h1>Create Task</h1>
<form method="post" th:action="@{/projects/{projectId}/tasks(projectId=${projectId})}" th:object="${taskForm}">
<div>
<label for="title">Title</label>
<input id="title" type="text" maxlength="200" required th:field="*{title}">
<p role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</p>
</div>
<div>
<label for="description">Description</label>
<textarea id="description" th:field="*{description}"></textarea>
</div>
<div>
<label for="assigneeMembershipId">Assignee</label>
<select id="assigneeMembershipId" required th:field="*{assigneeMembershipId}">
<option value="">Select an assignee</option>
<option th:each="assignee : ${assignees}" th:value="${assignee.membershipId}" th:text="${assignee.displayName}">Member</option>
</select>
<p role="alert" th:if="${#fields.hasErrors('assigneeMembershipId')}" th:errors="*{assigneeMembershipId}">Assignee error</p>
</div>
<div>
<label for="dueDate">Due date</label>
<input id="dueDate" type="date" th:field="*{dueDate}">
</div>
<button type="submit">Create Task</button>
</form>
</main>
</body>
</html>
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project tasks</title>
</head>
<body>
<main>
<h1>Project tasks</h1>
<p>Progress: <strong th:text="${progressLabel}">N/A</strong></p>
<dl>
<dt>TODO</dt><dd th:text="${taskList.progress.todo}">0</dd>
<dt>IN_PROGRESS</dt><dd th:text="${taskList.progress.inProgress}">0</dd>
<dt>BLOCKED</dt><dd th:text="${taskList.progress.blocked}">0</dd>
<dt>DONE</dt><dd th:text="${taskList.progress.done}">0</dd>
</dl>
<p><a th:href="@{/projects/{projectId}/tasks/new(projectId=${projectId})}">Create Task</a></p>
<table>
<caption>Current non-deleted Tasks</caption>
<thead><tr><th scope="col">Title</th><th scope="col">Status</th><th scope="col">Due date</th></tr></thead>
<tbody>
<tr th:each="task : ${taskList.tasks}">
<td><a th:href="@{/projects/{projectId}/tasks/{taskId}(projectId=${projectId},taskId=${task.id})}" th:text="${task.title}">Task</a></td>
<td th:text="${task.status}">TODO</td>
<td th:text="${task.dueDate ?: '—'}"></td>
</tr>
</tbody>
</table>
</main>
</body>
</html>
@@ -7,6 +7,7 @@ import com.lab.labtimesheet.tasks.CreateTaskCommand;
import com.lab.labtimesheet.tasks.TaskCommentView; import com.lab.labtimesheet.tasks.TaskCommentView;
import com.lab.labtimesheet.tasks.TaskDetails; import com.lab.labtimesheet.tasks.TaskDetails;
import com.lab.labtimesheet.tasks.TaskListView; import com.lab.labtimesheet.tasks.TaskListView;
import com.lab.labtimesheet.tasks.TaskAssigneeChoice;
import com.lab.labtimesheet.tasks.TaskNotFoundException; import com.lab.labtimesheet.tasks.TaskNotFoundException;
import com.lab.labtimesheet.tasks.TaskService; import com.lab.labtimesheet.tasks.TaskService;
import com.lab.labtimesheet.tasks.TaskStatus; import com.lab.labtimesheet.tasks.TaskStatus;
@@ -233,6 +234,16 @@ class TaskCreationIntegrationTest {
.isInstanceOf(TaskNotFoundException.class); .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) { private long insertUser(String email, String role) {
return jdbc.sql(""" return jdbc.sql("""
insert into app_users insert into app_users
@@ -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<CreateTaskCommand> 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);
}
}