Compare commits

...
Author SHA1 Message Date
sechmachine c64ec659e7 test: record complete repair workflow validation 2026-08-15 14:57:48 +07:00
sechmachine 445e4fedeb docs: require complete targeted repair workflow 2026-08-15 14:56:14 +07:00
sechmachine 10196d55b0 Merge commit 'bed8b502d461cda892bcbaee374ed165fc416ca2' into work/fix/platform/fix-branch-workflow 2026-08-15 14:47:58 +07:00
sechmachine 5b8a58f520 test: record all-guide branch workflow validation 2026-08-15 14:47:39 +07:00
sechmachine bed8b502d4 Merge commit 'ef08717ce54c83d431f191447dc8551040575a4c' 2026-08-15 14:46:15 +07:00
sechmachine 90e412cffa Merge commit '46279f740b584ac64398033804d759cc4c64fa97' 2026-08-15 14:46:15 +07:00
sechmachine 207c0bfe89 Merge commit '159e634ffcd4a77507802a432ebfb9e836442b86' 2026-08-15 14:46:15 +07:00
sechmachine f98e7f39ef test: align fix branch plan contract 2026-08-15 14:43:03 +07:00
sechmachine 9802d5d17f docs: align repair branch coordination authority 2026-08-15 14:41:48 +07:00
sechmachine 826054c3c5 Merge commit '432389220f53e06df88971f8a7b711a42cb85fee' 2026-08-15 14:38:46 +07:00
sechmachine 97e991317d test: cover every fix branch guide mutation 2026-08-15 14:38:12 +07:00
sechmachine ef08717ce5 docs(test): scope calendar authorization evidence 2026-08-15 14:36:29 +07:00
sechmachine 719e02ea90 test: strengthen fix branch workflow evidence 2026-08-15 14:32:26 +07:00
sechmachine d617769499 test: harden fix branch documentation regression 2026-08-15 14:31:07 +07:00
sechmachine c8d4e9eecc test(attendance): cover form-authenticated calendar access 2026-08-15 14:26:51 +07:00
sechmachine 46279f740b fix(ui): keep SMTP settings in Admin navigation 2026-08-15 14:20:52 +07:00
sechmachine 93ea47ae4a test: record fix branch workflow validation 2026-08-15 14:18:56 +07:00
sechmachine f013ad7707 docs: define durable fix branch workflow 2026-08-15 14:18:13 +07:00
sechmachine 159e634ffc fix(ui): preserve Lucide sprite presentation attributes 2026-08-15 14:17:04 +07:00
21 changed files with 901 additions and 27 deletions
+1
View File
@@ -55,6 +55,7 @@ Conflict-prevention rules:
- `work/platform` owns `src/main/resources/db/migration/**`, Maven/dependency configuration, Compose, container build files, and CI workflow files. Other branches request schema changes instead of independently allocating migration versions.
- `work/reports-ui` owns shared templates/fragments, shared design tokens, and general UI assets. Each domain branch owns its module-specific controllers and pages while consuming those shared fragments.
- A targeted repair shall use a clean, isolated `work/fix/<feature>/<what-fix>` branch and worktree from the taskmaster-verified current `main`. Do not use `work/<feature>/fix/<what-fix>`: the persistent `work/<feature>` ref already occupies that Git ref prefix.
- `work/tasks` exposes focused Task query/transfer operations required by Project workflows. `work/projects` owns the transaction that removes a member or completes a Project.
- `work/platform` owns HolidayAPI credential storage and the tested HTTP client. `work/attendance` owns preview interpretation, selection, deduplication, import, and day-off effects.
- Attendance time and Task work time remain separate. No branch may make one mutate or prove the other.
@@ -70,6 +70,11 @@ Use exactly these persistent branches unless the user changes the plan:
| `work/attendance` | Policy, calendar, attendance, corrections, leave, metrics |
| `work/reports-ui` | Shared Thymeleaf UI, dashboards, reports, exports |
For a targeted repair outside the next iteration, create a clean isolated
`work/fix/<feature>/<what-fix>` worktree from the taskmaster-verified current
`main`. Do not use `work/<feature>/fix/<what-fix>`: the persistent
`work/<feature>` ref already occupies that Git ref prefix.
Create one isolated worktree per branch. Give each implementation agent explicit ownership, tell it other agents share the repository, forbid reverting others' work, require medium-milestone local commits, and forbid push unless separately authorized.
Before any owner edits its module, require it to:
+2
View File
@@ -120,6 +120,8 @@ For a multi-branch iteration:
- Use one worktree and one named owner/subagent per branch. Tell every owner that other agents share the repository and it must not revert others' work.
- Before starting assigned module work, every owner verifies its worktree is clean, fetches or uses the taskmaster-verified latest `main`, and fast-forwards its persistent branch to that exact main SHA. Do not build new work on a stale pre-integration branch, and do not use a merge that would rewrite or discard branch history.
- A targeted repair uses a clean, isolated `work/fix/<feature>/<what-fix>` branch and worktree from the taskmaster-verified current `main`. Do not use `work/<feature>/fix/<what-fix>`: the persistent `work/<feature>` ref already occupies that Git ref prefix.
- Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
- Establish and commit the platform foundation before dependent persistence work.
- Exchange only full immutable SHAs from clean worktrees; never merge a moving branch or ambiguous short SHA.
- Preserve branch ownership. Request a producer-owned service/DTO boundary instead of reading its tables from a consumer.
+10
View File
@@ -62,6 +62,16 @@ npm ci
npm run build
```
### Use an isolated repair branch
For a targeted repair, start a clean worktree from the taskmaster-verified
current `main` on `work/fix/<feature>/<what-fix>`. Keep it separate from the
five persistent `work/<feature>` branches. Do not use
`work/<feature>/fix/<what-fix>` because the persistent `work/<feature>` ref
already occupies that Git ref prefix.
Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
## 3. Start the development containers
### PostgreSQL 18.4
+1
View File
@@ -79,6 +79,7 @@ The product joins attendance oversight and Project delivery without pretending t
3. **Attendance and Project work stay distinct.** The product may report them together, but one never derives or proves the other.
4. **Deadlines are enforced at every path.** Scheduled workers improve timeliness, while request-time guards preserve correctness when scheduling is late.
5. **Prefer explicit, reviewable operations.** Feature-owned controller/service/repository flows, constrained state transitions, focused integrations, and shared report datasets serve clarity over speculative machinery.
6. **Fixes preserve branch ownership.** A targeted repair uses a clean `work/fix/<feature>/<what-fix>` branch from verified `main`, not `work/<feature>/fix/<what-fix>`; persistent `work/<feature>` refs already occupy that Git ref prefix.
## Accessibility & Inclusion
+8
View File
@@ -126,6 +126,14 @@ companion record under [`docs/tests`](docs/tests/README.md).
| `work/attendance` | Policy, calendar, attendance workflows |
| `work/reports-ui` | Shared UI, dashboards, reporting presentation |
For a targeted repair, create a clean isolated branch and worktree from the
taskmaster-verified current `main` named
`work/fix/<feature>/<what-fix>`. Do not nest it as
`work/<feature>/fix/<what-fix>`: the persistent `work/<feature>` ref already
uses that Git ref prefix.
Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
Iteration 2 work must start from the merged Iteration 1 `main`, continue with
strict RED-to-GREEN TDD, add Javadoc during implementation, and update the
matching Markdown evidence record before each milestone commit.
+8
View File
@@ -120,6 +120,14 @@ Simple configuration or documentation changes use the smallest useful shell
check, followed by the affected Maven suite. Do not create an artificial Java
test only to check that a text file exists.
Run that check from the clean targeted-fix branch named
`work/fix/<feature>/<what-fix>` when repairing one feature. Do not use
`work/<feature>/fix/<what-fix>`: a persistent `work/<feature>` ref already
occupies that Git ref prefix. Record the expected RED and the matching GREEN
shell output in the evidence record.
Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
## 4. Useful commands
Run one test method:
@@ -0,0 +1,35 @@
# Access, Navigation, Icon, and Intern Picker Fix Plan
## Scope
Record the durable workflow for the targeted fixes in this plan. This plan does
not change product behavior, dependencies, schemas, or the five persistent
feature-branch ownership areas.
## Implementation steps
1. Prove RED: the contributor guides lack the realizable repair-branch name.
2. Add one branch rule to contributor guides, design records, plans, and tracked
coordination authority: `work/fix/<feature>/<what-fix>` from verified
`main`.
3. State why `work/<feature>/fix/<what-fix>` is invalid while its persistent
`work/<feature>` ref exists.
Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
4. Regenerate the local SRS after amending the existing operational requirement;
do not add a requirement ID or a use case.
5. Prove GREEN with the executable six-guide regression that independently
rejects a positive nested-form recommendation in every guide,
coordination-authority consistency, requirement/use-case counts, local-link
resolution, and an immutable base-to-candidate whitespace check. Commit the
tracked guidance locally; do not push or merge.
## Exit criteria
- The tracked guides, design record, implementation plan, root coordination
authority, and evidence record agree on the same repair-branch spelling.
- The local authoritative, explained, simple, and generated SRS catalogues keep
exactly 260 unique requirement IDs and the SRS keeps 14 use cases.
- The forbidden nested form is documented only as forbidden, not as a usable
branch name.
@@ -0,0 +1,42 @@
# Design Record: Durable Fix-Branch Workflow
- **Date:** 2026-08-15
- **Status:** Accepted
- **Related plan:** [Access, Navigation, Icon, and Intern Picker Fix Plan](../plans/2026-08-15-access-navigation-icon-intern-picker.md)
## Context
The repository keeps five persistent feature refs: `work/platform`,
`work/projects`, `work/tasks`, `work/attendance`, and `work/reports-ui`.
A proposed nested repair name such as `work/platform/fix/example` cannot coexist
with the existing `work/platform` ref because Git cannot use one ref as both a
leaf and a prefix.
## Decision
Use `work/fix/<feature>/<what-fix>` for each targeted repair. Create its clean,
isolated worktree from the taskmaster-verified current `main`. The `<feature>`
segment identifies the owning persistent area; it does not nest below that
persistent branch.
The forbidden form is `work/<feature>/fix/<what-fix>`. A repair owner preserves
other worktrees, records RED and GREEN evidence, commits locally, and does not
push or merge without separate authority.
Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
## Consequences
- Persistent feature branches remain available for their iteration ownership.
- A repair can be reviewed and handed off as one immutable branch head.
- Contributor documentation, local coordination authority, and generated SRS
traceability use the same spelling.
## Validation
The executable documentation validator checks the exact approved statement in
each of the six tracked guides and independently rejects an injected positive
nested-branch recommendation in every guide. The copied root coordination
authority uses the same rule and is checked separately for consistency. The
evidence record also verifies requirement counts, generated SRS use-case count,
and local Markdown links.
@@ -0,0 +1,210 @@
# Test Evidence: durable fix-branch documentation workflow
- **Test type:** Unit (documentation contract)
- **Requirement IDs:** `OPS-019`, `TST-009`, `TST-010`
- **Scenario IDs:** `AC-TST-001`
- **Test class/method:** `scripts/verify-fix-branch-workflow.cjs --self-test`
- **Implementation commit:** `f013ad7707b36959ddca891fe0d52f81bba3ee80`
- **Round-1 review-fix commits:** `d617769499362e92d058684501af3c1ae6b145b0`, `719e02ea902bfb2dbeddc04f12be3617be3427b5`
- **Round-2 all-guide regression commit:** `97e991317d55db4f7414678a89a45921802a14b8`
- **Round-2 coordination-authority commit:** `9802d5d17f5c07511e1f9cf59ace4b7e48fcdc0e`
- **Round-2 plan-contract commit:** `f98e7f39ef38c7882106ffb250155d2a72dcf0dd`
- **Round-3 complete-workflow commit:** `445e4fedeb0e06724b876c5731437d2c355cacb2`
## Protected behavior
Targeted repairs use the realizable `work/fix/<feature>/<what-fix>` branch and
clean worktree from taskmaster-verified `main`. Contributor guidance must reject
the impossible `work/<feature>/fix/<what-fix>` form while persistent
`work/<feature>` refs exist. The tracked copies of root coordination authority
must use the same rule. Every targeted-fix guide must also require the complete
lifecycle: latest `main`, TDD RED → GREEN, Javadoc during implementation,
companion evidence, independent review, and an authorized normal, non-force
merge.
## Test method
Use the tracked Node validator rather than an artificial Java test. It requires
the exact approved branch statement and complete targeted-repair lifecycle in
each of the six guides. Its self-test independently removes each of the six
lifecycle elements from every guide and asserts the file-specific rejection. It
also retains a fresh positive nested-branch mutation for every guide. The copied
root coordination files are compared byte-for-byte with their main-root sources
and checked for their exact approved rules. The original RED proves the
required branch name was absent from the four contributor guides; the first
review-fix RED proves the executable regression was absent.
## Hand-derived expected result
The required fix-branch spelling appears exactly once in each of the six tracked
documentation artifacts, and the only nested-form reference is inside that
artifact's exact approved statement. Each independent simulated positive
nested-branch recommendation must fail. The three tracked coordination files
must exactly match the authorized main-root versions. Loss of any lifecycle
element from any guide must fail. The existing SRS generator must still report
260 requirements and 14 use cases.
## RED
**Command**
```text
rg -n -F 'work/fix/<feature>/<what-fix>' AGENTS.md README.md DEVELOPMENT.md TESTING.md
```
**Observed result**
```text
exit 1; no matching lines
```
The failure was expected: the required realizable repair-branch rule was absent
before this documentation change.
### Review-fix RED
**Command**
```text
node scripts/verify-fix-branch-workflow.cjs --self-test
```
**Observed result**
```text
exit 1
Error: Cannot find module '.../scripts/verify-fix-branch-workflow.cjs'
```
The executable regression required to reject a positive nested-branch
recommendation did not exist.
### Round-2 RED
**Command**
```text
node scripts/verify-fix-branch-workflow.cjs --self-test | rg -x 'Positive nested branch recommendations: 6/6 rejected'
```
**Observed result**
```text
exit 1; no matching line
```
The prior self-test reported only a singular rejection and mutated only
`AGENTS.md`; it did not prove an independent rejection for each of the six
guides.
### Round-3 RED
**Command**
```text
node --check scripts/verify-fix-branch-workflow.cjs
node scripts/verify-fix-branch-workflow.cjs --self-test
```
**Observed result**
```text
exit 1
Error: AGENTS.md must contain the complete required targeted-repair workflow exactly once
README.md must contain the complete required targeted-repair workflow exactly once
DEVELOPMENT.md must contain the complete required targeted-repair workflow exactly once
TESTING.md must contain the complete required targeted-repair workflow exactly once
docs/superpowers/specs/2026-08-15-access-navigation-icon-intern-picker-design.md must contain the complete required targeted-repair workflow exactly once
docs/superpowers/plans/2026-08-15-access-navigation-icon-intern-picker.md must contain the complete required targeted-repair workflow exactly once
```
The six guides had branch naming but not the complete lifecycle contract.
## Initial GREEN
**Command**
```text
node scripts/verify-fix-branch-workflow.cjs --self-test
```
**Observed result**
```text
Fix-branch workflow documentation: 6 approved statements validated
Positive nested branch recommendation: rejected
```
### Round-2 GREEN
**Command**
```text
node --check scripts/verify-fix-branch-workflow.cjs
node scripts/verify-fix-branch-workflow.cjs --self-test
```
**Observed result**
```text
Fix-branch workflow documentation: 6 approved statements validated
Positive nested branch recommendations: 6/6 rejected
```
### Round-3 GREEN
**Command**
```text
node --check scripts/verify-fix-branch-workflow.cjs
node scripts/verify-fix-branch-workflow.cjs --self-test
```
**Observed result**
```text
Fix-branch workflow documentation: 6 approved statements validated
Targeted-repair workflow element removals: 36/36 rejected
Positive nested branch recommendations: 6/6 rejected
```
## Affected suite
**Command and result**
```text
node labtimesheet-docs-hub/ui-mockups/build-srs.cjs
node -e 'const fs=require("node:fs"); const checks=[["authoritative","labtimesheet-docs-hub/requirements-specification.md",/^\| ([A-Z]{2,4}-\d{3}) \|/gm],["explained","labtimesheet-docs-hub/explained/requirements-specification.md",/^\| ([A-Z]{2,4}-\d{3}) \|/gm],["simple","labtimesheet-docs-hub/explained/requirements-specification-simple.md",/^- \*\*([A-Z]{2,4}-\d{3}):\*\*/gm],["generated SRS","labtimesheet-docs-hub/software-requirements-specification.md",/^\| ([A-Z]{2,4}-\d{3}) \|/gm]]; for (const [name,file,pattern] of checks) { const ids=[...fs.readFileSync(file,"utf8").matchAll(pattern)].map(match=>match[1]); if (ids.length !== 260 || new Set(ids).size !== 260) throw new Error(`${name}: ${ids.length} rows, ${new Set(ids).size} unique`); console.log(`${name}: ${ids.length} rows, ${new Set(ids).size} unique IDs`); } const srs=fs.readFileSync("labtimesheet-docs-hub/software-requirements-specification.md","utf8"); const useCases=(srs.match(/^### 5\.\d+ UC-\d{2} —/gm)||[]).length; if (useCases !== 14) throw new Error(`SRS use cases: ${useCases}`); console.log(`generated SRS: ${useCases} use cases`);'
node -e 'const fs=require("node:fs"); const path=require("node:path"); let checked=0; const broken=[]; for (const file of process.argv.slice(1)) { const text=fs.readFileSync(file,"utf8"); for (const match of text.matchAll(/!?\[[^\]]*\]\(([^)]+)\)/g)) { const target=match[1].trim().replace(/^<|>$/g,"").split("#")[0].split("?")[0]; if (!target || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) continue; checked += 1; if (!fs.existsSync(path.resolve(path.dirname(file), decodeURIComponent(target)))) broken.push(`${file}: ${target}`); } } if (broken.length) throw new Error(`Broken local Markdown links:\n${broken.join("\n")}`); console.log(`Local Markdown links: ${checked} resolved`);' AGENTS.md README.md DEVELOPMENT.md TESTING.md docs/superpowers/specs/2026-08-15-access-navigation-icon-intern-picker-design.md docs/superpowers/plans/2026-08-15-access-navigation-icon-intern-picker.md docs/tests/unit/fix-branch-workflow-documentation.md
cmp -s .agents/PROJECT_PLAN.md /Users/sechmachine/Documents/WebProjects/labtimesheet/.agents/PROJECT_PLAN.md && cmp -s .agents/skills/orchestrate-labtimesheet-iteration/SKILL.md /Users/sechmachine/Documents/WebProjects/labtimesheet/.agents/skills/orchestrate-labtimesheet-iteration/SKILL.md && cmp -s PRODUCT.md /Users/sechmachine/Documents/WebProjects/labtimesheet/PRODUCT.md && node -e 'const fs=require("node:fs"); const files=[".agents/PROJECT_PLAN.md",".agents/skills/orchestrate-labtimesheet-iteration/SKILL.md","PRODUCT.md"]; const forms=["work/fix/<feature>/<what-fix>","work/<feature>/fix/<what-fix>"]; for (const file of files) { const text=fs.readFileSync(file,"utf8"); for (const form of forms) { if (text.split(form).length !== 2) throw new Error(file+": expected one "+form); } } console.log("Root coordination authority: "+files.length+" approved branch rules match exactly");'
git diff --check 8be1b754e188367b260981718a5d33fc2d4d8a3b 445e4fedeb0e06724b876c5731437d2c355cacb2
```
The SRS regeneration and count assertion ran from the main root because the
ignored requirements hub is local authority there. The root-authority
comparisons, link assertion, and exact base-to-candidate `git diff --check`
ran from this fix worktree; the SRS generator also rejects a broken local SRS
target before it writes the generated file.
```text
Wrote labtimesheet-docs-hub/software-requirements-specification.md
Requirements: 260; use cases: 14; screens: 48; mockup embeds: 48
authoritative: 260 rows, 260 unique IDs
explained: 260 rows, 260 unique IDs
simple: 260 rows, 260 unique IDs
generated SRS: 260 rows, 260 unique IDs
generated SRS: 14 use cases
Fix-branch workflow documentation: 6 approved statements validated
Targeted-repair workflow element removals: 36/36 rejected
Positive nested branch recommendations: 6/6 rejected
Root coordination authority: 3 approved branch rules match exactly
Local Markdown links: 7 resolved
git diff --check 8be1b754e188367b260981718a5d33fc2d4d8a3b 445e4fedeb0e06724b876c5731437d2c355cacb2: exit 0
```
## External-test boundaries
This documentation contract does not create or manipulate Git branches, start
the application, or replace branch-owner review. It validates the durable rule
and SRS traceability only; a taskmaster still authorizes branch creation,
integration, and any push.
@@ -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.
@@ -0,0 +1,70 @@
# 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.
@@ -0,0 +1,89 @@
# Test Evidence: persistent Admin SMTP settings navigation
- **Test type:** Web
- **Requirement IDs:** `ACC-007`, `INT-001`, `AUTH-002`, `UI-003`, `UI-008`, `UI-009`, `UI-010`
- **Scenario IDs:** `AC-ACC-003`, `AC-UI-002`, `AC-UI-003`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest#mentorAndInternDashboardsRenderRealScopedProjectTaskAndAttendanceData`
- **Implementation commit:** pending
## Protected behavior
An Admin always receives an SMTP settings destination in the shared sidebar, whether SMTP is restricted or active. The restricted-installation warning remains conditional. Mentor and Intern sidebars never expose the Admin-only destination, and the Admin link uses the local settings sprite plus the established collapsed-sidebar tooltip.
## Test method
The MVC slice renders the real dashboard controller, Spring Security Thymeleaf dialect, and shared layout with only the SMTP state and dashboard query services mocked at their public boundaries. It checks both Admin SMTP states and the active-SMTP Mentor/Intern views. The PostgreSQL 18.4 integration test activates SMTP through the real service, extracts rendered navigation links, requires the Admin SMTP route only for Admin, and follows every discovered link through the real controller/security stack.
## Hand-derived expected result
With SMTP restricted, an Admin dashboard contains the existing warning and a sidebar link to `/admin/smtp` identified by `data-tooltip="SMTP settings"`. After SMTP activation, the warning is absent but that same sidebar link remains. Mentor and Intern dashboards omit the SMTP-settings tooltip and route. The activated Admin link resolves successfully when followed.
## RED
**Command**
```text
JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/opt/node@24/bin:$PATH ./mvnw -DargLine=-javaagent:/Users/sechmachine/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.10/byte-buddy-agent-1.18.10.jar -Dtest=DashboardControllerWebTest test
```
**Observed result**
```text
Tests run: 6, Failures: 2, Errors: 0, Skipped: 0
DashboardControllerWebTest.adminRendersAdminDashboardForAuthenticatedIdentity: expected data-tooltip="SMTP settings" but it was absent.
DashboardControllerWebTest.activeSmtpKeepsAdminDashboardFreeOfTheRestrictedInstallationWarning: expected href="/admin/smtp" data-tooltip="SMTP settings" but it was absent.
BUILD FAILURE
```
The Mentor and Intern active-SMTP assertions passed in this RED run, so the failures establish the missing Admin navigation rather than an incorrect role fixture.
**Command**
```text
JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/opt/node@24/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -DargLine=-javaagent:/Users/sechmachine/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.10/byte-buddy-agent-1.18.10.jar -Dtest=RoleDashboardWebIntegrationTest test
```
**Observed result**
```text
PostgreSQL: 18.4 Testcontainer
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Expected Admin visible navigation paths to contain /admin/smtp, but rendered paths were /dashboard, /admin/accounts/new, /attendance/calendar.
BUILD FAILURE
```
## GREEN
**Command**
```text
JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/opt/node@24/bin:$PATH ./mvnw -DargLine=-javaagent:/Users/sechmachine/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.10/byte-buddy-agent-1.18.10.jar -Dtest=DashboardControllerWebTest test
JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/opt/node@24/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -DargLine=-javaagent:/Users/sechmachine/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.10/byte-buddy-agent-1.18.10.jar -Dtest=RoleDashboardWebIntegrationTest test
```
**Observed result**
```text
DashboardControllerWebTest: Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
RoleDashboardWebIntegrationTest: PostgreSQL 18.4 Testcontainer; Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
PATH=/opt/homebrew/opt/node@24/bin:$PATH node --version && PATH=/opt/homebrew/opt/node@24/bin:$PATH npm --version && PATH=/opt/homebrew/opt/node@24/bin:$PATH npm ci && PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run build
Node v24.19.0; npm 11.17.0; Tailwind CSS v4.3.3
BUILD SUCCESS
JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/opt/node@24/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -DargLine=-javaagent:/Users/sechmachine/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.10/byte-buddy-agent-1.18.10.jar -Dtest=SecurityResponseIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountWebIntegrationTest,SmtpOnboardingWebIntegrationTest,RoleDashboardWebIntegrationTest,UiContractWebTest,AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,ProjectControllerTest,TaskControllerTest,AttendanceControllerTest test
PostgreSQL 18.4 Testcontainers; Tests run: 81, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
MockMvc proves rendered role/state visibility, while the PostgreSQL integration test proves the real Admin route follow. They do not render the collapsed rail or inspect pixels, browser focus placement, or tooltip positioning; the existing CSS and local settings sprite are reused unchanged. Server-side direct-URL authorization remains the existing `/admin/**` Admin-only security rule and is not broadened by this layout-only change.
+108
View File
@@ -0,0 +1,108 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const repositoryRoot = path.resolve(__dirname, '..');
const validForm = '`work/fix/<feature>/<what-fix>`';
const invalidForm = '`work/<feature>/fix/<what-fix>`';
const requiredWorkflow = 'Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.';
const workflowElements = [
'taskmaster-verified latest `main`',
'TDD RED → GREEN',
'Javadoc during implementation',
'companion evidence',
'independent review',
'normal, non-force merge'
];
const documents = [
{
file: 'AGENTS.md',
approved: '- A targeted repair uses a clean, isolated `work/fix/<feature>/<what-fix>` branch and worktree from the taskmaster-verified current `main`. Do not use `work/<feature>/fix/<what-fix>`: the persistent `work/<feature>` ref already occupies that Git ref prefix.'
},
{
file: 'README.md',
approved: 'For a targeted repair, create a clean isolated branch and worktree from the\ntaskmaster-verified current `main` named\n`work/fix/<feature>/<what-fix>`. Do not nest it as\n`work/<feature>/fix/<what-fix>`: the persistent `work/<feature>` ref already\nuses that Git ref prefix.'
},
{
file: 'DEVELOPMENT.md',
approved: 'For a targeted repair, start a clean worktree from the taskmaster-verified\ncurrent `main` on `work/fix/<feature>/<what-fix>`. Keep it separate from the\nfive persistent `work/<feature>` branches. Do not use\n`work/<feature>/fix/<what-fix>` because the persistent `work/<feature>` ref\nalready occupies that Git ref prefix.'
},
{
file: 'TESTING.md',
approved: 'Run that check from the clean targeted-fix branch named\n`work/fix/<feature>/<what-fix>` when repairing one feature. Do not use\n`work/<feature>/fix/<what-fix>`: a persistent `work/<feature>` ref already\noccupies that Git ref prefix. Record the expected RED and the matching GREEN\nshell output in the evidence record.'
},
{
file: 'docs/superpowers/specs/2026-08-15-access-navigation-icon-intern-picker-design.md',
approved: 'Use `work/fix/<feature>/<what-fix>` for each targeted repair. Create its clean,\nisolated worktree from the taskmaster-verified current `main`. The `<feature>`\nsegment identifies the owning persistent area; it does not nest below that\npersistent branch.\n\nThe forbidden form is `work/<feature>/fix/<what-fix>`. A repair owner preserves\nother worktrees, records RED and GREEN evidence, commits locally, and does not\npush or merge without separate authority.'
},
{
file: 'docs/superpowers/plans/2026-08-15-access-navigation-icon-intern-picker.md',
approved: '2. Add one branch rule to contributor guides, design records, plans, and tracked\n coordination authority: `work/fix/<feature>/<what-fix>` from verified\n `main`.\n3. State why `work/<feature>/fix/<what-fix>` is invalid while its persistent\n `work/<feature>` ref exists.'
}
];
/** Validates that every tracked guide contains only its approved branch wording. */
function validate(contents) {
const failures = [];
for (const {file, approved} of documents) {
const content = contents.get(file);
if (count(content, validForm) !== 1) failures.push(`${file} must contain ${validForm} exactly once`);
if (!content.includes(approved)) failures.push(`${file} is missing its approved branch workflow statement`);
if (count(content, requiredWorkflow) !== 1) {
failures.push(`${file} must contain the complete required targeted-repair workflow exactly once`);
}
const outsideApprovedStatement = content.replace(approved, '');
if (outsideApprovedStatement.includes(validForm) || outsideApprovedStatement.includes(invalidForm)) {
failures.push(`${file} contains an unapproved branch-form reference`);
}
}
if (failures.length) throw new Error(failures.join('\n'));
}
function count(content, value) {
return content.split(value).length - 1;
}
function readContents() {
return new Map(documents.map(({file}) => [file, fs.readFileSync(path.join(repositoryRoot, file), 'utf8')]));
}
const contents = readContents();
validate(contents);
let workflowElementRejections = 0;
if (process.argv.includes('--self-test')) {
for (const {file} of documents) {
for (const workflowElement of workflowElements) {
const missingWorkflowElement = new Map(contents);
missingWorkflowElement.set(
file,
contents.get(file).replace(requiredWorkflow, requiredWorkflow.replace(workflowElement, ''))
);
assert.throws(
() => validate(missingWorkflowElement),
(error) => error instanceof Error
&& error.message.includes(`${file} must contain the complete required targeted-repair workflow exactly once`)
);
workflowElementRejections += 1;
}
const positiveRecommendation = new Map(contents);
positiveRecommendation.set(file, `${contents.get(file)}\nUse ${invalidForm} for a targeted repair.\n`);
assert.throws(
() => validate(positiveRecommendation),
(error) => error instanceof Error
&& error.message.includes(`${file} contains an unapproved branch-form reference`)
);
}
}
console.log(`Fix-branch workflow documentation: ${documents.length} approved statements validated`);
if (process.argv.includes('--self-test')) {
console.log(`Targeted-repair workflow element removals: ${workflowElementRejections}/${workflowElements.length * documents.length} rejected`);
console.log(`Positive nested branch recommendations: ${documents.length}/${documents.length} rejected`);
}
+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 {