Compare commits

..
6 changed files with 264 additions and 113 deletions
@@ -0,0 +1,91 @@
# Test Evidence: form-authenticated global calendar access
- **Test type:** Web
- **Requirement IDs:** `AUTH-002`, `CAL-001`, `SEC-001`, `SEC-013`
- **Scenario IDs:** `AC-SEC-005`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.CalendarAuthorizationWebIntegrationTest#formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied`
- **Implementation commit:** `c8d4e9eecc59c78941769487af30953fb31a83c5`
## Incident scope
This record covers only the reported HTTP 403 for a fresh Admin session on
`GET /attendance/calendar`. The separately supplied 500 about policy
materialization is not a calendar-session or identity-mapping claim. It is
cross-referenced to
`.superpowers/sdd/access-navigation-icon-intern-picker/task-4-intern-dashboard-report.md`,
which independently records valid current PostgreSQL policy/constraint state
and no reproduction of that 500.
## Protected behavior
The persisted first Admin can open global calendar management after a real CSRF-protected form login. Persisted Mentor and Intern accounts, each authenticated by the same form-login path, receive HTTP 403 for that route.
## Test method
The test posts the actual bootstrap form, logs in through Spring Security, and follows the resulting session to `/attendance/calendar`. It configures a test-only SMTP probe solely to activate Mentor and Intern accounts through the public AccountService, then logs in those accounts before asserting denial. Spring Boot applies Flyway to PostgreSQL 18.4 through the shared Testcontainers configuration.
## Hand-derived expected result
The bootstrap entity always has immutable `ADMIN` role, so its fresh authenticated session must receive HTTP 200 from the Admin-only calendar route. Immutable `MENTOR` and `INTERN` roles are not permitted by `CAL-001`, so their matching fresh authenticated sessions must receive HTTP 403. No calendar mutation is attempted.
## 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=CalendarAuthorizationWebIntegrationTest#formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied' test
```
**Observed result**
```text
No valid RED occurred. On exact base 8be1b754e188367b260981718a5d33fc2d4d8a3b,
the new incident reproducer passed immediately: Tests run: 1, Failures: 0,
Errors: 0, Skipped: 0; BUILD SUCCESS. The production authorization guard was
not temporarily weakened merely to manufacture a failing result.
```
## 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=CalendarAuthorizationWebIntegrationTest#formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied' test
```
**Observed result**
```text
No production correction was warranted. The strengthened regression, including
form-login authority assertions, passed: Tests run: 1, Failures: 0, Errors: 0,
Skipped: 0; BUILD SUCCESS. It observed Admin HTTP 200 and Mentor/Intern HTTP
403 after distinct persisted-account logins.
```
## 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=CalendarAuthorizationWebIntegrationTest,AttendanceControllerTest,AttendanceTemplateIntegrationTest,AuthenticationWebIntegrationTest,SecurityResponseIntegrationTest,RoleDashboardWebIntegrationTest test
Tests run: 16, Failures: 0, Errors: 0, Skipped: 0; BUILD SUCCESS.
Full backend suite:
./mvnw -q test
Result: exit code 0 with Java 25.0.4 and PostgreSQL 18.4 Testcontainers.
```
## External-test boundaries
This web test uses real Spring MVC, form authentication, account identity mapping, Flyway, and PostgreSQL 18.4. It substitutes only SMTP transport with an in-memory probe, does not exercise calendar mutations or a real browser, and does not establish production deployment configuration.
@@ -1,70 +0,0 @@
# Test Evidence: dark icon sprite presentation
- **Test type:** Web
- **Requirement IDs:** `UI-006`, `UI-009`, `UI-010`, `UI-018`
- **Scenario IDs:** `AC-UI-003`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest#generatedLucideSymbolsRetainCurrentColorStrokePresentation`
- **Implementation commit:** `pending`
## Protected behavior
Every local Lucide sprite symbol retains the source presentation attributes so icons referenced with `<use>` inherit `currentColor` rather than rendering with the SVG default black fill on dark surfaces.
## Test method
The focused web contract reads the generated classpath sprite, scans every emitted `<symbol>`, and checks the five presentation attributes on each symbol. It checks the deployable generated artifact rather than generator source text.
## Hand-derived expected result
Lucide 1.27.0 line icons use `fill="none"`, `stroke="currentColor"`, `stroke-width="2"`, `stroke-linecap="round"`, and `stroke-linejoin="round"` on their SVG root. Each selected generated symbol must preserve those values.
## RED
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=UiContractWebTest#generatedLucideSymbolsRetainCurrentColorStrokePresentation' test
```
**Observed result**
```text
UiContractWebTest.generatedLucideSymbolsRetainCurrentColorStrokePresentation
Missing fill on id="bell" viewBox="0 0 24 24" ==> expected: <true> but was: <false>
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
BUILD FAILURE
```
## GREEN
**Command**
```text
env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm ci
env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run build
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=UiContractWebTest#generatedLucideSymbolsRetainCurrentColorStrokePresentation' test
```
**Observed result**
```text
Node v24.19.0 and npm 11.17.0 installed the locked dependencies.
Tailwind CSS v4.3.3 rebuilt app.css and build-icons regenerated icons.svg.
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=UiContractWebTest' test
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
The deterministic asset contract proves the generated sprite carries theme-aware Lucide presentation attributes. It does not replace the taskmaster-owned integrated browser/detector pass for rendered layout and interactive states.
+2 -6
View File
@@ -10,14 +10,10 @@ 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 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 viewBox = svg.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24';
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}" ${presentation}>${body.trim()}</symbol>`;
return `<symbol id="${name}" viewBox="${viewBox}">${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" 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" />
<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" />
<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" 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" />
<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" />
<circle cx="12" cy="11" r="4" />
<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" />
<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" />
<path d="M8 10v4" />
<path d="M12 10v2" />
<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" />
<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" />
<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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"><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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"><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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"><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" 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" />
<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" />
<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" 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="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="M12 9v4" />
<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="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="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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" />
<circle cx="9" cy="7" r="4" /></symbol><symbol id="x" viewBox="0 0 24 24"><path d="M18 6 6 18" />
<path d="m6 6 12 12" /></symbol></svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -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) {
}
}
@@ -97,26 +97,6 @@ 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 {