Merge commit '32c8a2d315d2175760c5d4792988cd0aa5ab6dd0' into work/fix/projects/intern-picker

This commit is contained in:
sechmachine
2026-08-15 15:02:18 +07:00
21 changed files with 901 additions and 27 deletions
+6 -2
View File
@@ -10,10 +10,14 @@ const names = [
const output = resolve('src/main/resources/static/assets/icons.svg');
const symbols = await Promise.all(names.map(async (name) => {
const svg = await readFile(resolve(`node_modules/lucide-static/icons/${name}.svg`), 'utf8');
const viewBox = svg.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24';
const root = svg.match(/<svg\b([^>]*)>/)?.[1];
const viewBox = root?.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24';
const presentation = ['fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin']
.map((attribute) => root?.match(new RegExp(`${attribute}="[^"]+"`))?.[0])
.join(' ');
const body = svg.match(/<svg[\s\S]*?>([\s\S]*?)<\/svg>/)?.[1];
if (!body) throw new Error(`Invalid Lucide SVG: ${name}`);
return `<symbol id="${name}" viewBox="${viewBox}">${body.trim()}</symbol>`;
return `<symbol id="${name}" viewBox="${viewBox}" ${presentation}>${body.trim()}</symbol>`;
}));
await mkdir(dirname(output), { recursive: true });
+17 -17
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"><symbol id="bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" />
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol><symbol id="calendar-days" viewBox="0 0 24 24"><path d="M8 2v4" />
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"><symbol id="bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" />
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol><symbol id="calendar-days" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
@@ -8,27 +8,27 @@
<path d="M16 14h.01" />
<path d="M8 18h.01" />
<path d="M12 18h.01" />
<path d="M16 18h.01" /></symbol><symbol id="check-circle-2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" /></symbol><symbol id="chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6" /></symbol><symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol><symbol id="circle-user-round" viewBox="0 0 24 24"><path d="M17.925 20.056a6 6 0 0 0-11.851.001" />
<path d="M16 18h.01" /></symbol><symbol id="check-circle-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" /></symbol><symbol id="chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6" /></symbol><symbol id="chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></symbol><symbol id="circle-user-round" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.925 20.056a6 6 0 0 0-11.851.001" />
<circle cx="12" cy="11" r="4" />
<circle cx="12" cy="12" r="10" /></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" /></symbol><symbol id="folder-kanban" viewBox="0 0 24 24"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
<circle cx="12" cy="12" r="10" /></symbol><symbol id="clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" /></symbol><symbol id="folder-kanban" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
<path d="M8 10v4" />
<path d="M12 10v2" />
<path d="M16 10v6" /></symbol><symbol id="folder-open" viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></symbol><symbol id="inbox" viewBox="0 0 24 24"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></symbol><symbol id="layout-dashboard" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1" />
<path d="M16 10v6" /></symbol><symbol id="folder-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></symbol><symbol id="inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></symbol><symbol id="layout-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1" />
<rect width="7" height="5" x="14" y="3" rx="1" />
<rect width="7" height="9" x="14" y="12" rx="1" />
<rect width="7" height="5" x="3" y="16" rx="1" /></symbol><symbol id="list-check" viewBox="0 0 24 24"><path d="M16 5H3" />
<rect width="7" height="5" x="3" y="16" rx="1" /></symbol><symbol id="list-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 5H3" />
<path d="M16 12H3" />
<path d="M11 19H3" />
<path d="m15 18 2 2 4-4" /></symbol><symbol id="log-out" viewBox="0 0 24 24"><path d="m16 17 5-5-5-5" />
<path d="m15 18 2 2 4-4" /></symbol><symbol id="log-out" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /></symbol><symbol id="monitor" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2" />
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /></symbol><symbol id="monitor" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" /></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol><symbol id="panel-left" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" /></symbol><symbol id="settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
<circle cx="12" cy="12" r="3" /></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" />
<line x1="12" x2="12" y1="17" y2="21" /></symbol><symbol id="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol><symbol id="panel-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" /></symbol><symbol id="settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
<circle cx="12" cy="12" r="3" /></symbol><symbol id="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
@@ -36,10 +36,10 @@
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" /></symbol><symbol id="triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="m19.07 4.93-1.41 1.41" /></symbol><symbol id="triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" /></symbol><symbol id="users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<path d="M12 17h.01" /></symbol><symbol id="users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<path d="M16 3.128a4 4 0 0 1 0 7.744" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<circle cx="9" cy="7" r="4" /></symbol><symbol id="x" viewBox="0 0 24 24"><path d="M18 6 6 18" />
<circle cx="9" cy="7" r="4" /></symbol><symbol id="x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" />
<path d="m6 6 12 12" /></symbol></svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -23,6 +23,7 @@
<li><a class="nav-link" th:href="@{/dashboard}" data-tooltip="Overview" th:attr="aria-current=${activeNav == 'dashboard'} ? 'page' : null">
<svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#layout-dashboard}"></use></svg><span class="sidebar-label">Overview</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/accounts/new}" data-tooltip="Accounts" th:attr="aria-current=${activeNav == 'accounts'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#users}"></use></svg><span class="sidebar-label">Accounts</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/smtp}" data-tooltip="SMTP settings" th:attr="aria-current=${activeNav == 'smtp'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#settings}"></use></svg><span class="sidebar-label">SMTP settings</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/attendance/calendar}" data-tooltip="Global calendar" th:attr="aria-current=${activeNav == 'calendar'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#calendar-days}"></use></svg><span class="sidebar-label">Global calendar</span></a></li>
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/projects}" data-tooltip="Owned Projects" th:attr="aria-current=${activeNav == 'projects'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">Owned Projects</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance}" data-tooltip="My attendance" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li>
@@ -0,0 +1,154 @@
package com.lab.labtimesheet.feature.attendance.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
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.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import com.lab.labtimesheet.feature.integration.service.SmtpProbe;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@Import({TestcontainersConfiguration.class, CalendarAuthorizationWebIntegrationTest.MailProbeConfiguration.class})
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class CalendarAuthorizationWebIntegrationTest {
private static final String PASSWORD = "correct horse battery staple";
private static final String ADMIN_EMAIL = "admin@example.test";
@Autowired
private MockMvc mockMvc;
@Autowired
private AccountService accounts;
@Autowired
private SmtpConfigurationService smtp;
@Autowired
private RecordingSmtpProbe mail;
@Test
void formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied() throws Exception {
bootstrapFirstAdminThroughTheForm();
MockHttpSession adminSession = login(ADMIN_EMAIL, PASSWORD, "ADMIN");
mockMvc.perform(get("/attendance/calendar").session(adminSession))
.andExpect(status().isOk());
long adminId = accounts.requireActiveAdminId(ADMIN_EMAIL);
configureSmtp(adminId);
createAndActivate(adminId, new CreateAccountCommand(
"mentor@example.test", "Mentor", GlobalRole.MENTOR, null, null, null));
createAndActivate(adminId, new CreateAccountCommand(
"intern@example.test",
"Intern",
GlobalRole.INTERN,
"INT-001",
LocalDate.of(2026, 8, 1),
LocalDate.of(2026, 12, 31)));
mockMvc.perform(get("/attendance/calendar").session(login("mentor@example.test", PASSWORD, "MENTOR")))
.andExpect(status().isForbidden());
mockMvc.perform(get("/attendance/calendar").session(login("intern@example.test", PASSWORD, "INTERN")))
.andExpect(status().isForbidden());
}
private void bootstrapFirstAdminThroughTheForm() throws Exception {
mockMvc.perform(post("/bootstrap")
.with(csrf())
.param("email", ADMIN_EMAIL)
.param("displayName", "Admin")
.param("password", PASSWORD))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/admin/smtp?onboarding"));
}
private MockHttpSession login(String email, String password, String role) throws Exception {
var result = mockMvc.perform(post("/login")
.with(csrf())
.param("username", email)
.param("password", password))
.andExpect(status().is3xxRedirection())
.andExpect(authenticated().withUsername(email))
.andExpect(authenticated().withRoles(role))
.andReturn();
return (MockHttpSession) result.getRequest().getSession(false);
}
private void configureSmtp(long adminId) {
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
"mailpit", 1025, SecurityMode.NONE, null, null, ADMIN_EMAIL, "Lab Timesheet"));
smtp.testDraft(draftId, adminId, ADMIN_EMAIL);
smtp.activate(draftId, adminId);
mail.clear();
}
private void createAndActivate(long adminId, CreateAccountCommand command) {
var creation = accounts.create(command, adminId);
assertThat(creation.deliverySucceeded()).isTrue();
assertThat(accounts.activate(mail.activationTokenFor(command.email()), PASSWORD)).isTrue();
}
@TestConfiguration(proxyBeanMethods = false)
static class MailProbeConfiguration {
@Bean
@Primary
RecordingSmtpProbe recordingSmtpProbe() {
return new RecordingSmtpProbe();
}
}
static final class RecordingSmtpProbe implements SmtpProbe {
private final List<Message> messages = new ArrayList<>();
@Override
public void send(SmtpConnection connection, String recipient, String subject, String body) {
messages.add(new Message(recipient, body));
}
void clear() {
messages.clear();
}
String activationTokenFor(String recipient) {
String body = messages.stream()
.filter(message -> message.recipient().equals(recipient))
.findFirst()
.orElseThrow()
.body();
int tokenStart = body.indexOf("token=");
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
return body.substring(tokenStart + "token=".length()).trim();
}
}
record Message(String recipient, String body) {
}
}
@@ -44,7 +44,9 @@ class DashboardControllerWebTest {
.andExpect(model().attribute("dashboard", dashboard))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"This installation remains restricted until tested SMTP is active.")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("href=\"/admin/smtp\"")));
.andExpect(content().string(org.hamcrest.Matchers.containsString("href=\"/admin/smtp\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"data-tooltip=\"SMTP settings\"")));
verify(dashboards).admin("admin@example.test");
}
@@ -58,20 +60,25 @@ class DashboardControllerWebTest {
mvc.perform(get("/dashboard").with(user("admin@example.test").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
"This installation remains restricted until tested SMTP is active."))));
"This installation remains restricted until tested SMTP is active."))))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"href=\"/admin/smtp\" data-tooltip=\"SMTP settings\"")));
}
@Test
void mentorRendersMentorDashboardForAuthenticatedIdentity() throws Exception {
var dashboard = new DashboardView.Mentor("Mentor", 2, 4, 1);
given(dashboards.mentor("mentor@example.test")).willReturn(dashboard);
given(smtpConfiguration.hasActiveConfiguration()).willReturn(true);
mvc.perform(get("/dashboard").with(user("mentor@example.test").roles("MENTOR")))
.andExpect(status().isOk())
.andExpect(view().name("dashboard/mentor"))
.andExpect(model().attribute("dashboard", dashboard))
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
"This installation remains restricted until tested SMTP is active."))));
"This installation remains restricted until tested SMTP is active."))))
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
"data-tooltip=\"SMTP settings\""))));
verify(dashboards).mentor("mentor@example.test");
}
@@ -81,11 +88,14 @@ class DashboardControllerWebTest {
var dashboard = new DashboardView.Intern(
"Intern", DashboardView.AttendanceState.NOT_CHECKED_IN, 1, 0, List.of());
given(dashboards.intern("intern@example.test")).willReturn(dashboard);
given(smtpConfiguration.hasActiveConfiguration()).willReturn(true);
mvc.perform(get("/dashboard").with(user("intern@example.test").roles("INTERN")))
.andExpect(status().isOk())
.andExpect(view().name("dashboard/intern"))
.andExpect(model().attribute("dashboard", dashboard));
.andExpect(model().attribute("dashboard", dashboard))
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
"data-tooltip=\"SMTP settings\""))));
verify(dashboards).intern("intern@example.test");
}
@@ -136,12 +136,12 @@ class RoleDashboardWebIntegrationTest {
.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");
followEveryVisibleNavigationLink("admin@example.test", "ADMIN", true);
followEveryVisibleNavigationLink("mentor@example.test", "MENTOR", false);
followEveryVisibleNavigationLink("intern@example.test", "INTERN", false);
}
private void followEveryVisibleNavigationLink(String email, String role) throws Exception {
private void followEveryVisibleNavigationLink(String email, String role, boolean expectsSmtpSettings) throws Exception {
String dashboard = mvc.perform(get("/dashboard").with(user(email).roles(role)))
.andExpect(status().isOk())
.andReturn()
@@ -153,6 +153,11 @@ class RoleDashboardWebIntegrationTest {
paths.add(matcher.group(1));
}
assertThat(paths).isNotEmpty();
if (expectsSmtpSettings) {
assertThat(paths).contains("/admin/smtp");
} else {
assertThat(paths).doesNotContain("/admin/smtp");
}
for (String path : paths) {
mvc.perform(get(path).with(user(email).roles(role)))
.andExpect(status().isOk());
@@ -97,6 +97,26 @@ class UiContractWebTest {
assertTrue(themeBootstrap.contains("matchMedia('(prefers-color-scheme: dark)')"));
}
@Test
void generatedLucideSymbolsRetainCurrentColorStrokePresentation() throws Exception {
String icons = new ClassPathResource("static/assets/icons.svg")
.getContentAsString(StandardCharsets.UTF_8);
Matcher symbols = Pattern.compile("<symbol\\b([^>]*)>").matcher(icons);
int symbolCount = 0;
while (symbols.find()) {
String attributes = symbols.group(1);
assertTrue(attributes.contains("fill=\"none\""), () -> "Missing fill on " + attributes);
assertTrue(attributes.contains("stroke=\"currentColor\""), () -> "Missing stroke on " + attributes);
assertTrue(attributes.contains("stroke-width=\"2\""), () -> "Missing stroke width on " + attributes);
assertTrue(attributes.contains("stroke-linecap=\"round\""), () -> "Missing stroke linecap on " + attributes);
assertTrue(attributes.contains("stroke-linejoin=\"round\""), () -> "Missing stroke linejoin on " + attributes);
symbolCount++;
}
assertTrue(symbolCount > 0, "The generated sprite must contain symbols");
}
@Test
@WithMockUser(username = "admin@example.test", roles = "ADMIN")
void collapsedSidebarExposesStateAndKeyboardVisibleControlNames() throws Exception {