Compare commits

...
37 Commits
Author SHA1 Message Date
sechmachine 200a17e5bd docs(test): pin registry workflow evidence
Container / verify (push) Canceled after 4m34s
Container / amd64 (push) Canceled after 0s
Container / arm64 (push) Canceled after 0s
Container / manifest (push) Canceled after 0s
Verify / verify (push) Successful in 8m31s
2026-08-16 00:28:46 +07:00
sechmachine 4dd9f96a23 fix(ci): use Gitea actor for registry publishing 2026-08-16 00:28:15 +07:00
sechmachine d044277194 docs(test): record Gitea runner verification
Container / amd64 (push) Failing after 6m24s
Container / manifest (push) Skipped
Container / verify (push) Successful in 3m12s
Container / arm64 (push) Skipped
Verify / verify (push) Successful in 8m59s
2026-08-15 23:55:52 +07:00
sechmachine d13443e338 fix(ci): gate images behind verified Testcontainers
Verify / verify (push) Successful in 8m52s
2026-08-15 23:21:23 +07:00
sechmachine 7a6139017f docs(test): record production delivery evidence
Container / amd64 (push) Failing after 19m25s
Container / arm64 (push) Skipped
Container / manifest (push) Skipped
Verify / verify (push) Failing after 12m3s
2026-08-15 22:46:19 +07:00
sechmachine cea4378f56 feat(platform): add production CI and containers 2026-08-15 22:45:35 +07:00
sechmachine b13c547068 docs(test): pin Windows timezone evidence 2026-08-15 22:07:56 +07:00
sechmachine a9fb948769 fix(platform): normalize legacy Vietnam timezone 2026-08-15 22:07:27 +07:00
sechmachine 7bac998866 Merge commit '28ecfabe37b8a7360f0bda7be1533561e1005e20' 2026-08-15 15:56:20 +07:00
sechmachine 28ecfabe37 fix: preserve dev attendance policy wall-clock times 2026-08-15 15:51:43 +07:00
sechmachine c8735c8ab8 Merge commit '6de463221b6a7e9ae59768ae82d75303b65c0779' 2026-08-15 15:26:51 +07:00
sechmachine 6de463221b test(projects): repair accessibility MVC slice 2026-08-15 15:25:35 +07:00
sechmachine 0a139d5470 Merge commit 'f3ffdab48e3d5ce558ca20aef29079abfd123c75' 2026-08-15 15:18:39 +07:00
sechmachine f3ffdab48e fix(projects): preserve server picker recovery 2026-08-15 15:16:28 +07:00
sechmachine 5df9eff21e docs(tests): record merged picker verification 2026-08-15 15:03:55 +07:00
sechmachine e5ff128502 Merge commit '32c8a2d315d2175760c5d4792988cd0aa5ab6dd0' into work/fix/projects/intern-picker 2026-08-15 15:02:18 +07:00
sechmachine 169da1a9f4 feat(projects): replace numeric Intern inputs with picker 2026-08-15 15:02:03 +07:00
sechmachine 64c9370aa0 feat(projects): add Intern members atomically 2026-08-15 15:01:48 +07:00
sechmachine 32c8a2d315 Merge commit 'c64ec659e74ce44debf82903234428e04b371833' 2026-08-15 15:00:22 +07:00
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 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
59 changed files with 2940 additions and 85 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:
+16
View File
@@ -0,0 +1,16 @@
.git
.gitea
.idea
.agents
.superpowers
.env
.env.*
!.env.compose.example
labtimesheet-docs-hub
node_modules
target
docs
src/test
*.log
*.7z
.DS_Store
+20
View File
@@ -0,0 +1,20 @@
# Production Compose example only. Store the real file outside the repository with mode 0600.
# Use an immutable sha-<full-commit> tag. The moving main tag is for convenience, not rollback.
LAB_IMAGE=git.sechmachine.io.vn/sechmachine/labtimesheet:sha-replace-with-full-commit
# The app is intended to sit behind an HTTPS reverse proxy on the same host.
LAB_HTTP_BIND=127.0.0.1
LAB_HTTP_PORT=8080
LAB_PUBLIC_ORIGIN=https://timesheet.example.edu
LAB_FORWARD_HEADERS_STRATEGY=framework
LAB_SECURITY_MASTER_KEY=replace-with-base64-encoded-32-byte-key
# Bundled mode uses the Compose service name. For external mode, replace this URL and credentials.
LAB_DB_URL=jdbc:postgresql://postgres:5432/labtimesheet
LAB_DB_USERNAME=labtimesheet
LAB_DB_PASSWORD=replace-with-database-password
# Used only when the bundled-db profile is enabled.
POSTGRES_DB=labtimesheet
POSTGRES_USER=labtimesheet
POSTGRES_PASSWORD=replace-with-the-same-database-password
+207
View File
@@ -0,0 +1,207 @@
name: Container
'on':
workflow_dispatch:
push:
branches:
- main
permissions:
contents: read
concurrency:
group: container-${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
env:
REGISTRY: git.sechmachine.io.vn
IMAGE_NAME: sechmachine/labtimesheet
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
TESTCONTAINERS_HOST_OVERRIDE: host.docker.internal
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Java 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Set up Node 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: npm
- name: Verify Docker for PostgreSQL tests
run: docker info
- name: Install frontend dependencies
run: npm ci
- name: Run frontend tests
run: npm run test:ui
- name: Build frontend assets
run: npm run build
- name: Verify generated assets are committed
run: git diff --exit-code -- src/main/resources/static/assets/app.css src/main/resources/static/assets/icons.svg
- name: Run Maven tests
run: ./mvnw -B test
- name: Verify Javadoc
run: ./mvnw -B -DskipTests -Ddoclint=all javadoc:javadoc
- name: Verify whitespace
run: git diff --check
amd64:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Select image tags
id: image
env:
EVENT_NAME: ${{ gitea.event_name }}
GITEA_REF: ${{ gitea.ref }}
GITEA_SHA: ${{ gitea.sha }}
run: |
publish=false
image="$REGISTRY/$IMAGE_NAME"
if [ "$EVENT_NAME" = "push" ] && [ "$GITEA_REF" = "refs/heads/main" ]; then
publish=true
fi
{
echo "publish=$publish"
echo "image=$image"
echo "tags<<EOF"
echo "$image:sha-${GITEA_SHA}-amd64"
if [ "$publish" = "true" ]; then
echo "$image:sha-${GITEA_SHA}"
echo "$image:main"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Log in to registry
if: steps.image.outputs.publish == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build AMD64 image and publish main
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64
push: ${{ steps.image.outputs.publish }}
tags: ${{ steps.image.outputs.tags }}
build-args: |
VCS_REF=${{ gitea.sha }}
arm64:
# Gitea cannot schedule a probe on a missing label. Enable this repository variable only
# while a trusted ubuntu-latest-arm runner is registered and online.
if: vars.ARM64_RUNNER_AVAILABLE == 'true'
needs: verify
runs-on: ubuntu-latest-arm
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Select image tag
id: image
env:
EVENT_NAME: ${{ gitea.event_name }}
GITEA_REF: ${{ gitea.ref }}
GITEA_SHA: ${{ gitea.sha }}
run: |
publish=false
image="$REGISTRY/$IMAGE_NAME"
if [ "$EVENT_NAME" = "push" ] && [ "$GITEA_REF" = "refs/heads/main" ]; then
publish=true
fi
echo "publish=$publish" >> "$GITHUB_OUTPUT"
echo "tag=$image:sha-${GITEA_SHA}-arm64" >> "$GITHUB_OUTPUT"
- name: Log in to registry
if: steps.image.outputs.publish == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build native ARM64 image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/arm64
push: ${{ steps.image.outputs.publish }}
tags: ${{ steps.image.outputs.tag }}
build-args: |
VCS_REF=${{ gitea.sha }}
manifest:
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' && vars.ARM64_RUNNER_AVAILABLE == 'true'
needs: [amd64, arm64]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
packages: write
steps:
- name: Set up Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Publish combined manifest
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
GITEA_SHA: ${{ gitea.sha }}
run: |
docker buildx imagetools create \
--tag "$IMAGE:sha-${GITEA_SHA}" \
--tag "$IMAGE:main" \
"$IMAGE:sha-${GITEA_SHA}-amd64" \
"$IMAGE:sha-${GITEA_SHA}-arm64"
+61
View File
@@ -0,0 +1,61 @@
name: Verify
'on':
pull_request:
push:
permissions:
contents: read
concurrency:
group: verify-${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
TESTCONTAINERS_HOST_OVERRIDE: host.docker.internal
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Java 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Set up Node 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: npm
- name: Verify Docker for PostgreSQL tests
run: docker info
- name: Install frontend dependencies
run: npm ci
- name: Run frontend tests
run: npm run test:ui
- name: Build frontend assets
run: npm run build
- name: Verify generated assets are committed
run: git diff --exit-code -- src/main/resources/static/assets/app.css src/main/resources/static/assets/icons.svg
- name: Run Maven tests
run: ./mvnw -B test
- name: Verify Javadoc
run: ./mvnw -B -DskipTests -Ddoclint=all javadoc:javadoc
- name: Verify whitespace
run: git diff --check
+5 -1
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.
@@ -144,7 +146,9 @@ npm run build
When using local OrbStack Testcontainers, set the actual Docker socket for that machine. Tests must not depend on the developer database or a real SMTP server.
Before completion, run focused tests, the affected suite, the full suite appropriate to the branch, frontend build when assets changed, `git diff --check`, and an adversarial diff review. The integrated iteration additionally requires Flyway/PostgreSQL validation and a real local Java process connected to PostgreSQL; an application container does not substitute for that gate when containerization is deferred.
Before completion, run focused tests, the affected suite, the full suite appropriate to the branch, frontend build when assets changed, `git diff --check`, and an adversarial diff review. The integrated iteration additionally requires Flyway/PostgreSQL validation and a real local Java process connected to PostgreSQL. Production container checks supplement those gates; they do not substitute for them.
The root `Dockerfile`, `compose.yaml`, and `.env.compose.example` are production-only. Development runs Java from the IDE or Maven as documented in `DEVELOPMENT.md`. Only `main` may publish container images. Native ARM64 publication is gated by the repository variable `ARM64_RUNNER_AVAILABLE`; leave it absent or false unless a trusted `ubuntu-latest-arm` runner is online.
## Post-iteration integration and push
+68
View File
@@ -0,0 +1,68 @@
# Production Container Deployment
These files deploy Lab Timesheet in production. They are not the development workflow; continue using [DEVELOPMENT.md](DEVELOPMENT.md) for IDE work.
## 1. Prepare the host
Install Docker Engine and Docker Compose v2.20 or newer. Put an HTTPS reverse proxy in front of the application. By default, Compose binds the application only to `127.0.0.1:8080`.
Copy [`.env.compose.example`](.env.compose.example) to a protected path outside the repository:
```bash
sudo install -d -m 0700 /etc/labtimesheet
sudo install -m 0600 .env.compose.example /etc/labtimesheet/compose.env
sudo editor /etc/labtimesheet/compose.env
```
Generate `LAB_SECURITY_MASTER_KEY` with `openssl rand -base64 32`. Use an immutable `sha-<full-commit>` application image tag. Never place Admin-managed SMTP or HolidayAPI credentials in this file.
## 2. Choose the database topology
### Bundled PostgreSQL 18.4
Keep the example JDBC host `postgres`, then run:
```bash
docker compose --env-file /etc/labtimesheet/compose.env --profile bundled-db up -d
docker compose --env-file /etc/labtimesheet/compose.env ps
```
The application waits for PostgreSQL health and stores database files in the `postgres_data` named volume.
### External PostgreSQL
Set `LAB_DB_URL`, `LAB_DB_USERNAME`, and `LAB_DB_PASSWORD` for the external database. Do not enable the `bundled-db` profile:
```bash
docker compose --env-file /etc/labtimesheet/compose.env up -d app
docker compose --env-file /etc/labtimesheet/compose.env ps
```
The same application image is used in both modes.
## 3. Health and operation
- Liveness: `GET /actuator/health/liveness`
- Readiness: `GET /actuator/health/readiness` (includes PostgreSQL)
- Logs: `docker compose --env-file /etc/labtimesheet/compose.env logs -f app`
The container runs as UID/GID `10001`, with a read-only root filesystem, no Linux capabilities, and only `/tmp` writable. TLS termination is intentionally outside this Compose example.
Back up PostgreSQL with database-aware tooling such as `pg_dump`. The named volume survives container replacement, but it is not a backup. Test restore procedures before upgrades.
To update or roll back, change `LAB_IMAGE` to the required immutable SHA tag and run `docker compose ... up -d` again. Keep the previous SHA recorded until the new image is healthy.
## 4. Gitea Actions setup
Configure these repository settings:
| Kind | Name | Value |
|---|---|---|
| Variable | `ARM64_RUNNER_AVAILABLE` | `true` only while a trusted `ubuntu-latest-arm` runner is registered and online; otherwise omit it or set `false` |
| Secret | `REGISTRY_TOKEN` | Token for the triggering Gitea account with package read/write access |
The workflow publishes `git.sechmachine.io.vn/sechmachine/labtimesheet` and authenticates as the triggering Gitea account. `verify.yml` runs for every pull request and push. `container.yml` runs only when manually dispatched or when `main` is pushed, and it repeats verification before either architecture build. Manual runs build without publishing. A push to `main` publishes immutable `sha-<commit>` and convenience `main` tags.
When ARM64 is disabled, those canonical tags remain valid AMD64 images and the workflow succeeds. When it is enabled, the native ARM runner publishes an architecture tag and the final job replaces the canonical tags with a combined AMD64/ARM64 manifest. Gitea cannot discover an unavailable runner from inside an unscheduled job, so the repository variable is the deliberate availability gate.
The workflows stop at verification and image publication. They do not contain SSH deployment or receive host deployment secrets.
+17 -2
View File
@@ -4,8 +4,9 @@ This guide explains how to prepare and run Lab Timesheet on a developer
computer. The application runs from Java. PostgreSQL and Mailpit run in Docker
containers.
Application containerization and Docker Compose are planned for a later
iteration, so they are not required for Iteration 1 development.
The root Dockerfile and Compose file are production-only. They are not part of
the development loop. Development still runs Java from the IDE or Maven while
PostgreSQL and Mailpit run as separate local containers.
## 1. Install the required tools
@@ -62,6 +63,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
@@ -241,3 +252,7 @@ npm run build
For test setup, commands, TDD, and test evidence rules, read
[TESTING.md](TESTING.md).
For production image and Compose operation, read [DEPLOYMENT.md](DEPLOYMENT.md).
Do not use the production Compose file as a replacement for this development
setup.
+38
View File
@@ -0,0 +1,38 @@
# Multi-stage production build. Base images are pinned multi-architecture indexes.
FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS frontend
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN npm ci
COPY src/main ./src/main
RUN npm run build
FROM eclipse-temurin:25-jdk-alpine@sha256:5ecfde8e5ecde5954ea3721155b345ef56c1d579b940c761318ad4c05959a151 AS builder
WORKDIR /workspace
RUN apk add --no-cache curl
COPY .mvn .mvn
COPY mvnw pom.xml ./
RUN ./mvnw -B -Dmaven.test.skip=true dependency:go-offline
COPY src/main ./src/main
COPY --from=frontend /workspace/src/main/resources/static/assets/app.css ./src/main/resources/static/assets/app.css
COPY --from=frontend /workspace/src/main/resources/static/assets/icons.svg ./src/main/resources/static/assets/icons.svg
RUN ./mvnw -B -Dmaven.test.skip=true package
FROM eclipse-temurin:25-jre-alpine@sha256:28db6fdf60e38945e43d840c0333aeaec66c15943070104f7586fd3c9d1665b0
ARG VCS_REF=unknown
ARG SOURCE_URL=https://git.sechmachine.io.vn/sechmachine/labtimesheet
LABEL org.opencontainers.image.title="Lab Timesheet" \
org.opencontainers.image.source="${SOURCE_URL}" \
org.opencontainers.image.revision="${VCS_REF}"
RUN addgroup -S -g 10001 app && adduser -S -D -H -u 10001 -G app app
WORKDIR /app
COPY --from=builder --chown=10001:10001 /workspace/target/*.war /app/app.war
ENV SPRING_PROFILES_ACTIVE=prod \
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0"
EXPOSE 8080
USER 10001:10001
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:8080/actuator/health/readiness || exit 1
ENTRYPOINT ["java", "-jar", "/app/app.war"]
+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
+24 -1
View File
@@ -50,7 +50,7 @@ The baseline schema includes later-workflow tables; table presence does not mean
the corresponding feature is complete.
- Iteration 2: Project invitations and approved membership exits, broader Project lifecycle transfers, Task edit/delete/reassignment and work logs, leave, missed-checkout corrections, notifications, schedulers, and complete metrics.
- Iteration 3: HTML/XLSX/PDF report parity, Chart.js trends, production security hardening, application containers, Compose, Gitea CI publication, and deployment scaffolding.
- Iteration 3: HTML/XLSX/PDF report parity, Chart.js trends, remaining production security hardening, and operational backup/restore qualification.
- Mobile layouts are best-effort. Desktop is the supported interface target.
## Architecture and versions
@@ -116,6 +116,21 @@ See [TESTING.md](TESTING.md) for setup, test commands, the required TDD cycle,
evidence records, best practices, and common fixes. Every behavior test has a
companion record under [`docs/tests`](docs/tests/README.md).
## Continuous integration and production containers
Gitea Actions verifies every pull request and push. The separate container
workflow runs only for a manual dispatch or a push to `main`, and its verification
job must pass before either image build starts. Manual runs build without publishing;
`main` pushes publish Linux AMD64 and add native Linux ARM64 only when the repository
explicitly declares that its ARM runner is online. Every published revision has an
immutable `sha-<full-commit>` tag, with `main` as a convenience alias.
The production image is a non-root Java 25 image. The root [compose.yaml](compose.yaml)
supports either a persistent PostgreSQL 18.4 sidecar or an external PostgreSQL
database. It is not used for development. Follow [DEPLOYMENT.md](DEPLOYMENT.md)
and start from [`.env.compose.example`](.env.compose.example); keep the real
production environment file outside the repository.
## Branch ownership
| Branch | Primary area |
@@ -126,6 +141,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.
+23
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:
@@ -211,6 +219,17 @@ claim never replaces a test command and result.
- Give tests names that describe the rule and expected result.
- Clean up temporary browser data, application processes, and manually started containers after end-to-end work.
### What CI runs
Gitea runs the frontend tests/build, complete Maven/PostgreSQL suite, Javadoc,
generated-asset check, and whitespace check for every pull request and push.
The separate container workflow runs only when manually dispatched or when
`main` is pushed. It repeats the verification job before building either image.
Manual runs do not publish; only a push to `main` publishes.
Run focused and affected tests locally before pushing. CI is the shared
confirmation, not a substitute for local RED and GREEN evidence.
## 8. Common problems
### Testcontainers cannot find Docker
@@ -218,6 +237,10 @@ claim never replaces a test command and result.
Start Docker Desktop or OrbStack. Run `docker version`. OrbStack users should
also check the `DOCKER_HOST` command shown in Section 1.
The Gitea Docker runner exposes the daemon through Docker Desktop, so its jobs
set `TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal`. Keep that override if
the runner stays containerized; otherwise Ryuk may try an unreachable bridge IP.
### The wrong Java version is used
Run `java -version` and `./mvnw -version`. Both should report Java 25. Set
+60 -6
View File
@@ -1,9 +1,63 @@
# Production deployment example. Development continues to run Java from the IDE.
name: labtimesheet-prod
services:
postgres:
image: 'postgres:latest'
app:
image: "${LAB_IMAGE:?Set LAB_IMAGE to an immutable sha-* image tag}"
restart: unless-stopped
environment:
- 'POSTGRES_DB=mydatabase'
- 'POSTGRES_PASSWORD=secret'
- 'POSTGRES_USER=myuser'
SPRING_PROFILES_ACTIVE: prod
LAB_DB_URL: "${LAB_DB_URL:?Set the JDBC PostgreSQL URL}"
LAB_DB_USERNAME: "${LAB_DB_USERNAME:?Set the database username}"
LAB_DB_PASSWORD: "${LAB_DB_PASSWORD:?Set the database password}"
LAB_PUBLIC_ORIGIN: "${LAB_PUBLIC_ORIGIN:?Set the public HTTPS origin}"
LAB_SECURITY_MASTER_KEY: "${LAB_SECURITY_MASTER_KEY:?Set a Base64 256-bit key}"
LAB_FORWARD_HEADERS_STRATEGY: "${LAB_FORWARD_HEADERS_STRATEGY:?Set the explicit proxy strategy}"
ports:
- '5432'
# Bind locally by default; terminate HTTPS in a reverse proxy on the same host.
- "${LAB_HTTP_BIND:-127.0.0.1}:${LAB_HTTP_PORT:-8080}:8080"
depends_on:
postgres:
condition: service_healthy
# External-database mode leaves the bundled-db profile disabled.
required: false
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
stop_grace_period: 40s
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/actuator/health/readiness"]
interval: 30s
timeout: 5s
start_period: 45s
retries: 3
postgres:
# Pinned PostgreSQL 18.4 multi-architecture image.
image: postgres:18.4@sha256:a02db8cac496f15b094798a38254f14d6e00741f709360e5e00bb6668ea31636
profiles: ["bundled-db"]
restart: unless-stopped
environment:
POSTGRES_DB: "${POSTGRES_DB:-labtimesheet}"
POSTGRES_USER: "${POSTGRES_USER:-labtimesheet}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?Set the bundled PostgreSQL password}"
volumes:
# PostgreSQL 18 stores versioned data beneath this parent directory.
- postgres_data:/var/lib/postgresql
shm_size: 256mb
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
volumes:
# Container replacement keeps this volume. It is not a substitute for backups.
postgres_data:
@@ -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,83 @@
# Test Evidence: development-profile attendance policy time hydration
- **Test type:** Integration
- **Requirement IDs:** `ATT-002`, `ATT-003`, `I1-ATT-01`
- **Scenario IDs:** `AC-ATT-001`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.CalendarDevelopmentProfileWebIntegrationTest#v1SeededPolicyLetsFormAuthenticatedAdminOpenCalendarInAsiaHoChiMinhDevelopmentProfile`
- **Implementation commit:** pending
## Protected behavior
The unmodified V1 attendance policy must hydrate its `time` schedule as the configured local wall-clock values when the development profile runs in `Asia/Ho_Chi_Minh`. A form-authenticated Admin can therefore open the calendar without weakening the policy rule that requires the checkout cutoff to be before local midnight.
## Test method
The test starts the application with the real `dev` profile plus isolated test configuration, forces the JVM default zone to `Asia/Ho_Chi_Minh` before JPA starts, and uses PostgreSQL 18.4 Testcontainers with Flyway V1. It bootstraps an Admin through the form, logs in through the form, and requests `/attendance/calendar`, which resolves the current policy through `AttendanceApplicationService.currentBusinessDate`.
## Hand-derived expected result
V1 explicitly stores `scheduled_start = 08:30`, `scheduled_end = 15:30`, and `checkout_grace_minutes = 30`. The checkout cutoff is therefore `16:00`, which is strictly before local midnight, so the calendar request returns HTTP 200.
## RED
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=CalendarDevelopmentProfileWebIntegrationTest' test
```
**Observed result**
```text
PostgreSQL 18.4 Testcontainers applied Flyway V1, then the form-authenticated GET /attendance/calendar failed.
BUILD FAILURE: CalendarDevelopmentProfileWebIntegrationTest ... ServletException caused by
IllegalArgumentException: checkout cutoff must be before local midnight
at AttendancePolicy.java:58 via AttendancePolicyEntity.toDomain and AttendanceApplicationService.currentBusinessDate.
```
## GREEN
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=CalendarDevelopmentProfileWebIntegrationTest' test
```
**Observed result**
```text
PostgreSQL 18.4 Testcontainers applied Flyway V1.
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:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=CalendarAuthorizationWebIntegrationTest,CalendarDevelopmentProfileWebIntegrationTest,RoleDashboardWebIntegrationTest,AttendancePersistenceIntegrationTest,AttendancePolicyTest' test
Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=*Attendance*Test,*Calendar*Test,Dashboard*Test,RoleDashboardWebIntegrationTest,AdminDashboardWebTest' test
Tests run: 60, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Java 26 smoke
**Command and result**
```text
env JAVA_HOME=/Users/sechmachine/Library/Java/JavaVirtualMachines/corretto-26.0.2/Contents/Home PATH=/Users/sechmachine/Library/Java/JavaVirtualMachines/corretto-26.0.2/Contents/Home/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw clean compile -DskipTests
Amazon Corretto 26.0.2 compiled 129 source files with release 25.
BUILD SUCCESS
```
## External-test boundaries
This integration test proves the fresh Flyway/JPA/real-login calendar path under the development profile and Vietnam JVM zone. It does not operate the already-running browser-gate application or exercise the Intern dashboard UI itself; both paths resolve the same policy timeline.
@@ -0,0 +1,106 @@
# Test Evidence: Gitea Testcontainers and container workflow gates
- **Test type:** Integration
- **Requirement IDs:** `OPS-011`, `OPS-012`, `TST-001`, `TST-005`, `TST-009`
- **Scenario IDs:** `AC-OPS-002`, `AC-OPS-004`
- **Test class/method:** `src/test/js/delivery-contract.test.mjs`
- **Implementation commit:** `d13443e338770dec0ca9822600f9a9d8405dfdbb`
## Protected behavior
Gitea verification must reach Docker Desktop-published Testcontainers ports from
inside its job container. Container builds may start only after an equivalent
verification job succeeds, and the container workflow may run only by manual
dispatch or by a push to `main`. Every third-party workflow action is pinned to
the reviewed latest release commit rather than a moving tag.
## Test method
The dependency-free delivery contract reads both workflow files and checks the
Testcontainers host override, event filters, verify-to-build dependencies, and
the complete allowlist of immutable action SHAs. The remote failure log supplies
the production-shaped network reproduction because it ran inside the real Gitea
Docker runner.
## Hand-derived expected result
The runner already resolves `host.docker.internal` to its Docker host. Therefore
Testcontainers must use that host instead of the job-network gateway
`172.17.0.1`. Pull requests and non-main branch pushes must never schedule the
container workflow. Manual dispatches build but do not publish, while main pushes
publish only after verification succeeds.
## RED
**Command**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin \
node --test src/test/js/delivery-contract.test.mjs
tea actions runs logs 174 --repo sechmachine/labtimesheet \
--login sechmachine-git
```
**Observed result**
```text
Delivery contract: 4 tests, 1 passed, 3 failed. The workflows lacked the
Testcontainers host override, container event/dependency gates, and current
action pins.
Gitea run 174 found Docker at unix:///var/run/docker.sock but selected host
172.17.0.1. Ryuk started, then repeated connections to 172.17.0.1:57499 were
refused. Maven ended with 217 tests, 64 errors.
```
## GREEN
**Command**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin \
node --test src/test/js/delivery-contract.test.mjs
```
**Observed result**
```text
Delivery contract: 4 tests, 4 passed.
```
## Affected suite
**Command and result**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin npm run test:ui
Result: 5 tests passed.
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
./mvnw -B test
Result: 205 tests passed across 44 suites; 0 failures, errors, or skips.
npm ci && npm run build
Result: Tailwind and Lucide assets built successfully; tracked assets remained unchanged.
./mvnw -B -DskipTests -Ddoclint=all javadoc:javadoc
Result: BUILD SUCCESS with 83 existing missing-comment warnings and no production Java change.
Ruby YAML parsing and git diff --check
Result: both workflow files parsed and the diff check passed.
Gitea Actions run 177 on `work/fix/platform/ci-testcontainers-actions`
Result: Verify completed successfully in 8 minutes on the real Docker-mode runner.
The non-main branch push scheduled `verify.yml` only; `container.yml` did not run.
```
## External-test boundaries
The local contract cannot prove action-runner compatibility, registry credentials,
or availability of the optional ARM runner. Those are checked by the actual Gitea
branch verification and main container runs. Release freshness was checked against
the official upstream release APIs on 2026-08-15; the immutable pins remain stable,
but a later release requires an intentional reviewed update.
@@ -0,0 +1,63 @@
# Test Evidence: Gitea container registry authentication
- **Test type:** Integration
- **Requirement IDs:** `OPS-012`, `OPS-016`, `OPS-017`
- **Scenario IDs:** `AC-OPS-004`
- **Test class/method:** `src/test/js/delivery-contract.test.mjs``container workflow runs only manually or on main and verifies before either image build`
- **Implementation commit:** `4dd9f96a231316ce2c14755157a380a2123c2f0b`
## Protected behavior
A push to `main` publishes `git.sechmachine.io.vn/sechmachine/labtimesheet` by authenticating the triggering Gitea account with the repository `REGISTRY_TOKEN`. Publication does not depend on separately configured image-name or username settings.
## Test method
The dependency-free Node contract reads the committed workflow and checks its fixed registry/image coordinates, actor-based username, token secret, and absence of the obsolete `CONTAINER_IMAGE` and `REGISTRY_USERNAME` settings. Ruby's YAML parser separately checks workflow syntax.
## Hand-derived expected result
The repository and package location are stable project facts. Therefore the workflow needs one credential only: a token belonging to the triggering actor with package read/write permission. Manual dispatch still builds without publishing; only a `main` push logs in and publishes.
## RED
**Command**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin node --test src/test/js/delivery-contract.test.mjs
```
**Observed result**
```text
4 tests ran: 3 passed, 1 failed. The container contract could not find the fixed registry/image or actor-based login. Real Gitea Container run 179 independently failed before registry login with "Repository variable CONTAINER_IMAGE is required", so REGISTRY_TOKEN was never used.
```
## GREEN
**Command**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin node --test src/test/js/delivery-contract.test.mjs
ruby -e 'require "yaml"; YAML.safe_load(File.read(".gitea/workflows/container.yml"), aliases: true); puts "container workflow YAML: OK"'
```
**Observed result**
```text
Delivery contract: 4 tests, 4 passed. Container workflow YAML: OK.
```
## Affected suite
**Command and result**
```text
git diff --check
! rg -n 'CONTAINER_IMAGE|REGISTRY_USERNAME' .gitea/workflows/container.yml DEPLOYMENT.md
Both checks passed. Application tests were deliberately not repeated because the change is limited to workflow metadata, its contract test, and deployment guidance; the container workflow retains its mandatory verify job before building.
```
## External-test boundaries
Local checks do not authenticate to the private registry. The first `main` push containing this change is the production-shaped check of `REGISTRY_TOKEN`, package permissions, and registry publication.
@@ -0,0 +1,107 @@
# Test Evidence: Production delivery baseline
- **Test type:** Integration
- **Requirement IDs:** `OPS-005``OPS-013`, `OPS-017`, `TST-001`, `TST-005`, `TST-009`
- **Scenario IDs:** `AC-OPS-002`, `AC-OPS-003`, `AC-OPS-004`
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest.rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden`, `src/test/js/delivery-contract.test.mjs`
- **Implementation commit:** `cea4378f5699de4919c283b12371941d6742ca4b`
## Protected behavior
The production image runs as a non-root Java 25 process, exposes health probes, and accepts the same environment-backed datasource configuration with either the optional PostgreSQL 18.4 Compose sidecar or an external database. Gitea verifies every pull request and push, publishes immutable SHA plus `main` image tags only from `main`, and skips native ARM64 work unless the matching runner is explicitly available.
## Test method
The existing PostgreSQL-backed bootstrap integration test requests the liveness and readiness endpoints before initialization. A dependency-free Node contract checks the deployment files for the required runtime, Compose, trigger, permission, publication, and optional-runner boundaries. Docker and Compose validation then exercise the real build and both database topologies.
## Hand-derived expected result
An absent ARM runner must skip the ARM job without blocking AMD64 publication. Enabling the runner creates an ARM64 architecture tag and a combined manifest, while the canonical SHA and `main` tags remain valid AMD64 images when ARM is disabled. Compose must preserve PostgreSQL data in a named volume and must not require the bundled database when an external JDBC URL is supplied.
## RED
**Command**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin node --test src/test/js/delivery-contract.test.mjs
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
./mvnw '-Dtest=BootstrapIntegrationTest#rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden' test
```
**Observed result**
```text
The delivery contract ran 3 tests and failed all 3 because Dockerfile,
.gitea/workflows/verify.yml, and .gitea/workflows/container.yml did not exist.
The PostgreSQL-backed bootstrap test ran 1 test and failed because
/actuator/health/liveness returned 404 instead of 200.
```
## GREEN
**Command**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin node --test src/test/js/delivery-contract.test.mjs
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
./mvnw '-Dtest=BootstrapIntegrationTest#rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden' test
```
**Observed result**
```text
Delivery contract: 3 tests, 3 passed.
Bootstrap health regression: 1 test, 1 passed against PostgreSQL 18.4.
```
## Affected suite
**Command and result**
```text
npm ci
npm run test:ui
npm run build
git diff --exit-code -- src/main/resources/static/assets/app.css src/main/resources/static/assets/icons.svg
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
./mvnw test
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
docker build --check .
docker build --platform linux/amd64 --build-arg VCS_REF=validation -t labtimesheet:ci-amd64 .
docker build --platform linux/arm64 --build-arg VCS_REF=validation -t labtimesheet:ci-arm64 .
docker compose --env-file .env.compose.example config
docker compose --env-file .env.compose.example --profile bundled-db config
Frontend tests: 4 passed; generated assets remained byte-clean.
Maven: 217 tests across 49 suites, 0 failures, 0 errors, 0 skipped.
Javadoc: BUILD SUCCESS; 83 pre-existing repository-wide warnings.
Dockerfile check: passed without warnings. Both Linux architecture images built
and reported the requested platform, UID/GID 10001, and readiness HEALTHCHECK.
Both Compose configurations parsed successfully.
Real smoke tests used the AMD64 image with disposable resources. Bundled mode
started PostgreSQL 18.4 with the named volume and returned UP from liveness and
readiness on 127.0.0.1:28080. External mode used a separately started
PostgreSQL 18.4 service and returned UP from readiness on 127.0.0.1:28081.
All disposable containers, networks, and the bundled test volume were removed.
git diff --check: passed.
```
## External-test boundaries
Local validation cannot prove that the private Gitea registry credentials are configured or that an `ubuntu-latest-arm` runner is online. Repository variable `ARM64_RUNNER_AVAILABLE` is the scheduler-safe availability signal because an unavailable runner label cannot be discovered from inside a job that has not yet been scheduled.
@@ -0,0 +1,45 @@
# Integration Test Evidence
## Requirement and scenario IDs
- AUTH-001, AUTH-002, AUTH-011; PRJ-003, PRJ-004, PRJ-017; ERR-001, ERR-003; TST-001 through TST-010.
- AC-AUTH-001, AC-AUTH-010, AC-PRJ-001, AC-TST-001.
## Behavior under test
The owning Mentor adds several eligible nonmembers under one Project lock and transaction. Null, empty, duplicate, current-member, invalid, or stale/noneligible selections reject the whole batch; no valid prefix becomes a membership.
## Expected result derivation
The fixture begins with one Leader. A successful two-Intern batch must yield three current memberships. Every rejected batch leaves the eligible and stale candidate membership count at zero.
## RED
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest,ProjectServiceIntegrationTest' test` failed during test compilation with eight `cannot find symbol` errors for the requested `ProjectService.addMembers(long,long,List<Long>)` API. Production compiled first; the failure was the missing behavior boundary rather than the environment or fixture.
## GREEN
The focused PostgreSQL command was:
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectServiceIntegrationTest#ownerAddsSeveralEligibleMembersInOneLockedTransaction+memberBatchRejectsMissingDuplicateCurrentAndStaleSelectionsWithoutPartialMutation' test`
Result: 2 tests, 0 failures, 0 errors, 0 skipped against PostgreSQL 18.4. The
successful case added two memberships; the rejection case covered null, empty, duplicate,
invalid, current-member, and one-valid-plus-one-stale selections without partial persistence.
## Affected suite
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectServiceIntegrationTest' test`
passed 9/9 tests with no failures, errors, or skips.
The complete Project plus layer-architecture command was:
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest' test`
Result: 38 tests, 0 failures, 0 errors, 0 skipped.
## External boundaries
PostgreSQL 18.4 Testcontainers provides the real schema, constraints, JPA transaction, and Project pessimistic lock path. The test does not exercise concurrent requests; existing Project locking coverage remains unchanged.
After merging exact reviewed `main` `32c8a2d315d2175760c5d4792988cd0aa5ab6dd0`, the affected command was rerun with `UiContractWebTest` included. It passed 45/45 tests with no failures, errors, or skips; the Project service portion remained 9/9 against PostgreSQL 18.4.
@@ -0,0 +1,82 @@
# Test Evidence: Windows legacy Vietnam timezone startup
- **Test type:** Integration
- **Requirement IDs:** `ARC-001`, `ARC-003`, `GOV-011`, `ATT-002`, `TST-001`, `TST-005`
- **Scenario IDs:** `N/A — user-reported cross-platform startup defect`
- **Test class/method:** `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.mainCanonicalizesLegacyAliasBeforeStartingSpring`, `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.canonicalizesLegacyVietnamAliasBeforePostgresConnects`, `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.leavesSupportedSystemTimeZoneUnchanged`
- **Implementation commit:** `a9fb9487692e84f5a7e7923570cbded58c362a2f`
## Protected behavior
The executable entry point replaces the legacy Windows JVM timezone ID `Asia/Saigon` with the canonical business timezone ID `Asia/Ho_Chi_Minh` before pgJDBC opens a PostgreSQL connection. Other supported operating-system timezone IDs remain unchanged.
## Test method
The entry-point test replaces Spring startup with Mockito's existing static test seam, invokes the real `main` method with a legacy JVM default, and checks that normalization happens before Spring starts. The PostgreSQL test starts a real PostgreSQL 18.4 Testcontainer, proves pgJDBC 42.7.11 is rejected while the JVM default is `Asia/Saigon`, invokes the same startup normalization, and then opens a valid JDBC connection. A negative test verifies that an unrelated supported timezone is not overwritten.
## Hand-derived expected result
PostgreSQL does not accept `Asia/Saigon` as a startup `TimeZone`, while the approved business timezone is `Asia/Ho_Chi_Minh`. Therefore only the legacy alias is replaced, the following connection succeeds, and a supported non-Vietnam timezone remains unchanged.
## RED
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=ApplicationTimeZoneIntegrationTest test
```
**Observed result**
```text
BUILD FAILURE during test compilation.
ApplicationTimeZoneIntegrationTest.java: cannot find symbol normalizeDefaultTimeZone()
```
The failing test established that the application had no pre-Spring normalization boundary.
A second mutation check temporarily removed the new call from `main` and ran:
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw '-Dtest=ApplicationTimeZoneIntegrationTest#mainCanonicalizesLegacyAliasBeforeStartingSpring' test
```
It failed `1/1` with `expected: "Asia/Ho_Chi_Minh" but was: "Asia/Saigon"`, proving the test protects the entry-point ordering rather than only the helper.
## GREEN
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=ApplicationTimeZoneIntegrationTest test
```
**Observed result**
```text
Tests run: 3, 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:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ApplicationTimeZoneIntegrationTest,LabtimesheetApplicationTests,PlatformFoundationTest,TimeConfigurationTest,CalendarDevelopmentProfileWebIntegrationTest' test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test
Tests run: 217, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
BUILD SUCCESS
```
A local Java 25 process was also started with `-Duser.timezone=Asia/Saigon` against a disposable PostgreSQL 18.4 database on port `55439`. Hikari connected, Flyway migrated the fresh database, Tomcat started on port `18080`, and Spring reported `Started LabtimesheetApplication`. The process shut down cleanly and the disposable database container was removed.
## External-test boundaries
The regression executes the installed pgJDBC version against PostgreSQL 18.4 and reproduces the exact rejected timezone value from the Windows report. It does not run the Windows JVM itself; the supplied Windows log is the evidence that its OS/JDK mapping produced `Asia/Saigon`.
@@ -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.
+52
View File
@@ -0,0 +1,52 @@
# Web Test Evidence
## Requirement and scenario IDs
- AUTH-001, AUTH-002, AUTH-011; PRJ-001, PRJ-004, PRJ-005, PRJ-006, PRJ-017; UI-001, UI-005, UI-014, UI-018; TST-001 through TST-010.
- AC-AUTH-001, AC-AUTH-010, AC-PRJ-001, AC-PRJ-003, AC-PRJ-009, AC-UI-005, AC-TST-001.
## Behavior under test
Project creation, direct member addition, and leadership reassignment render only server-provided eligible Intern choices. The native dialog picker exposes name, student code, and internship dates while numeric identifiers remain form values rather than visible labels. Local search, selection summaries, focus, apply, cancel, empty results, and retained server errors remain usable without adding a client API.
## Expected result derivation
The expected options are literal fixtures from the Account public DTO. Project membership history independently determines which eligible users are valid nonmembers or current-member leadership candidates. Native dialog controls keep server forms and CSRF as the mutation boundary.
## RED
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run test:ui` executed the dependency-free interaction contract first: 1 test, 1 failure. Opening the picker left `dialog.open` undefined because no picker behavior existed.
The combined Java RED command was `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest,ProjectServiceIntegrationTest' test`. After correcting test-only assertion imports, test compilation failed only because the requested `ProjectService.addMembers(long,long,List<Long>)` API did not exist. Controller rendering RED will be rerun after that producer API compiles.
After the producer API compiled, `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest' test` ran 19 tests with 4 expected assertion failures for the missing eligible-option model, filtered multi-select markup, and retained selection rendering. A separate no-roster regression ran 1 test with 1 assertion failure because the disabled picker trigger had no reachable explanatory copy.
Independent review added rendered regressions before the correction. The same focused controller command ran 22 tests with exactly 3 failures and no errors: both closed-dialog radio contracts detected browser `required`, and stale batch recovery lacked the count-only replacement message. The new missing-selection POST contracts already passed through server Bean Validation.
## GREEN
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest' test` passed the initial rendered picker suite at 19/19. After adding the no-roster regression, the affected Project command below passed the expanded controller suite at 20/20.
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run test:ui` passed 1/1 executable tests with no failures, proving local name/student-code filtering, summary updates, initial search focus, apply retention, cancel rollback, and opener focus restoration.
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run build` succeeded with Tailwind CSS 4.3.3 and the existing local icon builder. No dependency was added.
After the review correction, the focused controller command passed 22/22. Creation and leadership radios no longer use closed-dialog browser constraint validation; missing selections re-render their server field errors. A failed member batch retains submitted option 21 when refreshed eligibility contains only 21, omits all rendered value/ID markup for stale option 22, and reports one unavailable selection without exposing its identifier.
## Affected suite
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest' test` passed 38/38 tests with no failures, errors, or skips.
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw -DskipTests compile` succeeded. Project-scoped `javadoc:javadoc` with `-Ddoclint=all` succeeded; it retained four non-fatal default-constructor warnings, including pre-existing advice/query types. `git diff --check` passed.
## External boundaries
No browser loop or Impeccable detector is run on this branch; the root owner performs one integrated pass. MockMvc proves rendered semantics and a dependency-free Node test executes the dialog/search/selection behavior with controlled DOM boundaries.
After merging exact reviewed `main` `32c8a2d315d2175760c5d4792988cd0aa5ab6dd0`, `npm ci`, the 1/1 UI test, frontend build, compile, Project-scoped Javadoc/doclint, and diff check all succeeded. The first affected Java command added the updated shared `UiContractWebTest` and passed 45/45 tests with no failures, errors, or skips.
The bounded post-review affected command reran `ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest,UiContractWebTest` and passed 47/47 with no failures, errors, or skips, including 9/9 Project service tests against PostgreSQL 18.4. The UI test remained 1/1; frontend build, compile, Project-scoped Javadoc/doclint, and `git diff --check` also succeeded.
The root-owned final full suite then exposed a branch-induced MVC-slice fixture RED: 213 tests ran with 0 failures and 3 errors, all `ProjectTaskFormAccessibilityWebTest` context errors because the slice did not provide the new ProjectController AccountService dependency. A focused reproduction ran the class at 3 tests, 0 failures, 3 errors and reported the same missing AccountService constructor dependency.
The smallest test-only correction supplies the controller's AccountService and Clock dependencies and the existing ProjectQueryService mock's authenticated Mentor response. The intermediate focused runs exposed each dependency in order; no production code changed. Final focused GREEN: `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectTaskFormAccessibilityWebTest' test` passed 3/3 with no failures, errors, or skips. The root owner retains the broader rerun.
@@ -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.
+2 -1
View File
@@ -8,7 +8,8 @@
"scripts": {
"build": "npm run build:css && npm run build:icons",
"build:css": "tailwindcss -i src/main/frontend/app.css -o src/main/resources/static/assets/app.css --minify",
"build:icons": "node src/main/frontend/build-icons.mjs"
"build:icons": "node src/main/frontend/build-icons.mjs",
"test:ui": "node --test src/test/js/*.test.mjs"
},
"devDependencies": {
"@tailwindcss/cli": "4.3.3",
+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`);
}
+16
View File
@@ -120,6 +120,7 @@
.primary-action { margin-left: auto; }
.button { display: inline-flex; min-height: 2.35rem; align-items: center; justify-content: center; gap: .45rem; border: 1px solid var(--border-strong); border-radius: .5rem; padding: .5rem .8rem; background: var(--panel); color: var(--ink); font-weight: 650; text-decoration: none; cursor: pointer; }
.button-primary { border-color: var(--ink); background: var(--ink); color: var(--panel); }
.button:disabled { cursor: not-allowed; opacity: .55; }
.button-danger { border-color: color-mix(in srgb, var(--danger), transparent 65%); background: color-mix(in srgb, var(--danger), transparent 90%); color: var(--danger); }
.panel { border: 1px solid var(--border); border-radius: .75rem; background: var(--panel); box-shadow: 0 10px 28px rgb(20 25 35 / .06); }
.panel-header { padding: .9rem 1rem; border-bottom: 1px solid var(--border); }
@@ -170,6 +171,21 @@
.notification-menu { min-width: 18rem; padding: .75rem; }
dialog { max-width: 30rem; border: 1px solid var(--border); border-radius: .9rem; background: var(--panel); color: var(--ink); padding: 1.25rem; }
dialog::backdrop { background: rgb(0 0 0 / .45); }
.picker-trigger { justify-content: flex-start; }
.picker-summary { margin: 0; color: var(--muted); font-size: .78rem; }
.picker-drawer { width: min(32rem, 100%); max-width: 32rem; height: 100dvh; max-height: 100dvh; margin: 0 0 0 auto; border-radius: .9rem 0 0 .9rem; padding: 0; }
.picker-header, .picker-footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; }
.picker-header { border-bottom: 1px solid var(--border); }
.picker-header .field-help { margin: .2rem 0 0; }
.picker-body { display: grid; gap: .5rem; padding: 1rem; }
.picker-options { display: grid; gap: .5rem; margin-top: .5rem; }
.picker-option { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: .75rem; border: 1px solid var(--border); border-radius: .65rem; padding: .75rem; cursor: pointer; }
.picker-option:hover { border-color: var(--border-strong); background: var(--panel-muted); }
.picker-option input { margin-top: .2rem; }
.picker-option span { display: grid; gap: .18rem; min-width: 0; }
.picker-option small, .picker-empty { color: var(--muted); }
.picker-empty { margin: 1rem 0; text-align: center; }
.picker-footer { border-top: 1px solid var(--border); justify-content: flex-end; }
@keyframes pulse { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; } }
}
+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 });
@@ -1,23 +1,43 @@
package com.lab.labtimesheet;
import java.util.TimeZone;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.lab.labtimesheet.config.SecurityProperties;
/** Application entry point and root component-scan boundary for Lab Timesheet. */
/**
* Application entry point and root component-scan boundary for Lab Timesheet.
*
* <p>The entry point also canonicalizes the legacy Windows Vietnam timezone alias before database
* drivers inspect the JVM default timezone.
*/
@SpringBootApplication
@EnableConfigurationProperties(SecurityProperties.class)
public class LabtimesheetApplication {
private static final String LEGACY_VIETNAM_TIME_ZONE = "Asia/Saigon";
private static final String BUSINESS_TIME_ZONE = "Asia/Ho_Chi_Minh";
/**
* Starts the standalone Spring Boot process.
* Canonicalizes the process timezone and starts the standalone Spring Boot process.
*
* @param args command-line arguments forwarded to Spring Boot
*/
public static void main(String[] args) {
normalizeDefaultTimeZone();
SpringApplication.run(LabtimesheetApplication.class, args);
}
/**
* Replaces the legacy Windows Vietnam alias before pgJDBC sends it to PostgreSQL as a startup
* parameter. Other supported system timezones remain unchanged.
*/
static void normalizeDefaultTimeZone() {
if (LEGACY_VIETNAM_TIME_ZONE.equals(TimeZone.getDefault().getID())) {
TimeZone.setDefault(TimeZone.getTimeZone(BUSINESS_TIME_ZONE));
}
}
}
@@ -12,8 +12,9 @@ import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy;
/**
* Defines form authentication, role-based Admin routes, CSRF protection, and response security headers.
* Bootstrap access is further constrained by {@link BootstrapAccessFilter} until initialization completes.
* Defines form authentication, role-based Admin routes, public health probes, CSRF protection, and response
* security headers. Bootstrap access is further constrained by {@link BootstrapAccessFilter} until initialization
* completes.
*/
@Configuration(proxyBeanMethods = false)
class SecurityConfiguration {
@@ -34,7 +35,7 @@ class SecurityConfiguration {
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/bootstrap/**", "/activate/**", "/login", "/error", "/assets/**",
"/actuator/health")
"/actuator/health", "/actuator/health/**")
.permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
@@ -44,7 +44,8 @@ public class BootstrapAccessFilter extends OncePerRequestFilter {
private static boolean allowedBeforeBootstrap(String path) {
return path.equals("/bootstrap") || path.startsWith("/bootstrap/")
|| path.equals("/actuator/health") || path.startsWith("/assets/")
|| path.equals("/actuator/health") || path.startsWith("/actuator/health/")
|| path.startsWith("/assets/")
|| path.equals("/error");
}
}
@@ -1,13 +1,21 @@
package com.lab.labtimesheet.feature.project.controller;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm;
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm;
import com.lab.labtimesheet.feature.project.model.dto.ProjectMembersForm;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import jakarta.validation.Valid;
import java.security.Principal;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -33,6 +41,8 @@ public class ProjectController {
private final ProjectQueryService pages;
private final ProjectService projects;
private final AccountService accounts;
private final Clock clock;
/**
* Lists only Projects visible to the authenticated actor and exposes Project creation only
@@ -64,6 +74,7 @@ public class ProjectController {
throw new ProjectAccessDeniedException();
}
model.addAttribute("projectForm", new ProjectCreateForm());
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
return "projects/form";
}
@@ -73,22 +84,30 @@ public class ProjectController {
* @param principal authenticated user
* @param projectForm validated browser input
* @param bindingResult binding and domain validation results
* @param model response model used when validation fails
* @return a redirect to the created Project, or the creation form on validation failure
*/
@PostMapping
public String create(
Principal principal,
@Valid @ModelAttribute("projectForm") ProjectCreateForm projectForm,
BindingResult bindingResult) {
BindingResult bindingResult,
Model model) {
var actor = pages.authenticatedActor(principal.getName());
if (!"MENTOR".equals(actor.role())) {
throw new ProjectAccessDeniedException();
}
if (bindingResult.hasErrors()) {
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
return "projects/form";
}
try {
long projectId = projects.create(actorId(principal), projectForm.toCommand());
long projectId = projects.create(actor.userId(), projectForm.toCommand());
return "redirect:/projects/" + projectId;
} catch (ProjectRuleViolationException exception) {
bindingResult.rejectValue(
"initialLeaderUserId", "project.initialLeader.ineligible", exception.getMessage());
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
return "projects/form";
}
}
@@ -139,41 +158,51 @@ public class ProjectController {
@GetMapping("/{projectId}/members")
public String members(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("members", pages.members(actorId, projectId));
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
populateMembersModel(actorId, projectId, model);
model.addAttribute("projectMembersForm", new ProjectMembersForm());
return "projects/members";
}
/**
* Adds an eligible Intern or re-renders membership history with the submitted identifier
* and a safe validation message.
* Adds all selected eligible Interns atomically or re-renders membership history with every
* still-eligible selection retained and a count of unavailable choices.
*
* @param principal authenticated user
* @param projectId owning Project identifier
* @param memberForm validated Intern selection
* @param membersForm validated Intern selection
* @param bindingResult binding and domain validation results
* @param model response model used on failure
* @return a membership redirect after success, or the membership view on validation failure
*/
@PostMapping("/{projectId}/members")
public String addMember(
public String addMembers(
Principal principal,
@PathVariable long projectId,
@Valid @ModelAttribute("projectMemberForm") ProjectMemberForm memberForm,
@Valid @ModelAttribute("projectMembersForm") ProjectMembersForm membersForm,
BindingResult bindingResult,
Model model) {
long actorId = actorId(principal);
boolean rejectedByService = false;
if (!bindingResult.hasErrors()) {
try {
projects.addMember(actorId, projectId, memberForm.internUserId());
projects.addMembers(actorId, projectId, membersForm.internUserIds());
return "redirect:/projects/" + projectId + "/members";
} catch (ProjectRuleViolationException exception) {
bindingResult.rejectValue("internUserId", "project.member.ineligible", exception.getMessage());
rejectedByService = true;
bindingResult.rejectValue(
"internUserIds", "project.members.ineligible", exception.getMessage());
}
}
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("members", pages.members(actorId, projectId));
var refreshedOptions = populateMembersModel(actorId, projectId, model);
if (rejectedByService) {
Set<Long> refreshedIds = refreshedOptions.stream()
.map(EligibleInternOption::userId)
.collect(Collectors.toUnmodifiableSet());
long unavailableSelectionCount = membersForm.internUserIds().stream()
.filter(userId -> !refreshedIds.contains(userId))
.count();
model.addAttribute("unavailableSelectionCount", unavailableSelectionCount);
}
return "projects/members";
}
@@ -189,8 +218,7 @@ public class ProjectController {
@GetMapping("/{projectId}/leadership")
public String leadership(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("leadership", pages.leadership(actorId, projectId));
populateLeadershipModel(actorId, projectId, model);
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
return "projects/leadership";
}
@@ -221,11 +249,48 @@ public class ProjectController {
bindingResult.rejectValue("internUserId", "project.leader.ineligible", exception.getMessage());
}
}
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("leadership", pages.leadership(actorId, projectId));
populateLeadershipModel(actorId, projectId, model);
return "projects/leadership";
}
private List<EligibleInternOption> populateMembersModel(long actorId, long projectId, Model model) {
var project = pages.detail(actorId, projectId);
var members = pages.members(actorId, projectId);
model.addAttribute("project", project);
model.addAttribute("members", members);
if (project.canManage()) {
Set<Long> currentMemberIds = members.stream()
.filter(member -> member.leftAt() == null)
.map(member -> member.internUserId())
.collect(Collectors.toUnmodifiableSet());
var options = eligibleInternOptions().stream()
.filter(option -> !currentMemberIds.contains(option.userId()))
.toList();
model.addAttribute("eligibleInternOptions", options);
return options;
}
return List.of();
}
private void populateLeadershipModel(long actorId, long projectId, Model model) {
var project = pages.detail(actorId, projectId);
model.addAttribute("project", project);
model.addAttribute("leadership", pages.leadership(actorId, projectId));
if (project.canManage()) {
Set<Long> replacementIds = pages.members(actorId, projectId).stream()
.filter(member -> member.leftAt() == null && !member.currentLeader())
.map(member -> member.internUserId())
.collect(Collectors.toUnmodifiableSet());
model.addAttribute("eligibleInternOptions", eligibleInternOptions().stream()
.filter(option -> replacementIds.contains(option.userId()))
.toList());
}
}
private List<EligibleInternOption> eligibleInternOptions() {
return accounts.eligibleInternOptions(LocalDate.now(clock));
}
private long actorId(Principal principal) {
return pages.authenticatedUserId(principal.getName());
}
@@ -0,0 +1,18 @@
package com.lab.labtimesheet.feature.project.model.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.util.List;
/**
* Browser form for one atomic owning-Mentor direct-add selection.
*
* @param internUserIds distinct positive Intern account identifiers selected in the picker
*/
public record ProjectMembersForm(@NotEmpty List<@Positive Long> internUserIds) {
/** Creates an empty form for the initial membership page. */
public ProjectMembersForm() {
this(List.of());
}
}
@@ -1,6 +1,7 @@
package com.lab.labtimesheet.feature.project.service;
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
@@ -9,6 +10,9 @@ import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import com.lab.labtimesheet.feature.task.service.TaskQueryService;
import java.time.Clock;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
@@ -68,9 +72,42 @@ public class ProjectService {
*/
@Transactional
public void addMember(long actorUserId, long projectId, long internUserId) {
addMembers(actorUserId, projectId, List.of(internUserId));
}
/**
* Adds a complete selection of eligible nonmembers while holding one Project write lock.
* Every identifier is revalidated after owner authorization and before the aggregate changes,
* so missing, duplicate, stale, ineligible, or current-member selections leave membership
* unchanged.
*
* @param actorUserId authenticated owning Mentor
* @param projectId Project to update
* @param internUserIds distinct eligible Intern account identifiers
* @throws com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException when
* the selection is null, empty, malformed, duplicate, stale, ineligible, or already
* contains a current member
*/
@Transactional
public void addMembers(long actorUserId, long projectId, List<Long> internUserIds) {
var project = lockedProject(projectId);
project.authorizeOwner(actorUserId);
project.addMember(actorUserId, eligibleIntern(internUserId), clock.instant());
if (internUserIds == null || internUserIds.isEmpty()) {
throw new ProjectRuleViolationException("Select at least one Intern");
}
if (internUserIds.stream().anyMatch(userId -> userId == null || userId <= 0)
|| new HashSet<>(internUserIds).size() != internUserIds.size()) {
throw new ProjectRuleViolationException("Intern selection is invalid");
}
var selectedInterns = internUserIds.stream().map(this::eligibleIntern).toList();
if (selectedInterns.stream().anyMatch(intern -> !intern.isEligible())
|| selectedInterns.stream().anyMatch(intern -> project.hasCurrentMember(intern.userId()))) {
throw new ProjectRuleViolationException("One or more selected Interns are no longer eligible");
}
var addedAt = clock.instant();
selectedInterns.forEach(intern -> project.addMember(actorUserId, intern, addedAt));
projects.flush();
}
@@ -145,7 +182,7 @@ public class ProjectService {
}
private ProjectInternEligibility eligibleIntern(long userId) {
return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId));
return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId, LocalDate.now(clock)));
}
private void requireActiveMentor(long userId) {
-4
View File
@@ -10,10 +10,6 @@ spring:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
jdbc:
time_zone: UTC
flyway:
enabled: true
locations: classpath:db/migration
+53
View File
@@ -0,0 +1,53 @@
# Production profile. Supply every LAB_* value from the deployment environment.
spring:
datasource:
url: "${LAB_DB_URL}"
username: "${LAB_DB_USERNAME}"
password: "${LAB_DB_PASSWORD}"
flyway:
enabled: true
locations: classpath:db/migration
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
docker:
compose:
enabled: false
lifecycle:
timeout-per-shutdown-phase: 30s
server:
port: 8080
shutdown: graceful
forward-headers-strategy: "${LAB_FORWARD_HEADERS_STRATEGY}"
servlet:
session:
cookie:
http-only: true
secure: true
same-site: strict
error:
include-message: never
include-stacktrace: never
management:
endpoints:
web:
exposure:
include: "health,info"
endpoint:
health:
show-details: never
probes:
enabled: true
group:
liveness:
include: "livenessState"
readiness:
include: "readinessState,db"
lab:
public-origin: "${LAB_PUBLIC_ORIGIN}"
security:
master-key: "${LAB_SECURITY_MASTER_KEY}"
+6
View File
@@ -10,3 +10,9 @@ spring:
hibernate:
ddl-auto: validate
open-in-view: false
management:
endpoint:
health:
probes:
enabled: true
File diff suppressed because one or more lines are too long
+53
View File
@@ -37,4 +37,57 @@ document.addEventListener('DOMContentLoaded', () => {
try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); }
catch (_) { /* Collapse still works for this page. */ }
});
document.querySelectorAll('[data-intern-picker]').forEach((picker) => {
const open = picker.querySelector('[data-picker-open]');
const dialog = picker.querySelector('[data-picker-dialog]');
const search = picker.querySelector('[data-picker-search]');
const summary = picker.querySelector('[data-picker-summary]');
const empty = picker.querySelector('[data-picker-empty]');
const cancel = picker.querySelector('[data-picker-cancel]');
const apply = picker.querySelector('[data-picker-apply]');
const options = [...picker.querySelectorAll('[data-picker-option]')];
let initialSelection = [];
const inputs = () => options.map((option) => option.querySelector('input'));
const updateSummary = () => {
const selected = options
.filter((option) => option.querySelector('input').checked)
.map((option) => option.querySelector('[data-picker-label]').textContent.trim());
summary.textContent = selected.length === 0
? `No Intern${inputs()[0]?.type === 'radio' ? '' : 's'} selected`
: `${selected.length} Intern${selected.length === 1 ? '' : 's'} selected: ${selected.join(', ')}`;
};
const filter = () => {
const query = search.value.trim().toLocaleLowerCase();
let visible = 0;
options.forEach((option) => {
option.hidden = !option.dataset.pickerSearch.toLocaleLowerCase().includes(query);
if (!option.hidden) visible += 1;
});
empty.hidden = visible !== 0;
};
const restore = () => {
inputs().forEach((input, index) => { input.checked = initialSelection[index]; });
updateSummary();
};
inputs().forEach((input) => input.addEventListener('change', updateSummary));
search.addEventListener('input', filter);
open.addEventListener('click', () => {
initialSelection = inputs().map((input) => input.checked);
search.value = '';
filter();
dialog.showModal();
search.focus();
});
cancel.addEventListener('click', () => {
restore();
dialog.close();
});
dialog.addEventListener('cancel', restore);
dialog.addEventListener('close', () => open.focus());
apply.addEventListener('click', () => dialog.close());
updateSummary();
});
});
+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>
@@ -26,10 +26,29 @@
<div class="form-grid form-grid-three">
<div class="field"><label class="field-label" for="startDate">Start date</label><input class="control" id="startDate" type="date" th:field="*{startDate}" required th:attr="aria-invalid=${#fields.hasErrors('startDate')},aria-describedby=${#fields.hasErrors('startDate') ? 'startDate-error' : null}"><p class="field-error" id="startDate-error" role="alert" th:if="${#fields.hasErrors('startDate')}" th:errors="*{startDate}">Start date error</p></div>
<div class="field"><label class="field-label" for="endDate">End date</label><input class="control" id="endDate" type="date" th:field="*{endDate}" required th:attr="aria-invalid=${#fields.hasErrors('endDate') or #fields.hasErrors('dateRangeValid')},aria-describedby=${#fields.hasErrors('endDate') ? 'endDate-error' : (#fields.hasErrors('dateRangeValid') ? 'dateRangeValid-error' : null)}"><p class="field-error" id="endDate-error" role="alert" th:if="${#fields.hasErrors('endDate')}" th:errors="*{endDate}">End date error</p><p class="field-error" id="dateRangeValid-error" role="alert" th:if="${#fields.hasErrors('dateRangeValid')}" th:errors="*{dateRangeValid}">Date range error</p></div>
<div class="field">
<label class="field-label" for="leader">Initial Leader user ID</label>
<input class="control" id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">
<div class="field" data-intern-picker>
<span class="field-label">Initial Leader</span>
<button class="button picker-trigger" type="button" data-picker-open
th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">Choose an eligible Intern</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Intern selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible Interns are available.</p>
<p class="field-error" id="initialLeaderUserId-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="leader-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="leader-picker-title">Choose initial Leader</h2><p class="field-help">Only currently eligible Interns are available.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="leader-search">Search by name or Student Code</label>
<input class="control" id="leader-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="radio" th:field="*{initialLeaderUserId}" th:value="${option.userId}">
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible Interns.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
</div>
<div class="form-actions"><a class="button" th:href="@{/projects}">Cancel</a><button class="button button-primary" type="submit">Create Project</button></div>
@@ -20,10 +20,32 @@
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}">Leader</td><td th:text="${#temporals.format(term.startedAt, 'dd/MM/yyyy HH:mm')}">Started</td><td th:text="${term.endedAt == null ? 'Current' : #temporals.format(term.endedAt, 'dd/MM/yyyy HH:mm')}">Current</td></tr></tbody>
</table></div>
</section>
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
<form class="panel form-panel form-grid" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the Leader selection.</div>
<div class="field"><label class="field-label" for="leader">New Leader user ID</label><input class="control" id="leader" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'leadership-intern-user-error' : null}"><p class="field-error" id="leadership-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
<span></span><button class="button button-primary" type="submit">Change Leader</button>
<div class="field" data-intern-picker>
<span class="field-label">New Leader</span>
<button class="button picker-trigger" type="button" data-picker-open th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'leadership-intern-user-error' : null}">Choose a current member</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Intern selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible current members are available.</p>
<p class="field-error" id="leadership-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="leadership-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="leadership-picker-title">Choose new Leader</h2><p class="field-help">Only eligible current members other than the current Leader are available.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="leadership-search">Search by name or Student Code</label>
<input class="control" id="leadership-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="radio" th:field="*{internUserId}" th:value="${option.userId}">
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible current members.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Change Leader</button></div>
</form>
</main>
</body>
@@ -20,10 +20,34 @@
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}">Intern</td><td th:text="${#temporals.format(member.joinedAt, 'dd/MM/yyyy HH:mm')}">Joined</td><td th:text="${member.leftAt == null ? 'Current' : #temporals.format(member.leftAt, 'dd/MM/yyyy HH:mm')}">Current</td><td><span class="badge" th:classappend="${member.currentLeader ? ' badge-success' : ''}" th:text="${member.currentLeader ? 'Leader' : 'Member'}">Member</span></td></tr></tbody>
</table></div>
</section>
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMemberForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the member selection.</div>
<div class="field"><label class="field-label" for="intern">Intern user ID</label><input class="control" id="intern" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'member-intern-user-error' : null}"><p class="field-error" id="member-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
<span></span><button class="button button-primary" type="submit">Add member</button>
<form class="panel form-panel form-grid" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMembersForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}"><strong>Member selection could not be saved.</strong><ul><li th:each="fieldError : ${#fields.allErrors()}" th:text="${fieldError}">Selection error</li></ul></div>
<div class="field" data-intern-picker>
<span class="field-label">Interns to add</span>
<button class="button picker-trigger" type="button" data-picker-open th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('internUserIds')},aria-describedby=${#fields.hasErrors('internUserIds') ? 'member-intern-user-error' : null}">Choose eligible Interns</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Interns selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible nonmembers are available.</p>
<p class="field-error" id="member-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserIds')}" th:errors="*{internUserIds}"></p>
<p class="field-help" role="status" th:if="${unavailableSelectionCount != null and unavailableSelectionCount > 0}"
th:text="${unavailableSelectionCount == 1 ? '1 previously selected Intern is no longer eligible; choose a replacement.' : unavailableSelectionCount + ' previously selected Interns are no longer eligible; choose replacements.'}">Unavailable selection recovery</p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="member-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="member-picker-title">Add Project members</h2><p class="field-help">Select one or more eligible Interns who are not current members.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="member-search">Search by name or Student Code</label>
<input class="control" id="member-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="checkbox" th:field="*{internUserIds}" th:value="${option.userId}">
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible Interns.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Add selected members</button></div>
</form>
</main>
</body>
@@ -0,0 +1,80 @@
package com.lab.labtimesheet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mockStatic;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.TimeZone;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.boot.SpringApplication;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
class ApplicationTimeZoneIntegrationTest {
@Test
void mainCanonicalizesLegacyAliasBeforeStartingSpring() {
TimeZone originalTimeZone = TimeZone.getDefault();
String[] args = {"--spring.profiles.active=test"};
try (MockedStatic<SpringApplication> springApplication = mockStatic(SpringApplication.class)) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Saigon"));
LabtimesheetApplication.main(args);
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Ho_Chi_Minh");
springApplication.verify(() -> SpringApplication.run(LabtimesheetApplication.class, args));
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
@Test
void canonicalizesLegacyVietnamAliasBeforePostgresConnects() throws SQLException {
TimeZone originalTimeZone = TimeZone.getDefault();
try (PostgreSQLContainer postgres =
new PostgreSQLContainer(DockerImageName.parse("postgres:18.4"))) {
postgres.start();
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Saigon"));
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Saigon");
assertThatThrownBy(() -> openConnection(postgres))
.isInstanceOf(SQLException.class)
.hasMessageContaining("Asia/Saigon");
LabtimesheetApplication.normalizeDefaultTimeZone();
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Ho_Chi_Minh");
try (Connection connection = openConnection(postgres)) {
assertThat(connection.isValid(1)).isTrue();
}
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
@Test
void leavesSupportedSystemTimeZoneUnchanged() {
TimeZone originalTimeZone = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Paris"));
LabtimesheetApplication.normalizeDefaultTimeZone();
assertThat(TimeZone.getDefault().getID()).isEqualTo("Europe/Paris");
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
private static Connection openConnection(PostgreSQLContainer postgres) throws SQLException {
return DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
}
}
@@ -61,6 +61,8 @@ class BootstrapIntegrationTest {
void rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden() throws Exception {
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk());
mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/bootstrap"));
@@ -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) {
}
}
@@ -0,0 +1,87 @@
package com.lab.labtimesheet.feature.attendance.controller;
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 java.util.TimeZone;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Import;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.servlet.MockMvc;
@Import(TestcontainersConfiguration.class)
@SpringBootTest(properties = {
"LAB_SMTP_HOST=localhost",
"LAB_SMTP_PORT=1025",
"LAB_SERVER_PORT=0",
"LAB_FORWARD_HEADERS_STRATEGY=none",
"LAB_PUBLIC_ORIGIN=http://localhost:8080",
"LAB_SECURITY_MASTER_KEY=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
})
@AutoConfigureMockMvc
@ActiveProfiles({"dev", "test"})
@ContextConfiguration(initializers = CalendarDevelopmentProfileWebIntegrationTest.AsiaHoChiMinhTimeZoneInitializer.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class CalendarDevelopmentProfileWebIntegrationTest {
private static final String ADMIN_EMAIL = "admin@example.test";
private static final String PASSWORD = "correct horse battery staple";
@Autowired
private MockMvc mockMvc;
@Test
void v1SeededPolicyLetsFormAuthenticatedAdminOpenCalendarInAsiaHoChiMinhDevelopmentProfile() 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"));
var login = mockMvc.perform(post("/login")
.with(csrf())
.param("username", ADMIN_EMAIL)
.param("password", PASSWORD))
.andExpect(status().is3xxRedirection())
.andExpect(authenticated().withUsername(ADMIN_EMAIL))
.andReturn();
mockMvc.perform(get("/attendance/calendar")
.session((MockHttpSession) login.getRequest().getSession(false)))
.andExpect(status().isOk());
}
@AfterAll
static void restoreSystemDefaultTimeZone() {
TimeZone.setDefault(AsiaHoChiMinhTimeZoneInitializer.originalDefaultTimeZone());
}
static final class AsiaHoChiMinhTimeZoneInitializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
private static final TimeZone ORIGINAL_DEFAULT_TIME_ZONE = TimeZone.getDefault();
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Ho_Chi_Minh"));
}
static TimeZone originalDefaultTimeZone() {
return ORIGINAL_DEFAULT_TIME_ZONE;
}
}
}
@@ -1,5 +1,7 @@
package com.lab.labtimesheet.feature.project.controller;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -17,6 +19,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
@@ -27,9 +31,13 @@ import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import java.time.Instant;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
@@ -50,9 +58,120 @@ class ProjectControllerTest {
@MockitoBean
private ProjectService projects;
@MockitoBean
private AccountService accounts;
@MockitoBean
private Clock clock;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@BeforeEach
void serverBusinessDate() {
when(clock.instant()).thenReturn(Instant.parse("2026-08-15T01:00:00Z"));
when(clock.getZone()).thenReturn(ZoneId.of("Asia/Ho_Chi_Minh"));
}
@Test
@WithMockUser(username = "mentor@example.test")
void projectCreationRendersSearchableEligibleLeaderOptionsWithoutVisibleNumericIds() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
option(20L, "Nguyen An", "STU-020"),
option(21L, "Tran Binh", "STU-021")));
String html = mvc.perform(get("/projects/new"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("eligibleInternOptions"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("data-intern-picker")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("type=\"radio\"")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("Nguyen An")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("STU-020")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("01/08/2026 31/12/2026")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("Initial Leader user ID"))))
.andReturn().getResponse().getContentAsString();
assertFalse(containsRequiredRadio(html));
}
@Test
@WithMockUser(username = "mentor@example.test")
void projectCreationExplainsWhenNoEligibleLeaderIsAvailable() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of());
mvc.perform(get("/projects/new"))
.andExpect(status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("No eligible Interns are available.")));
}
@Test
@WithMockUser(username = "mentor@example.test")
void memberAndLeadershipPickersExposeOnlyValidServerFilteredOptions() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
when(pages.members(10L, 30L)).thenReturn(List.of(
new ProjectMemberView(40L, 20L, "Current Leader", Instant.parse("2026-08-15T00:00:00Z"), null, true),
new ProjectMemberView(41L, 21L, "Current Member", Instant.parse("2026-08-15T00:00:00Z"), null, false)));
when(pages.leadership(10L, 30L)).thenReturn(List.of());
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
option(20L, "Current Leader", "STU-020"),
option(21L, "Current Member", "STU-021"),
option(22L, "Eligible Nonmember", "STU-022")));
String membersHtml = mvc.perform(get("/projects/30/members"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertTrue(membersHtml.contains("name=\"internUserIds\""));
assertTrue(membersHtml.contains("Eligible Nonmember"));
assertFalse(membersHtml.contains("data-picker-label>Current Member"));
String leadershipHtml = mvc.perform(get("/projects/30/leadership"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertTrue(leadershipHtml.contains("type=\"radio\""));
assertTrue(leadershipHtml.contains("Current Member"));
assertFalse(leadershipHtml.contains("Eligible Nonmember"));
assertFalse(leadershipHtml.contains("data-picker-label>Current Leader"));
assertFalse(containsRequiredRadio(leadershipHtml));
}
@Test
@WithMockUser(username = "mentor@example.test")
void rejectedMemberBatchRetainsEligibleSelectionsAndExplainsUnavailableCountWithoutIds() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
when(pages.members(10L, 30L)).thenReturn(List.of());
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
option(21L, "First Intern", "STU-021")));
doThrow(new ProjectRuleViolationException("One or more selected Interns are no longer eligible"))
.when(projects).addMembers(10L, 30L, List.of(21L, 22L));
mvc.perform(post("/projects/30/members")
.with(csrf())
.param("internUserIds", "21", "22"))
.andExpect(status().isOk())
.andExpect(view().name("projects/members"))
.andExpect(model().attributeHasFieldErrors("projectMembersForm", "internUserIds"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("One or more selected Interns are no longer eligible")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("1 previously selected Intern is no longer eligible; choose a replacement.")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("value=\"21\" id=\"internUserIds1\" name=\"internUserIds\" checked=\"checked\"")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("value=\"22\""))));
}
@Test
@WithMockUser(username = "mentor@example.test")
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
@@ -144,7 +263,8 @@ class ProjectControllerTest {
@Test
@WithMockUser(username = "mentor@example.test")
void validCreateSubmissionUsesAuthenticatedMentorAndRedirectsToDetail() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(projects.create(
10L,
new ProjectCreateCommand(
@@ -166,6 +286,50 @@ class ProjectControllerTest {
.andExpect(redirectedUrl("/projects/30"));
}
@Test
@WithMockUser(username = "mentor@example.test")
void missingInitialLeaderReRendersServerFieldError() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
mvc.perform(post("/projects")
.with(csrf())
.param("name", "Intern Portal Refresh")
.param("startDate", "2026-08-15")
.param("endDate", "2026-09-30"))
.andExpect(status().isOk())
.andExpect(view().name("projects/form"))
.andExpect(model().attributeHasFieldErrors("projectForm", "initialLeaderUserId"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("id=\"initialLeaderUserId-error\"")));
verify(projects, never()).create(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any());
}
@Test
@WithMockUser(username = "mentor@example.test")
void missingReplacementLeaderReRendersServerFieldError() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
when(pages.leadership(10L, 30L)).thenReturn(List.of());
when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView(
41L, 21L, "Current Member", Instant.parse("2026-08-15T00:00:00Z"), null, false)));
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
option(21L, "Current Member", "STU-021")));
mvc.perform(post("/projects/30/leadership").with(csrf()))
.andExpect(status().isOk())
.andExpect(view().name("projects/leadership"))
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("id=\"leadership-intern-user-error\"")));
verify(projects, never()).changeLeader(
org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyLong());
}
@Test
@WithMockUser(username = "mentor@example.test")
void owningMentorCanActivateAPlannedProject() throws Exception {
@@ -217,6 +381,9 @@ class ProjectControllerTest {
@Test
@WithMockUser(username = "mentor@example.test")
void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
mvc.perform(post("/projects")
.with(csrf())
.param("name", " ")
@@ -239,6 +406,8 @@ class ProjectControllerTest {
@Test
@WithMockUser(username = "mentor@example.test")
void domainValidationErrorsStayOnTheirSafeFormsWithRetainedInput() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView(
@@ -255,7 +424,7 @@ class ProjectControllerTest {
99L)))
.thenThrow(new ProjectRuleViolationException("Intern must have an active account and internship"));
doThrow(new ProjectRuleViolationException("Intern is already a current Project member"))
.when(projects).addMember(10L, 30L, 20L);
.when(projects).addMembers(10L, 30L, List.of(20L));
doThrow(new ProjectRuleViolationException("Selected Intern is already the current Leader"))
.when(projects).changeLeader(10L, 30L, 20L);
@@ -274,12 +443,14 @@ class ProjectControllerTest {
mvc.perform(post("/projects/30/members")
.with(csrf())
.param("internUserId", "20"))
.param("internUserIds", "20"))
.andExpect(status().isOk())
.andExpect(view().name("projects/members"))
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
.andExpect(model().attributeHasFieldErrors("projectMembersForm", "internUserIds"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("value=\"20\"")));
.string(containsString("Intern is already a current Project member")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("value=\"20\""))));
mvc.perform(post("/projects/30/leadership")
.with(csrf())
@@ -288,7 +459,9 @@ class ProjectControllerTest {
.andExpect(view().name("projects/leadership"))
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("value=\"20\"")));
.string(containsString("Selected Intern is already the current Leader")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("value=\"20\""))));
}
@Test
@@ -388,4 +561,19 @@ class ProjectControllerTest {
"Current Leader",
true);
}
private static EligibleInternOption option(long userId, String name, String studentCode) {
return new EligibleInternOption(
userId,
name,
studentCode,
LocalDate.of(2026, 8, 1),
LocalDate.of(2026, 12, 31));
}
private static boolean containsRequiredRadio(String html) {
return Pattern.compile("<input(?=[^>]*type=\\\"radio\\\")(?=[^>]*required(?:=|\\s|>))[^>]*>")
.matcher(html)
.find();
}
}
@@ -106,6 +106,53 @@ class ProjectServiceIntegrationTest {
user("other-mentor@example.test", "MENTOR"), projectId, Long.MAX_VALUE));
}
@Test
void ownerAddsSeveralEligibleMembersInOneLockedTransaction() {
long mentorId = user("mentor-batch-add@example.test", "MENTOR");
long leaderId = intern("leader-batch-add@example.test", "I017");
long firstMemberId = intern("first-batch-add@example.test", "I018");
long secondMemberId = intern("second-batch-add@example.test", "I019");
long projectId = createProject(mentorId, leaderId, "Batch membership");
projectService.addMembers(mentorId, projectId, List.of(firstMemberId, secondMemberId));
assertEquals(3, count("""
select count(*) from project_memberships
where project_id = ? and left_at is null
""", projectId));
}
@Test
void memberBatchRejectsMissingDuplicateCurrentAndStaleSelectionsWithoutPartialMutation() {
long mentorId = user("mentor-batch-guard@example.test", "MENTOR");
long leaderId = intern("leader-batch-guard@example.test", "I020");
long eligibleId = intern("eligible-batch-guard@example.test", "I021");
long staleId = intern("stale-batch-guard@example.test", "I022");
long projectId = createProject(mentorId, leaderId, "Batch guard");
jdbc.update("update intern_profiles set internship_end_date = date '2026-08-13' where user_id = ?", staleId);
entityManager.clear();
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, null));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of()));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of(eligibleId, eligibleId)));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of(Long.MAX_VALUE)));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of(mentorId)));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of(leaderId)));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of(eligibleId, staleId)));
assertEquals(0, count("""
select count(*) from project_memberships
where project_id = ? and intern_user_id in (?, ?) and left_at is null
""", projectId, eligibleId, staleId));
}
@Test
void leaderChangeClosesOneTermAndDoesNotMoveTaskAssignments() {
long mentorId = user("mentor-leader@example.test", "MENTOR");
@@ -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");
}
@@ -8,14 +8,20 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import com.lab.labtimesheet.feature.project.controller.ProjectController;
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import com.lab.labtimesheet.feature.task.controller.TaskController;
import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice;
import com.lab.labtimesheet.feature.task.service.TaskService;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -34,12 +40,26 @@ class ProjectTaskFormAccessibilityWebTest {
@MockitoBean
private ProjectService projects;
@MockitoBean
private AccountService accounts;
@MockitoBean
private Clock clock;
@MockitoBean
private TaskService tasks;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@BeforeEach
void mentorActor() {
given(projectQueries.authenticatedActor("mentor@example.test"))
.willReturn(new ProjectActorView(10L, "MENTOR"));
given(clock.instant()).willReturn(Instant.parse("2026-08-15T01:00:00Z"));
given(clock.getZone()).willReturn(ZoneId.of("Asia/Ho_Chi_Minh"));
}
@Test
void projectFieldErrorsHaveStableIdsAndInputAssociations() throws Exception {
mvc.perform(post("/projects")
@@ -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 {
+88
View File
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const read = (path) => readFileSync(resolve(root, path), "utf8");
const expectedActionPins = new Map([
["actions/checkout", "3d3c42e5aac5ba805825da76410c181273ba90b1"],
["actions/setup-java", "b6effb05e454b25005698d916606bdc6ffcbf961"],
["actions/setup-node", "820762786026740c76f36085b0efc47a31fe5020"],
["docker/setup-buildx-action", "bb05f3f5519dd87d3ba754cc423b652a5edd6d2c"],
["docker/login-action", "dbcb813823bdd20940b903addbd779551569679f"],
["docker/build-push-action", "53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"],
]);
test("production image and Compose keep one image usable with bundled or external PostgreSQL", () => {
const dockerfile = read("Dockerfile");
const compose = read("compose.yaml");
const environment = read(".env.compose.example");
const production = read("src/main/resources/application-prod.yaml");
assert.match(dockerfile, /FROM node:24-alpine@sha256:/);
assert.match(dockerfile, /FROM eclipse-temurin:25-jre-alpine@sha256:/);
assert.match(dockerfile, /USER 10001:10001/);
assert.match(dockerfile, /HEALTHCHECK .*health\/readiness/);
assert.match(compose, /profiles: \["bundled-db"\]/);
assert.match(compose, /condition: service_healthy/);
assert.match(compose, /required: false/);
assert.match(compose, /postgres_data:\s*$/m);
assert.match(environment, /^LAB_DB_URL=/m);
assert.match(environment, /^LAB_IMAGE=/m);
assert.match(production, /same-site: strict/);
assert.match(production, /include: "readinessState,db"/);
});
test("verification workflow checks every pull request and pushed branch without write permission", () => {
const workflow = read(".gitea/workflows/verify.yml");
assert.match(workflow, /pull_request:/);
assert.match(workflow, /push:/);
assert.match(workflow, /contents: read/);
assert.match(workflow, /npm ci/);
assert.match(workflow, /npm run test:ui/);
assert.match(workflow, /npm run build/);
assert.match(workflow, /\.\/mvnw -B test/);
assert.match(workflow, /TESTCONTAINERS_HOST_OVERRIDE: host\.docker\.internal/);
assert.doesNotMatch(workflow, /permissions:\s*write-all/);
});
test("container workflow runs only manually or on main and verifies before either image build", () => {
const workflow = read(".gitea/workflows/container.yml");
assert.match(workflow, /'on':\n workflow_dispatch:\n push:\n branches:\n - main/);
assert.doesNotMatch(workflow, /^ pull_request:/m);
assert.match(workflow, /jobs:\n verify:/);
assert.match(workflow, /amd64:\n needs: verify/);
assert.match(workflow, /arm64:[\s\S]*?needs: verify/);
assert.match(workflow, /TESTCONTAINERS_HOST_OVERRIDE: host\.docker\.internal/);
assert.match(workflow, /runs-on: ubuntu-latest-arm/);
assert.match(workflow, /vars\.ARM64_RUNNER_AVAILABLE == 'true'/);
assert.match(workflow, /gitea\.ref == 'refs\/heads\/main'/);
assert.match(workflow, /sha-\$\{GITEA_SHA\}-amd64/);
assert.match(workflow, /sha-\$\{GITEA_SHA\}-arm64/);
assert.match(workflow, /imagetools create/);
assert.match(workflow, /REGISTRY: git\.sechmachine\.io\.vn/);
assert.match(workflow, /IMAGE_NAME: sechmachine\/labtimesheet/);
assert.match(workflow, /username: \$\{\{ gitea\.actor \}\}/);
assert.match(workflow, /password: \$\{\{ secrets\.REGISTRY_TOKEN \}\}/);
assert.doesNotMatch(workflow, /CONTAINER_IMAGE|REGISTRY_USERNAME/);
assert.doesNotMatch(workflow, /ssh|DEPLOY_HOST|DEPLOY_KEY/i);
});
test("workflows pin every action to the latest reviewed immutable release", () => {
const workflows = [
read(".gitea/workflows/verify.yml"),
read(".gitea/workflows/container.yml"),
].join("\n");
const uses = [...workflows.matchAll(/uses:\s+([^@\s]+)@([0-9a-f]{40})/g)];
assert.ok(uses.length > 0);
for (const [, action, pin] of uses) {
assert.equal(pin, expectedActionPins.get(action), `unexpected pin for ${action}`);
}
assert.deepEqual(new Set(uses.map(([, action]) => action)), new Set(expectedActionPins.keys()));
assert.doesNotMatch(workflows, /uses:\s+[^\s]+@v\d/);
});
+98
View File
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
class Target {
listeners = new Map();
addEventListener(type, listener) {
this.listeners.set(type, listener);
}
dispatch(type) {
this.listeners.get(type)?.({preventDefault() {}, target: this});
}
}
test('picker searches name and student code, summarizes selection, and cancels safely', () => {
const open = Object.assign(new Target(), {focus() { this.focused = true; }});
const cancel = new Target();
const apply = new Target();
const search = Object.assign(new Target(), {value: '', focus() { this.focused = true; }});
const summary = {textContent: ''};
const empty = {hidden: true};
const firstInput = Object.assign(new Target(), {checked: false, type: 'checkbox'});
const secondInput = Object.assign(new Target(), {checked: false, type: 'checkbox'});
const options = [
option('Nguyen An STU-020', 'Nguyen An (STU-020)', firstInput),
option('Tran Binh STU-021', 'Tran Binh (STU-021)', secondInput),
];
const dialog = Object.assign(new Target(), {
showModal() { this.open = true; },
close() { this.open = false; this.dispatch('close'); },
});
const picker = {
querySelector(selector) {
return new Map([
['[data-picker-open]', open], ['[data-picker-dialog]', dialog],
['[data-picker-search]', search], ['[data-picker-summary]', summary],
['[data-picker-empty]', empty], ['[data-picker-cancel]', cancel],
['[data-picker-apply]', apply],
]).get(selector) ?? null;
},
querySelectorAll(selector) {
return selector === '[data-picker-option]' ? options : [];
},
};
let ready;
const document = {
documentElement: {dataset: {}, style: {}},
addEventListener(type, listener) { if (type === 'DOMContentLoaded') ready = listener; },
querySelector() { return null; },
querySelectorAll(selector) { return selector === '[data-intern-picker]' ? [picker] : []; },
};
vm.runInNewContext(readFileSync('src/main/resources/static/assets/app.js', 'utf8'), {
document,
localStorage: {getItem() { return null; }, setItem() {}, removeItem() {}},
matchMedia() { return {matches: false}; },
});
ready();
open.dispatch('click');
assert.equal(dialog.open, true);
assert.equal(search.focused, true);
search.value = 'stu-021';
search.dispatch('input');
assert.equal(options[0].hidden, true);
assert.equal(options[1].hidden, false);
assert.equal(empty.hidden, true);
secondInput.checked = true;
secondInput.dispatch('change');
assert.equal(summary.textContent, '1 Intern selected: Tran Binh (STU-021)');
cancel.dispatch('click');
assert.equal(secondInput.checked, false);
assert.equal(summary.textContent, 'No Interns selected');
assert.equal(open.focused, true);
open.dispatch('click');
secondInput.checked = true;
secondInput.dispatch('change');
apply.dispatch('click');
assert.equal(secondInput.checked, true);
});
function option(searchValue, label, input) {
return {
hidden: false,
dataset: {pickerSearch: searchValue},
querySelector(selector) {
if (selector === 'input') return input;
if (selector === '[data-picker-label]') return {textContent: label};
return null;
},
};
}