From 7acd5252058ddf9c6c9b71693a49a440e02a3500 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:27:35 +0700 Subject: [PATCH 1/5] docs(project): record feature JPA milestone evidence --- docs/tests/integration/projects-workflows.md | 75 ++++++++++++++++++++ docs/tests/unit/projects-domain.md | 10 +-- docs/tests/unit/projects-layer-structure.md | 75 ++++++++++++++++++++ docs/tests/web/projects-pages.md | 73 +++++++++++++++++++ 4 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 docs/tests/integration/projects-workflows.md create mode 100644 docs/tests/unit/projects-layer-structure.md create mode 100644 docs/tests/web/projects-pages.md diff --git a/docs/tests/integration/projects-workflows.md b/docs/tests/integration/projects-workflows.md new file mode 100644 index 0000000..dd985a1 --- /dev/null +++ b/docs/tests/integration/projects-workflows.md @@ -0,0 +1,75 @@ +# Test Evidence: Atomic Project workflows + +- **Test type:** Integration +- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-017`, `AUTH-001`–`AUTH-004`, `DB-003`, `DB-007` +- **Scenario IDs:** `AC-PRJ-001`–`AC-PRJ-003`, `AC-PRJ-009` +- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest` +- **Implementation commit:** `25a855e` + +## Protected behavior + +PostgreSQL transactions persist a planned Project with its initial membership and leadership term, reject unauthorized or duplicate direct additions, change exactly one Leader without moving Task assignments, and enforce role/membership visibility without ID disclosure. + +## Test method + +A Spring Boot integration test uses the platform-owned PostgreSQL 18.4 Testcontainer and Flyway V1 schema. It calls the public Project service and verifies committed-shape rows and negative-case non-mutation with independent SQL. + +## Hand-derived expected result + +Creation yields one Project, one active membership, and one current leadership term. Direct addition yields one membership per Project/Intern pair while allowing the same Intern in a second Project. Leader change yields one closed and one current term while the Task assignee ID remains unchanged. Admin, owner, and historical member visibility is allowed; unrelated IDs are denied uniformly. + +## RED + +**Command** + +```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 -Dtest=ProjectServiceIntegrationTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class CreateProjectCommand +[ERROR] cannot find symbol: class ProjectService +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```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 -Dtest=ProjectServiceIntegrationTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest +[INFO] Tests run: 4, 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: 25, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, real concurrent transaction races, Iteration 2 invitations/removals/completion, or Task-module business rules beyond preserving stored assignment IDs. `I1-PRJ-04` remains `IN_PROGRESS`: the activation Task-assignee guard will be implemented only after the Task feature exposes its concrete query service. diff --git a/docs/tests/unit/projects-domain.md b/docs/tests/unit/projects-domain.md index 18e5512..27467e4 100644 --- a/docs/tests/unit/projects-domain.md +++ b/docs/tests/unit/projects-domain.md @@ -3,8 +3,8 @@ - **Test type:** Unit - **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004` - **Scenario IDs:** `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009` -- **Test class/method:** `com.lab.labtimesheet.projects.domain.ProjectTest` -- **Implementation commit:** `3483347` +- **Test class/method:** `com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest` +- **Implementation commit:** `25a855e` ## Protected behavior @@ -43,13 +43,13 @@ export PATH="$JAVA_HOME/bin:$PATH" ```text export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:$PATH" -./mvnw -Dtest=ProjectTest test +./mvnw -Dtest=ProjectEntityTest test ``` **Observed result** ```text -[INFO] Running com.lab.labtimesheet.projects.domain.ProjectTest +[INFO] Running com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest [INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -61,7 +61,7 @@ export PATH="$JAVA_HOME/bin:$PATH" ```text export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:$PATH" -./mvnw -Dtest=ProjectTest test +./mvnw -Dtest=ProjectEntityTest test [INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS diff --git a/docs/tests/unit/projects-layer-structure.md b/docs/tests/unit/projects-layer-structure.md new file mode 100644 index 0000000..e27ad70 --- /dev/null +++ b/docs/tests/unit/projects-layer-structure.md @@ -0,0 +1,75 @@ +# Test Evidence: Project layer and JPA structure + +- **Test type:** Unit +- **Requirement IDs:** `ARC-002`, `ARC-005`–`ARC-007`, `OPS-018`–`OPS-020`, `TST-001`–`TST-010` +- **Scenario IDs:** `I1-PRJ-01`–`I1-PRJ-05` +- **Test class/method:** `com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest#projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa` +- **Implementation commit:** `25a855e` + +## Protected behavior + +Project-owned production code follows the authoritative feature-first package layout, persists aggregate entities through Spring Data JPA, keeps JDBC operations out of Project business services, and does not shadow Account or Task persistence. + +## Test method + +Plain JUnit inspects the public Project entity, repository, and service types. It verifies their exact feature/layer packages, the entity's JPA mapping, the repository's `JpaRepository` contract, the absence of JDBC service dependencies, and the absence of foreign-table Account/Task shadow entities. + +## Hand-derived expected result + +The Project aggregate is under `feature.project.model.entity`, persistence under `feature.project.repository`, business logic under `feature.project.service`, the service has zero JDBC collaborators, and Account/Task persistence remains owned by those features. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectPersistenceStructureTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class ProjectUserRepository +[ERROR] cannot find symbol: class ProjectInternProfileRepository +[ERROR] cannot find symbol: class ProjectTaskRepository +[INFO] BUILD FAILURE +``` + +The RED was observed after removing Project-owned shadow mappings of Account and Task tables. It proves the service still required cross-feature dependencies and could not be made green by retaining forbidden repositories. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectPersistenceStructureTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest +[INFO] Tests run: 1, 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" +./mvnw -Dtest=LayerStructureTest,ProjectPersistenceStructureTest,ProjectEntityTest test + +[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This check does not prove database mappings, transaction behavior, MVC routing, or runtime authorization; those remain covered by PostgreSQL and MockMvc tests. diff --git a/docs/tests/web/projects-pages.md b/docs/tests/web/projects-pages.md new file mode 100644 index 0000000..df25b1f --- /dev/null +++ b/docs/tests/web/projects-pages.md @@ -0,0 +1,73 @@ +# Test Evidence: Authorized Project pages + +- **Test type:** Web +- **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` + +## Protected behavior + +Authenticated users receive only authorized Project routes; guessed IDs return a non-disclosing not-found response; valid Mentor create requests use the authenticated identity; invalid forms do not mutate; state changes require CSRF. + +## Test method + +MockMvc exercises the real controller, binding, Bean Validation, exception mapping, view selection, redirect, Spring Security authentication, and CSRF filter. Only application/query services are mocked. + +## Hand-derived expected result + +An authorized list request renders `projects/list`. An unauthorized direct ID returns 404. Member and leadership routes authorize through actor plus Project ID. A valid create redirects to the created detail ID; a blank name and zero Leader ID render field errors and make no service call. POST without CSRF returns 403. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectControllerTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class ProjectController +[ERROR] cannot find symbol: class ProjectPageService +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectControllerTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.controller.ProjectControllerTest +[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: 25, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## 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. From a9ee99a6fef0a12483ee0d6ad3390fa1e8708d35 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:30:22 +0700 Subject: [PATCH 2/5] fix(project): hide mentor-only controls --- .../project/controller/ProjectController.java | 6 ++++- .../project/model/dto/ProjectDetail.java | 3 ++- .../project/service/ProjectQueryService.java | 3 ++- .../templates/projects/leadership.html | 2 +- .../resources/templates/projects/members.html | 2 +- .../controller/ProjectControllerTest.java | 24 ++++++++++++++++--- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java index 16c4758..79cfd10 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -1,5 +1,6 @@ package com.lab.labtimesheet.feature.project.controller; +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm; import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm; import com.lab.labtimesheet.feature.project.service.ProjectQueryService; @@ -34,7 +35,10 @@ public class ProjectController { } @GetMapping("/new") - public String createForm(Model model) { + public String createForm(Principal principal, Model model) { + if (!"MENTOR".equals(pages.authenticatedActor(principal.getName()).role())) { + throw new ProjectAccessDeniedException(); + } model.addAttribute("projectForm", new ProjectCreateForm()); return "projects/form"; } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java index 4af0e54..72a13a0 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java @@ -10,5 +10,6 @@ public record ProjectDetail( LocalDate startDate, LocalDate endDate, String mentorName, - String leaderName) { + String leaderName, + boolean canManage) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java index eadff93..593bfc9 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -64,7 +64,8 @@ public class ProjectQueryService { project.startDate(), project.endDate(), displayName(project.mentorUserId()), - displayName(project.currentLeader().internUserId())); + displayName(project.currentLeader().internUserId()), + project.mentorUserId() == actorUserId); } @Transactional(readOnly = true) diff --git a/src/main/resources/templates/projects/leadership.html b/src/main/resources/templates/projects/leadership.html index 378a69c..abe41e9 100644 --- a/src/main/resources/templates/projects/leadership.html +++ b/src/main/resources/templates/projects/leadership.html @@ -7,7 +7,7 @@
Leadership history
LeaderStartedEnded
-
+
diff --git a/src/main/resources/templates/projects/members.html b/src/main/resources/templates/projects/members.html index e0238eb..8ee0e9f 100644 --- a/src/main/resources/templates/projects/members.html +++ b/src/main/resources/templates/projects/members.html @@ -7,7 +7,7 @@
Membership history
InternJoinedLeftRole
-
+
diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java index ed3ef9f..4a13cec 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -3,6 +3,8 @@ package com.lab.labtimesheet.feature.project.controller; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -13,6 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand; +import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView; import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail; import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary; import com.lab.labtimesheet.feature.project.service.ProjectQueryService; @@ -79,16 +82,31 @@ class ProjectControllerTest { LocalDate.of(2026, 8, 15), LocalDate.of(2026, 9, 30), "Mentor", - "Leader")); + "Leader", + false)); when(pages.members(20L, 30L)).thenReturn(List.of()); when(pages.leadership(20L, 30L)).thenReturn(List.of()); mvc.perform(get("/projects/30/members")) .andExpect(status().isOk()) - .andExpect(view().name("projects/members")); + .andExpect(view().name("projects/members")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString("Add member")))); mvc.perform(get("/projects/30/leadership")) .andExpect(status().isOk()) - .andExpect(view().name("projects/leadership")); + .andExpect(view().name("projects/leadership")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString("Change Leader")))); + } + + @Test + @WithMockUser(username = "member@example.test") + void nonMentorCannotOpenProjectCreationForm() throws Exception { + when(pages.authenticatedActor("member@example.test")) + .thenReturn(new ProjectActorView(20L, "INTERN")); + + mvc.perform(get("/projects/new")) + .andExpect(status().isNotFound()); } @Test From 2f2573159c34833b2e70a6c6a07f433da308af7b Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:38:03 +0700 Subject: [PATCH 3/5] fix(project): hide unavailable create action --- docs/tests/web/projects-pages.md | 14 ++++++++++++- .../project/controller/ProjectController.java | 4 +++- .../resources/templates/projects/list.html | 2 +- .../controller/ProjectControllerTest.java | 20 +++++++++++++++++-- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/tests/web/projects-pages.md b/docs/tests/web/projects-pages.md index df25b1f..905adb2 100644 --- a/docs/tests/web/projects-pages.md +++ b/docs/tests/web/projects-pages.md @@ -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. diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java index 79cfd10..113b50a 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -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"; } diff --git a/src/main/resources/templates/projects/list.html b/src/main/resources/templates/projects/list.html index 9379ac8..67ec7ac 100644 --- a/src/main/resources/templates/projects/list.html +++ b/src/main/resources/templates/projects/list.html @@ -4,7 +4,7 @@

Projects

- Create Project + Create Project

No authorized Projects.

diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java index 4a13cec..2a26916 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -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 { From 19a3518f74c283df3cc742264426b995a8bfa359 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:03:51 +0700 Subject: [PATCH 4/5] feat(project): lock task mutation context --- .../project/service/ProjectQueryService.java | 15 ++++- .../project/service/ProjectService.java | 9 +++ .../ProjectServiceIntegrationTest.java | 7 ++- .../ProjectTaskMutationContextTest.java | 59 +++++++++++++++++++ 4 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java index 593bfc9..1867eda 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -97,7 +97,12 @@ public class ProjectQueryService { @Transactional(readOnly = true) public ProjectTaskContext taskContext(long actorUserId, long projectId) { - var project = visibleProject(actorUserId, projectId); + var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new); + return taskContext(actorUserId, project); + } + + ProjectTaskContext taskContext(long actorUserId, ProjectEntity project) { + requireVisibleProject(actorUserId, project); var activeMembers = project.memberships().stream() .filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId())) .map(membership -> new ProjectTaskMemberView( @@ -142,15 +147,19 @@ public class ProjectQueryService { } private ProjectEntity visibleProject(long actorUserId, long projectId) { - var actor = activeActor(actorUserId); var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new); + requireVisibleProject(actorUserId, project); + return project; + } + + private void requireVisibleProject(long actorUserId, ProjectEntity project) { + var actor = activeActor(actorUserId); var visible = "ADMIN".equals(actor.role().name()) || ("MENTOR".equals(actor.role().name()) && project.mentorUserId() == actorUserId) || ("INTERN".equals(actor.role().name()) && project.hasEverHadMember(actorUserId)); if (!visible) { throw new ProjectAccessDeniedException(); } - return project; } private List visibleProjects(AccountIdentity actor, long actorUserId) { diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java index 31c994f..946b7c3 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java @@ -4,6 +4,7 @@ import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedExcepti import com.lab.labtimesheet.feature.account.service.AccountService; import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility; 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.entity.ProjectEntity; import com.lab.labtimesheet.feature.project.repository.ProjectRepository; import java.time.Clock; @@ -15,14 +16,17 @@ public class ProjectService { private final ProjectRepository projects; private final AccountService accounts; + private final ProjectQueryService queries; private final Clock clock; public ProjectService( ProjectRepository projects, AccountService accounts, + ProjectQueryService queries, Clock clock) { this.projects = projects; this.accounts = accounts; + this.queries = queries; this.clock = clock; } @@ -61,6 +65,11 @@ public class ProjectService { projects.flush(); } + @Transactional + public ProjectTaskContext taskMutationContext(long actorUserId, long projectId) { + return queries.taskContext(actorUserId, lockedProject(projectId)); + } + private ProjectEntity lockedProject(long projectId) { return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new); } diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java index 8db027a..a353e45 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java @@ -144,11 +144,14 @@ class ProjectServiceIntegrationTest { assertEquals(List.of(), projectPages.listVisible(unrelatedId)); assertEquals(projectId, projectPages.detail(memberId, projectId).id()); assertEquals("INTERN", projectPages.authenticatedActor("member-view@example.test").role()); - var taskContext = projectPages.taskContext(memberId, projectId); + var taskContext = projectService.taskMutationContext(memberId, projectId); assertEquals(mentorId, taskContext.mentorUserId()); assertEquals("PLANNED", taskContext.status()); assertEquals(2, taskContext.activeMembers().size()); assertEquals(membershipId(projectId, leaderId), taskContext.currentLeaderMembershipId()); + assertEquals(taskContext, projectPages.taskContext(memberId, projectId)); + assertThrows(ProjectAccessDeniedException.class, + () -> projectService.taskMutationContext(otherMentorId, projectId)); jdbc.update(""" update projects set status = 'ACTIVE', activated_at = ?, updated_at = ? where id = ? """, dbTime(NOW.plusSeconds(30)), dbTime(NOW.plusSeconds(30)), projectId); @@ -169,7 +172,7 @@ class ProjectServiceIntegrationTest { entityManager.clear(); assertEquals(projectId, projectPages.detail(memberId, projectId).id()); - assertTrue(projectPages.taskContext(memberId, projectId).activeMembers().stream() + assertTrue(projectService.taskMutationContext(memberId, projectId).activeMembers().stream() .noneMatch(member -> member.userId() == memberId)); assertEquals(0, projectPages.dashboardSummary(memberId).activeProjectCount()); assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount()); diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java new file mode 100644 index 0000000..8f31971 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java @@ -0,0 +1,59 @@ +package com.lab.labtimesheet.feature.project.service; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext; +import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; +import com.lab.labtimesheet.feature.project.repository.ProjectRepository; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ProjectTaskMutationContextTest { + + @Mock + private ProjectRepository projects; + + @Mock + private AccountService accounts; + + @Mock + private ProjectQueryService queries; + + @Mock + private ProjectEntity project; + + @Test + void loadsTheProjectForUpdateBeforeBuildingTheTaskMutationContext() { + long actorUserId = 20L; + long projectId = 30L; + var expected = new ProjectTaskContext( + projectId, + 10L, + "ACTIVE", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + 40L, + List.of()); + var service = new ProjectService(projects, accounts, queries, Clock.systemUTC()); + when(projects.findLockedById(projectId)).thenReturn(Optional.of(project)); + when(queries.taskContext(actorUserId, project)).thenReturn(expected); + + var actual = service.taskMutationContext(actorUserId, projectId); + + assertSame(expected, actual); + verify(projects).findLockedById(projectId); + verify(projects, never()).findById(projectId); + verify(queries).taskContext(actorUserId, project); + } +} From c3f92d4e310d625d95d374e974c478444331f6d7 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:04:29 +0700 Subject: [PATCH 5/5] docs(project): record locked task context evidence --- .../unit/project-task-mutation-context.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/tests/unit/project-task-mutation-context.md diff --git a/docs/tests/unit/project-task-mutation-context.md b/docs/tests/unit/project-task-mutation-context.md new file mode 100644 index 0000000..b71c70d --- /dev/null +++ b/docs/tests/unit/project-task-mutation-context.md @@ -0,0 +1,75 @@ +# Test Evidence: Locked Project context for Task mutations + +- **Test type:** Unit +- **Requirement IDs:** `AUTH-001`, `AUTH-011`, `PRJ-012` +- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-010`, `AC-PRJ-006` +- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectTaskMutationContextTest#loadsTheProjectForUpdateBeforeBuildingTheTaskMutationContext` +- **Implementation commit:** `19a3518` + +## Protected behavior + +Task mutations obtain their Project authorization and current lifecycle, Leader, owning-Mentor, and active-member facts from a DTO-only Project service boundary after the Project row has been locked for update. Missing and unauthorized Projects retain the same non-disclosing denial behavior. + +## Test method + +The isolated service test invokes `ProjectService.taskMutationContext(actorUserId, projectId)`, verifies that `ProjectRepository.findLockedById` is used and the ordinary `findById` path is not used, and verifies that only the locked entity is passed to the existing Project-owned authorization and DTO mapper. The PostgreSQL integration test additionally exercises the public API with authorized, unauthorized, current-member, and former-member data. + +## Hand-derived expected result + +Exactly one pessimistic Project lookup occurs before context evaluation. The returned `ProjectTaskContext` exposes scalar/DTO facts only; no Project repository or entity crosses the feature boundary. When called from Task's active transaction, Spring's default `REQUIRED` propagation keeps the row lock in that transaction through its commit or rollback. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectTaskMutationContextTest test +``` + +**Observed result** + +```text +[ERROR] constructor ProjectService ... cannot be applied to given types +[ERROR] incompatible types: ProjectEntity cannot be converted to long +[INFO] BUILD FAILURE +``` + +The test failed to compile because Project had no mutation-context API and its context mapper accepted only an unlocked Project ID lookup. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectTaskMutationContextTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectTaskMutationContextTest +[INFO] Tests run: 1, 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 -Dtest='Project*Test' test + +[INFO] Tests run: 20, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +The unit test proves the locked repository path and DTO-only handoff, while the integration coverage proves current Project authorization/member mapping against PostgreSQL 18.4. It does not orchestrate two concurrent database transactions; the lock-retention guarantee relies on the public method's `@Transactional` default `REQUIRED` propagation and the Task caller retaining its outer transaction.
Authorized Projects