fix(ui): resolve round one shell findings

This commit is contained in:
sechmachine
2026-08-15 02:17:01 +07:00
parent c5c143c3cd
commit 8388b4cc9b
18 changed files with 526 additions and 24 deletions
@@ -6,6 +6,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
@@ -51,6 +55,20 @@ class AttendanceTemplateIntegrationTest {
.andExpect(content().string(containsString("src=\"/assets/theme.js\"")));
}
@Test
void populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation() throws Exception {
mvc.perform(get("/template-contract/attendance/history/populated")
.with(user("intern@example.test").roles("INTERN")))
.andExpect(status().isOk())
.andExpect(content().string(containsString("14/08/2026")))
.andExpect(content().string(containsString("09:05")))
.andExpect(content().string(containsString("16:00")))
.andExpect(content().string(containsString("08:3015:30 (Asia/Ho_Chi_Minh)")))
.andExpect(content().string(containsString("Late")))
.andExpect(content().string(containsString("Early departure")))
.andExpect(content().string(containsString("Missing checkout")));
}
@Controller
public static class TemplateController {
@@ -63,6 +81,27 @@ class AttendanceTemplateIntegrationTest {
return "attendance/history";
}
@GetMapping("/template-contract/attendance/history/populated")
String populatedHistory(Model model) {
model.addAttribute("ownHistory", true);
model.addAttribute("from", LocalDate.of(2026, 8, 1));
model.addAttribute("to", LocalDate.of(2026, 8, 31));
model.addAttribute("items", List.of(
new AttendanceHistoryItem(
LocalDate.of(2026, 8, 14),
Instant.parse("2026-08-14T02:05:00Z"),
Instant.parse("2026-08-14T09:00:00Z"),
AttendancePolicy.seeded(1L),
new AttendanceViolations(true, true, false)),
new AttendanceHistoryItem(
LocalDate.of(2026, 8, 13),
Instant.parse("2026-08-13T01:30:00Z"),
null,
AttendancePolicy.seeded(1L),
new AttendanceViolations(true, false, true))));
return "attendance/history";
}
@GetMapping("/template-contract/attendance/calendar")
String calendar(Model model) {
model.addAttribute("today", LocalDate.of(2026, 8, 15));
@@ -0,0 +1,108 @@
package com.lab.labtimesheet.feature.reporting.controller;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.BDDMockito.given;
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.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.feature.project.controller.ProjectController;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import com.lab.labtimesheet.feature.task.controller.TaskController;
import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice;
import com.lab.labtimesheet.feature.task.service.TaskService;
import java.util.List;
import org.junit.jupiter.api.Test;
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({ProjectController.class, TaskController.class})
class ProjectTaskFormAccessibilityWebTest {
@Autowired
private MockMvc mvc;
@MockitoBean
private ProjectQueryService projectQueries;
@MockitoBean
private ProjectService projects;
@MockitoBean
private TaskService tasks;
@Test
void projectFieldErrorsHaveStableIdsAndInputAssociations() throws Exception {
mvc.perform(post("/projects")
.with(user("mentor@example.test").roles("MENTOR"))
.with(csrf())
.param("name", " ")
.param("startDate", "2026-08-01")
.param("endDate", "")
.param("initialLeaderUserId", "0"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("aria-describedby=\"name-error\"")))
.andExpect(content().string(containsString("id=\"name-error\"")))
.andExpect(content().string(containsString("aria-describedby=\"endDate-error\"")))
.andExpect(content().string(containsString("id=\"endDate-error\"")))
.andExpect(content().string(containsString("aria-describedby=\"initialLeaderUserId-error\"")))
.andExpect(content().string(containsString("id=\"initialLeaderUserId-error\"")));
mvc.perform(post("/projects")
.with(user("mentor@example.test").roles("MENTOR"))
.with(csrf())
.param("name", "Project")
.param("startDate", "")
.param("endDate", "2026-08-31")
.param("initialLeaderUserId", "7"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("aria-describedby=\"startDate-error\"")))
.andExpect(content().string(containsString("id=\"startDate-error\"")));
}
@Test
void projectDateRangeErrorIsAssociatedWithEndDate() throws Exception {
mvc.perform(post("/projects")
.with(user("mentor@example.test").roles("MENTOR"))
.with(csrf())
.param("name", "Project")
.param("startDate", "2026-08-31")
.param("endDate", "2026-08-01")
.param("initialLeaderUserId", "7"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("aria-describedby=\"dateRangeValid-error\"")))
.andExpect(content().string(containsString("id=\"dateRangeValid-error\"")));
}
@Test
void taskFieldErrorsHaveStableIdsAndControlAssociations() throws Exception {
given(tasks.assignmentChoices("leader@example.test", 10L))
.willReturn(List.of(new TaskAssigneeChoice(7L, "Member")));
mvc.perform(post("/projects/10/tasks")
.with(user("leader@example.test").roles("INTERN"))
.with(csrf())
.param("title", " ")
.param("dueDate", "2026-08-20"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("aria-describedby=\"title-error\"")))
.andExpect(content().string(containsString("id=\"title-error\"")))
.andExpect(content().string(containsString("aria-describedby=\"assigneeMembershipId-error\"")))
.andExpect(content().string(containsString("id=\"assigneeMembershipId-error\"")));
mvc.perform(post("/projects/10/tasks")
.with(user("leader@example.test").roles("INTERN"))
.with(csrf())
.param("title", "Task")
.param("assigneeMembershipId", "7")
.param("dueDate", "invalid"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("aria-describedby=\"dueDate-error\"")))
.andExpect(content().string(containsString("id=\"dueDate-error\"")));
}
}
@@ -25,7 +25,11 @@ import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
import com.lab.labtimesheet.feature.task.service.TaskService;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -45,6 +49,9 @@ import org.springframework.transaction.annotation.Transactional;
@Transactional
class RoleDashboardWebIntegrationTest {
private static final Pattern NAVIGATION_LINK = Pattern.compile(
"<a class=\"(?:brand|nav-link|account|icon-button)\" href=\"([^\"]+)\"");
@Autowired
private MockMvc mvc;
@@ -128,6 +135,28 @@ class RoleDashboardWebIntegrationTest {
.andExpect(content().string(containsString("Resolve accessibility review")))
.andExpect(content().string(containsString("BLOCKED")))
.andExpect(content().string(containsString("20/08/2026")));
followEveryVisibleNavigationLink("admin@example.test", "ADMIN");
followEveryVisibleNavigationLink("mentor@example.test", "MENTOR");
followEveryVisibleNavigationLink("intern@example.test", "INTERN");
}
private void followEveryVisibleNavigationLink(String email, String role) throws Exception {
String dashboard = mvc.perform(get("/dashboard").with(user(email).roles(role)))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
Matcher matcher = NAVIGATION_LINK.matcher(dashboard);
Set<String> paths = new LinkedHashSet<>();
while (matcher.find()) {
paths.add(matcher.group(1));
}
assertThat(paths).isNotEmpty();
for (String path : paths) {
mvc.perform(get(path).with(user(email).roles(role)))
.andExpect(status().isOk());
}
}
private long initializeAdminAndSmtp() {
@@ -0,0 +1,69 @@
package com.lab.labtimesheet.feature.reporting.controller;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
@WebMvcTest(SharedErrorTemplateWebTest.ErrorTemplateController.class)
@Import(SharedErrorTemplateWebTest.ErrorTemplateController.class)
class SharedErrorTemplateWebTest {
@Autowired
private MockMvc mvc;
@Test
@WithMockUser(username = "intern@example.test", roles = "INTERN")
void notFoundPageUsesSharedShellWithoutDisclosingRecordDetails() throws Exception {
mvc.perform(get("/template-contract/error/404"))
.andExpect(status().isNotFound())
.andExpect(content().string(containsString("class=\"app-shell\"")))
.andExpect(content().string(containsString("Page not found")))
.andExpect(content().string(not(containsString("secret Project"))));
}
@Test
@WithMockUser(username = "mentor@example.test", roles = "MENTOR")
void conflictPageUsesSharedShellWithoutRenderingExceptionDetails() throws Exception {
mvc.perform(get("/template-contract/error/409"))
.andExpect(status().isConflict())
.andExpect(content().string(containsString("class=\"app-shell\"")))
.andExpect(content().string(containsString("Request could not be completed")))
.andExpect(content().string(not(containsString("internal lifecycle detail"))));
}
@Controller
static class ErrorTemplateController {
@GetMapping("/template-contract/error/404")
@ResponseStatus(HttpStatus.NOT_FOUND)
String notFound(Model model) {
model.addAttribute("errorStatus", 404);
model.addAttribute("errorTitle", "Page not found");
model.addAttribute("errorMessage", "The requested resource is unavailable or you may not have access.");
return "error/generic";
}
@GetMapping("/template-contract/error/409")
@ResponseStatus(HttpStatus.CONFLICT)
String conflict(Model model) {
model.addAttribute("errorStatus", 409);
model.addAttribute("errorTitle", "Request could not be completed");
model.addAttribute("errorMessage", "The request conflicts with its current state. Review and try again.");
return "error/generic";
}
}
}
@@ -32,7 +32,7 @@ class UiContractWebTest {
@Test
@WithMockUser(username = "mentor@example.test", roles = "MENTOR")
void sharedShellRendersAuthorizedDesktopNavigationBeforeDomainPagesIntegrate() throws Exception {
void mentorShellRendersOnlyReachableAuthorizedNavigation() throws Exception {
MvcResult result = mvc.perform(get("/ui-contract"))
.andExpect(status().isOk())
.andReturn();
@@ -44,10 +44,29 @@ class UiContractWebTest {
assertTrue(html.contains("Logout"));
assertFalse(html.contains("Accounts"));
assertFalse(html.contains("My attendance"));
assertFalse(html.contains("Intern attendance"));
assertFalse(html.contains("href=\"/attendance\""));
assertFalse(html.contains("href=\"/profile\""));
assertFalse(html.contains("href=\"/notifications\""));
assertTrue(html.indexOf("/assets/theme.js") < html.indexOf("/assets/app.css"));
assertTrue(html.contains("href=\"/assets/icons.svg#panel-left\""));
}
@Test
@WithMockUser(username = "intern@example.test", roles = "INTERN")
void internShellLinksToTheReachableOwnAttendanceRoute() throws Exception {
String html = mvc.perform(get("/ui-contract"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8);
assertTrue(html.contains("href=\"/attendance\""));
assertFalse(html.contains("href=\"/attendance/me\""));
assertFalse(html.contains("href=\"/profile\""));
assertFalse(html.contains("href=\"/notifications\""));
}
@Test
void compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite() throws Exception {
ClassPathResource css = new ClassPathResource("static/assets/app.css");