fix(project): hide unavailable create action

This commit is contained in:
sechmachine
2026-08-15 00:38:03 +07:00
parent a9ee99a6fe
commit 2f2573159c
4 changed files with 35 additions and 5 deletions
+13 -1
View File
@@ -4,7 +4,7 @@
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004``PRJ-006`, `SEC-001`, `ERR-001`
- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `I1-PRJ-05`
- **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest`
- **Implementation commit:** `25a855e`
- **Implementation commits:** `25a855e`, `a9ee99a`
## Protected behavior
@@ -68,6 +68,18 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
[INFO] BUILD SUCCESS
```
## Mentor-only control regression
**RED:** the focused MockMvc run reported two expected failures: `GET /projects/new` returned `200` for an Intern instead of non-disclosing `404`, and the member page rendered the `Add member` form for a non-owner.
**GREEN:** rerunning `./mvnw -Dtest=ProjectControllerTest test` after the controller/DTO/template correction passed 7 tests with zero failures, errors, or skips.
## Role-aware Project-list action regression
**RED:** the focused MockMvc run reported two expected failures after adding the list-action regression: the controller still resolved only a user ID, so the Mentor fixture was queried as user `0`, and an Intern-facing Project list rendered the `Create Project` link.
**GREEN:** rerunning `./mvnw -Dtest=ProjectControllerTest test` after resolving the public actor view and conditionally rendering the link passed 8 tests with zero failures, errors, or skips.
## External-test boundaries
This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, browser accessibility, or Iteration 2 invitation/exit/completion pages. The activation route remains deferred with `I1-PRJ-04` until the Task feature query dependency is available.
@@ -30,7 +30,9 @@ public class ProjectController {
@GetMapping
public String list(Principal principal, Model model) {
model.addAttribute("projects", pages.listVisible(actorId(principal)));
var actor = pages.authenticatedActor(principal.getName());
model.addAttribute("projects", pages.listVisible(actor.userId()));
model.addAttribute("canCreateProject", "MENTOR".equals(actor.role()));
return "projects/list";
}
@@ -4,7 +4,7 @@
<body>
<main>
<h1>Projects</h1>
<a href="/projects/new">Create Project</a>
<a th:if="${canCreateProject}" href="/projects/new">Create Project</a>
<p th:if="${#lists.isEmpty(projects)}">No authorized Projects.</p>
<table th:unless="${#lists.isEmpty(projects)}">
<caption>Authorized Projects</caption>
@@ -44,7 +44,8 @@ class ProjectControllerTest {
@Test
@WithMockUser(username = "mentor@example.test")
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(pages.listVisible(10L)).thenReturn(List.of(new ProjectSummary(
30L,
"Intern Portal Refresh",
@@ -55,11 +56,26 @@ class ProjectControllerTest {
mvc.perform(get("/projects"))
.andExpect(status().isOk())
.andExpect(view().name("projects/list"))
.andExpect(model().attributeExists("projects"));
.andExpect(model().attributeExists("projects"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("Create Project")));
verify(pages).listVisible(10L);
}
@Test
@WithMockUser(username = "member@example.test")
void nonMentorProjectListOmitsTheCreateLink() throws Exception {
when(pages.authenticatedActor("member@example.test"))
.thenReturn(new ProjectActorView(20L, "INTERN"));
when(pages.listVisible(20L)).thenReturn(List.of());
mvc.perform(get("/projects"))
.andExpect(status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("Create Project"))));
}
@Test
@WithMockUser(username = "member@example.test")
void guessedProjectIdReturnsTheSameNotFoundResponseAsAMissingProject() throws Exception {