From 4b37f8fd05804d2d76e11cec1afce52919f2eb59 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:31:39 +0700 Subject: [PATCH 01/62] feat: establish iteration 1 platform foundation --- docs/tests/integration/platform-foundation.md | 78 ++ .../labtimesheet/accounts/ModuleBoundary.java | 7 + .../attendance/ModuleBoundary.java | 7 + .../configuration/ModuleBoundary.java | 7 + .../configuration/TimeConfiguration.java | 15 + .../notifications/ModuleBoundary.java | 7 + .../labtimesheet/projects/ModuleBoundary.java | 7 + .../reporting/ModuleBoundary.java | 7 + src/main/resources/application.yaml | 2 + .../resources/db/migration/V1__baseline.sql | 1123 +++++++++++++++++ .../labtimesheet/PlatformFoundationTest.java | 75 ++ .../TestcontainersConfiguration.java | 11 + src/test/resources/application-test.yaml | 3 + 13 files changed, 1349 insertions(+) create mode 100644 docs/tests/integration/platform-foundation.md create mode 100644 src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/TimeConfiguration.java create mode 100644 src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java create mode 100644 src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java create mode 100644 src/main/resources/db/migration/V1__baseline.sql create mode 100644 src/test/java/com/lab/labtimesheet/PlatformFoundationTest.java diff --git a/docs/tests/integration/platform-foundation.md b/docs/tests/integration/platform-foundation.md new file mode 100644 index 0000000..c7ebe09 --- /dev/null +++ b/docs/tests/integration/platform-foundation.md @@ -0,0 +1,78 @@ +# Test Evidence: Platform foundation + +- **Test type:** Integration +- **Requirement IDs:** `ARC-001–ARC-008, DB-003–DB-012, OPS-003, TST-001–TST-010` +- **Scenario IDs:** `AC-DB-001, AC-OPS-002, AC-TST-001` +- **Test class/method:** `com.lab.labtimesheet.PlatformFoundationTest` +- **Implementation commit:** `this milestone commit` + +## Protected behavior + +The application starts with the six required package boundaries, Flyway creates the approved 23-table/56-foreign-key PostgreSQL catalog and seed, and tests receive deterministic time without a developer database. + +## Test method + +A full Spring context starts against a PostgreSQL 18.4 Testcontainer. JDBC catalog queries independently count application tables and foreign keys and inspect the seed. Class loading checks the declared package boundaries, and the injected test `Clock` is asserted exactly. + +## Hand-derived expected result + +The approved DDL catalog contains 23 application tables and 56 foreign keys. The seed has checkout grace 30 and five Monday–Friday rows. Test time is `2026-08-14T00:00:00Z` in `Asia/Ho_Chi_Minh`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=PlatformFoundationTest test +``` + +**Observed result** + +```text +Tests run: 3, Failures: 1, Errors: 1, Skipped: 0 +PlatformFoundationTest.flywayCreatesApprovedPostgresCatalog: expected: 23 but was: 0 +PlatformFoundationTest.applicationExposesRequiredModulePackages: ClassNotFound com.lab.labtimesheet.accounts.package-info +BUILD FAILURE +``` + +Flyway reported zero migrations and the first required boundary class was absent, so the failure was caused by the missing foundation. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=PlatformFoundationTest test +``` + +**Observed result** + +```text +Successfully applied 1 migration to schema "public", now at version v1 +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## External-test boundaries + +This proves migration replay and catalog shape on an ephemeral local PostgreSQL 18.4 container. It does not prove application container, Compose, CI, external SMTP, browser, publication, or deployment behavior; those boundaries are deferred or owned elsewhere. diff --git a/src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java new file mode 100644 index 0000000..491317a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.accounts; + +/** Accounts and security module boundary. */ +public final class ModuleBoundary { + private ModuleBoundary() { + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java new file mode 100644 index 0000000..4dd3875 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.attendance; + +/** Attendance, leave, and corrections module boundary. */ +public final class ModuleBoundary { + private ModuleBoundary() { + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java new file mode 100644 index 0000000..a5e60ca --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.configuration; + +/** Configuration, integrations, and calendar module boundary. */ +public final class ModuleBoundary { + private ModuleBoundary() { + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/TimeConfiguration.java b/src/main/java/com/lab/labtimesheet/configuration/TimeConfiguration.java new file mode 100644 index 0000000..78baeb7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/TimeConfiguration.java @@ -0,0 +1,15 @@ +package com.lab.labtimesheet.configuration; + +import java.time.Clock; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +class TimeConfiguration { + + @Bean + Clock applicationClock() { + return Clock.systemUTC(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java new file mode 100644 index 0000000..76fb6b7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.notifications; + +/** Notifications module boundary. */ +public final class ModuleBoundary { + private ModuleBoundary() { + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java new file mode 100644 index 0000000..15b62e2 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.projects; + +/** Projects and tasks module boundary. */ +public final class ModuleBoundary { + private ModuleBoundary() { + } +} diff --git a/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java new file mode 100644 index 0000000..c29e4f6 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.reporting; + +/** Reporting module boundary. */ +public final class ModuleBoundary { + private ModuleBoundary() { + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 70f4092..345106f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -7,4 +7,6 @@ spring: compose: enabled: false jpa: + hibernate: + ddl-auto: validate open-in-view: false diff --git a/src/main/resources/db/migration/V1__baseline.sql b/src/main/resources/db/migration/V1__baseline.sql new file mode 100644 index 0000000..7d08b5f --- /dev/null +++ b/src/main/resources/db/migration/V1__baseline.sql @@ -0,0 +1,1123 @@ +-- Lab Timesheet & Project Management System +-- Review baseline for PostgreSQL 18.4. +-- +-- REVIEW REQUIRED — IMPLEMENTATION NOT AUTHORIZED. +-- After approval, the platform owner may promote this file to the initial +-- Flyway migration. It is intentionally not an application migration yet. + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- 1. Singleton installation/bootstrap state. +CREATE TABLE system_state ( + singleton_id smallint PRIMARY KEY DEFAULT 1, + initialized boolean NOT NULL DEFAULT false, + initialized_at timestamptz, + bootstrap_admin_id bigint, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT ck_system_state_singleton CHECK (singleton_id = 1), + CONSTRAINT ck_system_state_initialization CHECK ( + (initialized = false AND initialized_at IS NULL AND bootstrap_admin_id IS NULL) + OR + (initialized = true AND initialized_at IS NOT NULL AND bootstrap_admin_id IS NOT NULL) + ), + CONSTRAINT ck_system_state_version CHECK (version >= 0) +); + +INSERT INTO system_state (singleton_id) VALUES (1); + +-- 2. Authentication identity and immutable global role. +CREATE TABLE app_users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email varchar(320) NOT NULL, + display_name varchar(120) NOT NULL, + password_hash varchar(255), + global_role varchar(16) NOT NULL, + account_status varchar(32) NOT NULL DEFAULT 'PENDING_ACTIVATION', + activated_at timestamptz, + locked_at timestamptz, + deactivated_at timestamptz, + last_login_at timestamptz, + created_by_user_id bigint, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_app_users_created_by + FOREIGN KEY (created_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_app_users_email CHECK (length(btrim(email)) > 3), + CONSTRAINT ck_app_users_display_name CHECK (length(btrim(display_name)) > 0), + CONSTRAINT ck_app_users_global_role CHECK (global_role IN ('ADMIN', 'MENTOR', 'INTERN')), + CONSTRAINT ck_app_users_account_status CHECK ( + account_status IN ('PENDING_ACTIVATION', 'ACTIVE', 'LOCKED', 'DEACTIVATED') + ), + CONSTRAINT ck_app_users_pending_password CHECK ( + (account_status = 'PENDING_ACTIVATION' AND password_hash IS NULL) + OR + (account_status <> 'PENDING_ACTIVATION' AND length(btrim(password_hash)) > 0) + ), + CONSTRAINT ck_app_users_activated_state CHECK ( + (account_status = 'PENDING_ACTIVATION' AND activated_at IS NULL) + OR + (account_status <> 'PENDING_ACTIVATION' AND activated_at IS NOT NULL) + ), + CONSTRAINT ck_app_users_lock_timestamp CHECK ( + (account_status = 'LOCKED' AND locked_at IS NOT NULL) + OR + (account_status <> 'LOCKED' AND locked_at IS NULL) + ), + CONSTRAINT ck_app_users_deactivation_timestamp CHECK ( + (account_status = 'DEACTIVATED' AND deactivated_at IS NOT NULL) + OR + (account_status <> 'DEACTIVATED' AND deactivated_at IS NULL) + ), + CONSTRAINT ck_app_users_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_app_users_email_ci ON app_users (lower(btrim(email))); +CREATE INDEX ix_app_users_created_by ON app_users (created_by_user_id); +CREATE INDEX ix_app_users_role_status ON app_users (global_role, account_status); + +ALTER TABLE system_state + ADD CONSTRAINT fk_system_state_bootstrap_admin + FOREIGN KEY (bootstrap_admin_id) REFERENCES app_users (id) ON DELETE RESTRICT; + +CREATE INDEX ix_system_state_bootstrap_admin ON system_state (bootstrap_admin_id); + +CREATE FUNCTION prevent_app_user_role_change() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.global_role IS DISTINCT FROM OLD.global_role THEN + RAISE EXCEPTION 'global_role is immutable for app_user %', OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_app_users_immutable_role +BEFORE UPDATE OF global_role ON app_users +FOR EACH ROW +EXECUTE FUNCTION prevent_app_user_role_change(); + +-- 3. Hashed, single-use account activation and password-reset tokens. +CREATE TABLE user_action_tokens ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + purpose varchar(24) NOT NULL, + token_hash bytea NOT NULL, + expires_at timestamptz NOT NULL, + used_at timestamptz, + invalidated_at timestamptz, + issued_by_user_id bigint, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + CONSTRAINT fk_user_action_tokens_user + FOREIGN KEY (user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT fk_user_action_tokens_issuer + FOREIGN KEY (issued_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT uq_user_action_tokens_hash UNIQUE (token_hash), + CONSTRAINT ck_user_action_tokens_purpose CHECK (purpose IN ('ACTIVATION', 'PASSWORD_RESET')), + CONSTRAINT ck_user_action_tokens_hash_length CHECK (octet_length(token_hash) = 32), + CONSTRAINT ck_user_action_tokens_expiry CHECK (expires_at > created_at), + CONSTRAINT ck_user_action_tokens_terminal_state CHECK ( + NOT (used_at IS NOT NULL AND invalidated_at IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX uq_user_action_tokens_one_live + ON user_action_tokens (user_id, purpose) + WHERE used_at IS NULL AND invalidated_at IS NULL; +CREATE INDEX ix_user_action_tokens_user ON user_action_tokens (user_id, created_at DESC); +CREATE INDEX ix_user_action_tokens_issuer ON user_action_tokens (issued_by_user_id); +CREATE INDEX ix_user_action_tokens_expiry + ON user_action_tokens (expires_at) + WHERE used_at IS NULL AND invalidated_at IS NULL; + +-- 4. Intern-only profile and internship lifecycle. +CREATE TABLE intern_profiles ( + user_id bigint PRIMARY KEY, + student_code varchar(64) NOT NULL, + department varchar(120), + phone varchar(32), + internship_start_date date NOT NULL, + internship_end_date date NOT NULL, + internship_status varchar(24) NOT NULL DEFAULT 'NOT_STARTED', + activated_at timestamptz, + completed_at timestamptz, + withdrawn_at timestamptz, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_intern_profiles_user + FOREIGN KEY (user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_intern_profiles_student_code CHECK (length(btrim(student_code)) > 0), + CONSTRAINT ck_intern_profiles_dates CHECK (internship_end_date >= internship_start_date), + CONSTRAINT ck_intern_profiles_status CHECK ( + internship_status IN ('NOT_STARTED', 'ACTIVE', 'COMPLETED', 'WITHDRAWN') + ), + CONSTRAINT ck_intern_profiles_active_timestamp CHECK ( + internship_status NOT IN ('ACTIVE', 'COMPLETED') OR activated_at IS NOT NULL + ), + CONSTRAINT ck_intern_profiles_completed_timestamp CHECK ( + (internship_status = 'COMPLETED' AND completed_at IS NOT NULL) + OR + (internship_status <> 'COMPLETED' AND completed_at IS NULL) + ), + CONSTRAINT ck_intern_profiles_withdrawn_timestamp CHECK ( + (internship_status = 'WITHDRAWN' AND withdrawn_at IS NOT NULL) + OR + (internship_status <> 'WITHDRAWN' AND withdrawn_at IS NULL) + ), + CONSTRAINT ck_intern_profiles_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_intern_profiles_student_code_ci + ON intern_profiles (lower(btrim(student_code))); +CREATE INDEX ix_intern_profiles_status_dates + ON intern_profiles (internship_status, internship_start_date, internship_end_date); + +-- 5. Versioned SMTP settings. Secrets are AES-256-GCM envelopes. +CREATE TABLE smtp_configurations ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + status varchar(16) NOT NULL DEFAULT 'DRAFT', + host varchar(255) NOT NULL, + port integer NOT NULL, + security_mode varchar(16) NOT NULL, + username varchar(320), + password_ciphertext bytea, + password_nonce bytea, + secret_key_version integer, + from_address varchar(320) NOT NULL, + from_name varchar(120) NOT NULL, + tested_at timestamptz, + tested_by_user_id bigint, + activated_at timestamptz, + activated_by_user_id bigint, + retired_at timestamptz, + retired_by_user_id bigint, + created_by_user_id bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_smtp_configurations_tested_by + FOREIGN KEY (tested_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT fk_smtp_configurations_activated_by + FOREIGN KEY (activated_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT fk_smtp_configurations_retired_by + FOREIGN KEY (retired_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT fk_smtp_configurations_created_by + FOREIGN KEY (created_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_smtp_configurations_status CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED')), + CONSTRAINT ck_smtp_configurations_port CHECK (port BETWEEN 1 AND 65535), + CONSTRAINT ck_smtp_configurations_security CHECK (security_mode IN ('NONE', 'STARTTLS', 'TLS')), + CONSTRAINT ck_smtp_configurations_host CHECK (length(btrim(host)) > 0), + CONSTRAINT ck_smtp_configurations_from CHECK (length(btrim(from_address)) > 3), + CONSTRAINT ck_smtp_configurations_from_name CHECK (length(btrim(from_name)) > 0), + CONSTRAINT ck_smtp_configurations_secret_pair CHECK ( + (username IS NULL AND password_ciphertext IS NULL + AND password_nonce IS NULL AND secret_key_version IS NULL) + OR + (username IS NOT NULL AND length(btrim(username)) > 0 + AND password_ciphertext IS NOT NULL + AND octet_length(password_ciphertext) > 16 + AND password_nonce IS NOT NULL + AND octet_length(password_nonce) = 12 + AND secret_key_version > 0) + ), + CONSTRAINT ck_smtp_configurations_test_actor CHECK ( + (tested_at IS NULL AND tested_by_user_id IS NULL) + OR + (tested_at IS NOT NULL AND tested_by_user_id IS NOT NULL) + ), + CONSTRAINT ck_smtp_configurations_activation CHECK ( + (status = 'DRAFT' AND activated_at IS NULL AND activated_by_user_id IS NULL) + OR + (status IN ('ACTIVE', 'RETIRED') + AND tested_at IS NOT NULL + AND tested_by_user_id IS NOT NULL + AND activated_at IS NOT NULL + AND activated_by_user_id IS NOT NULL) + ), + CONSTRAINT ck_smtp_configurations_retirement CHECK ( + (status = 'RETIRED' AND retired_at IS NOT NULL AND retired_by_user_id IS NOT NULL) + OR + (status <> 'RETIRED' AND retired_at IS NULL AND retired_by_user_id IS NULL) + ), + CONSTRAINT ck_smtp_configurations_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_smtp_configurations_one_active + ON smtp_configurations ((1)) WHERE status = 'ACTIVE'; +CREATE UNIQUE INDEX uq_smtp_configurations_one_draft + ON smtp_configurations ((1)) WHERE status = 'DRAFT'; +CREATE INDEX ix_smtp_configurations_created_by ON smtp_configurations (created_by_user_id); +CREATE INDEX ix_smtp_configurations_tested_by ON smtp_configurations (tested_by_user_id); +CREATE INDEX ix_smtp_configurations_activated_by ON smtp_configurations (activated_by_user_id); +CREATE INDEX ix_smtp_configurations_retired_by ON smtp_configurations (retired_by_user_id); + +-- 6. Versioned HolidayAPI credentials; country is intentionally fixed to VN. +CREATE TABLE holiday_api_configurations ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + status varchar(16) NOT NULL DEFAULT 'DRAFT', + country_code char(2) NOT NULL DEFAULT 'VN', + api_key_ciphertext bytea NOT NULL, + api_key_nonce bytea NOT NULL, + secret_key_version integer NOT NULL, + tested_at timestamptz, + tested_by_user_id bigint, + activated_at timestamptz, + activated_by_user_id bigint, + retired_at timestamptz, + retired_by_user_id bigint, + created_by_user_id bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_holiday_api_configurations_tested_by + FOREIGN KEY (tested_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT fk_holiday_api_configurations_activated_by + FOREIGN KEY (activated_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT fk_holiday_api_configurations_retired_by + FOREIGN KEY (retired_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT fk_holiday_api_configurations_created_by + FOREIGN KEY (created_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_holiday_api_configurations_status CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED')), + CONSTRAINT ck_holiday_api_configurations_country CHECK (country_code = 'VN'), + CONSTRAINT ck_holiday_api_configurations_ciphertext CHECK ( + octet_length(api_key_ciphertext) > 16 + ), + CONSTRAINT ck_holiday_api_configurations_nonce CHECK (octet_length(api_key_nonce) = 12), + CONSTRAINT ck_holiday_api_configurations_key_version CHECK (secret_key_version > 0), + CONSTRAINT ck_holiday_api_configurations_test_actor CHECK ( + (tested_at IS NULL AND tested_by_user_id IS NULL) + OR + (tested_at IS NOT NULL AND tested_by_user_id IS NOT NULL) + ), + CONSTRAINT ck_holiday_api_configurations_activation CHECK ( + (status = 'DRAFT' AND activated_at IS NULL AND activated_by_user_id IS NULL) + OR + (status IN ('ACTIVE', 'RETIRED') + AND tested_at IS NOT NULL + AND tested_by_user_id IS NOT NULL + AND activated_at IS NOT NULL + AND activated_by_user_id IS NOT NULL) + ), + CONSTRAINT ck_holiday_api_configurations_retirement CHECK ( + (status = 'RETIRED' AND retired_at IS NOT NULL AND retired_by_user_id IS NOT NULL) + OR + (status <> 'RETIRED' AND retired_at IS NULL AND retired_by_user_id IS NULL) + ), + CONSTRAINT ck_holiday_api_configurations_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_holiday_api_configurations_one_active + ON holiday_api_configurations ((1)) WHERE status = 'ACTIVE'; +CREATE UNIQUE INDEX uq_holiday_api_configurations_one_draft + ON holiday_api_configurations ((1)) WHERE status = 'DRAFT'; +CREATE INDEX ix_holiday_api_configurations_created_by + ON holiday_api_configurations (created_by_user_id); +CREATE INDEX ix_holiday_api_configurations_tested_by + ON holiday_api_configurations (tested_by_user_id); +CREATE INDEX ix_holiday_api_configurations_activated_by + ON holiday_api_configurations (activated_by_user_id); +CREATE INDEX ix_holiday_api_configurations_retired_by + ON holiday_api_configurations (retired_by_user_id); + +-- 7. Immutable-on-effective global attendance policy versions. +CREATE TABLE attendance_policy_versions ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + effective_from date NOT NULL, + timezone_name varchar(64) NOT NULL DEFAULT 'Asia/Ho_Chi_Minh', + scheduled_start time NOT NULL, + scheduled_end time NOT NULL, + check_in_grace_minutes integer NOT NULL, + checkout_grace_minutes integer NOT NULL, + monthly_leave_quota integer NOT NULL, + violation_penalty numeric(5,4) NOT NULL, + created_by_user_id bigint, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_attendance_policy_versions_created_by + FOREIGN KEY (created_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT uq_attendance_policy_versions_effective_from UNIQUE (effective_from), + CONSTRAINT ck_attendance_policy_versions_timezone CHECK ( + length(btrim(timezone_name)) > 0 + ), + CONSTRAINT ck_attendance_policy_versions_month_boundary CHECK ( + extract(day FROM effective_from) = 1 + ), + CONSTRAINT ck_attendance_policy_versions_schedule CHECK (scheduled_end > scheduled_start), + CONSTRAINT ck_attendance_policy_versions_check_in_grace CHECK ( + check_in_grace_minutes BETWEEN 0 AND 720 + ), + CONSTRAINT ck_attendance_policy_versions_checkout_grace CHECK ( + checkout_grace_minutes BETWEEN 0 AND 720 + ), + CONSTRAINT ck_attendance_policy_versions_checkout_cutoff CHECK ( + extract(epoch FROM scheduled_end) + checkout_grace_minutes * 60 < 86400 + ), + CONSTRAINT ck_attendance_policy_versions_quota CHECK (monthly_leave_quota BETWEEN 0 AND 31), + CONSTRAINT ck_attendance_policy_versions_penalty CHECK ( + violation_penalty >= 0 AND violation_penalty <= 1 + ), + CONSTRAINT ck_attendance_policy_versions_version CHECK (version >= 0) +); + +CREATE INDEX ix_attendance_policy_versions_lookup + ON attendance_policy_versions (effective_from DESC); +CREATE INDEX ix_attendance_policy_versions_created_by + ON attendance_policy_versions (created_by_user_id); + +-- 8. ISO-8601 weekdays attached to a policy version. +CREATE TABLE attendance_policy_workdays ( + policy_version_id bigint NOT NULL, + iso_weekday smallint NOT NULL, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + PRIMARY KEY (policy_version_id, iso_weekday), + CONSTRAINT fk_attendance_policy_workdays_policy + FOREIGN KEY (policy_version_id) REFERENCES attendance_policy_versions (id) ON DELETE RESTRICT, + CONSTRAINT ck_attendance_policy_workdays_iso_day CHECK (iso_weekday BETWEEN 1 AND 7) +); + +-- 9. Locally authoritative global holiday/custom calendar. +CREATE TABLE global_calendar_events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + calendar_date date NOT NULL, + name varchar(200) NOT NULL, + source varchar(24) NOT NULL, + source_uuid varchar(100), + actual_date date, + observed_date date, + public_holiday boolean, + is_day_off boolean NOT NULL, + imported_at timestamptz, + created_by_user_id bigint NOT NULL, + updated_by_user_id bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_global_calendar_events_created_by + FOREIGN KEY (created_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT fk_global_calendar_events_updated_by + FOREIGN KEY (updated_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_global_calendar_events_name CHECK (length(btrim(name)) > 0), + CONSTRAINT ck_global_calendar_events_source CHECK (source IN ('CUSTOM', 'HOLIDAY_API')), + CONSTRAINT ck_global_calendar_events_api_provenance CHECK ( + (source = 'CUSTOM' + AND source_uuid IS NULL + AND actual_date IS NULL + AND observed_date IS NULL + AND public_holiday IS NULL + AND imported_at IS NULL) + OR + (source = 'HOLIDAY_API' + AND source_uuid IS NOT NULL + AND length(btrim(source_uuid)) > 0 + AND actual_date IS NOT NULL + AND observed_date IS NOT NULL + AND public_holiday IS NOT NULL + AND imported_at IS NOT NULL) + ), + CONSTRAINT ck_global_calendar_events_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_global_calendar_events_source_uuid + ON global_calendar_events (source, source_uuid) + WHERE source_uuid IS NOT NULL; +CREATE INDEX ix_global_calendar_events_date + ON global_calendar_events (calendar_date); +CREATE INDEX ix_global_calendar_events_day_off_date + ON global_calendar_events (calendar_date) + WHERE is_day_off = true; +CREATE INDEX ix_global_calendar_events_created_by + ON global_calendar_events (created_by_user_id); +CREATE INDEX ix_global_calendar_events_updated_by + ON global_calendar_events (updated_by_user_id); + +-- 10. Mentor-owned project aggregate. +CREATE TABLE projects ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + mentor_user_id bigint NOT NULL, + name varchar(160) NOT NULL, + description text, + status varchar(16) NOT NULL DEFAULT 'PLANNED', + start_date date NOT NULL, + end_date date NOT NULL, + activated_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_projects_mentor + FOREIGN KEY (mentor_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_projects_name CHECK (length(btrim(name)) > 0), + CONSTRAINT ck_projects_status CHECK (status IN ('PLANNED', 'ACTIVE', 'COMPLETED')), + CONSTRAINT ck_projects_dates CHECK (end_date >= start_date), + CONSTRAINT ck_projects_activation CHECK ( + (status = 'PLANNED' AND activated_at IS NULL) + OR + (status IN ('ACTIVE', 'COMPLETED') AND activated_at IS NOT NULL) + ), + CONSTRAINT ck_projects_completion CHECK ( + (status = 'COMPLETED' AND completed_at IS NOT NULL) + OR + (status <> 'COMPLETED' AND completed_at IS NULL) + ), + CONSTRAINT ck_projects_version CHECK (version >= 0) +); + +CREATE INDEX ix_projects_mentor_status ON projects (mentor_user_id, status); +CREATE INDEX ix_projects_status_dates ON projects (status, start_date, end_date); + +-- 11. Interval-based many-to-many project membership. +-- Initial/direct addition records the owning Mentor in added_by_user_id; +-- invitation acceptance records the accepting Intern. +CREATE TABLE project_memberships ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id bigint NOT NULL, + intern_user_id bigint NOT NULL, + joined_at timestamptz NOT NULL DEFAULT current_timestamp, + left_at timestamptz, + added_by_user_id bigint NOT NULL, + removed_by_mentor_user_id bigint, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_project_memberships_project + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE RESTRICT, + CONSTRAINT fk_project_memberships_intern + FOREIGN KEY (intern_user_id) REFERENCES intern_profiles (user_id) ON DELETE RESTRICT, + CONSTRAINT fk_project_memberships_added_by + FOREIGN KEY (added_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT fk_project_memberships_removed_by + FOREIGN KEY (removed_by_mentor_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT uq_project_memberships_id_project UNIQUE (id, project_id), + CONSTRAINT ck_project_memberships_interval CHECK (left_at IS NULL OR left_at > joined_at), + CONSTRAINT ck_project_memberships_removal_actor CHECK ( + (left_at IS NULL AND removed_by_mentor_user_id IS NULL) + OR + (left_at IS NOT NULL AND removed_by_mentor_user_id IS NOT NULL) + ), + CONSTRAINT ck_project_memberships_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_project_memberships_one_active + ON project_memberships (project_id, intern_user_id) + WHERE left_at IS NULL; +CREATE INDEX ix_project_memberships_project_fk + ON project_memberships (project_id); +CREATE INDEX ix_project_memberships_intern_fk + ON project_memberships (intern_user_id); +CREATE INDEX ix_project_memberships_project_active + ON project_memberships (project_id, joined_at) + WHERE left_at IS NULL; +CREATE INDEX ix_project_memberships_intern_active + ON project_memberships (intern_user_id, joined_at) + WHERE left_at IS NULL; +CREATE INDEX ix_project_memberships_added_by + ON project_memberships (added_by_user_id); +CREATE INDEX ix_project_memberships_removed_by + ON project_memberships (removed_by_mentor_user_id); + +-- 12. Non-overlapping project-scoped leadership terms. +CREATE TABLE project_leadership_terms ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id bigint NOT NULL, + membership_id bigint NOT NULL, + started_at timestamptz NOT NULL DEFAULT current_timestamp, + ended_at timestamptz, + appointed_by_mentor_user_id bigint NOT NULL, + ended_by_mentor_user_id bigint, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + CONSTRAINT fk_project_leadership_terms_project + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE RESTRICT, + CONSTRAINT fk_project_leadership_terms_membership_project + FOREIGN KEY (membership_id, project_id) + REFERENCES project_memberships (id, project_id) ON DELETE RESTRICT, + CONSTRAINT fk_project_leadership_terms_appointed_by + FOREIGN KEY (appointed_by_mentor_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT fk_project_leadership_terms_ended_by + FOREIGN KEY (ended_by_mentor_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT uq_project_leadership_terms_id_project UNIQUE (id, project_id), + CONSTRAINT ck_project_leadership_terms_interval CHECK (ended_at IS NULL OR ended_at > started_at), + CONSTRAINT ck_project_leadership_terms_end_actor CHECK ( + (ended_at IS NULL AND ended_by_mentor_user_id IS NULL) + OR + (ended_at IS NOT NULL AND ended_by_mentor_user_id IS NOT NULL) + ), + CONSTRAINT ex_project_leadership_terms_no_overlap + EXCLUDE USING gist ( + project_id WITH =, + tstzrange(started_at, ended_at, '[)') WITH && + ) +); + +CREATE UNIQUE INDEX uq_project_leadership_terms_one_current + ON project_leadership_terms (project_id) + WHERE ended_at IS NULL; +CREATE INDEX ix_project_leadership_terms_membership + ON project_leadership_terms (membership_id, project_id, started_at DESC); +CREATE INDEX ix_project_leadership_terms_appointed_by + ON project_leadership_terms (appointed_by_mentor_user_id); +CREATE INDEX ix_project_leadership_terms_ended_by + ON project_leadership_terms (ended_by_mentor_user_id); + +-- 13. Leader-issued invitation to an eligible Intern. There is no expiry; +-- application transactions recheck Project, leadership, Intern, and membership state. +CREATE TABLE project_invitations ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id bigint NOT NULL, + invited_intern_user_id bigint NOT NULL, + issuing_leadership_term_id bigint NOT NULL, + status varchar(16) NOT NULL DEFAULT 'PENDING', + accepted_membership_id bigint, + resolved_at timestamptz, + resolved_by_user_id bigint, + resolution_code varchar(32), + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_project_invitations_project + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE RESTRICT, + CONSTRAINT fk_project_invitations_invited_intern + FOREIGN KEY (invited_intern_user_id) REFERENCES intern_profiles (user_id) ON DELETE RESTRICT, + CONSTRAINT fk_project_invitations_issuing_leadership_project + FOREIGN KEY (issuing_leadership_term_id, project_id) + REFERENCES project_leadership_terms (id, project_id) ON DELETE RESTRICT, + CONSTRAINT fk_project_invitations_accepted_membership_project + FOREIGN KEY (accepted_membership_id, project_id) + REFERENCES project_memberships (id, project_id) ON DELETE RESTRICT, + CONSTRAINT fk_project_invitations_resolved_by + FOREIGN KEY (resolved_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_project_invitations_status CHECK ( + status IN ('PENDING', 'ACCEPTED', 'DECLINED', 'REVOKED', 'SUPERSEDED') + ), + CONSTRAINT ck_project_invitations_resolution_code CHECK ( + resolution_code IS NULL OR resolution_code IN ( + 'INVITEE_ACCEPTED', 'INVITEE_DECLINED', + 'INVITER_REVOKED', 'MENTOR_REVOKED', 'LEADER_CHANGED', + 'PROJECT_COMPLETED', 'INVITEE_INELIGIBLE', 'MENTOR_DIRECT_ADD' + ) + ), + CONSTRAINT ck_project_invitations_resolution_state CHECK ( + (status = 'PENDING' + AND accepted_membership_id IS NULL + AND resolved_at IS NULL + AND resolved_by_user_id IS NULL + AND resolution_code IS NULL) + OR + (status = 'ACCEPTED' + AND accepted_membership_id IS NOT NULL + AND resolved_at IS NOT NULL + AND resolved_by_user_id IS NOT NULL + AND resolution_code = 'INVITEE_ACCEPTED') + OR + (status = 'DECLINED' + AND accepted_membership_id IS NULL + AND resolved_at IS NOT NULL + AND resolved_by_user_id IS NOT NULL + AND resolution_code = 'INVITEE_DECLINED') + OR + (status = 'REVOKED' + AND accepted_membership_id IS NULL + AND resolved_at IS NOT NULL + AND resolution_code IN ( + 'INVITER_REVOKED', 'MENTOR_REVOKED', 'LEADER_CHANGED', + 'PROJECT_COMPLETED', 'INVITEE_INELIGIBLE' + ) + AND ( + resolved_by_user_id IS NOT NULL + OR resolution_code IN ('LEADER_CHANGED', 'PROJECT_COMPLETED', 'INVITEE_INELIGIBLE') + )) + OR + (status = 'SUPERSEDED' + AND accepted_membership_id IS NULL + AND resolved_at IS NOT NULL + AND resolved_by_user_id IS NOT NULL + AND resolution_code = 'MENTOR_DIRECT_ADD') + ), + CONSTRAINT ck_project_invitations_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_project_invitations_one_pending + ON project_invitations (project_id, invited_intern_user_id) + WHERE status = 'PENDING'; +CREATE UNIQUE INDEX uq_project_invitations_accepted_membership + ON project_invitations (accepted_membership_id) + WHERE accepted_membership_id IS NOT NULL; +CREATE INDEX ix_project_invitations_project_status + ON project_invitations (project_id, status, created_at, id); +CREATE INDEX ix_project_invitations_invitee_status + ON project_invitations (invited_intern_user_id, status, created_at, id); +CREATE INDEX ix_project_invitations_issuing_leadership_project + ON project_invitations (issuing_leadership_term_id, project_id); +CREATE INDEX ix_project_invitations_accepted_membership_project + ON project_invitations (accepted_membership_id, project_id); +CREATE INDEX ix_project_invitations_resolved_by + ON project_invitations (resolved_by_user_id); + +-- 14. Mentor-decided request to close one active membership. Rights remain +-- unchanged while pending; approval/transfer/closure is one application transaction. +CREATE TABLE project_membership_exit_requests ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id bigint NOT NULL, + target_membership_id bigint NOT NULL, + requester_membership_id bigint NOT NULL, + request_type varchar(24) NOT NULL, + reason text NOT NULL, + status varchar(16) NOT NULL DEFAULT 'PENDING', + resolution_note text, + resolved_at timestamptz, + resolved_by_user_id bigint, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_project_membership_exit_requests_project + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE RESTRICT, + CONSTRAINT fk_project_membership_exit_requests_target_project + FOREIGN KEY (target_membership_id, project_id) + REFERENCES project_memberships (id, project_id) ON DELETE RESTRICT, + CONSTRAINT fk_project_membership_exit_requests_requester_project + FOREIGN KEY (requester_membership_id, project_id) + REFERENCES project_memberships (id, project_id) ON DELETE RESTRICT, + CONSTRAINT fk_project_membership_exit_requests_resolved_by + FOREIGN KEY (resolved_by_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_project_membership_exit_requests_type CHECK ( + request_type IN ('LEADER_REMOVAL', 'MEMBER_LEAVE') + ), + CONSTRAINT ck_project_membership_exit_requests_reason CHECK (length(btrim(reason)) > 0), + CONSTRAINT ck_project_membership_exit_requests_resolution_note CHECK ( + resolution_note IS NULL OR length(btrim(resolution_note)) > 0 + ), + CONSTRAINT ck_project_membership_exit_requests_participants CHECK ( + (request_type = 'LEADER_REMOVAL' AND requester_membership_id <> target_membership_id) + OR + (request_type = 'MEMBER_LEAVE' AND requester_membership_id = target_membership_id) + ), + CONSTRAINT ck_project_membership_exit_requests_status CHECK ( + status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED', 'SUPERSEDED') + ), + CONSTRAINT ck_project_membership_exit_requests_resolution CHECK ( + (status = 'PENDING' + AND resolution_note IS NULL + AND resolved_at IS NULL + AND resolved_by_user_id IS NULL) + OR + (status IN ('APPROVED', 'REJECTED', 'CANCELLED') + AND resolved_at IS NOT NULL + AND resolved_by_user_id IS NOT NULL) + OR + (status = 'SUPERSEDED' + AND resolved_at IS NOT NULL) + ), + CONSTRAINT ck_project_membership_exit_requests_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_project_membership_exit_requests_one_pending_target + ON project_membership_exit_requests (target_membership_id) + WHERE status = 'PENDING'; +CREATE INDEX ix_project_membership_exit_requests_project_status + ON project_membership_exit_requests (project_id, status, created_at, id); +CREATE INDEX ix_project_membership_exit_requests_target_project + ON project_membership_exit_requests (target_membership_id, project_id); +CREATE INDEX ix_project_membership_exit_requests_requester_project + ON project_membership_exit_requests (requester_membership_id, project_id); +CREATE INDEX ix_project_membership_exit_requests_requester_status + ON project_membership_exit_requests (requester_membership_id, status, created_at, id); +CREATE INDEX ix_project_membership_exit_requests_resolved_by + ON project_membership_exit_requests (resolved_by_user_id); + +-- 15. Single-assignee task. Assignee and actor memberships are constrained to one project. +CREATE TABLE tasks ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id bigint NOT NULL, + assignee_membership_id bigint NOT NULL, + title varchar(200) NOT NULL, + description text, + status varchar(24) NOT NULL DEFAULT 'TODO', + due_date date, + assigned_at timestamptz NOT NULL DEFAULT current_timestamp, + created_by_membership_id bigint NOT NULL, + assigned_by_membership_id bigint NOT NULL, + deleted_at timestamptz, + deleted_by_membership_id bigint, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_tasks_project + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE RESTRICT, + CONSTRAINT fk_tasks_assignee_project + FOREIGN KEY (assignee_membership_id, project_id) + REFERENCES project_memberships (id, project_id) ON DELETE RESTRICT, + CONSTRAINT fk_tasks_creator_project + FOREIGN KEY (created_by_membership_id, project_id) + REFERENCES project_memberships (id, project_id) ON DELETE RESTRICT, + CONSTRAINT fk_tasks_assigner_project + FOREIGN KEY (assigned_by_membership_id, project_id) + REFERENCES project_memberships (id, project_id) ON DELETE RESTRICT, + CONSTRAINT fk_tasks_deleter_project + FOREIGN KEY (deleted_by_membership_id, project_id) + REFERENCES project_memberships (id, project_id) ON DELETE RESTRICT, + CONSTRAINT uq_tasks_id_project UNIQUE (id, project_id), + CONSTRAINT ck_tasks_title CHECK (length(btrim(title)) > 0), + CONSTRAINT ck_tasks_status CHECK (status IN ('TODO', 'IN_PROGRESS', 'DONE', 'BLOCKED')), + CONSTRAINT ck_tasks_soft_delete_actor CHECK ( + (deleted_at IS NULL AND deleted_by_membership_id IS NULL) + OR + (deleted_at IS NOT NULL AND deleted_by_membership_id IS NOT NULL) + ), + CONSTRAINT ck_tasks_version CHECK (version >= 0) +); + +CREATE INDEX ix_tasks_project_status_active + ON tasks (project_id, status, id) + WHERE deleted_at IS NULL; +CREATE INDEX ix_tasks_assignee_status_active + ON tasks (assignee_membership_id, status, id) + WHERE deleted_at IS NULL; +CREATE INDEX ix_tasks_project_fk ON tasks (project_id); +CREATE INDEX ix_tasks_assignee_project_fk + ON tasks (assignee_membership_id, project_id); +CREATE INDEX ix_tasks_due_date_active + ON tasks (due_date, project_id) + WHERE deleted_at IS NULL AND due_date IS NOT NULL; +CREATE INDEX ix_tasks_creator ON tasks (created_by_membership_id, project_id); +CREATE INDEX ix_tasks_assigner ON tasks (assigned_by_membership_id, project_id); +CREATE INDEX ix_tasks_deleter ON tasks (deleted_by_membership_id, project_id); + +-- 16. Append-only task discussion. +CREATE TABLE task_comments ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + task_id bigint NOT NULL, + author_user_id bigint NOT NULL, + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + CONSTRAINT fk_task_comments_task + FOREIGN KEY (task_id) REFERENCES tasks (id) ON DELETE RESTRICT, + CONSTRAINT fk_task_comments_author + FOREIGN KEY (author_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_task_comments_body CHECK (length(btrim(body)) > 0) +); + +CREATE INDEX ix_task_comments_task_created + ON task_comments (task_id, created_at, id); +CREATE INDEX ix_task_comments_author + ON task_comments (author_user_id, created_at DESC); + +-- 17. Dated task effort; independent from attendance punches. +CREATE TABLE task_work_logs ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id bigint NOT NULL, + task_id bigint NOT NULL, + membership_id bigint NOT NULL, + work_date date NOT NULL, + minutes integer NOT NULL, + note text, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_task_work_logs_project + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE RESTRICT, + CONSTRAINT fk_task_work_logs_task_project + FOREIGN KEY (task_id, project_id) REFERENCES tasks (id, project_id) ON DELETE RESTRICT, + CONSTRAINT fk_task_work_logs_membership_project + FOREIGN KEY (membership_id, project_id) + REFERENCES project_memberships (id, project_id) ON DELETE RESTRICT, + CONSTRAINT ck_task_work_logs_minutes CHECK (minutes BETWEEN 1 AND 1440), + CONSTRAINT ck_task_work_logs_note CHECK (note IS NULL OR length(btrim(note)) > 0), + CONSTRAINT ck_task_work_logs_version CHECK (version >= 0) +); + +CREATE INDEX ix_task_work_logs_task_date + ON task_work_logs (task_id, project_id, work_date, id); +CREATE INDEX ix_task_work_logs_membership_date + ON task_work_logs (membership_id, project_id, work_date, id); +CREATE INDEX ix_task_work_logs_project_date + ON task_work_logs (project_id, work_date, id); + +-- 18. Raw server-authoritative attendance punch data. +CREATE TABLE attendance_records ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + intern_user_id bigint NOT NULL, + work_date date NOT NULL, + policy_version_id bigint NOT NULL, + check_in_at timestamptz NOT NULL, + check_out_at timestamptz, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_attendance_records_intern + FOREIGN KEY (intern_user_id) REFERENCES intern_profiles (user_id) ON DELETE RESTRICT, + CONSTRAINT fk_attendance_records_policy + FOREIGN KEY (policy_version_id) REFERENCES attendance_policy_versions (id) ON DELETE RESTRICT, + CONSTRAINT uq_attendance_records_intern_date UNIQUE (intern_user_id, work_date), + CONSTRAINT ck_attendance_records_checkout CHECK ( + check_out_at IS NULL OR check_out_at > check_in_at + ), + CONSTRAINT ck_attendance_records_version CHECK (version >= 0) +); + +CREATE INDEX ix_attendance_records_date_intern + ON attendance_records (work_date, intern_user_id); +CREATE INDEX ix_attendance_records_policy ON attendance_records (policy_version_id); +CREATE INDEX ix_attendance_records_missing_checkout + ON attendance_records (work_date, intern_user_id) + WHERE check_out_at IS NULL; + +-- 19. One missed-checkout correction request per attendance record. +CREATE TABLE attendance_corrections ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + attendance_record_id bigint NOT NULL, + requested_checkout_at timestamptz NOT NULL, + reason text NOT NULL, + status varchar(16) NOT NULL DEFAULT 'PENDING', + submitted_at timestamptz NOT NULL DEFAULT current_timestamp, + submission_deadline timestamptz NOT NULL, + decision_deadline timestamptz NOT NULL, + decided_by_mentor_user_id bigint, + decided_at timestamptz, + decision_note text, + locked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_attendance_corrections_record + FOREIGN KEY (attendance_record_id) REFERENCES attendance_records (id) ON DELETE RESTRICT, + CONSTRAINT fk_attendance_corrections_decided_by + FOREIGN KEY (decided_by_mentor_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT uq_attendance_corrections_record UNIQUE (attendance_record_id), + CONSTRAINT ck_attendance_corrections_reason CHECK (length(btrim(reason)) > 0), + CONSTRAINT ck_attendance_corrections_status CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')), + CONSTRAINT ck_attendance_corrections_submission_window CHECK ( + submission_deadline >= submitted_at + ), + CONSTRAINT ck_attendance_corrections_decision_window CHECK ( + decision_deadline > submitted_at + ), + CONSTRAINT ck_attendance_corrections_pending_decision CHECK ( + status <> 'PENDING' OR (decided_by_mentor_user_id IS NULL AND decided_at IS NULL) + ), + CONSTRAINT ck_attendance_corrections_decided_at CHECK ( + status = 'PENDING' OR decided_at IS NOT NULL + ), + CONSTRAINT ck_attendance_corrections_approval_actor CHECK ( + status <> 'APPROVED' OR decided_by_mentor_user_id IS NOT NULL + ), + CONSTRAINT ck_attendance_corrections_locked_state CHECK ( + locked_at IS NULL OR status IN ('APPROVED', 'REJECTED') + ), + CONSTRAINT ck_attendance_corrections_version CHECK (version >= 0) +); + +CREATE INDEX ix_attendance_corrections_decided_by + ON attendance_corrections (decided_by_mentor_user_id); +CREATE INDEX ix_attendance_corrections_pending_deadline + ON attendance_corrections (decision_deadline, id) + WHERE status = 'PENDING' AND locked_at IS NULL; + +-- 20. Append-only correction decision history. +CREATE TABLE attendance_correction_events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + correction_id bigint NOT NULL, + event_type varchar(24) NOT NULL, + from_status varchar(16), + to_status varchar(16) NOT NULL, + actor_user_id bigint, + note text, + occurred_at timestamptz NOT NULL DEFAULT current_timestamp, + CONSTRAINT fk_attendance_correction_events_correction + FOREIGN KEY (correction_id) REFERENCES attendance_corrections (id) ON DELETE RESTRICT, + CONSTRAINT fk_attendance_correction_events_actor + FOREIGN KEY (actor_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_attendance_correction_events_type CHECK ( + event_type IN ('SUBMITTED', 'APPROVED', 'REJECTED', 'REOPENED', 'AUTO_REJECTED', 'LOCKED') + ), + CONSTRAINT ck_attendance_correction_events_from_status CHECK ( + from_status IS NULL OR from_status IN ('PENDING', 'APPROVED', 'REJECTED') + ), + CONSTRAINT ck_attendance_correction_events_to_status CHECK ( + to_status IN ('PENDING', 'APPROVED', 'REJECTED') + ) +); + +CREATE INDEX ix_attendance_correction_events_correction + ON attendance_correction_events (correction_id, occurred_at, id); +CREATE INDEX ix_attendance_correction_events_actor + ON attendance_correction_events (actor_user_id, occurred_at DESC); + +-- 21. Full-day leave request and current decision state. +CREATE TABLE leave_requests ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + intern_user_id bigint NOT NULL, + start_date date NOT NULL, + end_date date NOT NULL, + reason text NOT NULL, + status varchar(16) NOT NULL DEFAULT 'PENDING', + submitted_at timestamptz NOT NULL DEFAULT current_timestamp, + first_counted_start_at timestamptz NOT NULL, + decided_by_mentor_user_id bigint, + decided_at timestamptz, + decision_note text, + cancelled_at timestamptz, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_leave_requests_intern + FOREIGN KEY (intern_user_id) REFERENCES intern_profiles (user_id) ON DELETE RESTRICT, + CONSTRAINT fk_leave_requests_decided_by + FOREIGN KEY (decided_by_mentor_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_leave_requests_dates CHECK (end_date >= start_date), + CONSTRAINT ck_leave_requests_reason CHECK (length(btrim(reason)) > 0), + CONSTRAINT ck_leave_requests_status CHECK ( + status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') + ), + CONSTRAINT ck_leave_requests_first_start CHECK (first_counted_start_at > submitted_at), + CONSTRAINT ck_leave_requests_decision CHECK ( + status IN ('PENDING', 'CANCELLED') OR decided_at IS NOT NULL + ), + CONSTRAINT ck_leave_requests_approval_actor CHECK ( + status <> 'APPROVED' OR decided_by_mentor_user_id IS NOT NULL + ), + CONSTRAINT ck_leave_requests_cancellation CHECK ( + (status = 'CANCELLED' AND cancelled_at IS NOT NULL) + OR + (status <> 'CANCELLED' AND cancelled_at IS NULL) + ), + CONSTRAINT ck_leave_requests_version CHECK (version >= 0), + CONSTRAINT ex_leave_requests_no_overlap + EXCLUDE USING gist ( + intern_user_id WITH =, + daterange(start_date, end_date, '[]') WITH && + ) + WHERE (status IN ('PENDING', 'APPROVED')) +); + +CREATE INDEX ix_leave_requests_intern_status_dates + ON leave_requests (intern_user_id, status, start_date, end_date); +CREATE INDEX ix_leave_requests_decided_by + ON leave_requests (decided_by_mentor_user_id); +CREATE INDEX ix_leave_requests_pending_cutoff + ON leave_requests (first_counted_start_at, id) + WHERE status = 'PENDING'; + +-- 22. Frozen workdays that consume leave quota. +CREATE TABLE leave_request_days ( + leave_request_id bigint NOT NULL, + leave_date date NOT NULL, + quota_month date NOT NULL, + policy_version_id bigint NOT NULL, + monthly_quota_snapshot integer NOT NULL, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + PRIMARY KEY (leave_request_id, leave_date), + CONSTRAINT fk_leave_request_days_request + FOREIGN KEY (leave_request_id) REFERENCES leave_requests (id) ON DELETE RESTRICT, + CONSTRAINT fk_leave_request_days_policy + FOREIGN KEY (policy_version_id) REFERENCES attendance_policy_versions (id) ON DELETE RESTRICT, + CONSTRAINT ck_leave_request_days_quota_month CHECK ( + extract(day FROM quota_month) = 1 + AND quota_month = date_trunc('month', leave_date)::date + ), + CONSTRAINT ck_leave_request_days_quota CHECK (monthly_quota_snapshot BETWEEN 0 AND 31) +); + +CREATE INDEX ix_leave_request_days_month + ON leave_request_days (quota_month, leave_date, leave_request_id); +CREATE INDEX ix_leave_request_days_policy + ON leave_request_days (policy_version_id); + +-- 23. In-app notification plus optional non-secret email outbox state. +CREATE TABLE notifications ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + recipient_user_id bigint NOT NULL, + notification_type varchar(32) NOT NULL, + title varchar(200) NOT NULL, + body text NOT NULL, + action_url varchar(500), + read_at timestamptz, + email_status varchar(24) NOT NULL DEFAULT 'NOT_REQUIRED', + email_to varchar(320), + email_subject varchar(255), + email_body text, + email_attempts integer NOT NULL DEFAULT 0, + email_next_attempt_at timestamptz, + email_sent_at timestamptz, + email_last_error varchar(1000), + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT fk_notifications_recipient + FOREIGN KEY (recipient_user_id) REFERENCES app_users (id) ON DELETE RESTRICT, + CONSTRAINT ck_notifications_type CHECK ( + notification_type IN ( + 'LEAVE_SUBMITTED', 'LEAVE_DECIDED', + 'CORRECTION_SUBMITTED', 'CORRECTION_DECIDED', + 'MEMBERSHIP_CHANGED', 'LEADERSHIP_CHANGED', + 'PROJECT_INVITATION_CREATED', 'PROJECT_INVITATION_RESOLVED', + 'MEMBERSHIP_EXIT_REQUESTED', 'MEMBERSHIP_EXIT_RESOLVED', + 'TASK_ASSIGNED', 'TASK_REASSIGNED', + 'TASK_STATUS_CHANGED', 'TASK_COMMENTED', 'SYSTEM' + ) + ), + CONSTRAINT ck_notifications_title CHECK (length(btrim(title)) > 0), + CONSTRAINT ck_notifications_body CHECK (length(btrim(body)) > 0), + CONSTRAINT ck_notifications_email_status CHECK ( + email_status IN ('NOT_REQUIRED', 'PENDING', 'SENT', 'FAILED', 'UNAVAILABLE') + ), + CONSTRAINT ck_notifications_email_attempts CHECK (email_attempts BETWEEN 0 AND 6), + CONSTRAINT ck_notifications_email_payload CHECK ( + email_status IN ('NOT_REQUIRED', 'UNAVAILABLE') + OR (email_to IS NOT NULL AND email_subject IS NOT NULL AND email_body IS NOT NULL) + ), + CONSTRAINT ck_notifications_email_sent CHECK ( + email_status <> 'SENT' OR email_sent_at IS NOT NULL + ), + CONSTRAINT ck_notifications_email_retry CHECK ( + email_status <> 'PENDING' OR email_next_attempt_at IS NOT NULL + ), + CONSTRAINT ck_notifications_version CHECK (version >= 0) +); + +CREATE INDEX ix_notifications_recipient_created + ON notifications (recipient_user_id, created_at DESC, id DESC); +CREATE INDEX ix_notifications_unread + ON notifications (recipient_user_id, created_at DESC) + WHERE read_at IS NULL; +CREATE INDEX ix_notifications_pending_email + ON notifications (email_next_attempt_at, id) + WHERE email_status = 'PENDING'; + +-- Default global policy. Monday=1 through Friday=5 under ISO-8601. +INSERT INTO attendance_policy_versions ( + effective_from, + timezone_name, + scheduled_start, + scheduled_end, + check_in_grace_minutes, + checkout_grace_minutes, + monthly_leave_quota, + violation_penalty, + created_by_user_id +) VALUES ( + DATE '1970-01-01', + 'Asia/Ho_Chi_Minh', + TIME '08:30:00', + TIME '15:30:00', + 30, + 30, + 3, + 0.2500, + NULL +); + +INSERT INTO attendance_policy_workdays (policy_version_id, iso_weekday) +SELECT id, weekday +FROM attendance_policy_versions +CROSS JOIN generate_series(1, 5) AS weekday +WHERE effective_from = DATE '1970-01-01'; + +COMMIT; diff --git a/src/test/java/com/lab/labtimesheet/PlatformFoundationTest.java b/src/test/java/com/lab/labtimesheet/PlatformFoundationTest.java new file mode 100644 index 0000000..5a75af0 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/PlatformFoundationTest.java @@ -0,0 +1,75 @@ +package com.lab.labtimesheet; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@ActiveProfiles("test") +class PlatformFoundationTest { + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private DataSource dataSource; + + @Autowired + private Clock clock; + + @Test + void applicationExposesRequiredModulePackages() throws ClassNotFoundException { + assertThat(applicationContext).isNotNull(); + assertThat(Class.forName("com.lab.labtimesheet.accounts.ModuleBoundary")).isNotNull(); + assertThat(Class.forName("com.lab.labtimesheet.configuration.ModuleBoundary")).isNotNull(); + assertThat(Class.forName("com.lab.labtimesheet.projects.ModuleBoundary")).isNotNull(); + assertThat(Class.forName("com.lab.labtimesheet.attendance.ModuleBoundary")).isNotNull(); + assertThat(Class.forName("com.lab.labtimesheet.notifications.ModuleBoundary")).isNotNull(); + assertThat(Class.forName("com.lab.labtimesheet.reporting.ModuleBoundary")).isNotNull(); + } + + @Test + void flywayCreatesApprovedPostgresCatalog() { + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + + Integer tables = jdbc.queryForObject(""" + select count(*) + from information_schema.tables + where table_schema = 'public' + and table_type = 'BASE TABLE' + and table_name <> 'flyway_schema_history' + """, Integer.class); + Integer foreignKeys = jdbc.queryForObject(""" + select count(*) + from pg_constraint c + join pg_namespace n on n.oid = c.connamespace + where n.nspname = 'public' and c.contype = 'f' + """, Integer.class); + + assertThat(tables).isEqualTo(23); + assertThat(foreignKeys).isEqualTo(56); + assertThat(jdbc.queryForObject( + "select checkout_grace_minutes from attendance_policy_versions where effective_from = date '1970-01-01'", + Integer.class)).isEqualTo(30); + assertThat(jdbc.queryForObject("select count(*) from attendance_policy_workdays", Integer.class)).isEqualTo(5); + } + + @Test + void testClockIsDeterministic() { + assertThat(clock.instant()).isEqualTo(Instant.parse("2026-08-14T00:00:00Z")); + assertThat(clock.getZone()).isEqualTo(ZoneId.of("Asia/Ho_Chi_Minh")); + } +} diff --git a/src/test/java/com/lab/labtimesheet/TestcontainersConfiguration.java b/src/test/java/com/lab/labtimesheet/TestcontainersConfiguration.java index e8643af..aed46da 100644 --- a/src/test/java/com/lab/labtimesheet/TestcontainersConfiguration.java +++ b/src/test/java/com/lab/labtimesheet/TestcontainersConfiguration.java @@ -1,8 +1,13 @@ package com.lab.labtimesheet; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; @@ -15,4 +20,10 @@ class TestcontainersConfiguration { return new PostgreSQLContainer(DockerImageName.parse("postgres:18.4")); } + @Bean + @Primary + Clock testClock() { + return Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneId.of("Asia/Ho_Chi_Minh")); + } + } diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index b71534c..9408fb4 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -2,3 +2,6 @@ spring: docker: compose: enabled: false +lab: + security: + master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= From 17a3c5dc70473e9f1010f8a197ad6916dae9eaea Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:31:50 +0700 Subject: [PATCH 02/62] feat(tasks): define status graph and progress --- docs/tests/unit/task-status-progress.md | 75 +++++++++++++++++++ .../lab/labtimesheet/tasks/TaskProgress.java | 40 ++++++++++ .../lab/labtimesheet/tasks/TaskStatus.java | 17 +++++ .../tasks/TaskDomainRulesTest.java | 61 +++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 docs/tests/unit/task-status-progress.md create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java create mode 100644 src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java diff --git a/docs/tests/unit/task-status-progress.md b/docs/tests/unit/task-status-progress.md new file mode 100644 index 0000000..6da6cc6 --- /dev/null +++ b/docs/tests/unit/task-status-progress.md @@ -0,0 +1,75 @@ +# Test Evidence: Fixed Task status graph and initial Project progress + +- **Test type:** Unit +- **Requirement IDs:** `TSK-007`, `TSK-008`, `PRJ-015`, `PRJ-016` +- **Scenario IDs:** `I1-TSK-03`, `I1-TSK-05`, `AC-TSK-003`, `AC-PRJ-008` +- **Test class/method:** `com.lab.labtimesheet.tasks.TaskDomainRulesTest` +- **Implementation commit:** `pending` + +## Protected behavior + +The Task status graph accepts exactly the seven specified directed edges. Initial Project progress counts each current Task status and represents a Project without current Tasks as no percentage rather than zero percent. + +## Test method + +One parameterized test checks all 16 source/target status pairs against a hand-written allowed-edge table. Two focused tests check empty progress and a four-Task example with two `DONE` Tasks. + +## Hand-derived expected result + +Allowed edges are `TODO` to `IN_PROGRESS` or `BLOCKED`; `IN_PROGRESS` to `DONE` or `BLOCKED`; `BLOCKED` to `TODO` or `IN_PROGRESS`; and `DONE` to `IN_PROGRESS`. All other pairs are forbidden. Zero Tasks has no percentage. Two `DONE` among four Tasks is 50%, with counts 1 `TODO`, 1 `IN_PROGRESS`, 0 `BLOCKED`, and 2 `DONE`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskDomainRulesTest test +``` + +**Observed result** + +```text +[ERROR] COMPILATION ERROR : +TaskDomainRulesTest.java:[16,30] cannot find symbol + symbol: class TaskStatus +[INFO] BUILD FAILURE +``` + +The test could not compile because the required Task status and progress domain types did not exist. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskDomainRulesTest test +``` + +**Observed result** + +```text +[INFO] Tests run: 18, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## 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 test + +[INFO] Tests run: 19, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This unit evidence does not prove current-assignee authorization, Project lifecycle enforcement, PostgreSQL persistence/query filtering, non-deleted selection, HTTP authorization, or rendered `N/A`. Those require the platform/Project foundation and PostgreSQL/web tests. diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java b/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java new file mode 100644 index 0000000..632d8e7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java @@ -0,0 +1,40 @@ +package com.lab.labtimesheet.tasks; + +import java.util.Collection; +import java.util.OptionalDouble; + +public record TaskProgress(int todo, int inProgress, int blocked, int done) { + + public static TaskProgress from(Collection statuses) { + int todo = 0; + int inProgress = 0; + int blocked = 0; + int done = 0; + for (TaskStatus status : statuses) { + switch (status) { + case TODO -> todo++; + case IN_PROGRESS -> inProgress++; + case BLOCKED -> blocked++; + case DONE -> done++; + } + } + return new TaskProgress(todo, inProgress, blocked, done); + } + + public int total() { + return todo + inProgress + blocked + done; + } + + public int count(TaskStatus status) { + return switch (status) { + case TODO -> todo; + case IN_PROGRESS -> inProgress; + case BLOCKED -> blocked; + case DONE -> done; + }; + } + + public OptionalDouble completionPercentage() { + return total() == 0 ? OptionalDouble.empty() : OptionalDouble.of(done * 100.0 / total()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java b/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java new file mode 100644 index 0000000..5aff97a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java @@ -0,0 +1,17 @@ +package com.lab.labtimesheet.tasks; + +public enum TaskStatus { + TODO, + IN_PROGRESS, + BLOCKED, + DONE; + + public boolean canTransitionTo(TaskStatus target) { + return switch (this) { + case TODO -> target == IN_PROGRESS || target == BLOCKED; + case IN_PROGRESS -> target == DONE || target == BLOCKED; + case BLOCKED -> target == TODO || target == IN_PROGRESS; + case DONE -> target == IN_PROGRESS; + }; + } +} diff --git a/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java b/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java new file mode 100644 index 0000000..af42eaf --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java @@ -0,0 +1,61 @@ +package com.lab.labtimesheet.tasks; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class TaskDomainRulesTest { + + private static final Map> ALLOWED_TRANSITIONS = Map.of( + TaskStatus.TODO, Set.of(TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED), + TaskStatus.IN_PROGRESS, Set.of(TaskStatus.DONE, TaskStatus.BLOCKED), + TaskStatus.BLOCKED, Set.of(TaskStatus.TODO, TaskStatus.IN_PROGRESS), + TaskStatus.DONE, Set.of(TaskStatus.IN_PROGRESS)); + + @ParameterizedTest + @MethodSource("allStatusTransitions") + void acceptsOnlyTheFixedStatusGraph(TaskStatus current, TaskStatus target, boolean expected) { + assertThat(current.canTransitionTo(target)).isEqualTo(expected); + } + + @Test + void reportsNoPercentageForAProjectWithoutTasks() { + TaskProgress progress = TaskProgress.from(List.of()); + + assertThat(progress.completionPercentage()).isEmpty(); + assertThat(progress.total()).isZero(); + assertThat(progress.count(TaskStatus.DONE)).isZero(); + } + + @Test + void countsStatusesAndDonePercentageFromCurrentTasks() { + TaskProgress progress = TaskProgress.from(List.of( + TaskStatus.TODO, + TaskStatus.IN_PROGRESS, + TaskStatus.DONE, + TaskStatus.DONE)); + + assertThat(progress.completionPercentage()).hasValue(50); + assertThat(progress.total()).isEqualTo(4); + assertThat(progress.count(TaskStatus.TODO)).isEqualTo(1); + assertThat(progress.count(TaskStatus.IN_PROGRESS)).isEqualTo(1); + assertThat(progress.count(TaskStatus.BLOCKED)).isZero(); + assertThat(progress.count(TaskStatus.DONE)).isEqualTo(2); + } + + private static Stream allStatusTransitions() { + return Stream.of(TaskStatus.values()) + .flatMap(current -> Stream.of(TaskStatus.values()) + .map(target -> Arguments.of( + current, + target, + ALLOWED_TRANSITIONS.get(current).contains(target)))); + } +} From 3483347bb85408f654ed0e0f4fef205c158bb1c4 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:32:14 +0700 Subject: [PATCH 03/62] feat(projects): enforce iteration 1 project lifecycle --- docs/tests/unit/projects-domain.md | 72 ++++++ .../projects/domain/EligibleIntern.java | 14 ++ .../projects/domain/LeadershipTerm.java | 50 +++++ .../labtimesheet/projects/domain/Project.java | 205 ++++++++++++++++++ .../projects/domain/ProjectAccessDenied.java | 8 + .../projects/domain/ProjectMembership.java | 37 ++++ .../projects/domain/ProjectRuleViolation.java | 8 + .../projects/domain/ProjectStatus.java | 7 + .../projects/domain/ProjectTest.java | 138 ++++++++++++ 9 files changed, 539 insertions(+) create mode 100644 docs/tests/unit/projects-domain.md create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/Project.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java create mode 100644 src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java diff --git a/docs/tests/unit/projects-domain.md b/docs/tests/unit/projects-domain.md new file mode 100644 index 0000000..694fbdf --- /dev/null +++ b/docs/tests/unit/projects-domain.md @@ -0,0 +1,72 @@ +# Test Evidence: Project lifecycle domain rules + +- **Test type:** Unit +- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004` +- **Scenario IDs:** `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009` +- **Test class/method:** `com.lab.labtimesheet.projects.domain.ProjectTest` +- **Implementation commit:** `pending` + +## Protected behavior + +Project creation cannot produce an empty or leaderless aggregate; direct membership rejects ineligible or duplicate current members; leadership changes leave one current term; activation is owning-Mentor-only and rejects invalid Task assignees. + +## Test method + +Plain JUnit drives the aggregate through its public factory and mutation methods. It asserts externally observable state and denials without Spring or database infrastructure. + +## Hand-derived expected result + +A planned Project starts with one current membership and one current leadership term. Adding a different eligible Intern yields two current memberships. Changing Leader closes one term and opens one term while retaining both memberships. Activation changes only `PLANNED` to `ACTIVE` when every supplied guard is true. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectTest test +``` + +**Observed result** + +```text +[ERROR] ProjectTest.java:[124,20] cannot find symbol: class Project +[ERROR] ProjectTest.java:[135,20] cannot find symbol: class EligibleIntern +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.projects.domain.ProjectTest +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectTest test + +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This unit test does not prove JPA/Flyway mappings, PostgreSQL constraints or transaction concurrency, Spring Security routing, Task-module query integration, or browser rendering. diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java b/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java new file mode 100644 index 0000000..47777c2 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.projects.domain; + +public record EligibleIntern(long userId, boolean accountActive, boolean internshipActive) { + + public EligibleIntern { + if (userId <= 0) { + throw new IllegalArgumentException("Intern user ID must be positive"); + } + } + + public boolean isEligible() { + return accountActive && internshipActive; + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java b/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java new file mode 100644 index 0000000..b91850a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java @@ -0,0 +1,50 @@ +package com.lab.labtimesheet.projects.domain; + +import java.time.Instant; + +public final class LeadershipTerm { + + private final ProjectMembership membership; + private final Instant startedAt; + private final long appointedByMentorUserId; + private Instant endedAt; + private Long endedByMentorUserId; + + LeadershipTerm(ProjectMembership membership, Instant startedAt, long appointedByMentorUserId) { + this.membership = membership; + this.startedAt = startedAt; + this.appointedByMentorUserId = appointedByMentorUserId; + } + + public long internUserId() { + return membership.internUserId(); + } + + public Instant startedAt() { + return startedAt; + } + + public long appointedByMentorUserId() { + return appointedByMentorUserId; + } + + public Instant endedAt() { + return endedAt; + } + + public Long endedByMentorUserId() { + return endedByMentorUserId; + } + + public boolean isCurrent() { + return endedAt == null; + } + + void end(Instant at, long mentorUserId) { + if (!isCurrent() || !at.isAfter(startedAt)) { + throw new ProjectRuleViolation("Leadership term end must follow its start"); + } + endedAt = at; + endedByMentorUserId = mentorUserId; + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/Project.java b/src/main/java/com/lab/labtimesheet/projects/domain/Project.java new file mode 100644 index 0000000..e81fa40 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/Project.java @@ -0,0 +1,205 @@ +package com.lab.labtimesheet.projects.domain; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class Project { + + private final long mentorUserId; + private final String name; + private final String description; + private final LocalDate startDate; + private final LocalDate endDate; + private final List memberships = new ArrayList<>(); + private final List leadershipTerms = new ArrayList<>(); + private ProjectStatus status = ProjectStatus.PLANNED; + private Instant activatedAt; + + private Project( + long mentorUserId, + String name, + String description, + LocalDate startDate, + LocalDate endDate) { + this.mentorUserId = mentorUserId; + this.name = name; + this.description = description; + this.startDate = startDate; + this.endDate = endDate; + } + + public static Project plan( + long mentorUserId, + String name, + String description, + LocalDate startDate, + LocalDate endDate, + EligibleIntern initialLeader, + Instant at) { + if (mentorUserId <= 0) { + throw new IllegalArgumentException("Mentor user ID must be positive"); + } + var normalizedName = requireText(name, "Project name is required"); + Objects.requireNonNull(startDate, "startDate"); + Objects.requireNonNull(endDate, "endDate"); + Objects.requireNonNull(at, "at"); + if (endDate.isBefore(startDate)) { + throw new ProjectRuleViolation("Project end date must not precede its start date"); + } + requireEligible(initialLeader); + + var project = new Project( + mentorUserId, + normalizedName, + normalizeOptionalText(description), + startDate, + endDate); + var membership = project.addEligibleMember(initialLeader, mentorUserId, at); + project.leadershipTerms.add(new LeadershipTerm(membership, at, mentorUserId)); + return project; + } + + public ProjectMembership addMember(long actorMentorUserId, EligibleIntern intern, Instant at) { + requireOwner(actorMentorUserId); + requireMutable(); + requireEligible(intern); + Objects.requireNonNull(at, "at"); + if (hasCurrentMember(intern.userId())) { + throw new ProjectRuleViolation("Intern is already a current Project member"); + } + return addEligibleMember(intern, actorMentorUserId, at); + } + + public void changeLeader(long actorMentorUserId, EligibleIntern intern, Instant at) { + requireOwner(actorMentorUserId); + requireMutable(); + requireEligible(intern); + Objects.requireNonNull(at, "at"); + var replacement = currentMembership(intern.userId()); + var current = currentLeadershipTerm(); + if (current.internUserId() == intern.userId()) { + throw new ProjectRuleViolation("Selected Intern is already the current Leader"); + } + + current.end(at, actorMentorUserId); + leadershipTerms.add(new LeadershipTerm(replacement, at, actorMentorUserId)); + } + + public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) { + requireOwner(actorMentorUserId); + Objects.requireNonNull(at, "at"); + if (status != ProjectStatus.PLANNED) { + throw new ProjectRuleViolation("Only a planned Project can be activated"); + } + if (memberships.stream().noneMatch(ProjectMembership::isCurrent) + || leadershipTerms.stream().noneMatch(LeadershipTerm::isCurrent)) { + throw new ProjectRuleViolation("Project requires a current member and Leader"); + } + if (!allTaskAssigneesAreCurrent) { + throw new ProjectRuleViolation("Every current Task assignee must be an active Project member"); + } + status = ProjectStatus.ACTIVE; + activatedAt = at; + } + + public long mentorUserId() { + return mentorUserId; + } + + public String name() { + return name; + } + + public String description() { + return description; + } + + public LocalDate startDate() { + return startDate; + } + + public LocalDate endDate() { + return endDate; + } + + public ProjectStatus status() { + return status; + } + + public Instant activatedAt() { + return activatedAt; + } + + public List memberships() { + return List.copyOf(memberships); + } + + public List leadershipTerms() { + return List.copyOf(leadershipTerms); + } + + public boolean hasCurrentMember(long internUserId) { + return memberships.stream() + .anyMatch(membership -> membership.internUserId() == internUserId && membership.isCurrent()); + } + + public ProjectMembership currentLeader() { + return currentMembership(currentLeadershipTerm().internUserId()); + } + + private ProjectMembership addEligibleMember(EligibleIntern intern, long addedByUserId, Instant at) { + var membership = new ProjectMembership(intern.userId(), at, addedByUserId); + memberships.add(membership); + return membership; + } + + private ProjectMembership currentMembership(long internUserId) { + return memberships.stream() + .filter(membership -> membership.internUserId() == internUserId && membership.isCurrent()) + .findFirst() + .orElseThrow(() -> new ProjectRuleViolation("Leader must be a current same-Project member")); + } + + private LeadershipTerm currentLeadershipTerm() { + return leadershipTerms.stream() + .filter(LeadershipTerm::isCurrent) + .findFirst() + .orElseThrow(() -> new ProjectRuleViolation("Project has no current Leader")); + } + + private void requireOwner(long actorMentorUserId) { + if (mentorUserId != actorMentorUserId) { + throw new ProjectAccessDenied(); + } + } + + private void requireMutable() { + if (status == ProjectStatus.COMPLETED) { + throw new ProjectRuleViolation("Completed Projects are read-only"); + } + } + + private static void requireEligible(EligibleIntern intern) { + Objects.requireNonNull(intern, "intern"); + if (!intern.isEligible()) { + throw new ProjectRuleViolation("Intern must have an active account and internship"); + } + } + + private static String requireText(String value, String message) { + if (value == null || value.trim().isEmpty()) { + throw new ProjectRuleViolation(message); + } + return value.trim(); + } + + private static String normalizeOptionalText(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + return value.trim(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java new file mode 100644 index 0000000..c60789a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.projects.domain; + +public final class ProjectAccessDenied extends RuntimeException { + + public ProjectAccessDenied() { + super("Project access denied"); + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java new file mode 100644 index 0000000..a48b86a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java @@ -0,0 +1,37 @@ +package com.lab.labtimesheet.projects.domain; + +import java.time.Instant; + +public final class ProjectMembership { + + private final long internUserId; + private final Instant joinedAt; + private final long addedByUserId; + private Instant leftAt; + + ProjectMembership(long internUserId, Instant joinedAt, long addedByUserId) { + this.internUserId = internUserId; + this.joinedAt = joinedAt; + this.addedByUserId = addedByUserId; + } + + public long internUserId() { + return internUserId; + } + + public Instant joinedAt() { + return joinedAt; + } + + public long addedByUserId() { + return addedByUserId; + } + + public Instant leftAt() { + return leftAt; + } + + public boolean isCurrent() { + return leftAt == null; + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java new file mode 100644 index 0000000..4357c9c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.projects.domain; + +public final class ProjectRuleViolation extends RuntimeException { + + public ProjectRuleViolation(String message) { + super(message); + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java new file mode 100644 index 0000000..0e24065 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.projects.domain; + +public enum ProjectStatus { + PLANNED, + ACTIVE, + COMPLETED +} diff --git a/src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java b/src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java new file mode 100644 index 0000000..8583541 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java @@ -0,0 +1,138 @@ +package com.lab.labtimesheet.projects.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +class ProjectTest { + + private static final Instant CREATED_AT = Instant.parse("2026-08-14T02:00:00Z"); + + @Test + void planningCreatesTheInitialLeaderMembershipAndTermTogether() { + var project = Project.plan( + 10L, + " Intern Portal Refresh ", + " Refresh the portal ", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + activeIntern(20L), + CREATED_AT); + + assertEquals(ProjectStatus.PLANNED, project.status()); + assertEquals("Intern Portal Refresh", project.name()); + assertEquals("Refresh the portal", project.description()); + assertEquals(1, project.memberships().size()); + assertEquals(20L, project.memberships().getFirst().internUserId()); + assertEquals(10L, project.memberships().getFirst().addedByUserId()); + assertEquals(1, project.leadershipTerms().size()); + assertEquals(20L, project.currentLeader().internUserId()); + } + + @Test + void planningRejectsAnIneligibleInitialLeaderAndInvalidDates() { + assertThrows(ProjectRuleViolation.class, () -> Project.plan( + 10L, + "Project", + null, + LocalDate.of(2026, 9, 1), + LocalDate.of(2026, 8, 31), + activeIntern(20L), + CREATED_AT)); + assertThrows(ProjectRuleViolation.class, () -> Project.plan( + 10L, + "Project", + null, + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 8, 31), + new EligibleIntern(20L, false, true), + CREATED_AT)); + } + + @Test + void ownerAddsEligibleMembersButNotDuplicateCurrentMemberships() { + var project = plannedProject(); + + project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(60)); + + assertEquals(2, project.memberships().size()); + assertTrue(project.hasCurrentMember(21L)); + assertThrows(ProjectRuleViolation.class, + () -> project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(120))); + assertThrows(ProjectAccessDenied.class, + () -> project.addMember(11L, activeIntern(22L), CREATED_AT.plusSeconds(120))); + } + + @Test + void theSameInternCanBelongToSeparateProjects() { + var first = plannedProject(); + var second = Project.plan( + 11L, + "Second", + null, + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + activeIntern(21L), + CREATED_AT); + + first.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(60)); + + assertTrue(first.hasCurrentMember(21L)); + assertTrue(second.hasCurrentMember(21L)); + } + + @Test + void ownerChangesExactlyOneLeaderWithoutChangingMemberships() { + var project = plannedProject(); + project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(60)); + + project.changeLeader(10L, activeIntern(21L), CREATED_AT.plusSeconds(120)); + + assertEquals(2, project.memberships().size()); + assertEquals(2, project.leadershipTerms().size()); + assertEquals(1, project.leadershipTerms().stream().filter(LeadershipTerm::isCurrent).count()); + assertEquals(21L, project.currentLeader().internUserId()); + assertFalse(project.leadershipTerms().getFirst().isCurrent()); + assertThrows(ProjectRuleViolation.class, + () -> project.changeLeader(10L, activeIntern(21L), CREATED_AT.plusSeconds(180))); + assertThrows(ProjectRuleViolation.class, + () -> project.changeLeader(10L, activeIntern(22L), CREATED_AT.plusSeconds(180))); + } + + @Test + void activationRequiresOwnerAndValidCurrentTaskAssignees() { + var project = plannedProject(); + + assertThrows(ProjectAccessDenied.class, + () -> project.activate(11L, true, CREATED_AT.plusSeconds(60))); + assertThrows(ProjectRuleViolation.class, + () -> project.activate(10L, false, CREATED_AT.plusSeconds(60))); + + project.activate(10L, true, CREATED_AT.plusSeconds(60)); + + assertEquals(ProjectStatus.ACTIVE, project.status()); + assertEquals(CREATED_AT.plusSeconds(60), project.activatedAt()); + assertThrows(ProjectRuleViolation.class, + () -> project.activate(10L, true, CREATED_AT.plusSeconds(120))); + } + + private static Project plannedProject() { + return Project.plan( + 10L, + "Project", + null, + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + activeIntern(20L), + CREATED_AT); + } + + private static EligibleIntern activeIntern(long userId) { + return new EligibleIntern(userId, true, true); + } +} From a28227db18c02910d68ccac758299521e3e09384 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:32:54 +0700 Subject: [PATCH 04/62] docs(projects): record domain green milestone --- docs/tests/unit/projects-domain.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tests/unit/projects-domain.md b/docs/tests/unit/projects-domain.md index 694fbdf..18e5512 100644 --- a/docs/tests/unit/projects-domain.md +++ b/docs/tests/unit/projects-domain.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004` - **Scenario IDs:** `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009` - **Test class/method:** `com.lab.labtimesheet.projects.domain.ProjectTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `3483347` ## Protected behavior From 71901d1670f633a1b594bdce3348efebe73fc175 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:34:12 +0700 Subject: [PATCH 05/62] feat: add attendance policy and punch domain --- docs/tests/unit/attendance-policy.md | 77 +++++++ .../tests/unit/attendance-punch-boundaries.md | 83 ++++++++ .../attendance/AttendanceDayContext.java | 3 + .../AttendanceDayContextProvider.java | 9 + .../attendance/AttendanceException.java | 15 ++ .../attendance/AttendancePolicy.java | 72 +++++++ .../attendance/AttendancePolicyTimeline.java | 36 ++++ .../attendance/AttendanceRecord.java | 50 +++++ .../attendance/AttendanceRejection.java | 12 ++ .../attendance/AttendanceRepository.java | 11 + .../attendance/AttendanceService.java | 62 ++++++ .../attendance/AttendanceViolations.java | 3 + .../attendance/AttendancePolicyTest.java | 65 ++++++ .../attendance/AttendanceServiceTest.java | 199 ++++++++++++++++++ 14 files changed, 697 insertions(+) create mode 100644 docs/tests/unit/attendance-policy.md create mode 100644 docs/tests/unit/attendance-punch-boundaries.md create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java create mode 100644 src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java create mode 100644 src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java diff --git a/docs/tests/unit/attendance-policy.md b/docs/tests/unit/attendance-policy.md new file mode 100644 index 0000000..03bc78d --- /dev/null +++ b/docs/tests/unit/attendance-policy.md @@ -0,0 +1,77 @@ +# Test Evidence: Attendance policy defaults and boundaries + +- **Test type:** Unit +- **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004` +- **Scenario IDs:** `AC-ATT-001` +- **Test class/method:** `com.lab.labtimesheet.attendance.AttendancePolicyTest` +- **Implementation commit:** `pending (committed with this evidence)` + +## Protected behavior + +The seeded policy applies from 1970-01-01 with the required timezone, schedule, +workdays, grace values, leave quota, and penalty. Grace outside 0..720 or a +checkout cutoff at midnight is rejected. + +## Test method + +Plain JUnit constructs the immutable policy and timeline directly, resolves two +dates, and exercises the validation boundary without Spring or persistence. + +## Hand-derived expected result + +08:30 plus 30 minutes makes the inclusive on-time boundary 09:00. 15:30 plus +30 minutes makes the inclusive checkout boundary 16:00. A 23:30 end plus 30 +minutes reaches midnight and is invalid. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendancePolicyTest test +``` + +**Observed result** + +```text +[ERROR] AttendancePolicyTest.java:[51,20] cannot find symbol + symbol: class AttendancePolicy +[INFO] BUILD FAILURE +Process exited 1. The test reached compilation and failed because the required policy domain did not exist. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendancePolicyTest test +``` + +**Observed result** + +```text +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest='Attendance*Test' test +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +This unit test does not prove the platform-owned Flyway seed, PostgreSQL policy +loading, policy-management authorization, or web rendering. diff --git a/docs/tests/unit/attendance-punch-boundaries.md b/docs/tests/unit/attendance-punch-boundaries.md new file mode 100644 index 0000000..85b00a2 --- /dev/null +++ b/docs/tests/unit/attendance-punch-boundaries.md @@ -0,0 +1,83 @@ +# Test Evidence: Attendance punch boundaries + +- **Test type:** Unit +- **Requirement IDs:** `GOV-011`, `GOV-012`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-009`, `ATT-010`, `ATT-011`, `ATT-012`, `ATT-016` +- **Scenario IDs:** `AC-ATT-002`, `AC-ATT-003`, `AC-ATT-004`, `AC-ATT-005` +- **Test class/method:** `com.lab.labtimesheet.attendance.AttendanceServiceTest` +- **Implementation commit:** `pending (committed with this evidence)` + +## Protected behavior + +Clock-controlled server time determines the local work date and raw punches. +Check-in rejects inactive, non-workday, day-off, leave, and duplicate attempts. +Exact grace/cutoff instants succeed; later checkout never writes raw checkout; +a missed checkout is not also an early departure. + +## Test method + +Plain JUnit uses a fixed `Clock`, the production domain service, and a minimal +in-memory repository port. Assertions cover stored state as well as rejection +codes, including non-overwrite behavior. + +## Hand-derived expected result + +Asia/Ho_Chi_Minh is UTC+07 for the tested date: 09:00 local is 02:00Z, +15:30 local is 08:30Z, and 16:00 local is 09:00Z. Equality is accepted; +adding one millisecond crosses each strict-later boundary. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceServiceTest test +``` + +**Observed result** + +```text +[ERROR] AttendanceServiceTest.java:[3,46] cannot find symbol + symbol: class AttendanceRejection +[ERROR] AttendanceServiceTest.java:[136,20] cannot find symbol + symbol: class AttendanceService +[INFO] 29 errors +[INFO] BUILD FAILURE +Process exited 1. The test reached compilation and failed because the required attendance domain did not exist. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceServiceTest test +``` + +**Observed result** + +```text +Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest='Attendance*Test' test +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +This unit test does not prove transaction isolation, PostgreSQL uniqueness, +platform account/intern-state queries, approved-leave persistence, Spring +Security, controller routing, or Thymeleaf rendering. diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java new file mode 100644 index 0000000..4ad222e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java @@ -0,0 +1,3 @@ +package com.lab.labtimesheet.attendance; + +public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java new file mode 100644 index 0000000..2ded1fa --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java @@ -0,0 +1,9 @@ +package com.lab.labtimesheet.attendance; + +import java.time.LocalDate; + +@FunctionalInterface +public interface AttendanceDayContextProvider { + + AttendanceDayContext get(long internId, LocalDate workDate); +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java new file mode 100644 index 0000000..fbae49a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java @@ -0,0 +1,15 @@ +package com.lab.labtimesheet.attendance; + +public final class AttendanceException extends RuntimeException { + + private final AttendanceRejection rejection; + + public AttendanceException(AttendanceRejection rejection) { + super(rejection.name()); + this.rejection = rejection; + } + + public AttendanceRejection rejection() { + return rejection; + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java b/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java new file mode 100644 index 0000000..1fe1bf4 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java @@ -0,0 +1,72 @@ +package com.lab.labtimesheet.attendance; + +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Objects; +import java.util.Set; + +public record AttendancePolicy( + long id, + LocalDate effectiveFrom, + ZoneId zoneId, + LocalTime scheduledStart, + LocalTime scheduledEnd, + int checkInGraceMinutes, + int checkoutGraceMinutes, + int monthlyLeaveQuota, + BigDecimal violationPenalty, + Set workdays) { + + private static final int MAX_GRACE_MINUTES = 720; + private static final int SECONDS_PER_DAY = 86_400; + + public AttendancePolicy { + Objects.requireNonNull(effectiveFrom, "effectiveFrom"); + Objects.requireNonNull(zoneId, "zoneId"); + Objects.requireNonNull(scheduledStart, "scheduledStart"); + Objects.requireNonNull(scheduledEnd, "scheduledEnd"); + Objects.requireNonNull(violationPenalty, "violationPenalty"); + workdays = Set.copyOf(workdays); + + requireGraceInRange(checkInGraceMinutes, "checkInGraceMinutes"); + requireGraceInRange(checkoutGraceMinutes, "checkoutGraceMinutes"); + if (!scheduledEnd.isAfter(scheduledStart)) { + throw new IllegalArgumentException("scheduledEnd must be after scheduledStart"); + } + if (scheduledEnd.toSecondOfDay() + checkoutGraceMinutes * 60 >= SECONDS_PER_DAY) { + throw new IllegalArgumentException("checkout cutoff must be before local midnight"); + } + } + + public static AttendancePolicy seeded(long id) { + return new AttendancePolicy( + id, + LocalDate.of(1970, 1, 1), + ZoneId.of("Asia/Ho_Chi_Minh"), + LocalTime.of(8, 30), + LocalTime.of(15, 30), + 30, + 30, + 3, + new BigDecimal("0.25"), + Set.of( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY)); + } + + public boolean isWorkday(LocalDate date) { + return workdays.contains(date.getDayOfWeek()); + } + + private static void requireGraceInRange(int value, String field) { + if (value < 0 || value > MAX_GRACE_MINUTES) { + throw new IllegalArgumentException(field + " must be between 0 and 720"); + } + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java b/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java new file mode 100644 index 0000000..662f8a6 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java @@ -0,0 +1,36 @@ +package com.lab.labtimesheet.attendance; + +import java.time.LocalDate; +import java.time.Instant; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public final class AttendancePolicyTimeline { + + private final List policies; + + public AttendancePolicyTimeline(Collection policies) { + this.policies = policies.stream() + .sorted(Comparator.comparing(AttendancePolicy::effectiveFrom)) + .toList(); + } + + public AttendancePolicy resolve(LocalDate date) { + Objects.requireNonNull(date, "date"); + return policies.stream() + .filter(policy -> !policy.effectiveFrom().isAfter(date)) + .reduce((first, second) -> second) + .orElseThrow(() -> new IllegalArgumentException("no attendance policy applies on " + date)); + } + + public AttendancePolicy resolve(Instant instant) { + Objects.requireNonNull(instant, "instant"); + return policies.stream() + .filter(policy -> !policy.effectiveFrom().isAfter( + instant.atZone(policy.zoneId()).toLocalDate())) + .reduce((first, second) -> second) + .orElseThrow(() -> new IllegalArgumentException("no attendance policy applies at " + instant)); + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java new file mode 100644 index 0000000..a58425c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java @@ -0,0 +1,50 @@ +package com.lab.labtimesheet.attendance; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.Objects; + +public record AttendanceRecord( + long internId, + LocalDate workDate, + AttendancePolicy policy, + Instant checkInAt, + Instant checkOutAt) { + + public AttendanceRecord { + Objects.requireNonNull(workDate, "workDate"); + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(checkInAt, "checkInAt"); + } + + public AttendanceRecord checkOut(Instant at) { + Objects.requireNonNull(at, "at"); + if (checkOutAt != null) { + throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_OUT); + } + if (at.isAfter(checkoutCutoff())) { + throw new AttendanceException(AttendanceRejection.CHECKOUT_CUTOFF_PASSED); + } + return new AttendanceRecord(internId, workDate, policy, checkInAt, at); + } + + public AttendanceViolations violations(Instant observedAt) { + boolean late = checkInAt.isAfter(scheduledStart().plusSeconds(policy.checkInGraceMinutes() * 60L)); + boolean missingCheckout = checkOutAt == null && observedAt.isAfter(checkoutCutoff()); + boolean earlyDeparture = checkOutAt != null && checkOutAt.isBefore(scheduledEnd()); + return new AttendanceViolations(late, earlyDeparture, missingCheckout); + } + + private Instant scheduledStart() { + return ZonedDateTime.of(workDate, policy.scheduledStart(), policy.zoneId()).toInstant(); + } + + private Instant scheduledEnd() { + return ZonedDateTime.of(workDate, policy.scheduledEnd(), policy.zoneId()).toInstant(); + } + + private Instant checkoutCutoff() { + return scheduledEnd().plusSeconds(policy.checkoutGraceMinutes() * 60L); + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java new file mode 100644 index 0000000..4cb1f6a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java @@ -0,0 +1,12 @@ +package com.lab.labtimesheet.attendance; + +public enum AttendanceRejection { + INACTIVE_INTERN, + NON_WORKDAY, + GLOBAL_DAY_OFF, + APPROVED_LEAVE, + ALREADY_CHECKED_IN, + NO_ATTENDANCE_RECORD, + ALREADY_CHECKED_OUT, + CHECKOUT_CUTOFF_PASSED +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java new file mode 100644 index 0000000..1091fe9 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java @@ -0,0 +1,11 @@ +package com.lab.labtimesheet.attendance; + +import java.time.LocalDate; +import java.util.Optional; + +public interface AttendanceRepository { + + Optional find(long internId, LocalDate workDate); + + AttendanceRecord save(AttendanceRecord record); +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java new file mode 100644 index 0000000..ac0fe59 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java @@ -0,0 +1,62 @@ +package com.lab.labtimesheet.attendance; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Objects; + +public final class AttendanceService { + + private final Clock clock; + private final AttendancePolicyTimeline policies; + private final AttendanceRepository records; + private final AttendanceDayContextProvider dayContexts; + + public AttendanceService( + Clock clock, + AttendancePolicyTimeline policies, + AttendanceRepository records, + AttendanceDayContextProvider dayContexts) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.policies = Objects.requireNonNull(policies, "policies"); + this.records = Objects.requireNonNull(records, "records"); + this.dayContexts = Objects.requireNonNull(dayContexts, "dayContexts"); + } + + public AttendanceRecord checkIn(long internId) { + Instant now = clock.instant(); + AttendancePolicy policy = policies.resolve(now); + LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate(); + AttendanceDayContext context = dayContexts.get(internId, workDate); + requireEligible(policy, workDate, context); + if (records.find(internId, workDate).isPresent()) { + throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN); + } + return records.save(new AttendanceRecord(internId, workDate, policy, now, null)); + } + + public AttendanceRecord checkOut(long internId) { + Instant now = clock.instant(); + AttendancePolicy currentPolicy = policies.resolve(now); + LocalDate workDate = now.atZone(currentPolicy.zoneId()).toLocalDate(); + AttendanceRecord record = records.find(internId, workDate) + .orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD)); + return records.save(record.checkOut(now)); + } + + private static void requireEligible( + AttendancePolicy policy, LocalDate workDate, AttendanceDayContext context) { + if (!context.activeIntern()) { + throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN); + } + if (!policy.isWorkday(workDate)) { + throw new AttendanceException(AttendanceRejection.NON_WORKDAY); + } + if (context.globalDayOff()) { + throw new AttendanceException(AttendanceRejection.GLOBAL_DAY_OFF); + } + if (context.approvedLeave()) { + throw new AttendanceException(AttendanceRejection.APPROVED_LEAVE); + } + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java new file mode 100644 index 0000000..521aa62 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java @@ -0,0 +1,3 @@ +package com.lab.labtimesheet.attendance; + +public record AttendanceViolations(boolean late, boolean earlyDeparture, boolean missingCheckout) {} diff --git a/src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java b/src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java new file mode 100644 index 0000000..5ea8d91 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java @@ -0,0 +1,65 @@ +package com.lab.labtimesheet.attendance; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class AttendancePolicyTest { + + @Test + void resolvesSeedPolicyForHistoricalAndCurrentDates() { + AttendancePolicy seeded = AttendancePolicy.seeded(1L); + AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded)); + + assertEquals(seeded, timeline.resolve(LocalDate.of(1970, 1, 1))); + assertEquals(seeded, timeline.resolve(LocalDate.of(2026, 8, 14))); + assertEquals(ZoneId.of("Asia/Ho_Chi_Minh"), seeded.zoneId()); + assertEquals(LocalTime.of(8, 30), seeded.scheduledStart()); + assertEquals(LocalTime.of(15, 30), seeded.scheduledEnd()); + assertEquals(30, seeded.checkInGraceMinutes()); + assertEquals(30, seeded.checkoutGraceMinutes()); + assertEquals(3, seeded.monthlyLeaveQuota()); + assertEquals(new BigDecimal("0.25"), seeded.violationPenalty()); + assertEquals( + Set.of( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY), + seeded.workdays()); + } + + @Test + void rejectsGraceOutsideZeroThroughSevenHundredTwenty() { + assertThrows(IllegalArgumentException.class, () -> policy(-1, 30, LocalTime.of(15, 30))); + assertThrows(IllegalArgumentException.class, () -> policy(30, 721, LocalTime.of(15, 30))); + } + + @Test + void rejectsCheckoutCutoffAtLocalMidnight() { + assertThrows(IllegalArgumentException.class, () -> policy(30, 30, LocalTime.of(23, 30))); + } + + private static AttendancePolicy policy( + int checkInGraceMinutes, int checkoutGraceMinutes, LocalTime scheduledEnd) { + return new AttendancePolicy( + 2L, + LocalDate.of(2026, 9, 1), + ZoneId.of("Asia/Ho_Chi_Minh"), + LocalTime.of(8, 30), + scheduledEnd, + checkInGraceMinutes, + checkoutGraceMinutes, + 3, + new BigDecimal("0.25"), + Set.of(DayOfWeek.MONDAY)); + } +} diff --git a/src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java b/src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java new file mode 100644 index 0000000..57ad526 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java @@ -0,0 +1,199 @@ +package com.lab.labtimesheet.attendance; + +import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_IN; +import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_OUT; +import static com.lab.labtimesheet.attendance.AttendanceRejection.APPROVED_LEAVE; +import static com.lab.labtimesheet.attendance.AttendanceRejection.CHECKOUT_CUTOFF_PASSED; +import static com.lab.labtimesheet.attendance.AttendanceRejection.GLOBAL_DAY_OFF; +import static com.lab.labtimesheet.attendance.AttendanceRejection.INACTIVE_INTERN; +import static com.lab.labtimesheet.attendance.AttendanceRejection.NON_WORKDAY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class AttendanceServiceTest { + + private static final long INTERN_ID = 42L; + private static final LocalDate WORKDAY = LocalDate.of(2026, 8, 14); + + @Test + void exactCheckInGraceBoundaryIsOnTimeAndFirstLaterInstantIsLate() { + AttendanceRecord exactBoundary = checkInAt("2026-08-14T02:00:00Z", activeDay(), seededPolicy()); + AttendanceRecord firstLater = checkInAt("2026-08-14T02:00:00.001Z", activeDay(), seededPolicy()); + + assertFalse(exactBoundary.violations(at("2026-08-14T02:00:00Z")).late()); + assertTrue(firstLater.violations(at("2026-08-14T02:00:00.001Z")).late()); + assertEquals(WORKDAY, exactBoundary.workDate()); + assertEquals(1L, exactBoundary.policy().id()); + } + + @Test + void rejectsIneligibleAndDuplicateCheckIns() { + assertCheckInRejected(INACTIVE_INTERN, new AttendanceDayContext(false, false, false)); + assertCheckInRejected(GLOBAL_DAY_OFF, new AttendanceDayContext(true, true, false)); + assertCheckInRejected(APPROVED_LEAVE, new AttendanceDayContext(true, false, true)); + + AttendancePolicy weekendOnly = policy(30, Set.of(DayOfWeek.SATURDAY)); + assertCheckInRejected(NON_WORKDAY, activeDay(), weekendOnly); + + InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository(); + AttendanceService service = serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), seededPolicy()); + service.checkIn(INTERN_ID); + + AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkIn(INTERN_ID)); + assertEquals(ALREADY_CHECKED_IN, exception.rejection()); + assertEquals(1, repository.records.size()); + } + + @Test + void checkoutIsInclusiveAtCutoffAndCannotBeOverwritten() { + InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy()); + AttendanceService atCutoff = serviceAt("2026-08-14T09:00:00Z", repository, activeDay(), seededPolicy()); + + AttendanceRecord checkedOut = atCutoff.checkOut(INTERN_ID); + + assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt()); + AttendanceService later = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy()); + AttendanceException repeated = assertThrows(AttendanceException.class, () -> later.checkOut(INTERN_ID)); + assertEquals(ALREADY_CHECKED_OUT, repeated.rejection()); + assertEquals(at("2026-08-14T09:00:00Z"), repository.record().checkOutAt()); + } + + @Test + void firstInstantAfterCheckoutCutoffIsRejectedWithoutRawCheckout() { + InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy()); + AttendanceService service = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy()); + + AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkOut(INTERN_ID)); + + assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection()); + assertNull(repository.record().checkOutAt()); + AttendanceViolations violations = repository.record().violations(at("2026-08-14T09:00:00.001Z")); + assertTrue(violations.missingCheckout()); + assertFalse(violations.earlyDeparture()); + } + + @Test + void zeroGraceCheckoutUsesScheduledEndAsInclusiveCutoff() { + AttendancePolicy zeroGrace = policy( + 0, + Set.of( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY)); + InMemoryAttendanceRepository repository = checkedInRepository(zeroGrace); + + AttendanceRecord checkedOut = serviceAt("2026-08-14T08:30:00Z", repository, activeDay(), zeroGrace) + .checkOut(INTERN_ID); + + assertEquals(at("2026-08-14T08:30:00Z"), checkedOut.checkOutAt()); + + InMemoryAttendanceRepository lateRepository = checkedInRepository(zeroGrace); + AttendanceException exception = assertThrows( + AttendanceException.class, + () -> serviceAt("2026-08-14T08:30:00.001Z", lateRepository, activeDay(), zeroGrace) + .checkOut(INTERN_ID)); + assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection()); + assertNull(lateRepository.record().checkOutAt()); + } + + private static AttendanceRecord checkInAt( + String instant, AttendanceDayContext context, AttendancePolicy policy) { + return serviceAt(instant, new InMemoryAttendanceRepository(), context, policy).checkIn(INTERN_ID); + } + + private static void assertCheckInRejected(AttendanceRejection rejection, AttendanceDayContext context) { + assertCheckInRejected(rejection, context, seededPolicy()); + } + + private static void assertCheckInRejected( + AttendanceRejection rejection, AttendanceDayContext context, AttendancePolicy policy) { + AttendanceException exception = assertThrows( + AttendanceException.class, + () -> serviceAt("2026-08-14T01:30:00Z", new InMemoryAttendanceRepository(), context, policy) + .checkIn(INTERN_ID)); + assertEquals(rejection, exception.rejection()); + } + + private static AttendanceService serviceAt( + String instant, + InMemoryAttendanceRepository repository, + AttendanceDayContext context, + AttendancePolicy policy) { + return new AttendanceService( + Clock.fixed(at(instant), ZoneOffset.UTC), + new AttendancePolicyTimeline(Set.of(policy)), + repository, + (internId, date) -> context); + } + + private static InMemoryAttendanceRepository checkedInRepository(AttendancePolicy policy) { + InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository(); + serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), policy).checkIn(INTERN_ID); + return repository; + } + + private static AttendanceDayContext activeDay() { + return new AttendanceDayContext(true, false, false); + } + + private static AttendancePolicy seededPolicy() { + return AttendancePolicy.seeded(1L); + } + + private static AttendancePolicy policy(int checkoutGraceMinutes, Set workdays) { + return new AttendancePolicy( + 2L, + LocalDate.of(1970, 1, 1), + ZoneId.of("Asia/Ho_Chi_Minh"), + LocalTime.of(8, 30), + LocalTime.of(15, 30), + 30, + checkoutGraceMinutes, + 3, + new BigDecimal("0.25"), + workdays); + } + + private static Instant at(String instant) { + return Instant.parse(instant); + } + + private static final class InMemoryAttendanceRepository implements AttendanceRepository { + + private final Map records = new HashMap<>(); + + @Override + public Optional find(long internId, LocalDate workDate) { + return Optional.ofNullable(records.get(workDate)); + } + + @Override + public AttendanceRecord save(AttendanceRecord record) { + records.put(record.workDate(), record); + return record; + } + + private AttendanceRecord record() { + return records.get(WORKDAY); + } + } +} From 597ebf1b49ef0a48fc8b75c6f8f9c16632747666 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:41:42 +0700 Subject: [PATCH 06/62] feat(tasks): persist iteration 1 task workflow --- docs/tests/integration/task-workflow.md | 88 ++++ docs/tests/unit/task-status-progress.md | 2 +- .../labtimesheet/tasks/CreateTaskCommand.java | 10 + .../labtimesheet/tasks/TaskCommentView.java | 5 + .../lab/labtimesheet/tasks/TaskDetails.java | 10 + .../lab/labtimesheet/tasks/TaskListView.java | 10 + .../tasks/TaskNotFoundException.java | 12 + .../lab/labtimesheet/tasks/TaskService.java | 412 ++++++++++++++++++ .../tasks/TaskValidationException.java | 12 + .../com/lab/labtimesheet/tasks/TaskView.java | 17 + .../TaskCreationIntegrationTest.java | 360 +++++++++++++++ 11 files changed, 937 insertions(+), 1 deletion(-) create mode 100644 docs/tests/integration/task-workflow.md create mode 100644 src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskListView.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskService.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskView.java create mode 100644 src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java diff --git a/docs/tests/integration/task-workflow.md b/docs/tests/integration/task-workflow.md new file mode 100644 index 0000000..0eb7806 --- /dev/null +++ b/docs/tests/integration/task-workflow.md @@ -0,0 +1,88 @@ +# Test Evidence: Iteration 1 Task persistence and authorization + +- **Test type:** Integration +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-007`–`AUTH-009`, `AUTH-011`, `PRJ-013`, `PRJ-015`, `PRJ-016`, `TSK-001`–`TSK-005`, `TSK-007`, `TSK-008`, `TSK-011`, `TSK-012`, `TSK-018` +- **Scenario IDs:** `I1-TSK-01`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-003`–`AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` +- **Test class/method:** `com.lab.labtimesheet.TaskCreationIntegrationTest` +- **Implementation commit:** `pending` + +## Protected behavior + +PostgreSQL-backed Task operations preserve generic same-Project membership actors, limit ordinary members to self-Task creation, allow current Leaders to assign active same-Project members, validate due dates, restrict status changes to the active current assignee, append authorized comments, exclude deleted Tasks from current reads/progress, render empty progress as absent, and deny guessed/cross-Project identifiers without writes. + +## Test method + +Eight transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. + +## Hand-derived expected result + +A self-Task stores one membership in creator, assigner, and assignee fields. A Leader-created Task retains the Leader membership as creator/assigner and the selected member as assignee. Project start/end due dates are valid; dates before, after, or on a current global day off are invalid. Only an active Project's current assignee can traverse an allowed status edge. Authorized member/Mentor comments append two rows. Four current Tasks with one in each status produce 25% and four unit counts; a deleted fifth Task is absent; zero Tasks has no percentage. + +## 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=TaskCreationIntegrationTest test +``` + +**Observed result** + +```text +[ERROR] TaskCreationIntegrationTest.java:[6,34] cannot find symbol + symbol: class CreateTaskCommand +[ERROR] TaskCreationIntegrationTest.java:[8,34] cannot find symbol + symbol: class TaskService +[INFO] BUILD FAILURE +``` + +After creation reached GREEN, the next cohesive workflow increment was separately observed RED: + +```text +[ERROR] TaskCreationIntegrationTest.java:[7,34] cannot find symbol + symbol: class TaskCommentView +[ERROR] TaskCreationIntegrationTest.java:[8,34] cannot find symbol + symbol: class TaskDetails +[ERROR] TaskCreationIntegrationTest.java:[9,34] cannot find symbol + symbol: class TaskListView +[INFO] BUILD FAILURE +``` + +## 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=TaskCreationIntegrationTest test +``` + +**Observed result** + +```text +[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## 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 test + +[INFO] Tests run: 30, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This evidence does not prove browser behavior, shared-shell integration, notification delivery, Iteration 2 work logs/reassignment/edit/deletion, or Iteration 3 concurrency/index plans. The status edge matrix is separately protected by unit evidence. HTTP form, CSRF, template, and direct-route behavior require the companion web evidence. diff --git a/docs/tests/unit/task-status-progress.md b/docs/tests/unit/task-status-progress.md index 6da6cc6..9e31804 100644 --- a/docs/tests/unit/task-status-progress.md +++ b/docs/tests/unit/task-status-progress.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `TSK-007`, `TSK-008`, `PRJ-015`, `PRJ-016` - **Scenario IDs:** `I1-TSK-03`, `I1-TSK-05`, `AC-TSK-003`, `AC-PRJ-008` - **Test class/method:** `com.lab.labtimesheet.tasks.TaskDomainRulesTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `17a3c5d` ## Protected behavior diff --git a/src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java b/src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java new file mode 100644 index 0000000..d10fc00 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.tasks; + +import java.time.LocalDate; + +public record CreateTaskCommand( + long projectId, + long assigneeMembershipId, + String title, + String description, + LocalDate dueDate) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java b/src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java new file mode 100644 index 0000000..25be01e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java @@ -0,0 +1,5 @@ +package com.lab.labtimesheet.tasks; + +import java.time.Instant; + +public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java b/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java new file mode 100644 index 0000000..2cacfe7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.tasks; + +import java.util.List; + +public record TaskDetails(TaskView task, List comments) { + + public TaskDetails { + comments = List.copyOf(comments); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskListView.java b/src/main/java/com/lab/labtimesheet/tasks/TaskListView.java new file mode 100644 index 0000000..1acfed2 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskListView.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.tasks; + +import java.util.List; + +public record TaskListView(List tasks, TaskProgress progress) { + + public TaskListView { + tasks = List.copyOf(tasks); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java b/src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java new file mode 100644 index 0000000..dde94dd --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java @@ -0,0 +1,12 @@ +package com.lab.labtimesheet.tasks; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public final class TaskNotFoundException extends RuntimeException { + + public TaskNotFoundException() { + super("Task or Project was not found"); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java new file mode 100644 index 0000000..2765ba8 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java @@ -0,0 +1,412 @@ +package com.lab.labtimesheet.tasks; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.List; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class TaskService { + + private final JdbcClient jdbc; + + public TaskService(JdbcClient jdbc) { + this.jdbc = jdbc; + } + + @Transactional + public TaskView create(String actorEmail, CreateTaskCommand command) { + String title = requireTitle(command.title()); + Actor actor = requireActiveActor(actorEmail); + Project project = requireOpenProject(command.projectId()); + long actorMembershipId = requireActorMembership(project.id(), actor.id()); + requireAssigneeMembership(project.id(), command.assigneeMembershipId()); + + if (actorMembershipId != command.assigneeMembershipId() + && !isCurrentLeader(project.id(), actorMembershipId)) { + throw new TaskNotFoundException(); + } + validateDueDate(project, command.dueDate()); + + long taskId = jdbc.sql(""" + insert into tasks + (project_id, assignee_membership_id, title, description, due_date, + created_by_membership_id, assigned_by_membership_id) + values (:projectId, :assigneeId, :title, :description, :dueDate, + :actorMembershipId, :actorMembershipId) + returning id + """) + .param("projectId", project.id()) + .param("assigneeId", command.assigneeMembershipId()) + .param("title", title) + .param("description", trimToNull(command.description())) + .param("dueDate", command.dueDate()) + .param("actorMembershipId", actorMembershipId) + .query(Long.class) + .single(); + return task(taskId); + } + + @Transactional + public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) { + Actor actor = requireActiveActor(actorEmail); + TaskView task = jdbc.sql(""" + select t.id, t.project_id, t.assignee_membership_id, t.title, t.description, + t.status, t.due_date, t.created_by_membership_id, + t.assigned_by_membership_id, t.assigned_at, t.created_at + from tasks t + join projects p on p.id = t.project_id + join project_memberships m on m.id = t.assignee_membership_id + and m.project_id = t.project_id + where t.id = :taskId + and t.project_id = :projectId + and t.deleted_at is null + and p.status = 'ACTIVE' + and m.intern_user_id = :actorId + and m.left_at is null + """) + .param("taskId", taskId) + .param("projectId", projectId) + .param("actorId", actor.id()) + .query(TaskService::mapTask) + .optional() + .orElseThrow(TaskNotFoundException::new); + if (!task.status().canTransitionTo(target)) { + throw new TaskValidationException("Task status transition is not allowed"); + } + jdbc.sql("update tasks set status = :status, updated_at = current_timestamp where id = :taskId") + .param("status", target.name()) + .param("taskId", taskId) + .update(); + return task(taskId); + } + + @Transactional + public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) { + String normalizedBody = requireCommentBody(body); + Actor actor = requireActiveActor(actorEmail); + ProjectAccess project = requireProjectAccess(projectId); + if ("COMPLETED".equals(project.status()) || !taskExists(projectId, taskId)) { + throw new TaskNotFoundException(); + } + if (actor.id() != project.mentorUserId() && !hasActiveMembership(projectId, actor.id())) { + throw new TaskNotFoundException(); + } + + long commentId = jdbc.sql(""" + insert into task_comments (task_id, author_user_id, body) + values (:taskId, :actorId, :body) + returning id + """) + .param("taskId", taskId) + .param("actorId", actor.id()) + .param("body", normalizedBody) + .query(Long.class) + .single(); + return comment(commentId); + } + + @Transactional(readOnly = true) + public TaskListView list(String actorEmail, long projectId) { + requireViewAccess(actorEmail, projectId); + List tasks = jdbc.sql(""" + select id, project_id, assignee_membership_id, title, description, status, + due_date, created_by_membership_id, assigned_by_membership_id, + assigned_at, created_at + from tasks + where project_id = :projectId and deleted_at is null + order by id + """) + .param("projectId", projectId) + .query(TaskService::mapTask) + .list(); + return new TaskListView(tasks, TaskProgress.from(tasks.stream().map(TaskView::status).toList())); + } + + @Transactional(readOnly = true) + public TaskDetails details(String actorEmail, long projectId, long taskId) { + requireViewAccess(actorEmail, projectId); + TaskView task = jdbc.sql(""" + select id, project_id, assignee_membership_id, title, description, status, + due_date, created_by_membership_id, assigned_by_membership_id, + assigned_at, created_at + from tasks + where id = :taskId and project_id = :projectId and deleted_at is null + """) + .param("taskId", taskId) + .param("projectId", projectId) + .query(TaskService::mapTask) + .optional() + .orElseThrow(TaskNotFoundException::new); + List comments = jdbc.sql(""" + select id, task_id, author_user_id, body, created_at + from task_comments + where task_id = :taskId + order by created_at, id + """) + .param("taskId", taskId) + .query(TaskService::mapComment) + .list(); + return new TaskDetails(task, comments); + } + + private Actor requireActiveActor(String email) { + Actor actor = requireReadableActor(email); + if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) { + throw new TaskNotFoundException(); + } + return actor; + } + + private Actor requireReadableActor(String email) { + return jdbc.sql(""" + select u.id, u.global_role, i.internship_status + from app_users u + left join intern_profiles i on i.user_id = u.id + where lower(btrim(u.email)) = lower(btrim(:email)) + and u.account_status = 'ACTIVE' + """) + .param("email", email) + .query((rs, rowNum) -> new Actor( + rs.getLong("id"), + rs.getString("global_role"), + rs.getString("internship_status"))) + .optional() + .orElseThrow(TaskNotFoundException::new); + } + + private Project requireOpenProject(long projectId) { + return jdbc.sql(""" + select id, status, start_date, end_date + from projects + where id = :projectId and status in ('PLANNED', 'ACTIVE') + """) + .param("projectId", projectId) + .query((rs, rowNum) -> new Project( + rs.getLong("id"), + rs.getString("status"), + rs.getObject("start_date", LocalDate.class), + rs.getObject("end_date", LocalDate.class))) + .optional() + .orElseThrow(TaskNotFoundException::new); + } + + private long requireActorMembership(long projectId, long userId) { + return jdbc.sql(""" + select m.id + from project_memberships m + join app_users u on u.id = m.intern_user_id + join intern_profiles i on i.user_id = m.intern_user_id + where m.project_id = :projectId + and m.intern_user_id = :userId + and m.left_at is null + and u.account_status = 'ACTIVE' + and i.internship_status = 'ACTIVE' + """) + .param("projectId", projectId) + .param("userId", userId) + .query(Long.class) + .optional() + .orElseThrow(TaskNotFoundException::new); + } + + private void requireAssigneeMembership(long projectId, long membershipId) { + boolean exists = jdbc.sql(""" + select exists ( + select 1 + from project_memberships m + join app_users u on u.id = m.intern_user_id + join intern_profiles i on i.user_id = m.intern_user_id + where m.id = :membershipId + and m.project_id = :projectId + and m.left_at is null + and u.account_status = 'ACTIVE' + and i.internship_status = 'ACTIVE' + ) + """) + .param("membershipId", membershipId) + .param("projectId", projectId) + .query(Boolean.class) + .single(); + if (!exists) { + throw new TaskNotFoundException(); + } + } + + private boolean isCurrentLeader(long projectId, long membershipId) { + return jdbc.sql(""" + select exists ( + select 1 from project_leadership_terms + where project_id = :projectId + and membership_id = :membershipId + and ended_at is null + ) + """) + .param("projectId", projectId) + .param("membershipId", membershipId) + .query(Boolean.class) + .single(); + } + + private void requireViewAccess(String actorEmail, long projectId) { + Actor actor = requireReadableActor(actorEmail); + ProjectAccess project = requireProjectAccess(projectId); + if ("ADMIN".equals(actor.role()) || actor.id() == project.mentorUserId()) { + return; + } + boolean member = jdbc.sql(""" + select exists ( + select 1 from project_memberships + where project_id = :projectId + and intern_user_id = :actorId + and (:completed or left_at is null) + ) + """) + .param("projectId", projectId) + .param("actorId", actor.id()) + .param("completed", "COMPLETED".equals(project.status())) + .query(Boolean.class) + .single(); + if (!member) { + throw new TaskNotFoundException(); + } + } + + private ProjectAccess requireProjectAccess(long projectId) { + return jdbc.sql("select status, mentor_user_id from projects where id = :projectId") + .param("projectId", projectId) + .query((rs, rowNum) -> new ProjectAccess( + rs.getString("status"), rs.getLong("mentor_user_id"))) + .optional() + .orElseThrow(TaskNotFoundException::new); + } + + private boolean hasActiveMembership(long projectId, long actorId) { + return jdbc.sql(""" + select exists ( + select 1 from project_memberships + where project_id = :projectId + and intern_user_id = :actorId + and left_at is null + ) + """) + .param("projectId", projectId) + .param("actorId", actorId) + .query(Boolean.class) + .single(); + } + + private boolean taskExists(long projectId, long taskId) { + return jdbc.sql(""" + select exists ( + select 1 from tasks + where id = :taskId and project_id = :projectId and deleted_at is null + ) + """) + .param("taskId", taskId) + .param("projectId", projectId) + .query(Boolean.class) + .single(); + } + + private void validateDueDate(Project project, LocalDate dueDate) { + if (dueDate == null) { + return; + } + if (dueDate.isBefore(project.startDate()) || dueDate.isAfter(project.endDate())) { + throw new TaskValidationException("Due date must be within Project dates"); + } + boolean dayOff = jdbc.sql(""" + select exists ( + select 1 from global_calendar_events + where calendar_date = :dueDate and is_day_off = true + ) + """) + .param("dueDate", dueDate) + .query(Boolean.class) + .single(); + if (dayOff) { + throw new TaskValidationException("Due date cannot be a current global day off"); + } + } + + private TaskView task(long taskId) { + return jdbc.sql(""" + select id, project_id, assignee_membership_id, title, description, status, + due_date, created_by_membership_id, assigned_by_membership_id, + assigned_at, created_at + from tasks + where id = :taskId + """) + .param("taskId", taskId) + .query(TaskService::mapTask) + .single(); + } + + private TaskCommentView comment(long commentId) { + return jdbc.sql(""" + select id, task_id, author_user_id, body, created_at + from task_comments + where id = :commentId + """) + .param("commentId", commentId) + .query(TaskService::mapComment) + .single(); + } + + private static TaskView mapTask(ResultSet rs, int rowNum) throws SQLException { + return new TaskView( + rs.getLong("id"), + rs.getLong("project_id"), + rs.getLong("assignee_membership_id"), + rs.getString("title"), + rs.getString("description"), + TaskStatus.valueOf(rs.getString("status")), + rs.getObject("due_date", LocalDate.class), + rs.getLong("created_by_membership_id"), + rs.getLong("assigned_by_membership_id"), + rs.getTimestamp("assigned_at").toInstant(), + rs.getTimestamp("created_at").toInstant()); + } + + private static TaskCommentView mapComment(ResultSet rs, int rowNum) throws SQLException { + return new TaskCommentView( + rs.getLong("id"), + rs.getLong("task_id"), + rs.getLong("author_user_id"), + rs.getString("body"), + rs.getTimestamp("created_at").toInstant()); + } + + private static String requireTitle(String title) { + String trimmed = trimToNull(title); + if (trimmed == null || trimmed.length() > 200) { + throw new TaskValidationException("Title is required and must not exceed 200 characters"); + } + return trimmed; + } + + private static String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private static String requireCommentBody(String body) { + String trimmed = trimToNull(body); + if (trimmed == null) { + throw new TaskValidationException("Comment body is required"); + } + return trimmed; + } + + private record Actor(long id, String role, String internshipStatus) {} + + private record Project(long id, String status, LocalDate startDate, LocalDate endDate) {} + + private record ProjectAccess(String status, long mentorUserId) {} +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java b/src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java new file mode 100644 index 0000000..c32968b --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java @@ -0,0 +1,12 @@ +package com.lab.labtimesheet.tasks; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.BAD_REQUEST) +public final class TaskValidationException extends RuntimeException { + + public TaskValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskView.java b/src/main/java/com/lab/labtimesheet/tasks/TaskView.java new file mode 100644 index 0000000..4b0fb48 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskView.java @@ -0,0 +1,17 @@ +package com.lab.labtimesheet.tasks; + +import java.time.Instant; +import java.time.LocalDate; + +public record TaskView( + long id, + long projectId, + long assigneeMembershipId, + String title, + String description, + TaskStatus status, + LocalDate dueDate, + long creatorMembershipId, + long assignerMembershipId, + Instant assignedAt, + Instant createdAt) {} diff --git a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java b/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java new file mode 100644 index 0000000..57f9634 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java @@ -0,0 +1,360 @@ +package com.lab.labtimesheet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.lab.labtimesheet.tasks.CreateTaskCommand; +import com.lab.labtimesheet.tasks.TaskCommentView; +import com.lab.labtimesheet.tasks.TaskDetails; +import com.lab.labtimesheet.tasks.TaskListView; +import com.lab.labtimesheet.tasks.TaskNotFoundException; +import com.lab.labtimesheet.tasks.TaskService; +import com.lab.labtimesheet.tasks.TaskStatus; +import com.lab.labtimesheet.tasks.TaskValidationException; +import com.lab.labtimesheet.tasks.TaskView; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class TaskCreationIntegrationTest { + + private static final LocalDate PROJECT_START = LocalDate.of(2026, 8, 1); + private static final LocalDate PROJECT_END = LocalDate.of(2026, 8, 31); + + @Autowired + private JdbcClient jdbc; + + @Autowired + private TaskService taskService; + + private long projectId; + private long leaderMembershipId; + private long memberMembershipId; + + @BeforeEach + void setUpProject() { + long mentorId = insertUser("mentor@example.test", "MENTOR"); + long leaderId = insertIntern("leader@example.test"); + long memberId = insertIntern("member@example.test"); + projectId = insertProject(mentorId, "PLANNED"); + leaderMembershipId = insertMembership(projectId, leaderId, mentorId); + memberMembershipId = insertMembership(projectId, memberId, mentorId); + jdbc.sql(""" + insert into project_leadership_terms + (project_id, membership_id, appointed_by_mentor_user_id) + values (:projectId, :membershipId, :mentorId) + """) + .param("projectId", projectId) + .param("membershipId", leaderMembershipId) + .param("mentorId", mentorId) + .update(); + } + + @Test + void activeMemberCreatesOnlyASelfAssignedTaskWithEqualActors() { + TaskView task = taskService.create( + "member@example.test", + new CreateTaskCommand(projectId, memberMembershipId, " Draft results ", " notes ", PROJECT_START)); + + assertThat(task.status()).isEqualTo(TaskStatus.TODO); + assertThat(task.title()).isEqualTo("Draft results"); + assertThat(task.description()).isEqualTo("notes"); + assertThat(task.creatorMembershipId()).isEqualTo(memberMembershipId); + assertThat(task.assignerMembershipId()).isEqualTo(memberMembershipId); + assertThat(task.assigneeMembershipId()).isEqualTo(memberMembershipId); + + assertThatThrownBy(() -> taskService.create( + "member@example.test", + new CreateTaskCommand(projectId, leaderMembershipId, "Forbidden", null, null))) + .isInstanceOf(TaskNotFoundException.class); + assertThat(taskCount()).isEqualTo(1); + } + + @Test + void currentLeaderCreatesForAnotherActiveSameProjectMember() { + TaskView task = taskService.create( + "leader@example.test", + new CreateTaskCommand(projectId, memberMembershipId, "Review results", null, PROJECT_END)); + + assertThat(task.creatorMembershipId()).isEqualTo(leaderMembershipId); + assertThat(task.assignerMembershipId()).isEqualTo(leaderMembershipId); + assertThat(task.assigneeMembershipId()).isEqualTo(memberMembershipId); + } + + @Test + void rejectsCrossProjectAndInactiveAssigneesWithoutWriting() { + long mentorId = userId("mentor@example.test"); + long outsiderId = insertIntern("outsider@example.test"); + long otherProjectId = insertProject(mentorId, "PLANNED"); + long otherMembershipId = insertMembership(otherProjectId, outsiderId, mentorId); + jdbc.sql("update project_memberships set left_at = joined_at + interval '1 second', removed_by_mentor_user_id = :mentorId where id = :id") + .param("mentorId", mentorId) + .param("id", memberMembershipId) + .update(); + + assertThatThrownBy(() -> taskService.create( + "leader@example.test", + new CreateTaskCommand(projectId, otherMembershipId, "Cross project", null, null))) + .isInstanceOf(TaskNotFoundException.class); + assertThatThrownBy(() -> taskService.create( + "leader@example.test", + new CreateTaskCommand(projectId, memberMembershipId, "Inactive", null, null))) + .isInstanceOf(TaskNotFoundException.class); + assertThat(taskCount()).isZero(); + } + + @Test + void acceptsProjectBoundaryDueDatesAndRejectsOutsideOrCurrentDayOff() { + taskService.create( + "leader@example.test", + new CreateTaskCommand(projectId, memberMembershipId, "Start boundary", null, PROJECT_START)); + taskService.create( + "leader@example.test", + new CreateTaskCommand(projectId, memberMembershipId, "End boundary", null, PROJECT_END)); + insertDayOff(LocalDate.of(2026, 8, 15)); + + assertThatThrownBy(() -> taskService.create( + "leader@example.test", + new CreateTaskCommand(projectId, memberMembershipId, "Before", null, PROJECT_START.minusDays(1)))) + .isInstanceOf(TaskValidationException.class); + assertThatThrownBy(() -> taskService.create( + "leader@example.test", + new CreateTaskCommand(projectId, memberMembershipId, "After", null, PROJECT_END.plusDays(1)))) + .isInstanceOf(TaskValidationException.class); + assertThatThrownBy(() -> taskService.create( + "leader@example.test", + new CreateTaskCommand(projectId, memberMembershipId, "Day off", null, LocalDate.of(2026, 8, 15)))) + .isInstanceOf(TaskValidationException.class); + assertThat(taskCount()).isEqualTo(2); + } + + @Test + void onlyCurrentAssigneeChangesStatusOnAnActiveProject() { + TaskView task = taskService.create( + "member@example.test", + new CreateTaskCommand(projectId, memberMembershipId, "Run experiment", null, null)); + + assertThatThrownBy(() -> taskService.changeStatus( + "member@example.test", projectId, task.id(), TaskStatus.IN_PROGRESS)) + .isInstanceOf(TaskNotFoundException.class); + activateProject(); + assertThatThrownBy(() -> taskService.changeStatus( + "leader@example.test", projectId, task.id(), TaskStatus.IN_PROGRESS)) + .isInstanceOf(TaskNotFoundException.class); + + TaskView inProgress = taskService.changeStatus( + "member@example.test", projectId, task.id(), TaskStatus.IN_PROGRESS); + + assertThat(inProgress.status()).isEqualTo(TaskStatus.IN_PROGRESS); + assertThatThrownBy(() -> taskService.changeStatus( + "member@example.test", projectId, task.id(), TaskStatus.TODO)) + .isInstanceOf(TaskValidationException.class); + } + + @Test + void activeMemberAndOwningMentorAppendCommentsUntilProjectCompletion() { + TaskView task = taskService.create( + "member@example.test", + new CreateTaskCommand(projectId, memberMembershipId, "Discuss results", null, null)); + insertIntern("outsider@example.test"); + + TaskCommentView memberComment = taskService.addComment( + "member@example.test", projectId, task.id(), " First note "); + TaskCommentView mentorComment = taskService.addComment( + "mentor@example.test", projectId, task.id(), "Mentor note"); + + assertThat(memberComment.body()).isEqualTo("First note"); + assertThat(mentorComment.authorUserId()).isEqualTo(userId("mentor@example.test")); + assertThatThrownBy(() -> taskService.addComment( + "outsider@example.test", projectId, task.id(), "Forbidden")) + .isInstanceOf(TaskNotFoundException.class); + assertThatThrownBy(() -> taskService.addComment( + "member@example.test", projectId, task.id(), " ")) + .isInstanceOf(TaskValidationException.class); + + completeProject(); + assertThatThrownBy(() -> taskService.addComment( + "mentor@example.test", projectId, task.id(), "Too late")) + .isInstanceOf(TaskNotFoundException.class); + assertThat(commentCount()).isEqualTo(2); + } + + @Test + void authorizedListsAndDetailsExcludeDeletedTasksAndReportEmptyAsNotApplicable() { + TaskView todo = createMemberTask("Todo"); + TaskView active = createMemberTask("Active"); + TaskView blocked = createMemberTask("Blocked"); + TaskView done = createMemberTask("Done"); + TaskView deleted = createMemberTask("Deleted"); + setStatus(active.id(), TaskStatus.IN_PROGRESS); + setStatus(blocked.id(), TaskStatus.BLOCKED); + setStatus(done.id(), TaskStatus.DONE); + softDelete(deleted.id()); + taskService.addComment("member@example.test", projectId, todo.id(), "Visible comment"); + + TaskListView list = taskService.list("member@example.test", projectId); + TaskDetails details = taskService.details("mentor@example.test", projectId, todo.id()); + + assertThat(list.tasks()).extracting(TaskView::title) + .containsExactly("Todo", "Active", "Blocked", "Done"); + assertThat(list.progress().total()).isEqualTo(4); + assertThat(list.progress().count(TaskStatus.TODO)).isEqualTo(1); + assertThat(list.progress().count(TaskStatus.IN_PROGRESS)).isEqualTo(1); + assertThat(list.progress().count(TaskStatus.BLOCKED)).isEqualTo(1); + assertThat(list.progress().count(TaskStatus.DONE)).isEqualTo(1); + assertThat(list.progress().completionPercentage()).hasValue(25.0); + assertThat(details.comments()).extracting(TaskCommentView::body).containsExactly("Visible comment"); + + long emptyProjectId = insertProject(userId("mentor@example.test"), "PLANNED"); + assertThat(taskService.list("mentor@example.test", emptyProjectId).progress().completionPercentage()) + .isEmpty(); + } + + @Test + void directAndCrossProjectTaskIdentifiersDoNotDiscloseRecords() { + TaskView task = createMemberTask("Private task"); + long otherProjectId = insertProject(userId("mentor@example.test"), "PLANNED"); + + assertThatThrownBy(() -> taskService.details( + "mentor@example.test", otherProjectId, task.id())) + .isInstanceOf(TaskNotFoundException.class); + assertThatThrownBy(() -> taskService.details( + "outsider@example.test", projectId, task.id())) + .isInstanceOf(TaskNotFoundException.class); + } + + private long insertUser(String email, String role) { + return jdbc.sql(""" + insert into app_users + (email, display_name, password_hash, global_role, account_status, activated_at) + values (:email, :email, 'hash', :role, 'ACTIVE', current_timestamp) + returning id + """) + .param("email", email) + .param("role", role) + .query(Long.class) + .single(); + } + + private long insertIntern(String email) { + long userId = insertUser(email, "INTERN"); + jdbc.sql(""" + insert into intern_profiles + (user_id, student_code, internship_start_date, internship_end_date, + internship_status, activated_at) + values (:userId, :studentCode, date '2026-01-01', date '2026-12-31', + 'ACTIVE', current_timestamp) + """) + .param("userId", userId) + .param("studentCode", "S" + userId) + .update(); + return userId; + } + + private long insertProject(long mentorId, String status) { + return jdbc.sql(""" + insert into projects + (mentor_user_id, name, status, start_date, end_date, activated_at) + values (:mentorId, 'Project', :status, :startDate, :endDate, + case when :status = 'ACTIVE' then current_timestamp else null end) + returning id + """) + .param("mentorId", mentorId) + .param("status", status) + .param("startDate", PROJECT_START) + .param("endDate", PROJECT_END) + .query(Long.class) + .single(); + } + + private long insertMembership(long targetProjectId, long internId, long mentorId) { + return jdbc.sql(""" + insert into project_memberships (project_id, intern_user_id, added_by_user_id) + values (:projectId, :internId, :mentorId) + returning id + """) + .param("projectId", targetProjectId) + .param("internId", internId) + .param("mentorId", mentorId) + .query(Long.class) + .single(); + } + + private void insertDayOff(LocalDate date) { + long mentorId = userId("mentor@example.test"); + jdbc.sql(""" + insert into global_calendar_events + (calendar_date, name, source, is_day_off, created_by_user_id, updated_by_user_id) + values (:date, 'Day off', 'CUSTOM', true, :userId, :userId) + """) + .param("date", date) + .param("userId", mentorId) + .update(); + } + + private long userId(String email) { + return jdbc.sql("select id from app_users where email = :email") + .param("email", email) + .query(Long.class) + .single(); + } + + private long taskCount() { + return jdbc.sql("select count(*) from tasks").query(Long.class).single(); + } + + private long commentCount() { + return jdbc.sql("select count(*) from task_comments").query(Long.class).single(); + } + + private TaskView createMemberTask(String title) { + return taskService.create( + "member@example.test", + new CreateTaskCommand(projectId, memberMembershipId, title, null, null)); + } + + private void activateProject() { + jdbc.sql("update projects set status = 'ACTIVE', activated_at = current_timestamp where id = :id") + .param("id", projectId) + .update(); + } + + private void completeProject() { + jdbc.sql(""" + update projects + set status = 'COMPLETED', activated_at = current_timestamp, + completed_at = current_timestamp + where id = :id + """) + .param("id", projectId) + .update(); + } + + private void setStatus(long taskId, TaskStatus status) { + jdbc.sql("update tasks set status = :status where id = :id") + .param("status", status.name()) + .param("id", taskId) + .update(); + } + + private void softDelete(long taskId) { + jdbc.sql(""" + update tasks + set deleted_at = current_timestamp, deleted_by_membership_id = :membershipId + where id = :id + """) + .param("membershipId", memberMembershipId) + .param("id", taskId) + .update(); + } +} From bac39812a31bcde915fa4884621b01be04d5489e Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:41:51 +0700 Subject: [PATCH 07/62] feat(ui): establish shared desktop shell and assets --- .gitignore | 1 + docs/tests/web/ui-shell-components.md | 82 ++ package-lock.json | 1248 +++++++++++++++++ package.json | 18 + src/main/frontend/app.css | 136 ++ src/main/frontend/build-icons.mjs | 20 + src/main/resources/static/assets/app.css | 2 + src/main/resources/static/assets/app.js | 35 + src/main/resources/static/assets/icons.svg | 45 + src/main/resources/static/assets/theme.js | 12 + .../templates/fragments/components.html | 33 + .../resources/templates/fragments/layout.html | 62 + .../labtimesheet/ui/UiContractWebTest.java | 103 ++ .../templates/test/components-consumer.html | 10 + .../templates/test/layout-consumer.html | 15 + 15 files changed, 1822 insertions(+) create mode 100644 docs/tests/web/ui-shell-components.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/main/frontend/app.css create mode 100644 src/main/frontend/build-icons.mjs create mode 100644 src/main/resources/static/assets/app.css create mode 100644 src/main/resources/static/assets/app.js create mode 100644 src/main/resources/static/assets/icons.svg create mode 100644 src/main/resources/static/assets/theme.js create mode 100644 src/main/resources/templates/fragments/components.html create mode 100644 src/main/resources/templates/fragments/layout.html create mode 100644 src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java create mode 100644 src/test/resources/templates/test/components-consumer.html create mode 100644 src/test/resources/templates/test/layout-consumer.html diff --git a/.gitignore b/.gitignore index 2982179..50451b5 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ target/ build/ !**/src/main/**/build/ !**/src/test/**/build/ +node_modules/ ### VS Code ### .vscode/ diff --git a/docs/tests/web/ui-shell-components.md b/docs/tests/web/ui-shell-components.md new file mode 100644 index 0000000..1d9f00d --- /dev/null +++ b/docs/tests/web/ui-shell-components.md @@ -0,0 +1,82 @@ +# Test Evidence: shared UI shell and components + +- **Test type:** Web +- **Requirement IDs:** `ARC-004`, `UI-001`–`UI-010`, `UI-013`–`UI-018`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04` +- **Scenario IDs:** `AC-UI-001`, `AC-UI-002`, `AC-UI-003`, `AC-UI-005` +- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest` +- **Implementation commit:** `pending` + +## Protected behavior + +Domain-owned Thymeleaf pages can render inside one desktop shell with role-filtered navigation, accessible controls/states, pre-paint local theme loading, and committed local CSS/JavaScript/Lucide assets. The tests catch missing fragments, unauthorized navigation leakage, inaccessible shared form/status markup, remote icon references, or a theme bootstrap loaded after CSS. + +## Test method + +A test-only domain page consumes the production layout fragment through MockMvc with a real Spring Security principal. A second page renders representative production fragments. The asset test reads the committed classpath artifacts produced by the pinned Node build. + +## Hand-derived expected result + +A Mentor sees `Owned Projects`, theme, profile identity, and logout, but not Admin `Accounts` or Intern `My attendance`. The theme script occurs before the stylesheet. Form label/control IDs match, errors use `role="alert"`, status includes a textual accessible name, confirmation copy is described, and the reduced sprite contains the selected symbols without remote resource references. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=UiContractWebTest test +``` + +**Observed result** + +```text +Tests run: 2, Failures: 1, Errors: 1, Skipped: 0 +UiContractWebTest.compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite expected: but was: +UiContractWebTest.sharedShellRendersAuthorizedDesktopNavigationBeforeDomainPagesIntegrate: Request processing failed: Error resolving template [fragments/layout] +BUILD FAILURE +``` + +The asset assertion failed because the committed build artifacts did not exist, and the rendering request reached the test controller but could not resolve the missing production layout. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=UiContractWebTest test +``` + +**Observed result** + +```text +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 4.514 s +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +npm ci +npm run build +git diff --exit-code -- src/main/resources/static/assets/app.css src/main/resources/static/assets/icons.svg +./mvnw test + +added 34 packages, audited 35 packages, found 0 vulnerabilities +Tailwind CSS v4.3.3: Done in 45ms +Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 7.697 s +``` + +## External-test boundaries + +The tests prove server-rendered authorization-aware markup and reproducible local assets. They do not replace manual browser checks for zero-flash paint timing, measured WCAG contrast, keyboard tooltip behavior, or page-level overflow at 1365×900; those remain final integrated UI gates. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..88f6113 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1248 @@ +{ + "name": "labtimesheet-ui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "labtimesheet-ui", + "devDependencies": { + "@tailwindcss/cli": "4.3.3", + "lucide-static": "1.27.0", + "tailwindcss": "4.3.3" + }, + "engines": { + "node": "24.x", + "npm": "11.x" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", + "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "2.5.1", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "enhanced-resolve": "^5.24.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.3.3" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/lucide-static": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.27.0.tgz", + "integrity": "sha512-ev1Wufm+RsKpQ6CfmS0PgYF+NTB1aaltfE+jUgBYGB8PV5b5qqHpzsTLPsMWFRFdpVMv66eIK8/Xf9d8cl3HhA==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ae3df74 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "labtimesheet-ui", + "private": true, + "engines": { + "node": "24.x", + "npm": "11.x" + }, + "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" + }, + "devDependencies": { + "@tailwindcss/cli": "4.3.3", + "lucide-static": "1.27.0", + "tailwindcss": "4.3.3" + } +} diff --git a/src/main/frontend/app.css b/src/main/frontend/app.css new file mode 100644 index 0000000..279580a --- /dev/null +++ b/src/main/frontend/app.css @@ -0,0 +1,136 @@ +@import "tailwindcss"; +@source "../resources/templates/**/*.html"; + +@theme { + --color-ink: #15171a; + --color-canvas: #f6f7f8; + --color-sidebar: #f0f1f2; + --color-panel: #ffffff; + --color-panel-muted: #f7f8f9; + --color-border: #dfe1e5; + --color-border-strong: #c9cdd3; + --color-muted: #626a75; + --color-accent: #3157e7; + --color-success: #087a48; + --color-warning: #996000; + --color-danger: #b42318; +} + +:root { + color-scheme: light; + --ink: #15171a; + --canvas: #f6f7f8; + --sidebar: #f0f1f2; + --panel: #ffffff; + --panel-muted: #f7f8f9; + --border: #dfe1e5; + --border-strong: #c9cdd3; + --muted: #626a75; + --subtle: #818894; + --accent: #3157e7; + --focus: #3157e7; + --success: #087a48; + --warning: #7a4d00; + --danger: #b42318; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --ink: #eceef1; + --canvas: #0b0c0e; + --sidebar: #111317; + --panel: #17191e; + --panel-muted: #1d2026; + --border: #30343d; + --border-strong: #454b57; + --muted: #b2b7c0; + --subtle: #969da8; + --accent: #8ca4ff; + --focus: #9eb2ff; + --success: #4fd19b; + --warning: #f0bc63; + --danger: #ff8e88; +} + +@layer base { + * { box-sizing: border-box; } + html { min-width: 64rem; background: var(--canvas); } + body { margin: 0; overflow-x: hidden; background: var(--canvas); color: var(--ink); font: 14px/1.45 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } + button, input, select, textarea { font: inherit; } + button, a, input, select, textarea { outline: none; } + :focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } + a { color: inherit; } +} + +@layer components { + .app-shell { display: grid; grid-template-columns: 16rem minmax(0, 1fr); min-height: 100vh; } + [data-sidebar-collapsed="true"] .app-shell { grid-template-columns: 4rem minmax(0, 1fr); } + .sidebar { position: sticky; top: 0; display: flex; height: 100vh; flex-direction: column; border-right: 1px solid var(--border); background: var(--sidebar); padding: 1rem .75rem; } + .brand, .account { display: flex; align-items: center; gap: .7rem; min-width: 0; padding: .25rem .4rem; } + .brand-mark { display: grid; width: 2rem; height: 2rem; flex: 0 0 auto; place-items: center; border-radius: .55rem; background: var(--ink); color: var(--panel); } + .sidebar-label { overflow: hidden; white-space: nowrap; } + [data-sidebar-collapsed="true"] .sidebar-label { width: 0; opacity: 0; } + .nav-label { margin: 1.6rem .6rem .4rem; color: var(--subtle); font-size: .68rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } + .nav-list { display: grid; gap: .2rem; margin: 0; padding: 0; list-style: none; } + .nav-link { display: flex; min-height: 2.5rem; align-items: center; gap: .7rem; border-radius: .55rem; padding: .55rem .7rem; color: var(--muted); font-weight: 600; text-decoration: none; } + .nav-link:hover, .nav-link[aria-current="page"] { background: var(--panel); color: var(--ink); box-shadow: 0 1px 2px rgb(20 25 35 / .08); } + .nav-icon { width: 1.05rem; height: 1.05rem; flex: 0 0 auto; } + .sidebar-footer { display: grid; gap: .7rem; margin-top: auto; } + .theme-field { display: grid; gap: .25rem; } + .theme-field select { min-height: 2.4rem; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .35rem .55rem; } + .logout-form button { width: 100%; border: 0; background: transparent; text-align: left; } + .app-column { min-width: 0; } + .app-header { display: flex; min-height: 3.75rem; align-items: center; gap: .8rem; border-bottom: 1px solid var(--border); padding: 0 1.5rem; } + .header-title { min-width: 0; font-weight: 700; } + .breadcrumb { color: var(--muted); font-weight: 400; } + .header-actions { display: flex; align-items: center; gap: .55rem; margin-left: auto; } + .icon-button { display: inline-grid; width: 2.5rem; height: 2.5rem; place-items: center; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); cursor: pointer; } + .page { min-width: 0; padding: 1.55rem; } + .page-heading { display: flex; align-items: end; gap: 1rem; margin-bottom: 1.1rem; } + .page-heading-copy { min-width: 0; } + .page-title { margin: 0; font-size: 1.56rem; line-height: 1.2; letter-spacing: -.025em; } + .page-description { max-width: 72ch; margin: .3rem 0 0; color: var(--muted); } + .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-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); } + .panel-title { margin: 0; font-size: 1rem; } + .metric-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; margin-bottom: 1rem; } + .metric { min-width: 0; padding: 1rem; } + .metric + .metric { border-left: 1px solid var(--border); } + .metric-label { color: var(--muted); font-size: .78rem; } + .metric-value { margin-top: .35rem; font-size: 1.4rem; font-weight: 700; font-variant-numeric: tabular-nums; } + .metric-detail { margin-top: .18rem; color: var(--muted); font-size: .78rem; } + .field { display: grid; gap: .35rem; } + .field-label { font-size: .78rem; font-weight: 650; } + .control { min-height: 2.45rem; width: 100%; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .55rem .65rem; } + .control[aria-invalid="true"] { border-color: var(--danger); } + .field-error { margin: 0; color: var(--danger); font-size: .78rem; } + .checkbox { display: flex; align-items: center; gap: .5rem; } + .badge { display: inline-flex; align-items: center; gap: .32rem; border: 1px solid var(--border); border-radius: 999px; padding: .15rem .45rem; font-size: .72rem; font-weight: 700; } + .badge::before { content: ""; width: .38rem; height: .38rem; border-radius: 50%; background: currentColor; } + .badge-success { color: var(--success); } + .badge-warning { color: var(--warning); } + .badge-danger { color: var(--danger); } + .alert { margin: .75rem 0; border: 1px solid var(--border); border-radius: .6rem; padding: .75rem .9rem; } + .alert-error { border-color: color-mix(in srgb, var(--danger), transparent 60%); color: var(--danger); } + .empty-state { padding: 2.5rem 1rem; text-align: center; } + .empty-state p { margin: .3rem auto 0; color: var(--muted); } + .table-scroll { max-width: 100%; overflow-x: auto; } + .data-table { width: 100%; min-width: 42rem; border-collapse: collapse; } + .data-table th { background: var(--panel-muted); color: var(--muted); font-size: .69rem; letter-spacing: .06em; text-align: left; text-transform: uppercase; } + .data-table th, .data-table td { border-bottom: 1px solid var(--border); padding: .7rem 1rem; } + .data-table tr:last-child td { border-bottom: 0; } + .tabs { display: inline-flex; gap: .2rem; border: 1px solid var(--border); border-radius: .55rem; background: var(--panel-muted); padding: .2rem; } + .tab { border-radius: .4rem; padding: .4rem .65rem; text-decoration: none; } + .tab[aria-current="page"] { background: var(--panel); box-shadow: 0 1px 2px rgb(20 25 35 / .08); } + .pagination { display: flex; align-items: center; justify-content: flex-end; gap: .4rem; padding: .8rem 1rem; } + .skeleton { height: 1rem; border-radius: .35rem; background: var(--panel-muted); animation: pulse 1.5s ease-in-out infinite; } + .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); } + @keyframes pulse { 50% { opacity: .45; } } + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; } } +} diff --git a/src/main/frontend/build-icons.mjs b/src/main/frontend/build-icons.mjs new file mode 100644 index 0000000..086f575 --- /dev/null +++ b/src/main/frontend/build-icons.mjs @@ -0,0 +1,20 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +const names = [ + 'bell', 'calendar-days', 'check-circle-2', 'chevron-left', 'chevron-right', + 'circle-user-round', 'clock', 'folder-kanban', 'folder-open', 'inbox', + 'layout-dashboard', 'list-check', 'log-out', 'monitor', 'moon', 'panel-left', + 'settings', 'sun', 'triangle-alert', 'users', 'x' +]; +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 body = svg.match(/([\s\S]*?)<\/svg>/)?.[1]; + if (!body) throw new Error(`Invalid Lucide SVG: ${name}`); + return `${body.trim()}`; +})); + +await mkdir(dirname(output), { recursive: true }); +await writeFile(output, `${symbols.join('')}\n`); diff --git a/src/main/resources/static/assets/app.css b/src/main/resources/static/assets/app.css new file mode 100644 index 0000000..8ea2535 --- /dev/null +++ b/src/main/resources/static/assets/app.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.static{position:static}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#dfe1e5;--border-strong:#c9cdd3;--muted:#626a75;--subtle:#818894;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#30343d;--border-strong:#454b57;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/src/main/resources/static/assets/app.js b/src/main/resources/static/assets/app.js new file mode 100644 index 0000000..649cad1 --- /dev/null +++ b/src/main/resources/static/assets/app.js @@ -0,0 +1,35 @@ +document.addEventListener('DOMContentLoaded', () => { + const root = document.documentElement; + const theme = document.querySelector('[data-theme-select]'); + const stored = (() => { + try { return localStorage.getItem('labtimesheet-theme') || 'system'; } + catch (_) { return 'system'; } + })(); + if (theme) { + theme.value = stored; + theme.addEventListener('change', () => { + try { + theme.value === 'system' + ? localStorage.removeItem('labtimesheet-theme') + : localStorage.setItem('labtimesheet-theme', theme.value); + } catch (_) { + // Theme still applies for this page when persistence is unavailable. + } + const dark = theme.value === 'dark' + || (theme.value === 'system' && matchMedia('(prefers-color-scheme: dark)').matches); + root.dataset.theme = dark ? 'dark' : 'light'; + root.style.colorScheme = dark ? 'dark' : 'light'; + }); + } + + let collapsed = false; + try { collapsed = localStorage.getItem('labtimesheet-sidebar') === 'collapsed'; } + catch (_) { /* Use the expanded default. */ } + root.dataset.sidebarCollapsed = String(collapsed); + document.querySelector('[data-sidebar-toggle]')?.addEventListener('click', () => { + collapsed = !collapsed; + root.dataset.sidebarCollapsed = String(collapsed); + try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); } + catch (_) { /* Collapse still works for this page. */ } + }); +}); diff --git a/src/main/resources/static/assets/icons.svg b/src/main/resources/static/assets/icons.svg new file mode 100644 index 0000000..8a608d2 --- /dev/null +++ b/src/main/resources/static/assets/icons.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/static/assets/theme.js b/src/main/resources/static/assets/theme.js new file mode 100644 index 0000000..1ee0c2a --- /dev/null +++ b/src/main/resources/static/assets/theme.js @@ -0,0 +1,12 @@ +(() => { + const systemIsDark = () => window.matchMedia('(prefers-color-scheme: dark)').matches; + let preference = 'system'; + try { + preference = localStorage.getItem('labtimesheet-theme') || 'system'; + } catch (_) { + // Browser privacy settings may disable storage; the system preference remains usable. + } + const dark = preference === 'dark' || (preference === 'system' && systemIsDark()); + document.documentElement.dataset.theme = dark ? 'dark' : 'light'; + document.documentElement.style.colorScheme = dark ? 'dark' : 'light'; +})(); diff --git a/src/main/resources/templates/fragments/components.html b/src/main/resources/templates/fragments/components.html new file mode 100644 index 0000000..01401ea --- /dev/null +++ b/src/main/resources/templates/fragments/components.html @@ -0,0 +1,33 @@ + + + +Action + +
+ + +

Error

+
+ +
+ + +

Error

+
+ + +

Title

+
Label
Value
Detail
+Status + +
Data
+ + +

Confirm

Consequence

+

No records

Nothing to show.

+
+

Notifications

No unread notifications.
Notification
+ + diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html new file mode 100644 index 0000000..27acb9e --- /dev/null +++ b/src/main/resources/templates/fragments/layout.html @@ -0,0 +1,62 @@ + + + + + + Lab Timesheet + + + + + +
+ +
+
+ +
Section / Page
+
+
+
+
+

Page

+
+
+ +
+
+
+ + diff --git a/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java b/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java new file mode 100644 index 0000000..f1717df --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java @@ -0,0 +1,103 @@ +package com.lab.labtimesheet.ui; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.context.annotation.Import; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.bind.annotation.GetMapping; + +@WebMvcTest(UiContractWebTest.ContractController.class) +@Import(UiContractWebTest.ContractController.class) +class UiContractWebTest { + + private final MockMvc mvc; + + @Autowired + UiContractWebTest(MockMvc mvc) { + this.mvc = mvc; + } + + @Test + @WithMockUser(username = "mentor@example.test", roles = "MENTOR") + void sharedShellRendersAuthorizedDesktopNavigationBeforeDomainPagesIntegrate() throws Exception { + MvcResult result = mvc.perform(get("/ui-contract")) + .andExpect(status().isOk()) + .andReturn(); + + String html = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertTrue(html.contains("Lab Timesheet")); + assertTrue(html.contains("Owned Projects")); + assertTrue(html.contains("Theme")); + assertTrue(html.contains("Logout")); + assertFalse(html.contains("Accounts")); + assertFalse(html.contains("My attendance")); + assertTrue(html.indexOf("/assets/theme.js") < html.indexOf("/assets/app.css")); + assertTrue(html.contains("href=\"/assets/icons.svg#panel-left\"")); + } + + @Test + void compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite() throws Exception { + ClassPathResource css = new ClassPathResource("static/assets/app.css"); + ClassPathResource theme = new ClassPathResource("static/assets/theme.js"); + ClassPathResource script = new ClassPathResource("static/assets/app.js"); + ClassPathResource sprite = new ClassPathResource("static/assets/icons.svg"); + + assertTrue(css.exists()); + assertTrue(theme.exists()); + assertTrue(script.exists()); + assertTrue(sprite.exists()); + + String icons = sprite.getContentAsString(StandardCharsets.UTF_8); + assertTrue(icons.contains("id=\"panel-left\"")); + assertTrue(icons.contains("id=\"circle-user-round\"")); + assertFalse(icons.contains(" + + +
+
+
+ + + + diff --git a/src/test/resources/templates/test/layout-consumer.html b/src/test/resources/templates/test/layout-consumer.html new file mode 100644 index 0000000..2cbcca3 --- /dev/null +++ b/src/test/resources/templates/test/layout-consumer.html @@ -0,0 +1,15 @@ + + + +Create Project +
+

Domain-owned content

+
+ + From bc70db1d0d8eaa68bb8e22db44e38af27b0fa945 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:46:09 +0700 Subject: [PATCH 08/62] feat: add bootstrap and SMTP onboarding --- .../integration/first-admin-bootstrap.md | 74 +++++++ docs/tests/integration/smtp-onboarding.md | 75 +++++++ pom.xml | 4 + .../labtimesheet/LabtimesheetApplication.java | 4 + .../accounts/BootstrapAccessFilter.java | 34 ++++ .../accounts/BootstrapController.java | 46 +++++ .../accounts/BootstrapService.java | 82 ++++++++ .../labtimesheet/accounts/HomeController.java | 12 ++ .../accounts/JdbcUserDetailsService.java | 37 ++++ .../accounts/SecurityConfiguration.java | 38 ++++ .../configuration/JavaMailSmtpProbe.java | 34 ++++ .../configuration/SecretCipher.java | 49 +++++ .../configuration/SecurityProperties.java | 29 +++ .../SmtpConfigurationService.java | 183 ++++++++++++++++++ .../configuration/SmtpController.java | 55 ++++++ .../labtimesheet/configuration/SmtpProbe.java | 6 + src/main/resources/application-dev.yaml | 4 + .../resources/templates/bootstrap/form.html | 16 ++ src/main/resources/templates/home.html | 5 + src/main/resources/templates/smtp/form.html | 19 ++ .../BootstrapIntegrationTest.java | 77 ++++++++ .../PlatformDatabaseTestSupport.java | 17 ++ .../lab/labtimesheet/SmtpIntegrationTest.java | 86 ++++++++ 23 files changed, 986 insertions(+) create mode 100644 docs/tests/integration/first-admin-bootstrap.md create mode 100644 docs/tests/integration/smtp-onboarding.md create mode 100644 src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java create mode 100644 src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java create mode 100644 src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java create mode 100644 src/main/java/com/lab/labtimesheet/accounts/HomeController.java create mode 100644 src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java create mode 100644 src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/SmtpController.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java create mode 100644 src/main/resources/templates/bootstrap/form.html create mode 100644 src/main/resources/templates/home.html create mode 100644 src/main/resources/templates/smtp/form.html create mode 100644 src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java create mode 100644 src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java create mode 100644 src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java diff --git a/docs/tests/integration/first-admin-bootstrap.md b/docs/tests/integration/first-admin-bootstrap.md new file mode 100644 index 0000000..c722108 --- /dev/null +++ b/docs/tests/integration/first-admin-bootstrap.md @@ -0,0 +1,74 @@ +# Test Evidence: Atomic first administrator bootstrap + +- **Test type:** Integration +- **Requirement IDs:** `ACC-001–ACC-004, SEC-001–SEC-002, GOV-013` +- **Scenario IDs:** `AC-ACC-001, AC-ACC-002, AC-SEC-001` +- **Test class/method:** `com.lab.labtimesheet.BootstrapIntegrationTest` +- **Implementation commit:** `this milestone commit` + +## Protected behavior + +Before initialization only bootstrap and health are reachable. Concurrent valid submissions create exactly one active Admin, atomically persist initialization, and permanently close bootstrap. + +## Test method + +A PostgreSQL 18.4 integration test releases two Java 25 virtual-thread-safe requests onto the same service concurrently and asserts the row-locked outcomes and database state. MockMvc checks pre/post-bootstrap route exposure. + +## Hand-derived expected result + +Two simultaneous submissions produce one `CREATED`, one `ALREADY_INITIALIZED`, one Admin row, and one initialized singleton. Later bootstrap requests cannot create another Admin. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test +``` + +**Observed result** + +```text +BootstrapIntegrationTest.java: cannot find symbol class BootstrapService +17 compilation errors +BUILD FAILURE +``` + +The public bootstrap behavior did not exist. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +./mvnw test +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +The command used the Java 25 and OrbStack environment exports shown above. + +## External-test boundaries + +This test does not prove deployment-network privacy for the temporary bootstrap route. Operations must still bootstrap on a private interface before public exposure. diff --git a/docs/tests/integration/smtp-onboarding.md b/docs/tests/integration/smtp-onboarding.md new file mode 100644 index 0000000..3b411f2 --- /dev/null +++ b/docs/tests/integration/smtp-onboarding.md @@ -0,0 +1,75 @@ +# Test Evidence: SMTP draft, test, and activation + +- **Test type:** Integration +- **Requirement IDs:** `INT-001–INT-008, ACC-011, SEC-001` +- **Scenario IDs:** `AC-INT-001, AC-INT-002, AC-ACC-004` +- **Test class/method:** `com.lab.labtimesheet.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted` +- **Implementation commit:** `this milestone commit` + +## Protected behavior + +SMTP credentials are AES-256-GCM encrypted, only a successfully tested draft can activate, and a failed test cannot alter the draft into an active configuration. + +## Test method + +The test persists a draft against PostgreSQL 18.4 using a deterministic test-only master key and a recording SMTP boundary. It forces send failure, inspects database state, rejects activation, then allows the probe and activates the tested draft. + +## Hand-derived expected result + +Ciphertext must not contain the submitted password. Failure leaves `status=DRAFT` and `tested_at=null`; activation fails. A successful test sets test provenance and permits exactly that draft to become `ACTIVE`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test +``` + +**Observed result** + +```text +SmtpAccountIntegrationTest.java: cannot find symbol class SmtpConfigurationService +SmtpAccountIntegrationTest.java: cannot find symbol class SmtpProbe +17 compilation errors +BUILD FAILURE +``` + +The SMTP revision and controllable delivery boundaries were absent. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +./mvnw test +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +The command used the Java 25 and OrbStack environment exports shown above. + +## External-test boundaries + +The test intentionally does not contact Mailpit or an external SMTP server. The production adapter is compiled, while delivery semantics are exercised through the recording boundary without network or secret egress. diff --git a/pom.xml b/pom.xml index bcf4400..4002dca 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,10 @@ 25 + + org.springframework.boot + spring-boot-starter-actuator + org.springframework.boot spring-boot-starter-data-jpa diff --git a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java index 33a58b6..317bb73 100644 --- a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java +++ b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java @@ -2,8 +2,12 @@ package com.lab.labtimesheet; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +import com.lab.labtimesheet.configuration.SecurityProperties; @SpringBootApplication +@EnableConfigurationProperties(SecurityProperties.class) public class LabtimesheetApplication { public static void main(String[] args) { diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java b/src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java new file mode 100644 index 0000000..816f8c4 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java @@ -0,0 +1,34 @@ +package com.lab.labtimesheet.accounts; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +class BootstrapAccessFilter extends OncePerRequestFilter { + private final BootstrapService bootstrap; + + BootstrapAccessFilter(BootstrapService bootstrap) { + this.bootstrap = bootstrap; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String path = request.getRequestURI(); + if (!bootstrap.isInitialized() && !allowedBeforeBootstrap(path)) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + chain.doFilter(request, response); + } + + private static boolean allowedBeforeBootstrap(String path) { + return path.equals("/bootstrap") || path.startsWith("/bootstrap/") + || path.equals("/actuator/health") || path.startsWith("/bootstrap-assets/") + || path.equals("/error"); + } +} diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java b/src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java new file mode 100644 index 0000000..d6ae88e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java @@ -0,0 +1,46 @@ +package com.lab.labtimesheet.accounts; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.server.ResponseStatusException; + +@Controller +@RequestMapping("/bootstrap") +class BootstrapController { + private final BootstrapService bootstrap; + + BootstrapController(BootstrapService bootstrap) { + this.bootstrap = bootstrap; + } + + @GetMapping + String form() { + requireOpen(); + return "bootstrap/form"; + } + + @PostMapping + String create(@RequestParam String email, @RequestParam String displayName, @RequestParam String password, + Model model) { + try { + if (bootstrap.bootstrap(email, displayName, password) == BootstrapService.BootstrapOutcome.CREATED) { + return "redirect:/login"; + } + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } catch (IllegalArgumentException validation) { + model.addAttribute("error", validation.getMessage()); + return "bootstrap/form"; + } + } + + private void requireOpen() { + if (bootstrap.isInitialized()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + } +} diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java b/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java new file mode 100644 index 0000000..42dbc58 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java @@ -0,0 +1,82 @@ +package com.lab.labtimesheet.accounts; + +import java.time.Clock; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Locale; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +@Service +public class BootstrapService { + + private final JdbcTemplate jdbc; + private final TransactionTemplate transactions; + private final PasswordEncoder passwords; + private final Clock clock; + + BootstrapService(JdbcTemplate jdbc, TransactionTemplate transactions, PasswordEncoder passwords, Clock clock) { + this.jdbc = jdbc; + this.transactions = transactions; + this.passwords = passwords; + this.clock = clock; + } + + public BootstrapOutcome bootstrap(String email, String displayName, String password) { + String normalizedEmail = normalizeEmail(email); + String normalizedName = requireText(displayName, "Display name"); + requirePassword(password); + + return transactions.execute(status -> { + Boolean initialized = jdbc.queryForObject( + "select initialized from system_state where singleton_id = 1 for update", Boolean.class); + if (Boolean.TRUE.equals(initialized)) { + return BootstrapOutcome.ALREADY_INITIALIZED; + } + + OffsetDateTime now = OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); + Long userId = jdbc.queryForObject(""" + insert into app_users + (email, display_name, password_hash, global_role, account_status, activated_at, created_at, updated_at) + values (?, ?, ?, 'ADMIN', 'ACTIVE', ?, ?, ?) + returning id + """, Long.class, normalizedEmail, normalizedName, passwords.encode(password), now, now, now); + jdbc.update(""" + update system_state + set initialized = true, initialized_at = ?, bootstrap_admin_id = ?, updated_at = ?, version = version + 1 + where singleton_id = 1 + """, now, userId, now); + return BootstrapOutcome.CREATED; + }); + } + + public boolean isInitialized() { + return Boolean.TRUE.equals(jdbc.queryForObject( + "select initialized from system_state where singleton_id = 1", Boolean.class)); + } + + static String normalizeEmail(String email) { + return requireText(email, "Email").toLowerCase(Locale.ROOT); + } + + static void requirePassword(String password) { + if (password == null || password.length() < 12 || password.length() > 128) { + throw new IllegalArgumentException("Password must contain 12 through 128 characters"); + } + } + + private static String requireText(String value, String field) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(field + " is required"); + } + return value.trim(); + } + + public enum BootstrapOutcome { + CREATED, + ALREADY_INITIALIZED + } +} diff --git a/src/main/java/com/lab/labtimesheet/accounts/HomeController.java b/src/main/java/com/lab/labtimesheet/accounts/HomeController.java new file mode 100644 index 0000000..4cc0e94 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/HomeController.java @@ -0,0 +1,12 @@ +package com.lab.labtimesheet.accounts; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +class HomeController { + @GetMapping("/") + String home() { + return "home"; + } +} diff --git a/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java b/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java new file mode 100644 index 0000000..3ef4067 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java @@ -0,0 +1,37 @@ +package com.lab.labtimesheet.accounts; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +class JdbcUserDetailsService implements UserDetailsService { + private final JdbcTemplate jdbc; + + JdbcUserDetailsService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + String email = BootstrapService.normalizeEmail(username); + return jdbc.query(""" + select email, password_hash, global_role, account_status + from app_users where lower(btrim(email)) = ? + """, resultSet -> { + if (!resultSet.next()) { + throw new UsernameNotFoundException("Invalid credentials"); + } + boolean active = "ACTIVE".equals(resultSet.getString("account_status")); + String hash = resultSet.getString("password_hash"); + return User.withUsername(resultSet.getString("email")) + .password(hash == null ? "{noop}unavailable" : hash) + .roles(resultSet.getString("global_role")) + .disabled(!active) + .build(); + }, email); + } +} diff --git a/src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java b/src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java new file mode 100644 index 0000000..4c85628 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java @@ -0,0 +1,38 @@ +package com.lab.labtimesheet.accounts; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.intercept.AuthorizationFilter; + +@Configuration(proxyBeanMethods = false) +class SecurityConfiguration { + + @Bean + PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + BootstrapAccessFilter bootstrapAccessFilter(BootstrapService bootstrap) { + return new BootstrapAccessFilter(bootstrap); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http, BootstrapAccessFilter bootstrapAccessFilter) + throws Exception { + return http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/bootstrap/**", "/activate/**", "/login", "/error", "/actuator/health") + .permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated()) + .formLogin(form -> form.defaultSuccessUrl("/", true)) + .logout(logout -> logout.logoutSuccessUrl("/login?logout")) + .addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class) + .build(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java b/src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java new file mode 100644 index 0000000..797d707 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java @@ -0,0 +1,34 @@ +package com.lab.labtimesheet.configuration; + +import java.util.Properties; + +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.stereotype.Component; + +@Component +class JavaMailSmtpProbe implements SmtpProbe { + + @Override + public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body) { + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + sender.setHost(connection.host()); + sender.setPort(connection.port()); + sender.setUsername(connection.username()); + sender.setPassword(connection.password()); + Properties properties = sender.getJavaMailProperties(); + if (connection.securityMode() == SmtpConfigurationService.SecurityMode.STARTTLS) { + properties.setProperty("mail.smtp.starttls.enable", "true"); + properties.setProperty("mail.smtp.starttls.required", "true"); + } else if (connection.securityMode() == SmtpConfigurationService.SecurityMode.TLS) { + sender.setProtocol("smtps"); + } + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(connection.fromAddress()); + message.setTo(recipient); + message.setSubject(subject); + message.setText(body); + sender.send(message); + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java b/src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java new file mode 100644 index 0000000..25433e7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java @@ -0,0 +1,49 @@ +package com.lab.labtimesheet.configuration; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Component; + +@Component +public class SecretCipher { + private static final int NONCE_BYTES = 12; + private static final int GCM_TAG_BITS = 128; + + private final SecretKeySpec key; + private final SecureRandom random = new SecureRandom(); + + SecretCipher(SecurityProperties properties) { + this.key = new SecretKeySpec(properties.decodedMasterKey(), "AES"); + } + + EncryptedSecret encrypt(String plaintext) { + byte[] nonce = new byte[NONCE_BYTES]; + random.nextBytes(nonce); + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, nonce)); + return new EncryptedSecret(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)), nonce, 1); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Unable to encrypt integration secret", exception); + } + } + + String decrypt(byte[] ciphertext, byte[] nonce) { + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, nonce)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Unable to decrypt integration secret", exception); + } + } + + record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) { + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java b/src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java new file mode 100644 index 0000000..a4cdace --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java @@ -0,0 +1,29 @@ +package com.lab.labtimesheet.configuration; + +import java.util.Base64; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("lab.security") +public class SecurityProperties { + private String masterKey; + + public String getMasterKey() { + return masterKey; + } + + public void setMasterKey(String masterKey) { + this.masterKey = masterKey; + } + + byte[] decodedMasterKey() { + if (masterKey == null || masterKey.isBlank()) { + throw new IllegalStateException("lab.security.master-key is required"); + } + byte[] decoded = Base64.getDecoder().decode(masterKey); + if (decoded.length != 32) { + throw new IllegalStateException("lab.security.master-key must decode to 256 bits"); + } + return decoded; + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java b/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java new file mode 100644 index 0000000..89387f0 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java @@ -0,0 +1,183 @@ +package com.lab.labtimesheet.configuration; + +import java.time.Clock; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +@Service +public class SmtpConfigurationService { + private final JdbcTemplate jdbc; + private final TransactionTemplate transactions; + private final SecretCipher secrets; + private final SmtpProbe probe; + private final Environment environment; + private final Clock clock; + + SmtpConfigurationService(JdbcTemplate jdbc, TransactionTemplate transactions, SecretCipher secrets, + SmtpProbe probe, Environment environment, Clock clock) { + this.jdbc = jdbc; + this.transactions = transactions; + this.secrets = secrets; + this.probe = probe; + this.environment = environment; + this.clock = clock; + } + + public long saveDraft(long adminId, SmtpDraft draft) { + validate(draft); + SecretCipher.EncryptedSecret password = draft.password() == null ? null : secrets.encrypt(draft.password()); + OffsetDateTime now = now(); + + return transactions.execute(status -> { + Long existing = jdbc.query("select id from smtp_configurations where status = 'DRAFT' for update", + resultSet -> resultSet.next() ? resultSet.getLong(1) : null); + Object[] values = values(draft, password, adminId, now); + if (existing == null) { + return jdbc.queryForObject(""" + insert into smtp_configurations + (status, host, port, security_mode, username, password_ciphertext, password_nonce, + secret_key_version, from_address, from_name, created_by_user_id, created_at, updated_at) + values ('DRAFT', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + returning id + """, Long.class, values); + } + jdbc.update(""" + update smtp_configurations + set host = ?, port = ?, security_mode = ?, username = ?, password_ciphertext = ?, + password_nonce = ?, secret_key_version = ?, from_address = ?, from_name = ?, + tested_at = null, tested_by_user_id = null, updated_at = ?, version = version + 1 + where id = ? + """, draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()), + password == null ? null : password.ciphertext(), password == null ? null : password.nonce(), + password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(), + now, existing); + return existing; + }); + } + + private static Object[] values(SmtpDraft draft, SecretCipher.EncryptedSecret password, long adminId, + OffsetDateTime now) { + return new Object[] { + draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()), + password == null ? null : password.ciphertext(), password == null ? null : password.nonce(), + password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(), + adminId, now, now + }; + } + + public void testDraft(long draftId, long adminId, String recipient) { + SmtpConnection connection = load(draftId, "DRAFT"); + probe.send(connection, recipient, "Lab Timesheet SMTP test", "SMTP configuration test succeeded."); + OffsetDateTime now = now(); + if (jdbc.update(""" + update smtp_configurations + set tested_at = ?, tested_by_user_id = ?, updated_at = ?, version = version + 1 + where id = ? and status = 'DRAFT' + """, now, adminId, now, draftId) != 1) { + throw new IllegalStateException("SMTP draft is no longer available"); + } + } + + public void activate(long draftId, long adminId) { + transactions.executeWithoutResult(status -> { + OffsetDateTime testedAt = jdbc.query(""" + select tested_at from smtp_configurations where id = ? and status = 'DRAFT' for update + """, resultSet -> resultSet.next() ? resultSet.getObject(1, OffsetDateTime.class) : null, draftId); + if (testedAt == null) { + throw new IllegalStateException("SMTP draft must pass a test before activation"); + } + OffsetDateTime now = now(); + jdbc.update(""" + update smtp_configurations + set status = 'RETIRED', retired_at = ?, retired_by_user_id = ?, updated_at = ?, version = version + 1 + where status = 'ACTIVE' + """, now, adminId, now); + jdbc.update(""" + update smtp_configurations + set status = 'ACTIVE', activated_at = ?, activated_by_user_id = ?, updated_at = ?, version = version + 1 + where id = ? and status = 'DRAFT' + """, now, adminId, now, draftId); + }); + } + + public boolean hasActiveConfiguration() { + return jdbc.queryForObject("select exists(select 1 from smtp_configurations where status = 'ACTIVE')", + Boolean.class); + } + + public SmtpConnection activeConnection() { + return jdbc.query("select id from smtp_configurations where status = 'ACTIVE'", + resultSet -> { + if (!resultSet.next()) { + throw new IllegalStateException("Active SMTP configuration is required"); + } + return load(resultSet.getLong(1), "ACTIVE"); + }); + } + + public void sendWithActiveConfiguration(String recipient, String subject, String body) { + probe.send(activeConnection(), recipient, subject, body); + } + + private SmtpConnection load(long id, String requiredStatus) { + return jdbc.query(""" + select host, port, security_mode, username, password_ciphertext, password_nonce, + from_address, from_name + from smtp_configurations where id = ? and status = ? + """, resultSet -> { + if (!resultSet.next()) { + throw new IllegalStateException("SMTP configuration is not available"); + } + byte[] ciphertext = resultSet.getBytes("password_ciphertext"); + return new SmtpConnection( + resultSet.getString("host"), resultSet.getInt("port"), + SecurityMode.valueOf(resultSet.getString("security_mode")), + resultSet.getString("username"), + ciphertext == null ? null : secrets.decrypt(ciphertext, resultSet.getBytes("password_nonce")), + resultSet.getString("from_address"), resultSet.getString("from_name")); + }, id, requiredStatus); + } + + private void validate(SmtpDraft draft) { + if (draft.host() == null || draft.host().isBlank() || draft.port() < 1 || draft.port() > 65535 + || draft.securityMode() == null || draft.fromAddress() == null || draft.fromAddress().isBlank() + || draft.fromName() == null || draft.fromName().isBlank()) { + throw new IllegalArgumentException("Valid SMTP host, port, security mode, From address and name are required"); + } + if ((clean(draft.username()) == null) != (draft.password() == null || draft.password().isEmpty())) { + throw new IllegalArgumentException("SMTP username and password must be supplied together"); + } + if (draft.securityMode() == SecurityMode.NONE + && !environment.acceptsProfiles(Profiles.of("dev", "test"))) { + throw new IllegalArgumentException("Plaintext SMTP is allowed only in dev and test"); + } + } + + private OffsetDateTime now() { + return OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); + } + + private static String clean(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + public enum SecurityMode { + NONE, + STARTTLS, + TLS + } + + public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password, + String fromAddress, String fromName) { + } + + public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password, + String fromAddress, String fromName) { + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpController.java b/src/main/java/com/lab/labtimesheet/configuration/SmtpController.java new file mode 100644 index 0000000..4b825c5 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/SmtpController.java @@ -0,0 +1,55 @@ +package com.lab.labtimesheet.configuration; + +import java.security.Principal; + +import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode; +import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +@RequestMapping("/admin/smtp") +class SmtpController { + private final SmtpConfigurationService smtp; + private final JdbcTemplate jdbc; + + SmtpController(SmtpConfigurationService smtp, JdbcTemplate jdbc) { + this.smtp = smtp; + this.jdbc = jdbc; + } + + @GetMapping + String form() { + return "smtp/form"; + } + + @PostMapping("/draft") + String saveDraft(@RequestParam String host, @RequestParam int port, @RequestParam SecurityMode securityMode, + @RequestParam(required = false) String username, @RequestParam(required = false) String password, + @RequestParam String fromAddress, @RequestParam String fromName, Principal principal) { + smtp.saveDraft(adminId(principal), + new SmtpDraft(host, port, securityMode, username, password, fromAddress, fromName)); + return "redirect:/admin/smtp"; + } + + @PostMapping("/test") + String test(@RequestParam long draftId, Principal principal) { + smtp.testDraft(draftId, adminId(principal), principal.getName()); + return "redirect:/admin/smtp"; + } + + @PostMapping("/activate") + String activate(@RequestParam long draftId, Principal principal) { + smtp.activate(draftId, adminId(principal)); + return "redirect:/admin/smtp"; + } + + private long adminId(Principal principal) { + return jdbc.queryForObject("select id from app_users where lower(btrim(email)) = lower(btrim(?))", Long.class, + principal.getName()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java b/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java new file mode 100644 index 0000000..df20793 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java @@ -0,0 +1,6 @@ +package com.lab.labtimesheet.configuration; + +@FunctionalInterface +public interface SmtpProbe { + void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body); +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 765f81d..062ef03 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -6,3 +6,7 @@ spring: mail: host: ${LAB_SMTP_HOST:localhost} port: ${LAB_SMTP_PORT:1025} +lab: + security: + # Explicit non-production key; production must supply its own 256-bit key. + master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= diff --git a/src/main/resources/templates/bootstrap/form.html b/src/main/resources/templates/bootstrap/form.html new file mode 100644 index 0000000..cbc74a0 --- /dev/null +++ b/src/main/resources/templates/bootstrap/form.html @@ -0,0 +1,16 @@ + + +Initialize Lab Timesheet + +
+

Create the first administrator

+

+
+ + + + +
+
+ + diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 0000000..8f23b78 --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,5 @@ + + +Lab Timesheet +

Lab Timesheet

+ diff --git a/src/main/resources/templates/smtp/form.html b/src/main/resources/templates/smtp/form.html new file mode 100644 index 0000000..8117d4b --- /dev/null +++ b/src/main/resources/templates/smtp/form.html @@ -0,0 +1,19 @@ + + +SMTP configuration + +
+

SMTP configuration

+
+ + + + + + + + +
+
+ + diff --git a/src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java b/src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java new file mode 100644 index 0000000..cbb750b --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java @@ -0,0 +1,77 @@ +package com.lab.labtimesheet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import com.lab.labtimesheet.accounts.BootstrapService; +import com.lab.labtimesheet.accounts.BootstrapService.BootstrapOutcome; +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.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class BootstrapIntegrationTest extends PlatformDatabaseTestSupport { + + @Autowired + private BootstrapService bootstrapService; + + @Autowired + private MockMvc mockMvc; + + @Test + void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception { + mockMvc.perform(get("/bootstrap")).andExpect(status().isOk()); + mockMvc.perform(get("/actuator/health")).andExpect(status().isOk()); + mockMvc.perform(get("/")).andExpect(status().isNotFound()); + + bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); + mockMvc.perform(get("/bootstrap")).andExpect(status().isNotFound()); + } + + @Test + void concurrentBootstrapCreatesExactlyOneAdminAndPermanentlyCloses() throws Exception { + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + try (var executor = Executors.newFixedThreadPool(2)) { + for (int i = 0; i < 2; i++) { + int suffix = i; + futures.add(executor.submit(() -> { + ready.countDown(); + start.await(); + return bootstrapService.bootstrap( + "admin" + suffix + "@example.com", "First Admin", "correct horse battery staple"); + })); + } + ready.await(); + start.countDown(); + } + + assertThat(futures).extracting(future -> future.get()).containsExactlyInAnyOrder( + BootstrapOutcome.CREATED, BootstrapOutcome.ALREADY_INITIALIZED); + assertThat(jdbc.queryForObject("select count(*) from app_users", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject( + "select count(*) from app_users where global_role = 'ADMIN' and account_status = 'ACTIVE'", + Integer.class)).isEqualTo(1); + assertThat(bootstrapService.bootstrap( + "another@example.com", "Another", "correct horse battery staple")) + .isEqualTo(BootstrapOutcome.ALREADY_INITIALIZED); + assertThat(jdbc.queryForObject("select initialized from system_state where singleton_id = 1", Boolean.class)) + .isTrue(); + } +} diff --git a/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java b/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java new file mode 100644 index 0000000..e45e249 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java @@ -0,0 +1,17 @@ +package com.lab.labtimesheet; + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +abstract class PlatformDatabaseTestSupport { + + @Autowired + protected JdbcTemplate jdbc; + + @BeforeEach + void resetPlatformData() { + jdbc.execute("TRUNCATE smtp_configurations, user_action_tokens, intern_profiles, app_users RESTART IDENTITY CASCADE"); + jdbc.update("insert into system_state (singleton_id) values (1)"); + } +} diff --git a/src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java b/src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java new file mode 100644 index 0000000..67708ad --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java @@ -0,0 +1,86 @@ +package com.lab.labtimesheet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import com.lab.labtimesheet.accounts.BootstrapService; +import com.lab.labtimesheet.configuration.SmtpConfigurationService; +import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode; +import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft; +import com.lab.labtimesheet.configuration.SmtpProbe; +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.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; + +@Import({TestcontainersConfiguration.class, SmtpIntegrationTest.MailProbeConfiguration.class}) +@SpringBootTest +@ActiveProfiles("test") +class SmtpIntegrationTest extends PlatformDatabaseTestSupport { + + @Autowired + private BootstrapService bootstrapService; + + @Autowired + private SmtpConfigurationService smtpService; + + @Autowired + private RecordingSmtpProbe smtpProbe; + + @Test + void failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted() { + bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); + long adminId = jdbc.queryForObject("select id from app_users", Long.class); + long draftId = smtpService.saveDraft(adminId, new SmtpDraft( + "mailpit", 1025, SecurityMode.NONE, "smtp-user", "smtp-password", "admin@example.com", "Lab")); + + byte[] ciphertext = jdbc.queryForObject( + "select password_ciphertext from smtp_configurations where id = ?", byte[].class, draftId); + assertThat(new String(ciphertext, StandardCharsets.ISO_8859_1)).doesNotContain("smtp-password"); + assertThat(jdbc.queryForObject("select octet_length(password_nonce) from smtp_configurations where id = ?", + Integer.class, draftId)).isEqualTo(12); + assertThat(jdbc.queryForObject("select secret_key_version from smtp_configurations where id = ?", + Integer.class, draftId)).isEqualTo(1); + smtpProbe.fail = true; + assertThatThrownBy(() -> smtpService.testDraft(draftId, adminId, "admin@example.com")) + .isInstanceOf(IllegalStateException.class); + assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId)) + .isEqualTo("DRAFT"); + assertThat(jdbc.queryForObject("select tested_at is null from smtp_configurations where id = ?", Boolean.class, + draftId)).isTrue(); + assertThatThrownBy(() -> smtpService.activate(draftId, adminId)).isInstanceOf(IllegalStateException.class); + + smtpProbe.fail = false; + smtpService.testDraft(draftId, adminId, "admin@example.com"); + smtpService.activate(draftId, adminId); + + assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId)) + .isEqualTo("ACTIVE"); + } + + @TestConfiguration(proxyBeanMethods = false) + static class MailProbeConfiguration { + @Bean + @Primary + RecordingSmtpProbe recordingSmtpProbe() { + return new RecordingSmtpProbe(); + } + } + + static final class RecordingSmtpProbe implements SmtpProbe { + private boolean fail; + + @Override + public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, + String body) { + if (fail) { + throw new IllegalStateException("simulated SMTP failure"); + } + } + } +} From 1ed23f4de9abdfb06108522f9d6a3257a60eca10 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:48:16 +0700 Subject: [PATCH 09/62] feat(tasks): add task pages and request boundaries --- docs/tests/integration/task-workflow.md | 6 +- docs/tests/web/task-pages.md | 78 +++++++++++ .../tasks/TaskAssigneeChoice.java | 3 + .../labtimesheet/tasks/TaskController.java | 108 ++++++++++++++ .../labtimesheet/tasks/TaskCreateForm.java | 13 ++ .../lab/labtimesheet/tasks/TaskService.java | 26 ++++ .../resources/templates/tasks/detail.html | 36 +++++ src/main/resources/templates/tasks/form.html | 37 +++++ src/main/resources/templates/tasks/list.html | 32 +++++ .../TaskCreationIntegrationTest.java | 11 ++ .../tasks/TaskControllerTest.java | 132 ++++++++++++++++++ 11 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 docs/tests/web/task-pages.md create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskController.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java create mode 100644 src/main/resources/templates/tasks/detail.html create mode 100644 src/main/resources/templates/tasks/form.html create mode 100644 src/main/resources/templates/tasks/list.html create mode 100644 src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java diff --git a/docs/tests/integration/task-workflow.md b/docs/tests/integration/task-workflow.md index 0eb7806..0ac76ea 100644 --- a/docs/tests/integration/task-workflow.md +++ b/docs/tests/integration/task-workflow.md @@ -12,7 +12,7 @@ PostgreSQL-backed Task operations preserve generic same-Project membership actor ## Test method -Eight transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. +Nine transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. ## Hand-derived expected result @@ -65,7 +65,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -79,7 +79,7 @@ export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -[INFO] Tests run: 30, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` diff --git a/docs/tests/web/task-pages.md b/docs/tests/web/task-pages.md new file mode 100644 index 0000000..d7db642 --- /dev/null +++ b/docs/tests/web/task-pages.md @@ -0,0 +1,78 @@ +# Test Evidence: Task pages and server-side request boundaries + +- **Test type:** Web +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-007`, `TSK-011`, `TSK-012` +- **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` +- **Test class/method:** `com.lab.labtimesheet.tasks.TaskControllerTest` +- **Implementation commit:** `pending` + +## Protected behavior + +Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, and show `N/A` for an empty Project. + +## Test method + +Six `@WebMvcTest` MockMvc tests render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, and exception-to-status mapping. Only the PostgreSQL-backed Task service is replaced at the controller boundary. + +## Hand-derived expected result + +Unauthenticated list access returns 401 under the current platform security baseline. An authorized empty list returns 200 and contains `N/A`. A denied guessed Task returns 404. A valid create request passes Project 10, assignee membership 7, the supplied fields, and the authenticated email to the service, then redirects to Task 25. Blank title stays on the form with a field error and no write. Valid status/comment posts redirect to Task 25. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskControllerTest test +``` + +**Observed result** + +```text +[ERROR] TaskControllerTest.java:[28,13] cannot find symbol + symbol: class TaskController +[INFO] BUILD FAILURE +``` + +The first sandboxed GREEN attempt then exposed an environment boundary, not an application failure: Mockito could not use Java 25 self-attach inside the restricted sandbox. The exact same command was rerun with approved escalation; one test expectation was corrected from a login redirect to the platform baseline's observed 401 response before the final GREEN run. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskControllerTest test +``` + +Run with approved sandbox escalation for Mockito Java 25 self-attach. + +**Observed result** + +```text +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## 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 test + +[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +The suite ran with approved escalation for OrbStack and Mockito self-attach. + +## External-test boundaries + +This slice test does not prove PostgreSQL state changes; those are covered by `TaskCreationIntegrationTest`. Shared shell styling/navigation remains owned by `work/reports-ui`. Browser journeys, notifications, Iteration 2 workflows, and narrow-screen behavior are outside this Iteration 1 Task evidence. diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java b/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java new file mode 100644 index 0000000..6e09bb1 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java @@ -0,0 +1,3 @@ +package com.lab.labtimesheet.tasks; + +public record TaskAssigneeChoice(long membershipId, String displayName) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskController.java b/src/main/java/com/lab/labtimesheet/tasks/TaskController.java new file mode 100644 index 0000000..0188b1a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskController.java @@ -0,0 +1,108 @@ +package com.lab.labtimesheet.tasks; + +import jakarta.validation.Valid; +import java.util.Locale; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +public class TaskController { + + private final TaskService taskService; + + public TaskController(TaskService taskService) { + this.taskService = taskService; + } + + @GetMapping("/projects/{projectId}/tasks") + String list(Authentication authentication, @PathVariable long projectId, Model model) { + TaskListView taskList = taskService.list(authentication.getName(), projectId); + model.addAttribute("projectId", projectId); + model.addAttribute("taskList", taskList); + model.addAttribute("progressLabel", progressLabel(taskList.progress())); + return "tasks/list"; + } + + @GetMapping("/projects/{projectId}/tasks/new") + String createForm(Authentication authentication, @PathVariable long projectId, Model model) { + model.addAttribute("taskForm", new TaskCreateForm("", "", null, null)); + populateForm(authentication.getName(), projectId, model); + return "tasks/form"; + } + + @PostMapping("/projects/{projectId}/tasks") + String create( + Authentication authentication, + @PathVariable long projectId, + @Valid @ModelAttribute("taskForm") TaskCreateForm form, + BindingResult bindingResult, + Model model) { + if (bindingResult.hasErrors()) { + populateForm(authentication.getName(), projectId, model); + return "tasks/form"; + } + TaskView task = taskService.create( + authentication.getName(), + new CreateTaskCommand( + projectId, + form.assigneeMembershipId(), + form.title(), + form.description(), + form.dueDate())); + return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id()); + } + + @GetMapping("/projects/{projectId}/tasks/{taskId}") + String details( + Authentication authentication, + @PathVariable long projectId, + @PathVariable long taskId, + Model model) { + model.addAttribute("projectId", projectId); + model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId)); + model.addAttribute("statuses", TaskStatus.values()); + return "tasks/detail"; + } + + @PostMapping("/projects/{projectId}/tasks/{taskId}/status") + String changeStatus( + Authentication authentication, + @PathVariable long projectId, + @PathVariable long taskId, + @RequestParam TaskStatus status) { + taskService.changeStatus(authentication.getName(), projectId, taskId, status); + return detailsRedirect(projectId, taskId); + } + + @PostMapping("/projects/{projectId}/tasks/{taskId}/comments") + String addComment( + Authentication authentication, + @PathVariable long projectId, + @PathVariable long taskId, + @RequestParam String body) { + taskService.addComment(authentication.getName(), projectId, taskId, body); + return detailsRedirect(projectId, taskId); + } + + private void populateForm(String actorEmail, long projectId, Model model) { + model.addAttribute("projectId", projectId); + model.addAttribute("assignees", taskService.assignmentChoices(actorEmail, projectId)); + } + + private static String detailsRedirect(long projectId, long taskId) { + return "redirect:/projects/%d/tasks/%d".formatted(projectId, taskId); + } + + private static String progressLabel(TaskProgress progress) { + return progress.completionPercentage().isEmpty() + ? "N/A" + : String.format(Locale.ROOT, "%.1f%%", progress.completionPercentage().getAsDouble()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java b/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java new file mode 100644 index 0000000..fb0a92d --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java @@ -0,0 +1,13 @@ +package com.lab.labtimesheet.tasks; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +public record TaskCreateForm( + @NotBlank @Size(max = 200) String title, + String description, + @NotNull Long assigneeMembershipId, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueDate) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java index 2765ba8..03fbe68 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java @@ -153,6 +153,32 @@ public class TaskService { return new TaskDetails(task, comments); } + @Transactional(readOnly = true) + public List assignmentChoices(String actorEmail, long projectId) { + Actor actor = requireActiveActor(actorEmail); + requireOpenProject(projectId); + long actorMembershipId = requireActorMembership(projectId, actor.id()); + boolean leader = isCurrentLeader(projectId, actorMembershipId); + return jdbc.sql(""" + select m.id, u.display_name + from project_memberships m + join app_users u on u.id = m.intern_user_id + join intern_profiles i on i.user_id = m.intern_user_id + where m.project_id = :projectId + and m.left_at is null + and u.account_status = 'ACTIVE' + and i.internship_status = 'ACTIVE' + and (:leader or m.id = :actorMembershipId) + order by m.id + """) + .param("projectId", projectId) + .param("leader", leader) + .param("actorMembershipId", actorMembershipId) + .query((rs, rowNum) -> new TaskAssigneeChoice( + rs.getLong("id"), rs.getString("display_name"))) + .list(); + } + private Actor requireActiveActor(String email) { Actor actor = requireReadableActor(email); if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) { diff --git a/src/main/resources/templates/tasks/detail.html b/src/main/resources/templates/tasks/detail.html new file mode 100644 index 0000000..1e9ba7d --- /dev/null +++ b/src/main/resources/templates/tasks/detail.html @@ -0,0 +1,36 @@ + + + + + + Task + + +
+

Task

+

No description

+

Status: TODO

+

Due date:

+ +
+ + + +
+ +
+

Comments

+
    +
  1. Comment
  2. +
+
+ + + +
+
+
+ + diff --git a/src/main/resources/templates/tasks/form.html b/src/main/resources/templates/tasks/form.html new file mode 100644 index 0000000..c604e00 --- /dev/null +++ b/src/main/resources/templates/tasks/form.html @@ -0,0 +1,37 @@ + + + + + + Create Task + + +
+

Create Task

+
+
+ + +

Title error

+
+
+ + +
+
+ + +

Assignee error

+
+
+ + +
+ +
+
+ + diff --git a/src/main/resources/templates/tasks/list.html b/src/main/resources/templates/tasks/list.html new file mode 100644 index 0000000..7b830e0 --- /dev/null +++ b/src/main/resources/templates/tasks/list.html @@ -0,0 +1,32 @@ + + + + + + Project tasks + + +
+

Project tasks

+

Progress: N/A

+
+
TODO
0
+
IN_PROGRESS
0
+
BLOCKED
0
+
DONE
0
+
+

Create Task

+ + + + + + + + + + +
Current non-deleted Tasks
TitleStatusDue date
TaskTODO
+
+ + diff --git a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java b/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java index 57f9634..ff52682 100644 --- a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java @@ -7,6 +7,7 @@ import com.lab.labtimesheet.tasks.CreateTaskCommand; import com.lab.labtimesheet.tasks.TaskCommentView; import com.lab.labtimesheet.tasks.TaskDetails; import com.lab.labtimesheet.tasks.TaskListView; +import com.lab.labtimesheet.tasks.TaskAssigneeChoice; import com.lab.labtimesheet.tasks.TaskNotFoundException; import com.lab.labtimesheet.tasks.TaskService; import com.lab.labtimesheet.tasks.TaskStatus; @@ -233,6 +234,16 @@ class TaskCreationIntegrationTest { .isInstanceOf(TaskNotFoundException.class); } + @Test + void createFormChoicesAreSelfOnlyForMembersAndAllActiveMembersForLeader() { + assertThat(taskService.assignmentChoices("member@example.test", projectId)) + .extracting(TaskAssigneeChoice::membershipId) + .containsExactly(memberMembershipId); + assertThat(taskService.assignmentChoices("leader@example.test", projectId)) + .extracting(TaskAssigneeChoice::membershipId) + .containsExactly(leaderMembershipId, memberMembershipId); + } + private long insertUser(String email, String role) { return jdbc.sql(""" insert into app_users diff --git a/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java b/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java new file mode 100644 index 0000000..6f69df5 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java @@ -0,0 +1,132 @@ +package com.lab.labtimesheet.tasks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(TaskController.class) +class TaskControllerTest { + + private static final String ACTOR_EMAIL = "member@example.test"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private TaskService taskService; + + @Test + void taskListRequiresAuthentication() throws Exception { + mockMvc.perform(get("/projects/10/tasks")) + .andExpect(status().isUnauthorized()); + + verifyNoInteractions(taskService); + } + + @Test + void emptyTaskListRendersNotApplicableProgress() throws Exception { + given(taskService.list(ACTOR_EMAIL, 10L)) + .willReturn(new TaskListView(List.of(), TaskProgress.from(List.of()))); + + mockMvc.perform(get("/projects/10/tasks").with(user(ACTOR_EMAIL))) + .andExpect(status().isOk()) + .andExpect(view().name("tasks/list")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("N/A"))); + } + + @Test + void guessedTaskIdentifierReturnsNotFoundWithoutRenderingDetails() throws Exception { + given(taskService.details(ACTOR_EMAIL, 10L, 999L)).willThrow(new TaskNotFoundException()); + + mockMvc.perform(get("/projects/10/tasks/999").with(user(ACTOR_EMAIL))) + .andExpect(status().isNotFound()); + } + + @Test + void validCreateFormUsesAuthenticatedIdentityAndRedirectsToCreatedTask() throws Exception { + given(taskService.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class))) + .willReturn(task(25L)); + + mockMvc.perform(post("/projects/10/tasks") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("title", "Draft") + .param("description", "Notes") + .param("assigneeMembershipId", "7") + .param("dueDate", "2026-08-20")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/10/tasks/25")); + + ArgumentCaptor command = ArgumentCaptor.forClass(CreateTaskCommand.class); + verify(taskService).create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), command.capture()); + assertThat(command.getValue()).isEqualTo(new CreateTaskCommand( + 10L, 7L, "Draft", "Notes", LocalDate.of(2026, 8, 20))); + } + + @Test + void blankCreateFormRendersValidationErrorWithoutWriting() throws Exception { + given(taskService.assignmentChoices(ACTOR_EMAIL, 10L)) + .willReturn(List.of(new TaskAssigneeChoice(7L, "Member"))); + + mockMvc.perform(post("/projects/10/tasks") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("title", " ") + .param("assigneeMembershipId", "7")) + .andExpect(status().isOk()) + .andExpect(view().name("tasks/form")) + .andExpect(model().attributeHasFieldErrors("taskForm", "title")); + + verify(taskService, org.mockito.Mockito.never()) + .create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class)); + } + + @Test + void statusAndCommentPostsUseAuthenticatedIdentityAndCsrf() throws Exception { + given(taskService.changeStatus(ACTOR_EMAIL, 10L, 25L, TaskStatus.IN_PROGRESS)) + .willReturn(task(25L)); + given(taskService.addComment(ACTOR_EMAIL, 10L, 25L, "Update")) + .willReturn(new TaskCommentView(3L, 25L, 5L, "Update", Instant.parse("2026-08-14T10:00:00Z"))); + + mockMvc.perform(post("/projects/10/tasks/25/status") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("status", "IN_PROGRESS")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/10/tasks/25")); + mockMvc.perform(post("/projects/10/tasks/25/comments") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("body", "Update")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/10/tasks/25")); + } + + private static TaskView task(long id) { + Instant instant = Instant.parse("2026-08-14T10:00:00Z"); + return new TaskView( + id, 10L, 7L, "Draft", "Notes", TaskStatus.TODO, + LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant); + } +} From 5638286b90bca024ba080112ece649b0d1399711 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:11:42 +0700 Subject: [PATCH 10/62] feat(reporting): add role dashboard template contracts --- docs/tests/web/dashboard-template-contract.md | 82 ++++++++++++++ docs/tests/web/ui-shell-components.md | 2 +- .../reporting/model/dto/DashboardView.java | 39 +++++++ .../reporting/ModuleBoundary.java | 7 -- src/main/resources/static/assets/app.css | 2 +- .../resources/templates/dashboard/admin.html | 26 +++++ .../resources/templates/dashboard/intern.html | 35 ++++++ .../resources/templates/dashboard/mentor.html | 23 ++++ .../reporting/ReportingArchitectureTest.java | 30 ++++++ .../controller/DashboardTemplateWebTest.java | 100 ++++++++++++++++++ 10 files changed, 337 insertions(+), 9 deletions(-) create mode 100644 docs/tests/web/dashboard-template-contract.md create mode 100644 src/main/java/com/lab/labtimesheet/feature/reporting/model/dto/DashboardView.java delete mode 100644 src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java create mode 100644 src/main/resources/templates/dashboard/admin.html create mode 100644 src/main/resources/templates/dashboard/intern.html create mode 100644 src/main/resources/templates/dashboard/mentor.html create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java diff --git a/docs/tests/web/dashboard-template-contract.md b/docs/tests/web/dashboard-template-contract.md new file mode 100644 index 0000000..bd4d905 --- /dev/null +++ b/docs/tests/web/dashboard-template-contract.md @@ -0,0 +1,82 @@ +# Test Evidence: role dashboard template contract + +- **Test type:** Web +- **Requirement IDs:** `AUTH-003`, `UI-003`, `UI-013`, `I1-UI-03` +- **Scenario IDs:** `AC-AUTH-002`, `AC-UI-005` +- **Test class/method:** `com.lab.labtimesheet.feature.reporting.ReportingArchitectureTest`, `com.lab.labtimesheet.feature.reporting.controller.DashboardTemplateWebTest` +- **Implementation commit:** `pending` + +## Protected behavior + +Reporting-owned Java starts under `com.lab.labtimesheet.feature.reporting` rather than global layer packages or a placeholder module marker. The Admin, Mentor, and Intern dashboard templates consume typed view DTOs and render role-correct metrics, actions, attendance state, and `dd/MM/yyyy` dates without illustrative production data. + +## Test method + +The architecture test loads the reporting view contract and rejects the superseded global-layer and module-boundary classes. A narrow MVC test controller supplies explicit DTO fixtures to the production Thymeleaf templates so their rendering contract can be verified before cross-feature service APIs are integrated. It does not replace the later database-backed `/dashboard` test. + +## Hand-derived expected result + +Admin markup contains system metrics and `Create account`; Mentor markup contains owned Project and global pending-decision summaries plus `Create Project`; Intern markup contains only the supplied assigned Task, checked-in state, `Check out`, and due date `18/08/2026`. Actions belonging to other roles are absent. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw clean -Dtest=ReportingArchitectureTest test +./mvnw clean -Dtest=DashboardTemplateWebTest test +``` + +**Observed result** + +```text +ReportingArchitectureTest: ClassNotFoundException: com.lab.labtimesheet.feature.reporting.model.dto.DashboardView +Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 + +DashboardTemplateWebTest: Error resolving template [dashboard/admin], [dashboard/mentor], and [dashboard/intern] +Tests run: 3, Failures: 0, Errors: 3, Skipped: 0 +BUILD FAILURE +``` + +The final feature package DTO and the three production dashboard templates were absent in the respective pre-implementation states. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw clean -Dtest=ReportingArchitectureTest,DashboardTemplateWebTest test +``` + +**Observed result** + +```text +Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 25.380 s +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +npm run build +./mvnw -Dtest=UiContractWebTest,ReportingArchitectureTest,DashboardTemplateWebTest test + +v24.19.0 / npm 11.17.0 +Tailwind CSS v4.3.3: Done in 56ms +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 24.823 s +``` + +## External-test boundaries + +These tests prove package and template contracts with controlled view DTOs. They deliberately do not claim that `/dashboard` is connected to real account, Project, Task, attendance, or notification data; that integration remains gated on the platform's cross-feature service APIs and will require a PostgreSQL/Testcontainers test after the pinned platform structure is merged. Browser viewport, contrast, and pre-paint behavior remain integrated UI gates. diff --git a/docs/tests/web/ui-shell-components.md b/docs/tests/web/ui-shell-components.md index 1d9f00d..97afb28 100644 --- a/docs/tests/web/ui-shell-components.md +++ b/docs/tests/web/ui-shell-components.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ARC-004`, `UI-001`–`UI-010`, `UI-013`–`UI-018`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04` - **Scenario IDs:** `AC-UI-001`, `AC-UI-002`, `AC-UI-003`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `bac3981` ## Protected behavior diff --git a/src/main/java/com/lab/labtimesheet/feature/reporting/model/dto/DashboardView.java b/src/main/java/com/lab/labtimesheet/feature/reporting/model/dto/DashboardView.java new file mode 100644 index 0000000..6f84cf4 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/reporting/model/dto/DashboardView.java @@ -0,0 +1,39 @@ +package com.lab.labtimesheet.feature.reporting.model.dto; + +import java.time.LocalDate; +import java.util.List; + +public sealed interface DashboardView { + + record Admin(long activeAccounts, long pendingActivations, + long activeInternships, long activeProjects) implements DashboardView { + } + + record Mentor(String displayName, long activeProjects, long activeMembers, + long blockedTasks, long pendingDecisions) implements DashboardView { + } + + record Intern(String displayName, AttendanceState attendanceState, long activeProjects, + long assignedTasks, long unreadNotifications, + List priorityTasks) implements DashboardView { + } + + record AssignedTask(String title, String projectName, String status, LocalDate dueDate) { + } + + enum AttendanceState { + NOT_CHECKED_IN("Not checked in"), + CHECKED_IN("Checked in"), + CHECKED_OUT("Checked out"); + + private final String label; + + AttendanceState(String label) { + this.label = label; + } + + public String label() { + return label; + } + } +} diff --git a/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java deleted file mode 100644 index c29e4f6..0000000 --- a/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.reporting; - -/** Reporting module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/main/resources/static/assets/app.css b/src/main/resources/static/assets/app.css index 8ea2535..cac788f 100644 --- a/src/main/resources/static/assets/app.css +++ b/src/main/resources/static/assets/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.static{position:static}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#dfe1e5;--border-strong:#c9cdd3;--muted:#626a75;--subtle:#818894;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#30343d;--border-strong:#454b57;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.static{position:static}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#dfe1e5;--border-strong:#c9cdd3;--muted:#626a75;--subtle:#818894;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#30343d;--border-strong:#454b57;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/src/main/resources/templates/dashboard/admin.html b/src/main/resources/templates/dashboard/admin.html new file mode 100644 index 0000000..95ec5c4 --- /dev/null +++ b/src/main/resources/templates/dashboard/admin.html @@ -0,0 +1,26 @@ + + + +Create account +
+

Account readiness, internship activity, and active Project work.

+
+
Active accounts
0
Can authenticate when otherwise eligible
+
Pending activation
0
Awaiting first password
+
Active internships
0
Current Intern lifecycle
+
Active Projects
0
Read-only Admin scope
+
+
+
+

Attention required

+
0 account(s) are waiting for activation.
+
+
+ + diff --git a/src/main/resources/templates/dashboard/intern.html b/src/main/resources/templates/dashboard/intern.html new file mode 100644 index 0000000..131d68f --- /dev/null +++ b/src/main/resources/templates/dashboard/intern.html @@ -0,0 +1,35 @@ + + + +
+ +
+
+

Today.

+
+
Attendance
Not checked in
Server-authoritative state
+
Active Projects
0
Current memberships
+
Assigned Tasks
0
Current assignment scope
+
Unread notifications
0
Own notifications only
+
+
+

Priority Tasks

+ +
+ + + + +
Current assigned Tasks
TaskProjectStatusDue
TaskProjectTODONo due date
+
+
+
+ + diff --git a/src/main/resources/templates/dashboard/mentor.html b/src/main/resources/templates/dashboard/mentor.html new file mode 100644 index 0000000..e5fc470 --- /dev/null +++ b/src/main/resources/templates/dashboard/mentor.html @@ -0,0 +1,23 @@ + + + +Create Project +
+

Review pending decisions, then scan attendance and owned Project health.

+
+
Active owned Projects
0
Only your Project scope
+
Active members
0
Across owned active Projects
+
Blocked Tasks
0
View and comment
+
Pending decisions
0
Global leave and corrections
+
+
+

Decision queue

0 request(s) need Mentor review.
+
+ + diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java new file mode 100644 index 0000000..8eed62b --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java @@ -0,0 +1,30 @@ +package com.lab.labtimesheet.feature.reporting; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class ReportingArchitectureTest { + + @Test + void reportingUsesFeaturePackageWithoutGlobalLayersOrPlaceholderBoundary() throws Exception { + Class view = Class.forName("com.lab.labtimesheet.feature.reporting.model.dto.DashboardView"); + assertTrue(view.isSealed()); + assertFalse(view.getPackageName().startsWith("com.lab.labtimesheet.model")); + + assertMissing("com.lab.labtimesheet.controller.DashboardController"); + assertMissing("com.lab.labtimesheet.dto.DashboardView"); + assertMissing("com.lab.labtimesheet.exception.DashboardAccessDeniedException"); + assertMissing("com.lab.labtimesheet.model.DashboardAccount"); + assertMissing("com.lab.labtimesheet.repository.DashboardRepository"); + assertMissing("com.lab.labtimesheet.service.DashboardService"); + assertMissing("com.lab.labtimesheet.reporting.ModuleBoundary"); + assertMissing("com.lab.labtimesheet.feature.reporting.ModuleBoundary"); + } + + private void assertMissing(String className) { + assertThrows(ClassNotFoundException.class, () -> Class.forName(className)); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java new file mode 100644 index 0000000..e4f790a --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java @@ -0,0 +1,100 @@ +package com.lab.labtimesheet.feature.reporting.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@WebMvcTest(DashboardTemplateWebTest.TemplateController.class) +@Import(DashboardTemplateWebTest.TemplateController.class) +class DashboardTemplateWebTest { + + private final MockMvc mvc; + + @Autowired + DashboardTemplateWebTest(MockMvc mvc) { + this.mvc = mvc; + } + + @Test + @WithMockUser(username = "admin@example.test", roles = "ADMIN") + void adminTemplateRendersSystemMetricsAndOnlyAdminAction() throws Exception { + mvc.perform(get("/template-contract/dashboard/admin")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("System overview"))) + .andExpect(content().string(containsString("Active accounts
3"))) + .andExpect(content().string(containsString("Create account"))) + .andExpect(content().string(not(containsString("Create Project")))) + .andExpect(content().string(not(containsString("Check in")))); + } + + @Test + @WithMockUser(username = "mentor@example.test", roles = "MENTOR") + void mentorTemplateRendersOwnedScopeAndOnlyMentorAction() throws Exception { + mvc.perform(get("/template-contract/dashboard/mentor")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Good morning, Minh Mentor"))) + .andExpect(content().string(containsString("Active owned Projects
1"))) + .andExpect(content().string(containsString("Pending decisions
2"))) + .andExpect(content().string(containsString("Create Project"))) + .andExpect(content().string(not(containsString("Create account")))) + .andExpect(content().string(not(containsString("Check in")))); + } + + @Test + @WithMockUser(username = "intern@example.test", roles = "INTERN") + void internTemplateRendersOwnWorkAndAttendanceAction() throws Exception { + mvc.perform(get("/template-contract/dashboard/intern")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Today"))) + .andExpect(content().string(containsString("Checked in"))) + .andExpect(content().string(containsString("My real task"))) + .andExpect(content().string(containsString("18/08/2026"))) + .andExpect(content().string(containsString("Check out"))) + .andExpect(content().string(not(containsString("Create Project")))) + .andExpect(content().string(not(containsString("Create account")))); + } + + @Controller + public static class TemplateController { + + @GetMapping("/template-contract/dashboard/admin") + String admin(Model model) { + model.addAttribute("dashboard", new DashboardView.Admin(3, 1, 2, 1)); + return "dashboard/admin"; + } + + @GetMapping("/template-contract/dashboard/mentor") + String mentor(Model model) { + model.addAttribute("dashboard", new DashboardView.Mentor("Minh Mentor", 1, 2, 1, 2)); + return "dashboard/mentor"; + } + + @GetMapping("/template-contract/dashboard/intern") + String intern(Model model) { + model.addAttribute("dashboard", new DashboardView.Intern( + "Mai Intern", + DashboardView.AttendanceState.CHECKED_IN, + 1, + 1, + 1, + List.of(new DashboardView.AssignedTask( + "My real task", "Intern Portal", "IN_PROGRESS", LocalDate.of(2026, 8, 18))))); + return "dashboard/intern"; + } + } +} From 3fdfbb2bf2886da5ad63c09226204a0161801d46 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:14:21 +0700 Subject: [PATCH 11/62] refactor: adopt feature package boundaries and JPA --- .../integration/first-admin-bootstrap.md | 10 +- docs/tests/integration/platform-foundation.md | 10 +- docs/tests/integration/smtp-onboarding.md | 8 +- .../unit/package-by-feature-structure.md | 76 +++++++ .../labtimesheet/LabtimesheetApplication.java | 2 +- .../accounts/BootstrapService.java | 82 -------- .../accounts/JdbcUserDetailsService.java | 37 ---- .../labtimesheet/accounts/ModuleBoundary.java | 7 - .../attendance/ModuleBoundary.java | 7 - .../SecurityConfiguration.java | 5 +- .../SecurityProperties.java | 4 +- .../TimeConfiguration.java | 3 +- .../configuration/ModuleBoundary.java | 7 - .../SmtpConfigurationService.java | 183 ---------------- .../labtimesheet/configuration/SmtpProbe.java | 6 - .../controller}/BootstrapAccessFilter.java | 7 +- .../controller}/BootstrapController.java | 3 +- .../account/controller}/HomeController.java | 2 +- .../feature/account/model/AccountStatus.java | 8 + .../feature/account/model/GlobalRole.java | 7 + .../account/model/InternshipStatus.java | 8 + .../account/model/dto/AccountIdentity.java | 12 ++ .../feature/account/model/entity/AppUser.java | 103 +++++++++ .../account/model/entity/InternProfile.java | 61 ++++++ .../account/model/entity/SystemState.java | 57 +++++ .../account/repository/AppUserRepository.java | 17 ++ .../repository/InternProfileRepository.java | 9 + .../repository/SystemStateRepository.java | 15 ++ .../account/service/AccountService.java | 78 +++++++ .../account/service/BootstrapService.java | 73 +++++++ .../service/DatabaseUserDetailsService.java | 32 +++ .../controller}/SmtpController.java | 18 +- .../integration/model/SecurityMode.java | 7 + .../feature/integration/model/SmtpStatus.java | 7 + .../model/dto/EncryptedSecret.java | 18 ++ .../integration/model/dto/SmtpConnection.java | 7 + .../integration/model/dto/SmtpDraft.java | 7 + .../model/entity/SmtpConfiguration.java | 198 ++++++++++++++++++ .../SmtpConfigurationRepository.java | 18 ++ .../service}/JavaMailSmtpProbe.java | 12 +- .../integration/service}/SecretCipher.java | 10 +- .../service/SmtpConfigurationService.java | 116 ++++++++++ .../integration/service/SmtpProbe.java | 8 + .../notifications/ModuleBoundary.java | 7 - .../labtimesheet/projects/ModuleBoundary.java | 7 - .../reporting/ModuleBoundary.java | 7 - .../LabtimesheetApplicationTests.java | 2 + .../PlatformDatabaseTestSupport.java | 17 -- .../TestLabtimesheetApplication.java | 2 + .../config/LayerStructureTest.java | 83 ++++++++ .../{ => config}/PlatformFoundationTest.java | 17 +- .../TestcontainersConfiguration.java | 4 +- .../service}/BootstrapIntegrationTest.java | 45 ++-- .../service}/SmtpIntegrationTest.java | 47 +++-- 54 files changed, 1137 insertions(+), 466 deletions(-) create mode 100644 docs/tests/unit/package-by-feature-structure.md delete mode 100644 src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java delete mode 100644 src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java delete mode 100644 src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java delete mode 100644 src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java rename src/main/java/com/lab/labtimesheet/{accounts => config}/SecurityConfiguration.java (89%) rename src/main/java/com/lab/labtimesheet/{configuration => config}/SecurityProperties.java (90%) rename src/main/java/com/lab/labtimesheet/{configuration => config}/TimeConfiguration.java (86%) delete mode 100644 src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java delete mode 100644 src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java delete mode 100644 src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java rename src/main/java/com/lab/labtimesheet/{accounts => feature/account/controller}/BootstrapAccessFilter.java (80%) rename src/main/java/com/lab/labtimesheet/{accounts => feature/account/controller}/BootstrapController.java (92%) rename src/main/java/com/lab/labtimesheet/{accounts => feature/account/controller}/HomeController.java (79%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java rename src/main/java/com/lab/labtimesheet/{configuration => feature/integration/controller}/SmtpController.java (73%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java rename src/main/java/com/lab/labtimesheet/{configuration => feature/integration/service}/JavaMailSmtpProbe.java (70%) rename src/main/java/com/lab/labtimesheet/{configuration => feature/integration/service}/SecretCipher.java (86%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java delete mode 100644 src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java delete mode 100644 src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java delete mode 100644 src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java create mode 100644 src/test/java/com/lab/labtimesheet/config/LayerStructureTest.java rename src/test/java/com/lab/labtimesheet/{ => config}/PlatformFoundationTest.java (69%) rename src/test/java/com/lab/labtimesheet/{ => config}/TestcontainersConfiguration.java (91%) rename src/test/java/com/lab/labtimesheet/{ => feature/account/service}/BootstrapIntegrationTest.java (56%) rename src/test/java/com/lab/labtimesheet/{ => feature/integration/service}/SmtpIntegrationTest.java (60%) diff --git a/docs/tests/integration/first-admin-bootstrap.md b/docs/tests/integration/first-admin-bootstrap.md index c722108..156e350 100644 --- a/docs/tests/integration/first-admin-bootstrap.md +++ b/docs/tests/integration/first-admin-bootstrap.md @@ -3,16 +3,16 @@ - **Test type:** Integration - **Requirement IDs:** `ACC-001–ACC-004, SEC-001–SEC-002, GOV-013` - **Scenario IDs:** `AC-ACC-001, AC-ACC-002, AC-SEC-001` -- **Test class/method:** `com.lab.labtimesheet.BootstrapIntegrationTest` +- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest` - **Implementation commit:** `this milestone commit` ## Protected behavior -Before initialization only bootstrap and health are reachable. Concurrent valid submissions create exactly one active Admin, atomically persist initialization, and permanently close bootstrap. +Before initialization only bootstrap and health are reachable. Concurrent valid submissions create exactly one active Admin, atomically persist initialization, and permanently close bootstrap. The public account service resolves the winning Admin by normalized email or ID without exposing JPA entities or repositories. ## Test method -A PostgreSQL 18.4 integration test releases two Java 25 virtual-thread-safe requests onto the same service concurrently and asserts the row-locked outcomes and database state. MockMvc checks pre/post-bootstrap route exposure. +A PostgreSQL 18.4 integration test releases two Java 25 virtual-thread-safe requests onto the same service concurrently and asserts the row-locked outcomes and database state through Spring Data JPA. MockMvc checks pre/post-bootstrap route exposure, and the account API is checked against the actual concurrent winner. ## Hand-derived expected result @@ -26,7 +26,7 @@ Two simultaneous submissions produce one `CREATED`, one `ALREADY_INITIALIZED`, o export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock -./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test +./mvnw -Dtest=BootstrapIntegrationTest test ``` **Observed result** @@ -53,7 +53,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/integration/platform-foundation.md b/docs/tests/integration/platform-foundation.md index c7ebe09..104382f 100644 --- a/docs/tests/integration/platform-foundation.md +++ b/docs/tests/integration/platform-foundation.md @@ -3,16 +3,16 @@ - **Test type:** Integration - **Requirement IDs:** `ARC-001–ARC-008, DB-003–DB-012, OPS-003, TST-001–TST-010` - **Scenario IDs:** `AC-DB-001, AC-OPS-002, AC-TST-001` -- **Test class/method:** `com.lab.labtimesheet.PlatformFoundationTest` +- **Test class/method:** `com.lab.labtimesheet.config.PlatformFoundationTest.flywayCreatesApprovedPostgresCatalog`, `com.lab.labtimesheet.config.PlatformFoundationTest.testClockIsDeterministic` - **Implementation commit:** `this milestone commit` ## Protected behavior -The application starts with the six required package boundaries, Flyway creates the approved 23-table/56-foreign-key PostgreSQL catalog and seed, and tests receive deterministic time without a developer database. +Flyway creates the approved 23-table/56-foreign-key PostgreSQL catalog and seed, and tests receive deterministic time without a developer database. Package structure is protected separately by `LayerStructureTest`. ## Test method -A full Spring context starts against a PostgreSQL 18.4 Testcontainer. JDBC catalog queries independently count application tables and foreign keys and inspect the seed. Class loading checks the declared package boundaries, and the injected test `Clock` is asserted exactly. +A full Spring context starts against a PostgreSQL 18.4 Testcontainer. JDBC is used only in this schema/catalog verification test to independently count application tables and foreign keys and inspect the seed. The injected test `Clock` is asserted exactly. ## Hand-derived expected result @@ -55,7 +55,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ```text Successfully applied 1 migration to schema "public", now at version v1 -Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` @@ -69,7 +69,7 @@ export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/integration/smtp-onboarding.md b/docs/tests/integration/smtp-onboarding.md index 3b411f2..7bc8955 100644 --- a/docs/tests/integration/smtp-onboarding.md +++ b/docs/tests/integration/smtp-onboarding.md @@ -3,7 +3,7 @@ - **Test type:** Integration - **Requirement IDs:** `INT-001–INT-008, ACC-011, SEC-001` - **Scenario IDs:** `AC-INT-001, AC-INT-002, AC-ACC-004` -- **Test class/method:** `com.lab.labtimesheet.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted` +- **Test class/method:** `com.lab.labtimesheet.feature.integration.service.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted` - **Implementation commit:** `this milestone commit` ## Protected behavior @@ -12,7 +12,7 @@ SMTP credentials are AES-256-GCM encrypted, only a successfully tested draft can ## Test method -The test persists a draft against PostgreSQL 18.4 using a deterministic test-only master key and a recording SMTP boundary. It forces send failure, inspects database state, rejects activation, then allows the probe and activates the tested draft. +The test persists a draft through Spring Data JPA against PostgreSQL 18.4 using a deterministic test-only master key and a recording SMTP boundary. It forces send failure, inspects database state, rejects activation, then allows the probe and activates the tested draft. ## Hand-derived expected result @@ -26,7 +26,7 @@ Ciphertext must not contain the submitted password. Failure leaves `status=DRAFT export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock -./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test +./mvnw -Dtest=SmtpIntegrationTest test ``` **Observed result** @@ -54,7 +54,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/unit/package-by-feature-structure.md b/docs/tests/unit/package-by-feature-structure.md new file mode 100644 index 0000000..bca5c91 --- /dev/null +++ b/docs/tests/unit/package-by-feature-structure.md @@ -0,0 +1,76 @@ +# Test Evidence: Package-by-feature structure + +- **Test type:** Unit +- **Requirement IDs:** `ARC-001–ARC-008` +- **Scenario IDs:** `AC-ARC-001` +- **Test class/method:** `com.lab.labtimesheet.config.LayerStructureTest.applicationUsesOnlyApprovedPackageByFeatureStructure` +- **Implementation commit:** `this milestone commit` + +## Protected behavior + +The Spring Boot application class remains in the root package, shared wiring remains in `config`, and business code uses only the approved feature and feature-layer packages. Legacy feature-first placeholders, global business layers, and cross-feature repository/entity imports are rejected. + +## Test method + +A no-dependency JUnit test inspects the production source tree. It checks the root directories, permits the complete seven-feature vocabulary for branch integration, limits nested packages to the approved feature layers, and scans Java imports for persistence leakage across features. + +## Hand-derived expected result + +The platform branch has only `config` and `feature` below `com.lab.labtimesheet`; its present features are a nonempty subset of account, integration, project, task, attendance, notification, and reporting. A feature may call another feature's public service/DTO API but must not import another feature's repository or entity. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +./mvnw -Dtest=LayerStructureTest test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +actual directories included exception, controller, projects, configuration, +repository, service, model, accounts, config, attendance, dto, reporting, +and notifications; expected feature and config +BUILD FAILURE +``` + +The failure exposed both the superseded global-layer worktree and the committed legacy `ModuleBoundary` package placeholders before the corrective move. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +./mvnw -Dtest=LayerStructureTest test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## External-test boundaries + +This source-tree regression protects package naming and import direction. It does not prove runtime authorization, database transaction behavior, browser flows, containerization, CI, or deployment. diff --git a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java index 317bb73..851a85c 100644 --- a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java +++ b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java @@ -4,7 +4,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import com.lab.labtimesheet.configuration.SecurityProperties; +import com.lab.labtimesheet.config.SecurityProperties; @SpringBootApplication @EnableConfigurationProperties(SecurityProperties.class) diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java b/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java deleted file mode 100644 index 42dbc58..0000000 --- a/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.lab.labtimesheet.accounts; - -import java.time.Clock; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.util.Locale; - -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.support.TransactionTemplate; - -@Service -public class BootstrapService { - - private final JdbcTemplate jdbc; - private final TransactionTemplate transactions; - private final PasswordEncoder passwords; - private final Clock clock; - - BootstrapService(JdbcTemplate jdbc, TransactionTemplate transactions, PasswordEncoder passwords, Clock clock) { - this.jdbc = jdbc; - this.transactions = transactions; - this.passwords = passwords; - this.clock = clock; - } - - public BootstrapOutcome bootstrap(String email, String displayName, String password) { - String normalizedEmail = normalizeEmail(email); - String normalizedName = requireText(displayName, "Display name"); - requirePassword(password); - - return transactions.execute(status -> { - Boolean initialized = jdbc.queryForObject( - "select initialized from system_state where singleton_id = 1 for update", Boolean.class); - if (Boolean.TRUE.equals(initialized)) { - return BootstrapOutcome.ALREADY_INITIALIZED; - } - - OffsetDateTime now = OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); - Long userId = jdbc.queryForObject(""" - insert into app_users - (email, display_name, password_hash, global_role, account_status, activated_at, created_at, updated_at) - values (?, ?, ?, 'ADMIN', 'ACTIVE', ?, ?, ?) - returning id - """, Long.class, normalizedEmail, normalizedName, passwords.encode(password), now, now, now); - jdbc.update(""" - update system_state - set initialized = true, initialized_at = ?, bootstrap_admin_id = ?, updated_at = ?, version = version + 1 - where singleton_id = 1 - """, now, userId, now); - return BootstrapOutcome.CREATED; - }); - } - - public boolean isInitialized() { - return Boolean.TRUE.equals(jdbc.queryForObject( - "select initialized from system_state where singleton_id = 1", Boolean.class)); - } - - static String normalizeEmail(String email) { - return requireText(email, "Email").toLowerCase(Locale.ROOT); - } - - static void requirePassword(String password) { - if (password == null || password.length() < 12 || password.length() > 128) { - throw new IllegalArgumentException("Password must contain 12 through 128 characters"); - } - } - - private static String requireText(String value, String field) { - if (value == null || value.trim().isEmpty()) { - throw new IllegalArgumentException(field + " is required"); - } - return value.trim(); - } - - public enum BootstrapOutcome { - CREATED, - ALREADY_INITIALIZED - } -} diff --git a/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java b/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java deleted file mode 100644 index 3ef4067..0000000 --- a/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.lab.labtimesheet.accounts; - -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; - -@Service -class JdbcUserDetailsService implements UserDetailsService { - private final JdbcTemplate jdbc; - - JdbcUserDetailsService(JdbcTemplate jdbc) { - this.jdbc = jdbc; - } - - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - String email = BootstrapService.normalizeEmail(username); - return jdbc.query(""" - select email, password_hash, global_role, account_status - from app_users where lower(btrim(email)) = ? - """, resultSet -> { - if (!resultSet.next()) { - throw new UsernameNotFoundException("Invalid credentials"); - } - boolean active = "ACTIVE".equals(resultSet.getString("account_status")); - String hash = resultSet.getString("password_hash"); - return User.withUsername(resultSet.getString("email")) - .password(hash == null ? "{noop}unavailable" : hash) - .roles(resultSet.getString("global_role")) - .disabled(!active) - .build(); - }, email); - } -} diff --git a/src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java deleted file mode 100644 index 491317a..0000000 --- a/src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.accounts; - -/** Accounts and security module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java deleted file mode 100644 index 4dd3875..0000000 --- a/src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.attendance; - -/** Attendance, leave, and corrections module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java similarity index 89% rename from src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java rename to src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java index 4c85628..568b833 100644 --- a/src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java @@ -1,5 +1,7 @@ -package com.lab.labtimesheet.accounts; +package com.lab.labtimesheet.config; +import com.lab.labtimesheet.feature.account.controller.BootstrapAccessFilter; +import com.lab.labtimesheet.feature.account.service.BootstrapService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -10,7 +12,6 @@ import org.springframework.security.web.access.intercept.AuthorizationFilter; @Configuration(proxyBeanMethods = false) class SecurityConfiguration { - @Bean PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); diff --git a/src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java b/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java similarity index 90% rename from src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java rename to src/main/java/com/lab/labtimesheet/config/SecurityProperties.java index a4cdace..9b00897 100644 --- a/src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.configuration; +package com.lab.labtimesheet.config; import java.util.Base64; @@ -16,7 +16,7 @@ public class SecurityProperties { this.masterKey = masterKey; } - byte[] decodedMasterKey() { + public byte[] decodedMasterKey() { if (masterKey == null || masterKey.isBlank()) { throw new IllegalStateException("lab.security.master-key is required"); } diff --git a/src/main/java/com/lab/labtimesheet/configuration/TimeConfiguration.java b/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java similarity index 86% rename from src/main/java/com/lab/labtimesheet/configuration/TimeConfiguration.java rename to src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java index 78baeb7..aba4a15 100644 --- a/src/main/java/com/lab/labtimesheet/configuration/TimeConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.configuration; +package com.lab.labtimesheet.config; import java.time.Clock; @@ -7,7 +7,6 @@ import org.springframework.context.annotation.Configuration; @Configuration(proxyBeanMethods = false) class TimeConfiguration { - @Bean Clock applicationClock() { return Clock.systemUTC(); diff --git a/src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java deleted file mode 100644 index a5e60ca..0000000 --- a/src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.configuration; - -/** Configuration, integrations, and calendar module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java b/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java deleted file mode 100644 index 89387f0..0000000 --- a/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java +++ /dev/null @@ -1,183 +0,0 @@ -package com.lab.labtimesheet.configuration; - -import java.time.Clock; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; - -import org.springframework.core.env.Environment; -import org.springframework.core.env.Profiles; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.support.TransactionTemplate; - -@Service -public class SmtpConfigurationService { - private final JdbcTemplate jdbc; - private final TransactionTemplate transactions; - private final SecretCipher secrets; - private final SmtpProbe probe; - private final Environment environment; - private final Clock clock; - - SmtpConfigurationService(JdbcTemplate jdbc, TransactionTemplate transactions, SecretCipher secrets, - SmtpProbe probe, Environment environment, Clock clock) { - this.jdbc = jdbc; - this.transactions = transactions; - this.secrets = secrets; - this.probe = probe; - this.environment = environment; - this.clock = clock; - } - - public long saveDraft(long adminId, SmtpDraft draft) { - validate(draft); - SecretCipher.EncryptedSecret password = draft.password() == null ? null : secrets.encrypt(draft.password()); - OffsetDateTime now = now(); - - return transactions.execute(status -> { - Long existing = jdbc.query("select id from smtp_configurations where status = 'DRAFT' for update", - resultSet -> resultSet.next() ? resultSet.getLong(1) : null); - Object[] values = values(draft, password, adminId, now); - if (existing == null) { - return jdbc.queryForObject(""" - insert into smtp_configurations - (status, host, port, security_mode, username, password_ciphertext, password_nonce, - secret_key_version, from_address, from_name, created_by_user_id, created_at, updated_at) - values ('DRAFT', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - returning id - """, Long.class, values); - } - jdbc.update(""" - update smtp_configurations - set host = ?, port = ?, security_mode = ?, username = ?, password_ciphertext = ?, - password_nonce = ?, secret_key_version = ?, from_address = ?, from_name = ?, - tested_at = null, tested_by_user_id = null, updated_at = ?, version = version + 1 - where id = ? - """, draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()), - password == null ? null : password.ciphertext(), password == null ? null : password.nonce(), - password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(), - now, existing); - return existing; - }); - } - - private static Object[] values(SmtpDraft draft, SecretCipher.EncryptedSecret password, long adminId, - OffsetDateTime now) { - return new Object[] { - draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()), - password == null ? null : password.ciphertext(), password == null ? null : password.nonce(), - password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(), - adminId, now, now - }; - } - - public void testDraft(long draftId, long adminId, String recipient) { - SmtpConnection connection = load(draftId, "DRAFT"); - probe.send(connection, recipient, "Lab Timesheet SMTP test", "SMTP configuration test succeeded."); - OffsetDateTime now = now(); - if (jdbc.update(""" - update smtp_configurations - set tested_at = ?, tested_by_user_id = ?, updated_at = ?, version = version + 1 - where id = ? and status = 'DRAFT' - """, now, adminId, now, draftId) != 1) { - throw new IllegalStateException("SMTP draft is no longer available"); - } - } - - public void activate(long draftId, long adminId) { - transactions.executeWithoutResult(status -> { - OffsetDateTime testedAt = jdbc.query(""" - select tested_at from smtp_configurations where id = ? and status = 'DRAFT' for update - """, resultSet -> resultSet.next() ? resultSet.getObject(1, OffsetDateTime.class) : null, draftId); - if (testedAt == null) { - throw new IllegalStateException("SMTP draft must pass a test before activation"); - } - OffsetDateTime now = now(); - jdbc.update(""" - update smtp_configurations - set status = 'RETIRED', retired_at = ?, retired_by_user_id = ?, updated_at = ?, version = version + 1 - where status = 'ACTIVE' - """, now, adminId, now); - jdbc.update(""" - update smtp_configurations - set status = 'ACTIVE', activated_at = ?, activated_by_user_id = ?, updated_at = ?, version = version + 1 - where id = ? and status = 'DRAFT' - """, now, adminId, now, draftId); - }); - } - - public boolean hasActiveConfiguration() { - return jdbc.queryForObject("select exists(select 1 from smtp_configurations where status = 'ACTIVE')", - Boolean.class); - } - - public SmtpConnection activeConnection() { - return jdbc.query("select id from smtp_configurations where status = 'ACTIVE'", - resultSet -> { - if (!resultSet.next()) { - throw new IllegalStateException("Active SMTP configuration is required"); - } - return load(resultSet.getLong(1), "ACTIVE"); - }); - } - - public void sendWithActiveConfiguration(String recipient, String subject, String body) { - probe.send(activeConnection(), recipient, subject, body); - } - - private SmtpConnection load(long id, String requiredStatus) { - return jdbc.query(""" - select host, port, security_mode, username, password_ciphertext, password_nonce, - from_address, from_name - from smtp_configurations where id = ? and status = ? - """, resultSet -> { - if (!resultSet.next()) { - throw new IllegalStateException("SMTP configuration is not available"); - } - byte[] ciphertext = resultSet.getBytes("password_ciphertext"); - return new SmtpConnection( - resultSet.getString("host"), resultSet.getInt("port"), - SecurityMode.valueOf(resultSet.getString("security_mode")), - resultSet.getString("username"), - ciphertext == null ? null : secrets.decrypt(ciphertext, resultSet.getBytes("password_nonce")), - resultSet.getString("from_address"), resultSet.getString("from_name")); - }, id, requiredStatus); - } - - private void validate(SmtpDraft draft) { - if (draft.host() == null || draft.host().isBlank() || draft.port() < 1 || draft.port() > 65535 - || draft.securityMode() == null || draft.fromAddress() == null || draft.fromAddress().isBlank() - || draft.fromName() == null || draft.fromName().isBlank()) { - throw new IllegalArgumentException("Valid SMTP host, port, security mode, From address and name are required"); - } - if ((clean(draft.username()) == null) != (draft.password() == null || draft.password().isEmpty())) { - throw new IllegalArgumentException("SMTP username and password must be supplied together"); - } - if (draft.securityMode() == SecurityMode.NONE - && !environment.acceptsProfiles(Profiles.of("dev", "test"))) { - throw new IllegalArgumentException("Plaintext SMTP is allowed only in dev and test"); - } - } - - private OffsetDateTime now() { - return OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); - } - - private static String clean(String value) { - return value == null || value.isBlank() ? null : value.trim(); - } - - public enum SecurityMode { - NONE, - STARTTLS, - TLS - } - - public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password, - String fromAddress, String fromName) { - } - - public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password, - String fromAddress, String fromName) { - } -} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java b/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java deleted file mode 100644 index df20793..0000000 --- a/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.lab.labtimesheet.configuration; - -@FunctionalInterface -public interface SmtpProbe { - void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body); -} diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java similarity index 80% rename from src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java rename to src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java index 816f8c4..a7afe85 100644 --- a/src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java @@ -1,17 +1,18 @@ -package com.lab.labtimesheet.accounts; +package com.lab.labtimesheet.feature.account.controller; import java.io.IOException; +import com.lab.labtimesheet.feature.account.service.BootstrapService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.filter.OncePerRequestFilter; -class BootstrapAccessFilter extends OncePerRequestFilter { +public class BootstrapAccessFilter extends OncePerRequestFilter { private final BootstrapService bootstrap; - BootstrapAccessFilter(BootstrapService bootstrap) { + public BootstrapAccessFilter(BootstrapService bootstrap) { this.bootstrap = bootstrap; } diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java similarity index 92% rename from src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java rename to src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java index d6ae88e..0daa0dc 100644 --- a/src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java @@ -1,5 +1,6 @@ -package com.lab.labtimesheet.accounts; +package com.lab.labtimesheet.feature.account.controller; +import com.lab.labtimesheet.feature.account.service.BootstrapService; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; diff --git a/src/main/java/com/lab/labtimesheet/accounts/HomeController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java similarity index 79% rename from src/main/java/com/lab/labtimesheet/accounts/HomeController.java rename to src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java index 4cc0e94..ac6a4e5 100644 --- a/src/main/java/com/lab/labtimesheet/accounts/HomeController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.accounts; +package com.lab.labtimesheet.feature.account.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java b/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java new file mode 100644 index 0000000..a62ff21 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.feature.account.model; + +public enum AccountStatus { + PENDING_ACTIVATION, + ACTIVE, + LOCKED, + DEACTIVATED +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java b/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java new file mode 100644 index 0000000..defdc1c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.account.model; + +public enum GlobalRole { + ADMIN, + MENTOR, + INTERN +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java b/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java new file mode 100644 index 0000000..fa86482 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.feature.account.model; + +public enum InternshipStatus { + NOT_STARTED, + ACTIVE, + COMPLETED, + WITHDRAWN +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java new file mode 100644 index 0000000..66110f5 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java @@ -0,0 +1,12 @@ +package com.lab.labtimesheet.feature.account.model.dto; + +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.GlobalRole; + +public record AccountIdentity( + long id, + String email, + String displayName, + GlobalRole role, + AccountStatus status) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java new file mode 100644 index 0000000..c7f5e9a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java @@ -0,0 +1,103 @@ +package com.lab.labtimesheet.feature.account.model.entity; + +import java.time.Instant; + +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +@Entity +@Table(name = "app_users") +public class AppUser { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 320) + private String email; + + @Column(name = "display_name", nullable = false, length = 120) + private String displayName; + + @Column(name = "password_hash", length = 255) + private String passwordHash; + + @Enumerated(EnumType.STRING) + @Column(name = "global_role", nullable = false, length = 16, updatable = false) + private GlobalRole globalRole; + + @Enumerated(EnumType.STRING) + @Column(name = "account_status", nullable = false, length = 32) + private AccountStatus accountStatus; + + @Column(name = "activated_at") + private Instant activatedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by_user_id") + private AppUser createdBy; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + private long version; + + protected AppUser() { + } + + private AppUser(String email, String displayName, String passwordHash, GlobalRole globalRole, + AccountStatus accountStatus, Instant activatedAt, AppUser createdBy, Instant now) { + this.email = email; + this.displayName = displayName; + this.passwordHash = passwordHash; + this.globalRole = globalRole; + this.accountStatus = accountStatus; + this.activatedAt = activatedAt; + this.createdBy = createdBy; + this.createdAt = now; + this.updatedAt = now; + } + + public static AppUser bootstrapAdmin(String email, String displayName, String passwordHash, Instant now) { + return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now); + } + + public Long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getDisplayName() { + return displayName; + } + + public String getPasswordHash() { + return passwordHash; + } + + public GlobalRole getGlobalRole() { + return globalRole; + } + + public AccountStatus getAccountStatus() { + return accountStatus; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java new file mode 100644 index 0000000..611116b --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java @@ -0,0 +1,61 @@ +package com.lab.labtimesheet.feature.account.model.entity; + +import java.time.Instant; +import java.time.LocalDate; + +import com.lab.labtimesheet.feature.account.model.InternshipStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +@Entity +@Table(name = "intern_profiles") +public class InternProfile { + @Id + @Column(name = "user_id") + private Long userId; + + @Column(name = "student_code", nullable = false, length = 64) + private String studentCode; + + @Column(length = 120) + private String department; + + @Column(length = 32) + private String phone; + + @Column(name = "internship_start_date", nullable = false) + private LocalDate internshipStartDate; + + @Column(name = "internship_end_date", nullable = false) + private LocalDate internshipEndDate; + + @Enumerated(EnumType.STRING) + @Column(name = "internship_status", nullable = false, length = 24) + private InternshipStatus internshipStatus; + + @Column(name = "activated_at") + private Instant activatedAt; + + @Column(name = "completed_at") + private Instant completedAt; + + @Column(name = "withdrawn_at") + private Instant withdrawnAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + private long version; + + protected InternProfile() { + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java new file mode 100644 index 0000000..e1ca36b --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java @@ -0,0 +1,57 @@ +package com.lab.labtimesheet.feature.account.model.entity; + +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +@Entity +@Table(name = "system_state") +public class SystemState { + @Id + @Column(name = "singleton_id") + private short singletonId; + + @Column(nullable = false) + private boolean initialized; + + @Column(name = "initialized_at") + private Instant initializedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "bootstrap_admin_id") + private AppUser bootstrapAdmin; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + private long version; + + protected SystemState() { + } + + public boolean isInitialized() { + return initialized; + } + + public void initialize(AppUser admin, Instant now) { + if (initialized) { + throw new IllegalStateException("Bootstrap is already complete"); + } + initialized = true; + initializedAt = now; + bootstrapAdmin = admin; + updatedAt = now; + } + +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java new file mode 100644 index 0000000..8097d42 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java @@ -0,0 +1,17 @@ +package com.lab.labtimesheet.feature.account.repository; + +import java.util.Optional; + +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.entity.AppUser; +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AppUserRepository extends JpaRepository { + @Query("select u from AppUser u where lower(trim(u.email)) = :email") + Optional findByNormalizedEmail(@Param("email") String email); + + long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java new file mode 100644 index 0000000..ed79914 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java @@ -0,0 +1,9 @@ +package com.lab.labtimesheet.feature.account.repository; + +import com.lab.labtimesheet.feature.account.model.InternshipStatus; +import com.lab.labtimesheet.feature.account.model.entity.InternProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InternProfileRepository extends JpaRepository { + boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java new file mode 100644 index 0000000..294ee7c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java @@ -0,0 +1,15 @@ +package com.lab.labtimesheet.feature.account.repository; + +import java.util.Optional; + +import com.lab.labtimesheet.feature.account.model.entity.SystemState; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; + +public interface SystemStateRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select s from SystemState s where s.singletonId = 1") + Optional findSingletonForUpdate(); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java new file mode 100644 index 0000000..c7c84c5 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java @@ -0,0 +1,78 @@ +package com.lab.labtimesheet.feature.account.service; + +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import com.lab.labtimesheet.feature.account.model.InternshipStatus; +import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity; +import com.lab.labtimesheet.feature.account.model.entity.AppUser; +import com.lab.labtimesheet.feature.account.repository.AppUserRepository; +import com.lab.labtimesheet.feature.account.repository.InternProfileRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AccountService { + private final AppUserRepository users; + private final InternProfileRepository internProfiles; + + AccountService(AppUserRepository users, InternProfileRepository internProfiles) { + this.users = users; + this.internProfiles = internProfiles; + } + + @Transactional(readOnly = true) + public AccountIdentity requireIdentityById(long userId) { + return users.findById(userId).map(AccountService::identity) + .orElseThrow(() -> new IllegalArgumentException("Account not found")); + } + + @Transactional(readOnly = true) + public AccountIdentity requireIdentityByEmail(String email) { + return users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)).map(AccountService::identity) + .orElseThrow(() -> new IllegalArgumentException("Account not found")); + } + + @Transactional(readOnly = true) + public boolean isEligibleIntern(long userId) { + return users.findById(userId) + .filter(user -> user.getGlobalRole() == GlobalRole.INTERN) + .filter(user -> user.getAccountStatus() == AccountStatus.ACTIVE) + .filter(user -> internProfiles.existsByUserIdAndInternshipStatus( + user.getId(), InternshipStatus.ACTIVE)) + .isPresent(); + } + + @Transactional(readOnly = true) + public AccountIdentity requireEligibleIntern(long userId) { + if (!isEligibleIntern(userId)) { + throw new IllegalArgumentException("An active Intern account and internship are required"); + } + return requireIdentityById(userId); + } + + @Transactional(readOnly = true) + public long requireActiveAdminId(String email) { + AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)) + .orElseThrow(() -> new IllegalStateException("Authenticated Admin is missing")); + return requireActiveAdmin(user); + } + + @Transactional(readOnly = true) + public long requireActiveAdminId(long userId) { + AppUser user = users.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("Admin not found")); + return requireActiveAdmin(user); + } + + private static long requireActiveAdmin(AppUser user) { + if (user.getGlobalRole() != GlobalRole.ADMIN || user.getAccountStatus() != AccountStatus.ACTIVE) { + throw new IllegalArgumentException("An active Admin is required"); + } + return user.getId(); + } + + private static AccountIdentity identity(AppUser user) { + return new AccountIdentity( + user.getId(), user.getEmail(), user.getDisplayName(), user.getGlobalRole(), user.getAccountStatus()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java new file mode 100644 index 0000000..288ca68 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java @@ -0,0 +1,73 @@ +package com.lab.labtimesheet.feature.account.service; + +import java.time.Clock; +import java.util.Locale; + +import com.lab.labtimesheet.feature.account.model.entity.AppUser; +import com.lab.labtimesheet.feature.account.model.entity.SystemState; +import com.lab.labtimesheet.feature.account.repository.AppUserRepository; +import com.lab.labtimesheet.feature.account.repository.SystemStateRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class BootstrapService { + private final SystemStateRepository systemStates; + private final AppUserRepository users; + private final PasswordEncoder passwords; + private final Clock clock; + + BootstrapService(SystemStateRepository systemStates, AppUserRepository users, PasswordEncoder passwords, + Clock clock) { + this.systemStates = systemStates; + this.users = users; + this.passwords = passwords; + this.clock = clock; + } + + @Transactional + public BootstrapOutcome bootstrap(String email, String displayName, String password) { + String normalizedEmail = normalizeEmail(email); + String normalizedName = requireText(displayName, "Display name"); + requirePassword(password); + + SystemState state = systemStates.findSingletonForUpdate() + .orElseThrow(() -> new IllegalStateException("System state is missing")); + if (state.isInitialized()) { + return BootstrapOutcome.ALREADY_INITIALIZED; + } + var now = clock.instant(); + AppUser admin = users.save(AppUser.bootstrapAdmin( + normalizedEmail, normalizedName, passwords.encode(password), now)); + state.initialize(admin, now); + return BootstrapOutcome.CREATED; + } + + @Transactional(readOnly = true) + public boolean isInitialized() { + return systemStates.findById((short) 1).map(SystemState::isInitialized).orElse(false); + } + + public static String normalizeEmail(String email) { + return requireText(email, "Email").toLowerCase(Locale.ROOT); + } + + public static void requirePassword(String password) { + if (password == null || password.length() < 12 || password.length() > 128) { + throw new IllegalArgumentException("Password must contain 12 through 128 characters"); + } + } + + private static String requireText(String value, String field) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(field + " is required"); + } + return value.trim(); + } + + public enum BootstrapOutcome { + CREATED, + ALREADY_INITIALIZED + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java new file mode 100644 index 0000000..0a78533 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java @@ -0,0 +1,32 @@ +package com.lab.labtimesheet.feature.account.service; + +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.repository.AppUserRepository; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +class DatabaseUserDetailsService implements UserDetailsService { + private final AppUserRepository users; + + DatabaseUserDetailsService(AppUserRepository users) { + this.users = users; + } + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var account = users.findByNormalizedEmail(BootstrapService.normalizeEmail(username)) + .orElseThrow(() -> new UsernameNotFoundException("Invalid credentials")); + String hash = account.getPasswordHash(); + return User.withUsername(account.getEmail()) + .password(hash == null ? "{noop}unavailable" : hash) + .roles(account.getGlobalRole().name()) + .disabled(account.getAccountStatus() != AccountStatus.ACTIVE) + .build(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpController.java b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java similarity index 73% rename from src/main/java/com/lab/labtimesheet/configuration/SmtpController.java rename to src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java index 4b825c5..436a746 100644 --- a/src/main/java/com/lab/labtimesheet/configuration/SmtpController.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java @@ -1,10 +1,11 @@ -package com.lab.labtimesheet.configuration; +package com.lab.labtimesheet.feature.integration.controller; import java.security.Principal; -import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode; -import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft; -import org.springframework.jdbc.core.JdbcTemplate; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.integration.model.SecurityMode; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -15,11 +16,11 @@ import org.springframework.web.bind.annotation.RequestParam; @RequestMapping("/admin/smtp") class SmtpController { private final SmtpConfigurationService smtp; - private final JdbcTemplate jdbc; + private final AccountService accounts; - SmtpController(SmtpConfigurationService smtp, JdbcTemplate jdbc) { + SmtpController(SmtpConfigurationService smtp, AccountService accounts) { this.smtp = smtp; - this.jdbc = jdbc; + this.accounts = accounts; } @GetMapping @@ -49,7 +50,6 @@ class SmtpController { } private long adminId(Principal principal) { - return jdbc.queryForObject("select id from app_users where lower(btrim(email)) = lower(btrim(?))", Long.class, - principal.getName()); + return accounts.requireActiveAdminId(principal.getName()); } } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java new file mode 100644 index 0000000..0356e10 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.integration.model; + +public enum SecurityMode { + NONE, + STARTTLS, + TLS +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java new file mode 100644 index 0000000..104b501 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.integration.model; + +public enum SmtpStatus { + DRAFT, + ACTIVE, + RETIRED +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java new file mode 100644 index 0000000..48cf6d6 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java @@ -0,0 +1,18 @@ +package com.lab.labtimesheet.feature.integration.model.dto; + +public record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) { + public EncryptedSecret { + ciphertext = ciphertext.clone(); + nonce = nonce.clone(); + } + + @Override + public byte[] ciphertext() { + return ciphertext.clone(); + } + + @Override + public byte[] nonce() { + return nonce.clone(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java new file mode 100644 index 0000000..e5659cc --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.integration.model.dto; + +import com.lab.labtimesheet.feature.integration.model.SecurityMode; + +public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password, + String fromAddress, String fromName) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java new file mode 100644 index 0000000..3df5bb5 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.integration.model.dto; + +import com.lab.labtimesheet.feature.integration.model.SecurityMode; + +public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password, + String fromAddress, String fromName) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java new file mode 100644 index 0000000..738ea1f --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java @@ -0,0 +1,198 @@ +package com.lab.labtimesheet.feature.integration.model.entity; + +import java.time.Instant; + +import com.lab.labtimesheet.feature.integration.model.SecurityMode; +import com.lab.labtimesheet.feature.integration.model.SmtpStatus; +import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +@Entity +@Table(name = "smtp_configurations") +public class SmtpConfiguration { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private SmtpStatus status; + + @Column(nullable = false, length = 255) + private String host; + + @Column(nullable = false) + private int port; + + @Enumerated(EnumType.STRING) + @Column(name = "security_mode", nullable = false, length = 16) + private SecurityMode securityMode; + + @Column(length = 320) + private String username; + + @Column(name = "password_ciphertext") + private byte[] passwordCiphertext; + + @Column(name = "password_nonce") + private byte[] passwordNonce; + + @Column(name = "secret_key_version") + private Integer secretKeyVersion; + + @Column(name = "from_address", nullable = false, length = 320) + private String fromAddress; + + @Column(name = "from_name", nullable = false, length = 120) + private String fromName; + + @Column(name = "tested_at") + private Instant testedAt; + + @Column(name = "tested_by_user_id") + private Long testedByUserId; + + @Column(name = "activated_at") + private Instant activatedAt; + + @Column(name = "activated_by_user_id") + private Long activatedByUserId; + + @Column(name = "retired_at") + private Instant retiredAt; + + @Column(name = "retired_by_user_id") + private Long retiredByUserId; + + @Column(name = "created_by_user_id", nullable = false) + private Long createdByUserId; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + private long version; + + protected SmtpConfiguration() { + } + + public static SmtpConfiguration draft(SmtpDraft draft, EncryptedSecret password, long adminId, Instant now) { + SmtpConfiguration configuration = new SmtpConfiguration(); + configuration.status = SmtpStatus.DRAFT; + configuration.createdByUserId = adminId; + configuration.createdAt = now; + configuration.updateDraft(draft, password, now); + return configuration; + } + + public void updateDraft(SmtpDraft draft, EncryptedSecret password, Instant now) { + if (status != SmtpStatus.DRAFT) { + throw new IllegalStateException("Only an SMTP draft can be edited"); + } + host = draft.host().trim(); + port = draft.port(); + securityMode = draft.securityMode(); + username = clean(draft.username()); + passwordCiphertext = password == null ? null : password.ciphertext(); + passwordNonce = password == null ? null : password.nonce(); + secretKeyVersion = password == null ? null : password.keyVersion(); + fromAddress = draft.fromAddress().trim(); + fromName = draft.fromName().trim(); + testedAt = null; + testedByUserId = null; + updatedAt = now; + } + + public void markTested(long adminId, Instant now) { + if (status != SmtpStatus.DRAFT) { + throw new IllegalStateException("SMTP draft is no longer available"); + } + testedAt = now; + testedByUserId = adminId; + updatedAt = now; + } + + public void activate(long adminId, Instant now) { + if (status != SmtpStatus.DRAFT || testedAt == null) { + throw new IllegalStateException("SMTP draft must pass a test before activation"); + } + status = SmtpStatus.ACTIVE; + activatedAt = now; + activatedByUserId = adminId; + updatedAt = now; + } + + public void retire(long adminId, Instant now) { + if (status != SmtpStatus.ACTIVE) { + throw new IllegalStateException("Only active SMTP can be retired"); + } + status = SmtpStatus.RETIRED; + retiredAt = now; + retiredByUserId = adminId; + updatedAt = now; + } + + private static String clean(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + public Long getId() { + return id; + } + + public SmtpStatus getStatus() { + return status; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } + + public SecurityMode getSecurityMode() { + return securityMode; + } + + public String getUsername() { + return username; + } + + public byte[] getPasswordCiphertext() { + return passwordCiphertext == null ? null : passwordCiphertext.clone(); + } + + public byte[] getPasswordNonce() { + return passwordNonce == null ? null : passwordNonce.clone(); + } + + public Integer getSecretKeyVersion() { + return secretKeyVersion; + } + + public String getFromAddress() { + return fromAddress; + } + + public String getFromName() { + return fromName; + } + + public Instant getTestedAt() { + return testedAt; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java b/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java new file mode 100644 index 0000000..9805e59 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java @@ -0,0 +1,18 @@ +package com.lab.labtimesheet.feature.integration.repository; + +import java.util.Optional; + +import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration; +import com.lab.labtimesheet.feature.integration.model.SmtpStatus; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; + +public interface SmtpConfigurationRepository extends JpaRepository { + Optional findByStatus(SmtpStatus status); + + boolean existsByStatus(SmtpStatus status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findWithLockByIdAndStatus(Long id, SmtpStatus status); +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbe.java similarity index 70% rename from src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java rename to src/main/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbe.java index 797d707..1783a39 100644 --- a/src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbe.java @@ -1,29 +1,29 @@ -package com.lab.labtimesheet.configuration; +package com.lab.labtimesheet.feature.integration.service; import java.util.Properties; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; +import com.lab.labtimesheet.feature.integration.model.SecurityMode; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.stereotype.Component; @Component class JavaMailSmtpProbe implements SmtpProbe { - @Override - public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body) { + public void send(SmtpConnection connection, String recipient, String subject, String body) { JavaMailSenderImpl sender = new JavaMailSenderImpl(); sender.setHost(connection.host()); sender.setPort(connection.port()); sender.setUsername(connection.username()); sender.setPassword(connection.password()); Properties properties = sender.getJavaMailProperties(); - if (connection.securityMode() == SmtpConfigurationService.SecurityMode.STARTTLS) { + if (connection.securityMode() == SecurityMode.STARTTLS) { properties.setProperty("mail.smtp.starttls.enable", "true"); properties.setProperty("mail.smtp.starttls.required", "true"); - } else if (connection.securityMode() == SmtpConfigurationService.SecurityMode.TLS) { + } else if (connection.securityMode() == SecurityMode.TLS) { sender.setProtocol("smtps"); } - SimpleMailMessage message = new SimpleMailMessage(); message.setFrom(connection.fromAddress()); message.setTo(recipient); diff --git a/src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java similarity index 86% rename from src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java rename to src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java index 25433e7..033d22f 100644 --- a/src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java @@ -1,13 +1,14 @@ -package com.lab.labtimesheet.configuration; +package com.lab.labtimesheet.feature.integration.service; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.SecureRandom; +import com.lab.labtimesheet.config.SecurityProperties; +import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret; import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; - import org.springframework.stereotype.Component; @Component @@ -19,7 +20,7 @@ public class SecretCipher { private final SecureRandom random = new SecureRandom(); SecretCipher(SecurityProperties properties) { - this.key = new SecretKeySpec(properties.decodedMasterKey(), "AES"); + key = new SecretKeySpec(properties.decodedMasterKey(), "AES"); } EncryptedSecret encrypt(String plaintext) { @@ -43,7 +44,4 @@ public class SecretCipher { throw new IllegalStateException("Unable to decrypt integration secret", exception); } } - - record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) { - } } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java new file mode 100644 index 0000000..d3f7e71 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java @@ -0,0 +1,116 @@ +package com.lab.labtimesheet.feature.integration.service; + +import java.time.Clock; + +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.integration.model.SecurityMode; +import com.lab.labtimesheet.feature.integration.model.SmtpStatus; +import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration; +import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SmtpConfigurationService { + private final SmtpConfigurationRepository configurations; + private final AccountService accounts; + private final SecretCipher secrets; + private final SmtpProbe probe; + private final Environment environment; + private final Clock clock; + + SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts, + SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock) { + this.configurations = configurations; + this.accounts = accounts; + this.secrets = secrets; + this.probe = probe; + this.environment = environment; + this.clock = clock; + } + + @Transactional + public long saveDraft(long adminId, SmtpDraft draft) { + validate(draft); + EncryptedSecret password = draft.password() == null ? null : secrets.encrypt(draft.password()); + var now = clock.instant(); + long verifiedAdminId = accounts.requireActiveAdminId(adminId); + SmtpConfiguration configuration = configurations.findByStatus(SmtpStatus.DRAFT) + .map(existing -> { + existing.updateDraft(draft, password, now); + return existing; + }) + .orElseGet(() -> SmtpConfiguration.draft(draft, password, verifiedAdminId, now)); + return configurations.save(configuration).getId(); + } + + public void testDraft(long draftId, long adminId, String recipient) { + SmtpConfiguration draft = configurations.findById(draftId) + .filter(configuration -> configuration.getStatus() == SmtpStatus.DRAFT) + .orElseThrow(() -> new IllegalStateException("SMTP configuration is not available")); + probe.send(connection(draft), recipient, "Lab Timesheet SMTP test", "SMTP configuration test succeeded."); + long verifiedAdminId = accounts.requireActiveAdminId(adminId); + draft.markTested(verifiedAdminId, clock.instant()); + configurations.save(draft); + } + + @Transactional + public void activate(long draftId, long adminId) { + SmtpConfiguration draft = configurations.findWithLockByIdAndStatus(draftId, SmtpStatus.DRAFT) + .orElseThrow(() -> new IllegalStateException("SMTP draft must pass a test before activation")); + long verifiedAdminId = accounts.requireActiveAdminId(adminId); + var now = clock.instant(); + configurations.findByStatus(SmtpStatus.ACTIVE) + .ifPresent(active -> active.retire(verifiedAdminId, now)); + draft.activate(verifiedAdminId, now); + } + + @Transactional(readOnly = true) + public boolean hasActiveConfiguration() { + return configurations.existsByStatus(SmtpStatus.ACTIVE); + } + + @Transactional(readOnly = true) + public SmtpConnection activeConnection() { + return configurations.findByStatus(SmtpStatus.ACTIVE) + .map(this::connection) + .orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required")); + } + + public void sendWithActiveConfiguration(String recipient, String subject, String body) { + probe.send(activeConnection(), recipient, subject, body); + } + + private SmtpConnection connection(SmtpConfiguration configuration) { + byte[] ciphertext = configuration.getPasswordCiphertext(); + return new SmtpConnection( + configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(), + configuration.getUsername(), + ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()), + configuration.getFromAddress(), configuration.getFromName()); + } + + private void validate(SmtpDraft draft) { + if (draft.host() == null || draft.host().isBlank() || draft.port() < 1 || draft.port() > 65535 + || draft.securityMode() == null || draft.fromAddress() == null || draft.fromAddress().isBlank() + || draft.fromName() == null || draft.fromName().isBlank()) { + throw new IllegalArgumentException("Valid SMTP host, port, security mode, From address and name are required"); + } + if ((clean(draft.username()) == null) != (draft.password() == null || draft.password().isEmpty())) { + throw new IllegalArgumentException("SMTP username and password must be supplied together"); + } + if (draft.securityMode() == SecurityMode.NONE + && !environment.acceptsProfiles(Profiles.of("dev", "test"))) { + throw new IllegalArgumentException("Plaintext SMTP is allowed only in dev and test"); + } + } + + private static String clean(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java new file mode 100644 index 0000000..c96308e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.feature.integration.service; + +import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; + +@FunctionalInterface +public interface SmtpProbe { + void send(SmtpConnection connection, String recipient, String subject, String body); +} diff --git a/src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java deleted file mode 100644 index 76fb6b7..0000000 --- a/src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.notifications; - -/** Notifications module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java deleted file mode 100644 index 15b62e2..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.projects; - -/** Projects and tasks module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java deleted file mode 100644 index c29e4f6..0000000 --- a/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.reporting; - -/** Reporting module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/test/java/com/lab/labtimesheet/LabtimesheetApplicationTests.java b/src/test/java/com/lab/labtimesheet/LabtimesheetApplicationTests.java index b2c6272..f82ca04 100644 --- a/src/test/java/com/lab/labtimesheet/LabtimesheetApplicationTests.java +++ b/src/test/java/com/lab/labtimesheet/LabtimesheetApplicationTests.java @@ -5,6 +5,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; +import com.lab.labtimesheet.config.TestcontainersConfiguration; + @Import(TestcontainersConfiguration.class) @SpringBootTest @ActiveProfiles("test") diff --git a/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java b/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java deleted file mode 100644 index e45e249..0000000 --- a/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.lab.labtimesheet; - -import org.junit.jupiter.api.BeforeEach; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.jdbc.core.JdbcTemplate; - -abstract class PlatformDatabaseTestSupport { - - @Autowired - protected JdbcTemplate jdbc; - - @BeforeEach - void resetPlatformData() { - jdbc.execute("TRUNCATE smtp_configurations, user_action_tokens, intern_profiles, app_users RESTART IDENTITY CASCADE"); - jdbc.update("insert into system_state (singleton_id) values (1)"); - } -} diff --git a/src/test/java/com/lab/labtimesheet/TestLabtimesheetApplication.java b/src/test/java/com/lab/labtimesheet/TestLabtimesheetApplication.java index 84e9703..baa3bda 100644 --- a/src/test/java/com/lab/labtimesheet/TestLabtimesheetApplication.java +++ b/src/test/java/com/lab/labtimesheet/TestLabtimesheetApplication.java @@ -2,6 +2,8 @@ package com.lab.labtimesheet; import org.springframework.boot.SpringApplication; +import com.lab.labtimesheet.config.TestcontainersConfiguration; + public class TestLabtimesheetApplication { public static void main(String[] args) { diff --git a/src/test/java/com/lab/labtimesheet/config/LayerStructureTest.java b/src/test/java/com/lab/labtimesheet/config/LayerStructureTest.java new file mode 100644 index 0000000..4460587 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/config/LayerStructureTest.java @@ -0,0 +1,83 @@ +package com.lab.labtimesheet.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import com.lab.labtimesheet.LabtimesheetApplication; +import org.junit.jupiter.api.Test; + +class LayerStructureTest { + private static final Path BASE_PACKAGE = Path.of("src/main/java/com/lab/labtimesheet"); + private static final Set APPROVED_ROOT_PACKAGES = Set.of("config", "feature"); + private static final Set APPROVED_FEATURES = Set.of( + "account", "integration", "project", "task", "attendance", "notification", "reporting"); + private static final Set APPROVED_FEATURE_PACKAGES = Set.of( + "controller", "exception", "model", "model/dto", "model/entity", "repository", "service"); + private static final Pattern INTERNAL_IMPORT = Pattern.compile( + "import com\\.lab\\.labtimesheet\\.feature\\.([^.]+)\\.(?:repository|model\\.entity)\\."); + + @Test + void applicationUsesOnlyApprovedPackageByFeatureStructure() throws IOException { + assertThat(LabtimesheetApplication.class.getPackageName()).isEqualTo("com.lab.labtimesheet"); + + try (var entries = Files.list(BASE_PACKAGE)) { + Set directories = entries + .filter(Files::isDirectory) + .map(path -> path.getFileName().toString()) + .collect(Collectors.toSet()); + + assertThat(directories).containsExactlyInAnyOrderElementsOf(APPROVED_ROOT_PACKAGES); + } + + Path featurePackage = BASE_PACKAGE.resolve("feature"); + try (var entries = Files.list(featurePackage)) { + Set features = entries + .filter(Files::isDirectory) + .map(path -> path.getFileName().toString()) + .collect(Collectors.toSet()); + + assertThat(features).isNotEmpty().isSubsetOf(APPROVED_FEATURES); + } + + try (var entries = Files.walk(featurePackage)) { + List featurePackages = entries + .filter(Files::isDirectory) + .filter(path -> path.getNameCount() > featurePackage.getNameCount() + 1) + .map(path -> path.subpath(featurePackage.getNameCount() + 1, path.getNameCount()).toString()) + .toList(); + + assertThat(featurePackages).allMatch(APPROVED_FEATURE_PACKAGES::contains); + } + + try (var entries = Files.walk(featurePackage)) { + List crossFeaturePersistenceImports = entries + .filter(path -> path.toString().endsWith(".java")) + .flatMap(path -> persistenceImportsFromAnotherFeature(featurePackage, path).stream()) + .toList(); + + assertThat(crossFeaturePersistenceImports).isEmpty(); + } + } + + private static List persistenceImportsFromAnotherFeature(Path featurePackage, Path source) { + String owningFeature = featurePackage.relativize(source).getName(0).toString(); + try { + return Files.readAllLines(source).stream() + .filter(line -> { + var matcher = INTERNAL_IMPORT.matcher(line); + return matcher.find() && !matcher.group(1).equals(owningFeature); + }) + .map(line -> source + ": " + line.trim()) + .toList(); + } catch (IOException exception) { + throw new IllegalStateException("Cannot inspect " + source, exception); + } + } +} diff --git a/src/test/java/com/lab/labtimesheet/PlatformFoundationTest.java b/src/test/java/com/lab/labtimesheet/config/PlatformFoundationTest.java similarity index 69% rename from src/test/java/com/lab/labtimesheet/PlatformFoundationTest.java rename to src/test/java/com/lab/labtimesheet/config/PlatformFoundationTest.java index 5a75af0..c089e8e 100644 --- a/src/test/java/com/lab/labtimesheet/PlatformFoundationTest.java +++ b/src/test/java/com/lab/labtimesheet/config/PlatformFoundationTest.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet; +package com.lab.labtimesheet.config; import static org.assertj.core.api.Assertions.assertThat; @@ -11,7 +11,6 @@ import javax.sql.DataSource; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; @@ -21,26 +20,12 @@ import org.springframework.test.context.ActiveProfiles; @ActiveProfiles("test") class PlatformFoundationTest { - @Autowired - private ApplicationContext applicationContext; - @Autowired private DataSource dataSource; @Autowired private Clock clock; - @Test - void applicationExposesRequiredModulePackages() throws ClassNotFoundException { - assertThat(applicationContext).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.accounts.ModuleBoundary")).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.configuration.ModuleBoundary")).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.projects.ModuleBoundary")).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.attendance.ModuleBoundary")).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.notifications.ModuleBoundary")).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.reporting.ModuleBoundary")).isNotNull(); - } - @Test void flywayCreatesApprovedPostgresCatalog() { JdbcTemplate jdbc = new JdbcTemplate(dataSource); diff --git a/src/test/java/com/lab/labtimesheet/TestcontainersConfiguration.java b/src/test/java/com/lab/labtimesheet/config/TestcontainersConfiguration.java similarity index 91% rename from src/test/java/com/lab/labtimesheet/TestcontainersConfiguration.java rename to src/test/java/com/lab/labtimesheet/config/TestcontainersConfiguration.java index aed46da..d074bc0 100644 --- a/src/test/java/com/lab/labtimesheet/TestcontainersConfiguration.java +++ b/src/test/java/com/lab/labtimesheet/config/TestcontainersConfiguration.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet; +package com.lab.labtimesheet.config; import java.time.Clock; import java.time.Instant; @@ -12,7 +12,7 @@ import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; @TestConfiguration(proxyBeanMethods = false) -class TestcontainersConfiguration { +public class TestcontainersConfiguration { @Bean @ServiceConnection diff --git a/src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java similarity index 56% rename from src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java rename to src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java index cbb750b..e2dbfef 100644 --- a/src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet; +package com.lab.labtimesheet.feature.account.service; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -10,28 +10,42 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.Future; -import com.lab.labtimesheet.accounts.BootstrapService; -import com.lab.labtimesheet.accounts.BootstrapService.BootstrapOutcome; +import com.lab.labtimesheet.config.TestcontainersConfiguration; +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import com.lab.labtimesheet.feature.account.repository.AppUserRepository; +import com.lab.labtimesheet.feature.account.repository.SystemStateRepository; 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.annotation.Import; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.web.servlet.MockMvc; @Import(TestcontainersConfiguration.class) @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") -class BootstrapIntegrationTest extends PlatformDatabaseTestSupport { +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class BootstrapIntegrationTest { @Autowired private BootstrapService bootstrapService; + @Autowired + private AccountService accountService; + @Autowired private MockMvc mockMvc; + @Autowired + private AppUserRepository users; + + @Autowired + private SystemStateRepository systemStates; + @Test void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception { mockMvc.perform(get("/bootstrap")).andExpect(status().isOk()); @@ -46,7 +60,7 @@ class BootstrapIntegrationTest extends PlatformDatabaseTestSupport { void concurrentBootstrapCreatesExactlyOneAdminAndPermanentlyCloses() throws Exception { CountDownLatch ready = new CountDownLatch(2); CountDownLatch start = new CountDownLatch(1); - List> futures = new ArrayList<>(); + List> futures = new ArrayList<>(); try (var executor = Executors.newFixedThreadPool(2)) { for (int i = 0; i < 2; i++) { @@ -63,15 +77,20 @@ class BootstrapIntegrationTest extends PlatformDatabaseTestSupport { } assertThat(futures).extracting(future -> future.get()).containsExactlyInAnyOrder( - BootstrapOutcome.CREATED, BootstrapOutcome.ALREADY_INITIALIZED); - assertThat(jdbc.queryForObject("select count(*) from app_users", Integer.class)).isEqualTo(1); - assertThat(jdbc.queryForObject( - "select count(*) from app_users where global_role = 'ADMIN' and account_status = 'ACTIVE'", - Integer.class)).isEqualTo(1); + BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); + assertThat(users.count()).isEqualTo(1); + assertThat(users.countByGlobalRoleAndAccountStatus(GlobalRole.ADMIN, AccountStatus.ACTIVE)).isEqualTo(1); + var createdUser = users.findAll().getFirst(); + var identityByEmail = accountService.requireIdentityByEmail(" " + createdUser.getEmail().toUpperCase() + " "); + assertThat(identityByEmail.email()).isEqualTo(createdUser.getEmail()); + assertThat(identityByEmail.displayName()).isEqualTo("First Admin"); + assertThat(identityByEmail.role()).isEqualTo(GlobalRole.ADMIN); + assertThat(identityByEmail.status()).isEqualTo(AccountStatus.ACTIVE); + assertThat(accountService.requireIdentityById(identityByEmail.id())).isEqualTo(identityByEmail); + assertThat(accountService.isEligibleIntern(identityByEmail.id())).isFalse(); assertThat(bootstrapService.bootstrap( "another@example.com", "Another", "correct horse battery staple")) - .isEqualTo(BootstrapOutcome.ALREADY_INITIALIZED); - assertThat(jdbc.queryForObject("select initialized from system_state where singleton_id = 1", Boolean.class)) - .isTrue(); + .isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); + assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue(); } } diff --git a/src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/integration/service/SmtpIntegrationTest.java similarity index 60% rename from src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java rename to src/test/java/com/lab/labtimesheet/feature/integration/service/SmtpIntegrationTest.java index 67708ad..7f5db18 100644 --- a/src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/integration/service/SmtpIntegrationTest.java @@ -1,14 +1,18 @@ -package com.lab.labtimesheet; +package com.lab.labtimesheet.feature.integration.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.nio.charset.StandardCharsets; -import com.lab.labtimesheet.accounts.BootstrapService; -import com.lab.labtimesheet.configuration.SmtpConfigurationService; -import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode; -import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft; -import com.lab.labtimesheet.configuration.SmtpProbe; + +import com.lab.labtimesheet.config.TestcontainersConfiguration; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.account.service.BootstrapService; +import com.lab.labtimesheet.feature.integration.model.SecurityMode; +import com.lab.labtimesheet.feature.integration.model.SmtpStatus; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -21,46 +25,47 @@ import org.springframework.test.context.ActiveProfiles; @Import({TestcontainersConfiguration.class, SmtpIntegrationTest.MailProbeConfiguration.class}) @SpringBootTest @ActiveProfiles("test") -class SmtpIntegrationTest extends PlatformDatabaseTestSupport { +class SmtpIntegrationTest { @Autowired private BootstrapService bootstrapService; + @Autowired + private AccountService accountService; + @Autowired private SmtpConfigurationService smtpService; @Autowired private RecordingSmtpProbe smtpProbe; + @Autowired + private SmtpConfigurationRepository configurations; + @Test void failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted() { bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); - long adminId = jdbc.queryForObject("select id from app_users", Long.class); + long adminId = accountService.requireActiveAdminId("admin@example.com"); long draftId = smtpService.saveDraft(adminId, new SmtpDraft( "mailpit", 1025, SecurityMode.NONE, "smtp-user", "smtp-password", "admin@example.com", "Lab")); - byte[] ciphertext = jdbc.queryForObject( - "select password_ciphertext from smtp_configurations where id = ?", byte[].class, draftId); + var savedDraft = configurations.findById(draftId).orElseThrow(); + byte[] ciphertext = savedDraft.getPasswordCiphertext(); assertThat(new String(ciphertext, StandardCharsets.ISO_8859_1)).doesNotContain("smtp-password"); - assertThat(jdbc.queryForObject("select octet_length(password_nonce) from smtp_configurations where id = ?", - Integer.class, draftId)).isEqualTo(12); - assertThat(jdbc.queryForObject("select secret_key_version from smtp_configurations where id = ?", - Integer.class, draftId)).isEqualTo(1); + assertThat(savedDraft.getPasswordNonce()).hasSize(12); + assertThat(savedDraft.getSecretKeyVersion()).isEqualTo(1); smtpProbe.fail = true; assertThatThrownBy(() -> smtpService.testDraft(draftId, adminId, "admin@example.com")) .isInstanceOf(IllegalStateException.class); - assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId)) - .isEqualTo("DRAFT"); - assertThat(jdbc.queryForObject("select tested_at is null from smtp_configurations where id = ?", Boolean.class, - draftId)).isTrue(); + assertThat(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.DRAFT); + assertThat(configurations.findById(draftId).orElseThrow().getTestedAt()).isNull(); assertThatThrownBy(() -> smtpService.activate(draftId, adminId)).isInstanceOf(IllegalStateException.class); smtpProbe.fail = false; smtpService.testDraft(draftId, adminId, "admin@example.com"); smtpService.activate(draftId, adminId); - assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId)) - .isEqualTo("ACTIVE"); + assertThat(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.ACTIVE); } @TestConfiguration(proxyBeanMethods = false) @@ -76,7 +81,7 @@ class SmtpIntegrationTest extends PlatformDatabaseTestSupport { private boolean fail; @Override - public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, + public void send(SmtpConnection connection, String recipient, String subject, String body) { if (fail) { throw new IllegalStateException("simulated SMTP failure"); From 1235204bf1298599264a07943ca1167432556bd2 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:17:28 +0700 Subject: [PATCH 12/62] feat: expose account identity and eligibility boundary --- docs/tests/integration/account-boundary.md | 74 +++++++++++++++++++ .../integration/first-admin-bootstrap.md | 4 +- docs/tests/integration/platform-foundation.md | 2 +- docs/tests/integration/smtp-onboarding.md | 2 +- .../unit/package-by-feature-structure.md | 2 +- .../repository/InternProfileRepository.java | 5 ++ .../account/service/AccountService.java | 16 ++++ .../service/BootstrapIntegrationTest.java | 23 ++++-- 8 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 docs/tests/integration/account-boundary.md diff --git a/docs/tests/integration/account-boundary.md b/docs/tests/integration/account-boundary.md new file mode 100644 index 0000000..8fe5c41 --- /dev/null +++ b/docs/tests/integration/account-boundary.md @@ -0,0 +1,74 @@ +# Test Evidence: Cross-feature account boundary + +- **Test type:** Integration +- **Requirement IDs:** `ACC-002, ACC-014, ACC-020–ACC-021, PRJ-017, ATT-007` +- **Scenario IDs:** `AC-ACC-002, AC-ATT-001` +- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest.exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes` +- **Implementation commit:** `this milestone commit` + +## Protected behavior + +Other features can resolve an account by normalized email or ID through an immutable identity DTO and can ask whether an Intern is active and within an inclusive internship interval for a supplied work date. They do not need access to account repositories or JPA entities. + +## Test method + +The PostgreSQL 18.4 integration test creates the initial Admin through the production bootstrap transaction, resolves the resulting identity through `AccountService`, and verifies ID/email equivalence, normalized lookup, role, status, and rejection by both current and date-aware Intern eligibility gates. Starting the context also parses the Spring Data derived interval query against the mapped `intern_profiles` entity. + +## Hand-derived expected result + +` ADMIN@EXAMPLE.COM ` resolves to the persisted `admin@example.com` identity. An active Admin is not an eligible Intern on `2026-08-14`. The date-aware gate requires an active Intern account, an `ACTIVE` internship, and `start_date <= workDate <= end_date`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +./mvnw -Dtest=BootstrapIntegrationTest test +``` + +**Observed result** + +```text +BootstrapIntegrationTest.java: method isEligibleIntern in class AccountService +cannot be applied to given types; required: long; found: long, java.time.LocalDate +Tests did not run; test compilation failed +BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=BootstrapIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## External-test boundaries + +The test proves identity lookup and rejection of a non-Intern plus successful repository-query initialization. The positive active-Intern and interval-edge cases remain part of I1-PLAT-06 activation/account lifecycle work; dependent features must still enforce their own authorization and transaction invariants. diff --git a/docs/tests/integration/first-admin-bootstrap.md b/docs/tests/integration/first-admin-bootstrap.md index 156e350..a94a6bf 100644 --- a/docs/tests/integration/first-admin-bootstrap.md +++ b/docs/tests/integration/first-admin-bootstrap.md @@ -53,7 +53,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` @@ -63,7 +63,7 @@ BUILD SUCCESS ```text ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/integration/platform-foundation.md b/docs/tests/integration/platform-foundation.md index 104382f..ec1b8cc 100644 --- a/docs/tests/integration/platform-foundation.md +++ b/docs/tests/integration/platform-foundation.md @@ -69,7 +69,7 @@ export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/integration/smtp-onboarding.md b/docs/tests/integration/smtp-onboarding.md index 7bc8955..8b7fe4b 100644 --- a/docs/tests/integration/smtp-onboarding.md +++ b/docs/tests/integration/smtp-onboarding.md @@ -64,7 +64,7 @@ BUILD SUCCESS ```text ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/unit/package-by-feature-structure.md b/docs/tests/unit/package-by-feature-structure.md index bca5c91..b8acc9a 100644 --- a/docs/tests/unit/package-by-feature-structure.md +++ b/docs/tests/unit/package-by-feature-structure.md @@ -67,7 +67,7 @@ export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java index ed79914..b6e11e6 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java @@ -1,9 +1,14 @@ package com.lab.labtimesheet.feature.account.repository; +import java.time.LocalDate; + import com.lab.labtimesheet.feature.account.model.InternshipStatus; import com.lab.labtimesheet.feature.account.model.entity.InternProfile; import org.springframework.data.jpa.repository.JpaRepository; public interface InternProfileRepository extends JpaRepository { boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status); + + boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual( + Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate); } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java index c7c84c5..1227365 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java @@ -1,5 +1,7 @@ package com.lab.labtimesheet.feature.account.service; +import java.time.LocalDate; + import com.lab.labtimesheet.feature.account.model.AccountStatus; import com.lab.labtimesheet.feature.account.model.GlobalRole; import com.lab.labtimesheet.feature.account.model.InternshipStatus; @@ -42,6 +44,20 @@ public class AccountService { .isPresent(); } + @Transactional(readOnly = true) + public boolean isEligibleIntern(long userId, LocalDate workDate) { + if (workDate == null) { + throw new IllegalArgumentException("Work date is required"); + } + return users.findById(userId) + .filter(user -> user.getGlobalRole() == GlobalRole.INTERN) + .filter(user -> user.getAccountStatus() == AccountStatus.ACTIVE) + .filter(user -> internProfiles + .existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual( + user.getId(), InternshipStatus.ACTIVE, workDate, workDate)) + .isPresent(); + } + @Transactional(readOnly = true) public AccountIdentity requireEligibleIntern(long userId) { if (!isEligibleIntern(userId)) { diff --git a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java index e2dbfef..d2edb50 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -80,17 +81,23 @@ class BootstrapIntegrationTest { BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); assertThat(users.count()).isEqualTo(1); assertThat(users.countByGlobalRoleAndAccountStatus(GlobalRole.ADMIN, AccountStatus.ACTIVE)).isEqualTo(1); - var createdUser = users.findAll().getFirst(); - var identityByEmail = accountService.requireIdentityByEmail(" " + createdUser.getEmail().toUpperCase() + " "); - assertThat(identityByEmail.email()).isEqualTo(createdUser.getEmail()); - assertThat(identityByEmail.displayName()).isEqualTo("First Admin"); - assertThat(identityByEmail.role()).isEqualTo(GlobalRole.ADMIN); - assertThat(identityByEmail.status()).isEqualTo(AccountStatus.ACTIVE); - assertThat(accountService.requireIdentityById(identityByEmail.id())).isEqualTo(identityByEmail); - assertThat(accountService.isEligibleIntern(identityByEmail.id())).isFalse(); assertThat(bootstrapService.bootstrap( "another@example.com", "Another", "correct horse battery staple")) .isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue(); } + + @Test + void exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes() { + bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple"); + + var identityByEmail = accountService.requireIdentityByEmail(" ADMIN@EXAMPLE.COM "); + assertThat(identityByEmail.email()).isEqualTo("admin@example.com"); + assertThat(identityByEmail.displayName()).isEqualTo("First Admin"); + assertThat(identityByEmail.role()).isEqualTo(GlobalRole.ADMIN); + assertThat(identityByEmail.status()).isEqualTo(AccountStatus.ACTIVE); + assertThat(accountService.requireIdentityById(identityByEmail.id())).isEqualTo(identityByEmail); + assertThat(accountService.isEligibleIntern(identityByEmail.id())).isFalse(); + assertThat(accountService.isEligibleIntern(identityByEmail.id(), LocalDate.of(2026, 8, 14))).isFalse(); + } } From 25a855ea90dc99aa119d7f0599ab4c68e4f6560b Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:25:55 +0700 Subject: [PATCH 13/62] refactor(project): adopt feature JPA boundaries --- .../project/controller/ProjectController.java | 97 ++++++ .../ProjectAccessDeniedException.java | 8 + .../exception/ProjectControllerAdvice.java | 21 ++ .../ProjectRuleViolationException.java | 8 + .../model/ProjectInternEligibility.java | 14 + .../project/model/ProjectLeaderChange.java | 7 + .../project/model}/ProjectStatus.java | 2 +- .../project/model/dto/ProjectActorView.java | 4 + .../model/dto/ProjectCreateCommand.java | 11 + .../project/model/dto/ProjectCreateForm.java | 30 ++ .../model/dto/ProjectDashboardSummary.java | 4 + .../project/model/dto/ProjectDetail.java | 14 + .../model/dto/ProjectLeadershipTermView.java | 10 + .../project/model/dto/ProjectMemberForm.java | 6 + .../project/model/dto/ProjectMemberView.java | 12 + .../project/model/dto/ProjectSummary.java | 6 + .../project/model/dto/ProjectTaskContext.java | 18 ++ .../model/dto/ProjectTaskMemberView.java | 4 + .../project/model/entity/ProjectEntity.java | 287 ++++++++++++++++++ .../entity/ProjectLeadershipTermEntity.java | 93 ++++++ .../model/entity/ProjectMembershipEntity.java | 79 +++++ .../project/repository/ProjectRepository.java | 29 ++ .../project/service/ProjectQueryService.java | 196 ++++++++++++ .../project/service/ProjectService.java | 82 +++++ .../projects/domain/EligibleIntern.java | 14 - .../projects/domain/LeadershipTerm.java | 50 --- .../labtimesheet/projects/domain/Project.java | 205 ------------- .../projects/domain/ProjectAccessDenied.java | 8 - .../projects/domain/ProjectMembership.java | 37 --- .../projects/domain/ProjectRuleViolation.java | 8 - .../resources/templates/projects/detail.html | 12 + .../resources/templates/projects/form.html | 19 ++ .../templates/projects/leadership.html | 13 + .../resources/templates/projects/list.html | 20 ++ .../resources/templates/projects/members.html | 13 + .../controller/ProjectControllerTest.java | 142 +++++++++ .../model/entity/ProjectEntityTest.java} | 49 +-- .../ProjectPersistenceStructureTest.java | 31 ++ .../ProjectServiceIntegrationTest.java | 231 ++++++++++++++ 39 files changed, 1549 insertions(+), 345 deletions(-) create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java rename src/main/java/com/lab/labtimesheet/{projects/domain => feature/project/model}/ProjectStatus.java (57%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/Project.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java create mode 100644 src/main/resources/templates/projects/detail.html create mode 100644 src/main/resources/templates/projects/form.html create mode 100644 src/main/resources/templates/projects/leadership.html create mode 100644 src/main/resources/templates/projects/list.html create mode 100644 src/main/resources/templates/projects/members.html create mode 100644 src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java rename src/test/java/com/lab/labtimesheet/{projects/domain/ProjectTest.java => feature/project/model/entity/ProjectEntityTest.java} (70%) create mode 100644 src/test/java/com/lab/labtimesheet/feature/project/repository/ProjectPersistenceStructureTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java new file mode 100644 index 0000000..16c4758 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -0,0 +1,97 @@ +package com.lab.labtimesheet.feature.project.controller; + +import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm; +import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm; +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 org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/projects") +public class ProjectController { + + private final ProjectQueryService pages; + private final ProjectService projects; + + public ProjectController(ProjectQueryService pages, ProjectService projects) { + this.pages = pages; + this.projects = projects; + } + + @GetMapping + public String list(Principal principal, Model model) { + model.addAttribute("projects", pages.listVisible(actorId(principal))); + return "projects/list"; + } + + @GetMapping("/new") + public String createForm(Model model) { + model.addAttribute("projectForm", new ProjectCreateForm()); + return "projects/form"; + } + + @PostMapping + public String create( + Principal principal, + @Valid @ModelAttribute("projectForm") ProjectCreateForm projectForm, + BindingResult bindingResult) { + if (bindingResult.hasErrors()) { + return "projects/form"; + } + long projectId = projects.create(actorId(principal), projectForm.toCommand()); + return "redirect:/projects/" + projectId; + } + + @GetMapping("/{projectId}") + public String detail(Principal principal, @PathVariable long projectId, Model model) { + model.addAttribute("project", pages.detail(actorId(principal), projectId)); + return "projects/detail"; + } + + @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)); + return "projects/members"; + } + + @PostMapping("/{projectId}/members") + public String addMember( + Principal principal, + @PathVariable long projectId, + @Valid @ModelAttribute ProjectMemberForm memberForm) { + projects.addMember(actorId(principal), projectId, memberForm.internUserId()); + return "redirect:/projects/" + projectId + "/members"; + } + + @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)); + return "projects/leadership"; + } + + @PostMapping("/{projectId}/leadership") + public String changeLeader( + Principal principal, + @PathVariable long projectId, + @Valid @ModelAttribute ProjectMemberForm memberForm) { + projects.changeLeader(actorId(principal), projectId, memberForm.internUserId()); + return "redirect:/projects/" + projectId + "/leadership"; + } + + private long actorId(Principal principal) { + return pages.authenticatedUserId(principal.getName()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java new file mode 100644 index 0000000..65ef0dd --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.feature.project.exception; + +public final class ProjectAccessDeniedException extends RuntimeException { + + public ProjectAccessDeniedException() { + super("Project access denied"); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java new file mode 100644 index 0000000..f6c381c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java @@ -0,0 +1,21 @@ +package com.lab.labtimesheet.feature.project.exception; + +import com.lab.labtimesheet.feature.project.controller.ProjectController; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = ProjectController.class) +public class ProjectControllerAdvice { + + @ExceptionHandler(ProjectAccessDeniedException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public void accessDenied() { + } + + @ExceptionHandler(ProjectRuleViolationException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public void conflict() { + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java new file mode 100644 index 0000000..86d24d6 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.feature.project.exception; + +public final class ProjectRuleViolationException extends RuntimeException { + + public ProjectRuleViolationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java new file mode 100644 index 0000000..63668ed --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.feature.project.model; + +public record ProjectInternEligibility(long userId, boolean eligible) { + + public ProjectInternEligibility { + if (userId <= 0) { + throw new IllegalArgumentException("Intern user ID must be positive"); + } + } + + public boolean isEligible() { + return eligible; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java new file mode 100644 index 0000000..7c1d67d --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.project.model; + +import com.lab.labtimesheet.feature.project.model.entity.ProjectMembershipEntity; +import java.time.Instant; + +public record ProjectLeaderChange(ProjectMembershipEntity replacement, Instant effectiveAt) { +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectStatus.java similarity index 57% rename from src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java rename to src/main/java/com/lab/labtimesheet/feature/project/model/ProjectStatus.java index 0e24065..75d9e0d 100644 --- a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectStatus.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.projects.domain; +package com.lab.labtimesheet.feature.project.model; public enum ProjectStatus { PLANNED, diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java new file mode 100644 index 0000000..6139322 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java @@ -0,0 +1,4 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +public record ProjectActorView(long userId, String role) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java new file mode 100644 index 0000000..2c2913f --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java @@ -0,0 +1,11 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.LocalDate; + +public record ProjectCreateCommand( + String name, + String description, + LocalDate startDate, + LocalDate endDate, + long initialLeaderUserId) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java new file mode 100644 index 0000000..b95332a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java @@ -0,0 +1,30 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +public record ProjectCreateForm( + @NotBlank @Size(max = 160) String name, + String description, + @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @NotNull @Positive Long initialLeaderUserId) { + + public ProjectCreateForm() { + this(null, null, null, null, null); + } + + @AssertTrue(message = "End date must not precede start date") + public boolean isDateRangeValid() { + return startDate == null || endDate == null || !endDate.isBefore(startDate); + } + + public ProjectCreateCommand toCommand() { + return new ProjectCreateCommand(name, description, startDate, endDate, initialLeaderUserId); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java new file mode 100644 index 0000000..8a970f0 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java @@ -0,0 +1,4 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +public record ProjectDashboardSummary(long activeProjectCount, long distinctActiveMemberCount) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java new file mode 100644 index 0000000..4af0e54 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.LocalDate; + +public record ProjectDetail( + long id, + String name, + String description, + String status, + LocalDate startDate, + LocalDate endDate, + String mentorName, + String leaderName) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java new file mode 100644 index 0000000..a63fd92 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.Instant; + +public record ProjectLeadershipTermView( + long id, + String leaderName, + Instant startedAt, + Instant endedAt) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java new file mode 100644 index 0000000..13d483c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java @@ -0,0 +1,6 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import jakarta.validation.constraints.Positive; + +public record ProjectMemberForm(@Positive long internUserId) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java new file mode 100644 index 0000000..b799097 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java @@ -0,0 +1,12 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.Instant; + +public record ProjectMemberView( + long membershipId, + long internUserId, + String displayName, + Instant joinedAt, + Instant leftAt, + boolean currentLeader) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java new file mode 100644 index 0000000..93de086 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java @@ -0,0 +1,6 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.LocalDate; + +public record ProjectSummary(long id, String name, String status, LocalDate startDate, LocalDate endDate) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java new file mode 100644 index 0000000..ffb3570 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java @@ -0,0 +1,18 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import java.time.LocalDate; +import java.util.List; + +public record ProjectTaskContext( + long projectId, + long mentorUserId, + String status, + LocalDate startDate, + LocalDate endDate, + Long currentLeaderMembershipId, + List activeMembers) { + + public ProjectTaskContext { + activeMembers = List.copyOf(activeMembers); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java new file mode 100644 index 0000000..3123f1d --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java @@ -0,0 +1,4 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +public record ProjectTaskMemberView(long membershipId, long userId, String displayName) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java new file mode 100644 index 0000000..eabaefc --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java @@ -0,0 +1,287 @@ +package com.lab.labtimesheet.feature.project.model.entity; + +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; +import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility; +import com.lab.labtimesheet.feature.project.model.ProjectLeaderChange; +import com.lab.labtimesheet.feature.project.model.ProjectStatus; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Entity +@Table(name = "projects") +public class ProjectEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "mentor_user_id", nullable = false) + private long mentorUserId; + + @Column(nullable = false, length = 160) + private String name; + + @Column + private String description; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + @OneToMany(mappedBy = "project", cascade = CascadeType.ALL) + private List memberships = new ArrayList<>(); + + @OneToMany(mappedBy = "project", cascade = CascadeType.ALL) + private List leadershipTerms = new ArrayList<>(); + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private ProjectStatus status = ProjectStatus.PLANNED; + + @Column(name = "activated_at") + private Instant activatedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + private long version; + + protected ProjectEntity() { + } + + private ProjectEntity( + long mentorUserId, + String name, + String description, + LocalDate startDate, + LocalDate endDate, + Instant createdAt) { + this.mentorUserId = mentorUserId; + this.name = name; + this.description = description; + this.startDate = startDate; + this.endDate = endDate; + this.createdAt = createdAt; + this.updatedAt = createdAt; + } + + public static ProjectEntity plan( + long mentorUserId, + String name, + String description, + LocalDate startDate, + LocalDate endDate, + ProjectInternEligibility initialLeader, + Instant at) { + if (mentorUserId <= 0) { + throw new IllegalArgumentException("Mentor user ID must be positive"); + } + var normalizedName = requireText(name, "Project name is required"); + Objects.requireNonNull(startDate, "startDate"); + Objects.requireNonNull(endDate, "endDate"); + Objects.requireNonNull(at, "at"); + if (endDate.isBefore(startDate)) { + throw new ProjectRuleViolationException("Project end date must not precede its start date"); + } + requireEligible(initialLeader); + + var project = new ProjectEntity( + mentorUserId, + normalizedName, + normalizeOptionalText(description), + startDate, + endDate, + at); + var membership = project.addEligibleMember(initialLeader, mentorUserId, at); + project.leadershipTerms.add(new ProjectLeadershipTermEntity(project, membership, at, mentorUserId)); + return project; + } + + public ProjectMembershipEntity addMember( + long actorMentorUserId, ProjectInternEligibility intern, Instant at) { + requireOwner(actorMentorUserId); + requireMutable(); + requireEligible(intern); + Objects.requireNonNull(at, "at"); + if (hasCurrentMember(intern.userId())) { + throw new ProjectRuleViolationException("Intern is already a current Project member"); + } + return addEligibleMember(intern, actorMentorUserId, at); + } + + public ProjectLeaderChange prepareLeaderChange( + long actorMentorUserId, ProjectInternEligibility intern, Instant at) { + requireOwner(actorMentorUserId); + requireMutable(); + requireEligible(intern); + Objects.requireNonNull(at, "at"); + var replacement = currentMembership(intern.userId()); + var current = currentLeadershipTerm(); + if (current.internUserId() == intern.userId()) { + throw new ProjectRuleViolationException("Selected Intern is already the current Leader"); + } + + var effectiveAt = current.end(at, actorMentorUserId); + updatedAt = effectiveAt; + return new ProjectLeaderChange(replacement, effectiveAt); + } + + public void completeLeaderChange(long actorMentorUserId, ProjectLeaderChange change) { + requireOwner(actorMentorUserId); + Objects.requireNonNull(change, "change"); + if (leadershipTerms.stream().anyMatch(ProjectLeadershipTermEntity::isCurrent)) { + throw new ProjectRuleViolationException("Current Leader must be closed before replacement"); + } + leadershipTerms.add(new ProjectLeadershipTermEntity( + this, change.replacement(), change.effectiveAt(), actorMentorUserId)); + } + + public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) { + requireOwner(actorMentorUserId); + Objects.requireNonNull(at, "at"); + if (status != ProjectStatus.PLANNED) { + throw new ProjectRuleViolationException("Only a planned Project can be activated"); + } + if (memberships.stream().noneMatch(ProjectMembershipEntity::isCurrent) + || leadershipTerms.stream().noneMatch(ProjectLeadershipTermEntity::isCurrent)) { + throw new ProjectRuleViolationException("Project requires a current member and Leader"); + } + if (!allTaskAssigneesAreCurrent) { + throw new ProjectRuleViolationException("Every current Task assignee must be an active Project member"); + } + status = ProjectStatus.ACTIVE; + activatedAt = at; + updatedAt = at; + } + + public Long id() { + return id; + } + + public long mentorUserId() { + return mentorUserId; + } + + public String name() { + return name; + } + + public String description() { + return description; + } + + public LocalDate startDate() { + return startDate; + } + + public LocalDate endDate() { + return endDate; + } + + public ProjectStatus status() { + return status; + } + + public Instant activatedAt() { + return activatedAt; + } + + public List memberships() { + return List.copyOf(memberships); + } + + public List leadershipTerms() { + return List.copyOf(leadershipTerms); + } + + public void authorizeOwner(long actorMentorUserId) { + requireOwner(actorMentorUserId); + } + + public boolean hasCurrentMember(long internUserId) { + return memberships.stream() + .anyMatch(membership -> membership.internUserId() == internUserId && membership.isCurrent()); + } + + public boolean hasEverHadMember(long internUserId) { + return memberships.stream().anyMatch(membership -> membership.internUserId() == internUserId); + } + + public ProjectMembershipEntity currentLeader() { + return currentMembership(currentLeadershipTerm().internUserId()); + } + + private ProjectMembershipEntity addEligibleMember( + ProjectInternEligibility intern, long addedByUserId, Instant at) { + var membership = new ProjectMembershipEntity(this, intern.userId(), at, addedByUserId); + memberships.add(membership); + updatedAt = at; + return membership; + } + + private ProjectMembershipEntity currentMembership(long internUserId) { + return memberships.stream() + .filter(membership -> membership.internUserId() == internUserId && membership.isCurrent()) + .findFirst() + .orElseThrow(() -> new ProjectRuleViolationException( + "Leader must be a current same-Project member")); + } + + private ProjectLeadershipTermEntity currentLeadershipTerm() { + return leadershipTerms.stream() + .filter(ProjectLeadershipTermEntity::isCurrent) + .findFirst() + .orElseThrow(() -> new ProjectRuleViolationException("Project has no current Leader")); + } + + private void requireOwner(long actorMentorUserId) { + if (mentorUserId != actorMentorUserId) { + throw new ProjectAccessDeniedException(); + } + } + + private void requireMutable() { + if (status == ProjectStatus.COMPLETED) { + throw new ProjectRuleViolationException("Completed Projects are read-only"); + } + } + + private static void requireEligible(ProjectInternEligibility intern) { + Objects.requireNonNull(intern, "intern"); + if (!intern.isEligible()) { + throw new ProjectRuleViolationException("Intern must have an active account and internship"); + } + } + + private static String requireText(String value, String message) { + if (value == null || value.trim().isEmpty()) { + throw new ProjectRuleViolationException(message); + } + return value.trim(); + } + + private static String normalizeOptionalText(String value) { + return value == null || value.trim().isEmpty() ? null : value.trim(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java new file mode 100644 index 0000000..62974e5 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java @@ -0,0 +1,93 @@ +package com.lab.labtimesheet.feature.project.model.entity; + +import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "project_leadership_terms") +public class ProjectLeadershipTermEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "project_id", nullable = false) + private ProjectEntity project; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "membership_id", nullable = false) + private ProjectMembershipEntity membership; + + @Column(name = "started_at", nullable = false) + private Instant startedAt; + + @Column(name = "appointed_by_mentor_user_id", nullable = false) + private long appointedByMentorUserId; + + @Column(name = "ended_at") + private Instant endedAt; + + @Column(name = "ended_by_mentor_user_id") + private Long endedByMentorUserId; + + protected ProjectLeadershipTermEntity() { + } + + ProjectLeadershipTermEntity( + ProjectEntity project, + ProjectMembershipEntity membership, + Instant startedAt, + long appointedByMentorUserId) { + this.project = project; + this.membership = membership; + this.startedAt = startedAt; + this.appointedByMentorUserId = appointedByMentorUserId; + } + + public Long id() { + return id; + } + + public long internUserId() { + return membership.internUserId(); + } + + public Instant startedAt() { + return startedAt; + } + + public long appointedByMentorUserId() { + return appointedByMentorUserId; + } + + public Instant endedAt() { + return endedAt; + } + + public Long endedByMentorUserId() { + return endedByMentorUserId; + } + + public boolean isCurrent() { + return endedAt == null; + } + + Instant end(Instant at, long mentorUserId) { + if (!isCurrent() || at.isBefore(startedAt)) { + throw new ProjectRuleViolationException("Leadership term end must follow its start"); + } + endedAt = at.equals(startedAt) ? startedAt.plusNanos(1_000) : at; + endedByMentorUserId = mentorUserId; + return endedAt; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java new file mode 100644 index 0000000..59f158c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java @@ -0,0 +1,79 @@ +package com.lab.labtimesheet.feature.project.model.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; + +@Entity +@Table(name = "project_memberships") +public class ProjectMembershipEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "project_id", nullable = false) + private ProjectEntity project; + + @Column(name = "intern_user_id", nullable = false) + private long internUserId; + + @Column(name = "joined_at", nullable = false) + private Instant joinedAt; + + @Column(name = "added_by_user_id", nullable = false) + private long addedByUserId; + + @Column(name = "left_at") + private Instant leftAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + private long version; + + protected ProjectMembershipEntity() { + } + + ProjectMembershipEntity(ProjectEntity project, long internUserId, Instant joinedAt, long addedByUserId) { + this.project = project; + this.internUserId = internUserId; + this.joinedAt = joinedAt; + this.addedByUserId = addedByUserId; + this.updatedAt = joinedAt; + } + + public Long id() { + return id; + } + + public long internUserId() { + return internUserId; + } + + public Instant joinedAt() { + return joinedAt; + } + + public long addedByUserId() { + return addedByUserId; + } + + public Instant leftAt() { + return leftAt; + } + + public boolean isCurrent() { + return leftAt == null; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java b/src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java new file mode 100644 index 0000000..c0be117 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java @@ -0,0 +1,29 @@ +package com.lab.labtimesheet.feature.project.repository; + +import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; +import jakarta.persistence.LockModeType; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProjectRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select project from ProjectEntity project where project.id = :id") + Optional findLockedById(@Param("id") long id); + + List findAllByOrderByUpdatedAtDescIdDesc(); + + List findByMentorUserIdOrderByUpdatedAtDescIdDesc(long mentorUserId); + + @Query(""" + select distinct project from ProjectEntity project + join project.memberships membership + where membership.internUserId = :internUserId + order by project.updatedAt desc, project.id desc + """) + List findVisibleToIntern(@Param("internUserId") long internUserId); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java new file mode 100644 index 0000000..eadff93 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -0,0 +1,196 @@ +package com.lab.labtimesheet.feature.project.service; + +import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.model.ProjectStatus; +import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail; +import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView; +import com.lab.labtimesheet.feature.project.model.dto.ProjectDashboardSummary; +import com.lab.labtimesheet.feature.project.model.dto.ProjectLeadershipTermView; +import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView; +import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView; +import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; +import com.lab.labtimesheet.feature.project.repository.ProjectRepository; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ProjectQueryService { + + private final ProjectRepository projects; + private final AccountService accounts; + + public ProjectQueryService(ProjectRepository projects, AccountService accounts) { + this.projects = projects; + this.accounts = accounts; + } + + @Transactional(readOnly = true) + public long authenticatedUserId(String email) { + return authenticatedActor(email).userId(); + } + + @Transactional(readOnly = true) + public ProjectActorView authenticatedActor(String email) { + try { + var actor = accounts.requireIdentityByEmail(email); + if (!"ACTIVE".equals(actor.status().name())) { + throw new ProjectAccessDeniedException(); + } + return new ProjectActorView(actor.id(), actor.role().name()); + } catch (IllegalArgumentException exception) { + throw new ProjectAccessDeniedException(); + } + } + + @Transactional(readOnly = true) + public List listVisible(long actorUserId) { + var actor = activeActor(actorUserId); + return visibleProjects(actor, actorUserId).stream().map(ProjectQueryService::summary).toList(); + } + + @Transactional(readOnly = true) + public ProjectDetail detail(long actorUserId, long projectId) { + var project = visibleProject(actorUserId, projectId); + return new ProjectDetail( + project.id(), + project.name(), + project.description(), + project.status().name(), + project.startDate(), + project.endDate(), + displayName(project.mentorUserId()), + displayName(project.currentLeader().internUserId())); + } + + @Transactional(readOnly = true) + public List members(long actorUserId, long projectId) { + var project = visibleProject(actorUserId, projectId); + var leaderUserId = project.currentLeader().internUserId(); + return project.memberships().stream() + .map(membership -> new ProjectMemberView( + membership.id(), + membership.internUserId(), + displayName(membership.internUserId()), + membership.joinedAt(), + membership.leftAt(), + membership.isCurrent() && membership.internUserId() == leaderUserId)) + .toList(); + } + + @Transactional(readOnly = true) + public List leadership(long actorUserId, long projectId) { + return visibleProject(actorUserId, projectId).leadershipTerms().stream() + .sorted((left, right) -> right.startedAt().compareTo(left.startedAt())) + .map(term -> new ProjectLeadershipTermView( + term.id(), + displayName(term.internUserId()), + term.startedAt(), + term.endedAt())) + .toList(); + } + + @Transactional(readOnly = true) + public ProjectTaskContext taskContext(long actorUserId, long projectId) { + var project = visibleProject(actorUserId, projectId); + var activeMembers = project.memberships().stream() + .filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId())) + .map(membership -> new ProjectTaskMemberView( + membership.id(), + membership.internUserId(), + displayName(membership.internUserId()))) + .toList(); + var currentLeader = project.currentLeader(); + var currentLeaderMembershipId = activeMembers.stream() + .filter(member -> member.membershipId() == currentLeader.id()) + .map(ProjectTaskMemberView::membershipId) + .findFirst() + .orElse(null); + return new ProjectTaskContext( + project.id(), + project.mentorUserId(), + project.status().name(), + project.startDate(), + project.endDate(), + currentLeaderMembershipId, + activeMembers); + } + + @Transactional(readOnly = true) + public ProjectDashboardSummary dashboardSummary(long actorUserId) { + var actor = activeActor(actorUserId); + var activeProjects = visibleProjects(actor, actorUserId).stream() + .filter(project -> project.status() == ProjectStatus.ACTIVE) + .filter(project -> !"INTERN".equals(actor.role().name()) + || (accounts.isEligibleIntern(actorUserId) && project.hasCurrentMember(actorUserId))) + .toList(); + var distinctActiveMembers = "MENTOR".equals(actor.role().name()) + ? activeProjects.stream() + .flatMap(project -> project.memberships().stream()) + .filter(membership -> membership.isCurrent() + && accounts.isEligibleIntern(membership.internUserId())) + .map(membership -> membership.internUserId()) + .distinct() + .count() + : 0L; + return new ProjectDashboardSummary(activeProjects.size(), distinctActiveMembers); + } + + private ProjectEntity visibleProject(long actorUserId, long projectId) { + var actor = activeActor(actorUserId); + var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new); + var visible = "ADMIN".equals(actor.role().name()) + || ("MENTOR".equals(actor.role().name()) && project.mentorUserId() == actorUserId) + || ("INTERN".equals(actor.role().name()) && project.hasEverHadMember(actorUserId)); + if (!visible) { + throw new ProjectAccessDeniedException(); + } + return project; + } + + private List visibleProjects(AccountIdentity actor, long actorUserId) { + return switch (actor.role().name()) { + case "ADMIN" -> projects.findAllByOrderByUpdatedAtDescIdDesc(); + case "MENTOR" -> projects.findByMentorUserIdOrderByUpdatedAtDescIdDesc(actorUserId); + case "INTERN" -> projects.findVisibleToIntern(actorUserId); + default -> throw new ProjectAccessDeniedException(); + }; + } + + private AccountIdentity activeActor(long actorUserId) { + try { + var actor = accounts.requireIdentityById(actorUserId); + if (!"ACTIVE".equals(actor.status().name())) { + throw new ProjectAccessDeniedException(); + } + return actor; + } catch (IllegalArgumentException exception) { + throw new ProjectAccessDeniedException(); + } + } + + private String displayName(long userId) { + try { + return accounts.requireIdentityById(userId).displayName(); + } catch (IllegalArgumentException exception) { + throw new ProjectAccessDeniedException(); + } + } + + private boolean isEligibleIntern(long userId) { + return accounts.isEligibleIntern(userId); + } + + private static ProjectSummary summary(ProjectEntity project) { + return new ProjectSummary( + project.id(), + project.name(), + project.status().name(), + project.startDate(), + project.endDate()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java new file mode 100644 index 0000000..31c994f --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java @@ -0,0 +1,82 @@ +package com.lab.labtimesheet.feature.project.service; + +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +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; +import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; +import com.lab.labtimesheet.feature.project.repository.ProjectRepository; +import java.time.Clock; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ProjectService { + + private final ProjectRepository projects; + private final AccountService accounts; + private final Clock clock; + + public ProjectService( + ProjectRepository projects, + AccountService accounts, + Clock clock) { + this.projects = projects; + this.accounts = accounts; + this.clock = clock; + } + + @Transactional + public long create(long actorUserId, ProjectCreateCommand command) { + requireActiveMentor(actorUserId); + var project = ProjectEntity.plan( + actorUserId, + command.name(), + command.description(), + command.startDate(), + command.endDate(), + eligibleIntern(command.initialLeaderUserId()), + clock.instant()); + return projects.saveAndFlush(project).id(); + } + + @Transactional + public void addMember(long actorUserId, long projectId, long internUserId) { + var project = lockedProject(projectId); + project.authorizeOwner(actorUserId); + project.addMember(actorUserId, eligibleIntern(internUserId), clock.instant()); + projects.flush(); + } + + @Transactional + public void changeLeader(long actorUserId, long projectId, long internUserId) { + var project = lockedProject(projectId); + project.authorizeOwner(actorUserId); + var change = project.prepareLeaderChange(actorUserId, eligibleIntern(internUserId), clock.instant()); + + // PostgreSQL rejects overlapping terms immediately. Flush the old term's + // end before inserting its replacement; the transaction remains atomic. + projects.flush(); + project.completeLeaderChange(actorUserId, change); + projects.flush(); + } + + private ProjectEntity lockedProject(long projectId) { + return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new); + } + + private ProjectInternEligibility eligibleIntern(long userId) { + return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId)); + } + + private void requireActiveMentor(long userId) { + try { + var identity = accounts.requireIdentityById(userId); + if (!"MENTOR".equals(identity.role().name()) || !"ACTIVE".equals(identity.status().name())) { + throw new ProjectAccessDeniedException(); + } + } catch (IllegalArgumentException exception) { + throw new ProjectAccessDeniedException(); + } + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java b/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java deleted file mode 100644 index 47777c2..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -public record EligibleIntern(long userId, boolean accountActive, boolean internshipActive) { - - public EligibleIntern { - if (userId <= 0) { - throw new IllegalArgumentException("Intern user ID must be positive"); - } - } - - public boolean isEligible() { - return accountActive && internshipActive; - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java b/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java deleted file mode 100644 index b91850a..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -import java.time.Instant; - -public final class LeadershipTerm { - - private final ProjectMembership membership; - private final Instant startedAt; - private final long appointedByMentorUserId; - private Instant endedAt; - private Long endedByMentorUserId; - - LeadershipTerm(ProjectMembership membership, Instant startedAt, long appointedByMentorUserId) { - this.membership = membership; - this.startedAt = startedAt; - this.appointedByMentorUserId = appointedByMentorUserId; - } - - public long internUserId() { - return membership.internUserId(); - } - - public Instant startedAt() { - return startedAt; - } - - public long appointedByMentorUserId() { - return appointedByMentorUserId; - } - - public Instant endedAt() { - return endedAt; - } - - public Long endedByMentorUserId() { - return endedByMentorUserId; - } - - public boolean isCurrent() { - return endedAt == null; - } - - void end(Instant at, long mentorUserId) { - if (!isCurrent() || !at.isAfter(startedAt)) { - throw new ProjectRuleViolation("Leadership term end must follow its start"); - } - endedAt = at; - endedByMentorUserId = mentorUserId; - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/Project.java b/src/main/java/com/lab/labtimesheet/projects/domain/Project.java deleted file mode 100644 index e81fa40..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/Project.java +++ /dev/null @@ -1,205 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -public final class Project { - - private final long mentorUserId; - private final String name; - private final String description; - private final LocalDate startDate; - private final LocalDate endDate; - private final List memberships = new ArrayList<>(); - private final List leadershipTerms = new ArrayList<>(); - private ProjectStatus status = ProjectStatus.PLANNED; - private Instant activatedAt; - - private Project( - long mentorUserId, - String name, - String description, - LocalDate startDate, - LocalDate endDate) { - this.mentorUserId = mentorUserId; - this.name = name; - this.description = description; - this.startDate = startDate; - this.endDate = endDate; - } - - public static Project plan( - long mentorUserId, - String name, - String description, - LocalDate startDate, - LocalDate endDate, - EligibleIntern initialLeader, - Instant at) { - if (mentorUserId <= 0) { - throw new IllegalArgumentException("Mentor user ID must be positive"); - } - var normalizedName = requireText(name, "Project name is required"); - Objects.requireNonNull(startDate, "startDate"); - Objects.requireNonNull(endDate, "endDate"); - Objects.requireNonNull(at, "at"); - if (endDate.isBefore(startDate)) { - throw new ProjectRuleViolation("Project end date must not precede its start date"); - } - requireEligible(initialLeader); - - var project = new Project( - mentorUserId, - normalizedName, - normalizeOptionalText(description), - startDate, - endDate); - var membership = project.addEligibleMember(initialLeader, mentorUserId, at); - project.leadershipTerms.add(new LeadershipTerm(membership, at, mentorUserId)); - return project; - } - - public ProjectMembership addMember(long actorMentorUserId, EligibleIntern intern, Instant at) { - requireOwner(actorMentorUserId); - requireMutable(); - requireEligible(intern); - Objects.requireNonNull(at, "at"); - if (hasCurrentMember(intern.userId())) { - throw new ProjectRuleViolation("Intern is already a current Project member"); - } - return addEligibleMember(intern, actorMentorUserId, at); - } - - public void changeLeader(long actorMentorUserId, EligibleIntern intern, Instant at) { - requireOwner(actorMentorUserId); - requireMutable(); - requireEligible(intern); - Objects.requireNonNull(at, "at"); - var replacement = currentMembership(intern.userId()); - var current = currentLeadershipTerm(); - if (current.internUserId() == intern.userId()) { - throw new ProjectRuleViolation("Selected Intern is already the current Leader"); - } - - current.end(at, actorMentorUserId); - leadershipTerms.add(new LeadershipTerm(replacement, at, actorMentorUserId)); - } - - public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) { - requireOwner(actorMentorUserId); - Objects.requireNonNull(at, "at"); - if (status != ProjectStatus.PLANNED) { - throw new ProjectRuleViolation("Only a planned Project can be activated"); - } - if (memberships.stream().noneMatch(ProjectMembership::isCurrent) - || leadershipTerms.stream().noneMatch(LeadershipTerm::isCurrent)) { - throw new ProjectRuleViolation("Project requires a current member and Leader"); - } - if (!allTaskAssigneesAreCurrent) { - throw new ProjectRuleViolation("Every current Task assignee must be an active Project member"); - } - status = ProjectStatus.ACTIVE; - activatedAt = at; - } - - public long mentorUserId() { - return mentorUserId; - } - - public String name() { - return name; - } - - public String description() { - return description; - } - - public LocalDate startDate() { - return startDate; - } - - public LocalDate endDate() { - return endDate; - } - - public ProjectStatus status() { - return status; - } - - public Instant activatedAt() { - return activatedAt; - } - - public List memberships() { - return List.copyOf(memberships); - } - - public List leadershipTerms() { - return List.copyOf(leadershipTerms); - } - - public boolean hasCurrentMember(long internUserId) { - return memberships.stream() - .anyMatch(membership -> membership.internUserId() == internUserId && membership.isCurrent()); - } - - public ProjectMembership currentLeader() { - return currentMembership(currentLeadershipTerm().internUserId()); - } - - private ProjectMembership addEligibleMember(EligibleIntern intern, long addedByUserId, Instant at) { - var membership = new ProjectMembership(intern.userId(), at, addedByUserId); - memberships.add(membership); - return membership; - } - - private ProjectMembership currentMembership(long internUserId) { - return memberships.stream() - .filter(membership -> membership.internUserId() == internUserId && membership.isCurrent()) - .findFirst() - .orElseThrow(() -> new ProjectRuleViolation("Leader must be a current same-Project member")); - } - - private LeadershipTerm currentLeadershipTerm() { - return leadershipTerms.stream() - .filter(LeadershipTerm::isCurrent) - .findFirst() - .orElseThrow(() -> new ProjectRuleViolation("Project has no current Leader")); - } - - private void requireOwner(long actorMentorUserId) { - if (mentorUserId != actorMentorUserId) { - throw new ProjectAccessDenied(); - } - } - - private void requireMutable() { - if (status == ProjectStatus.COMPLETED) { - throw new ProjectRuleViolation("Completed Projects are read-only"); - } - } - - private static void requireEligible(EligibleIntern intern) { - Objects.requireNonNull(intern, "intern"); - if (!intern.isEligible()) { - throw new ProjectRuleViolation("Intern must have an active account and internship"); - } - } - - private static String requireText(String value, String message) { - if (value == null || value.trim().isEmpty()) { - throw new ProjectRuleViolation(message); - } - return value.trim(); - } - - private static String normalizeOptionalText(String value) { - if (value == null || value.trim().isEmpty()) { - return null; - } - return value.trim(); - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java deleted file mode 100644 index c60789a..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -public final class ProjectAccessDenied extends RuntimeException { - - public ProjectAccessDenied() { - super("Project access denied"); - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java deleted file mode 100644 index a48b86a..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -import java.time.Instant; - -public final class ProjectMembership { - - private final long internUserId; - private final Instant joinedAt; - private final long addedByUserId; - private Instant leftAt; - - ProjectMembership(long internUserId, Instant joinedAt, long addedByUserId) { - this.internUserId = internUserId; - this.joinedAt = joinedAt; - this.addedByUserId = addedByUserId; - } - - public long internUserId() { - return internUserId; - } - - public Instant joinedAt() { - return joinedAt; - } - - public long addedByUserId() { - return addedByUserId; - } - - public Instant leftAt() { - return leftAt; - } - - public boolean isCurrent() { - return leftAt == null; - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java deleted file mode 100644 index 4357c9c..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.lab.labtimesheet.projects.domain; - -public final class ProjectRuleViolation extends RuntimeException { - - public ProjectRuleViolation(String message) { - super(message); - } -} diff --git a/src/main/resources/templates/projects/detail.html b/src/main/resources/templates/projects/detail.html new file mode 100644 index 0000000..5dd745e --- /dev/null +++ b/src/main/resources/templates/projects/detail.html @@ -0,0 +1,12 @@ + + +Project + +
+

Project

+

+
Status
Mentor
Leader
+ +
+ + diff --git a/src/main/resources/templates/projects/form.html b/src/main/resources/templates/projects/form.html new file mode 100644 index 0000000..2834cfa --- /dev/null +++ b/src/main/resources/templates/projects/form.html @@ -0,0 +1,19 @@ + + +Create Project + +
+

Create Project

+
+ +

+ + + + +

+ +
+
+ + diff --git a/src/main/resources/templates/projects/leadership.html b/src/main/resources/templates/projects/leadership.html new file mode 100644 index 0000000..378a69c --- /dev/null +++ b/src/main/resources/templates/projects/leadership.html @@ -0,0 +1,13 @@ + + +Project leadership + +
+

Project leadership

+ + +
Leadership history
LeaderStartedEnded
+
+
+ + diff --git a/src/main/resources/templates/projects/list.html b/src/main/resources/templates/projects/list.html new file mode 100644 index 0000000..9379ac8 --- /dev/null +++ b/src/main/resources/templates/projects/list.html @@ -0,0 +1,20 @@ + + +Projects + +
+

Projects

+ Create Project +

No authorized Projects.

+ + + + + + + + +
Authorized Projects
NameStatusDates
ProjectPLANNEDStartEnd
+
+ + diff --git a/src/main/resources/templates/projects/members.html b/src/main/resources/templates/projects/members.html new file mode 100644 index 0000000..e0238eb --- /dev/null +++ b/src/main/resources/templates/projects/members.html @@ -0,0 +1,13 @@ + + +Project members + +
+

Project members

+ + +
Membership history
InternJoinedLeftRole
+
+
+ + diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java new file mode 100644 index 0000000..ed3ef9f --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -0,0 +1,142 @@ +package com.lab.labtimesheet.feature.project.controller; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand; +import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail; +import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary; +import com.lab.labtimesheet.feature.project.service.ProjectQueryService; +import com.lab.labtimesheet.feature.project.service.ProjectService; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ProjectController.class) +class ProjectControllerTest { + + @Autowired + private MockMvc mvc; + + @MockitoBean + private ProjectQueryService pages; + + @MockitoBean + private ProjectService projects; + + @Test + @WithMockUser(username = "mentor@example.test") + void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception { + when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + when(pages.listVisible(10L)).thenReturn(List.of(new ProjectSummary( + 30L, + "Intern Portal Refresh", + "PLANNED", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30)))); + + mvc.perform(get("/projects")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/list")) + .andExpect(model().attributeExists("projects")); + + verify(pages).listVisible(10L); + } + + @Test + @WithMockUser(username = "member@example.test") + void guessedProjectIdReturnsTheSameNotFoundResponseAsAMissingProject() throws Exception { + when(pages.authenticatedUserId("member@example.test")).thenReturn(20L); + when(pages.detail(20L, 999L)).thenThrow(new ProjectAccessDeniedException()); + + mvc.perform(get("/projects/999")) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(username = "member@example.test") + void memberAndLeadershipPagesUseTheSameProjectScopedAuthorization() throws Exception { + when(pages.authenticatedUserId("member@example.test")).thenReturn(20L); + when(pages.detail(20L, 30L)).thenReturn(new ProjectDetail( + 30L, + "Intern Portal Refresh", + null, + "PLANNED", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + "Mentor", + "Leader")); + when(pages.members(20L, 30L)).thenReturn(List.of()); + when(pages.leadership(20L, 30L)).thenReturn(List.of()); + + mvc.perform(get("/projects/30/members")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/members")); + mvc.perform(get("/projects/30/leadership")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/leadership")); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void validCreateSubmissionUsesAuthenticatedMentorAndRedirectsToDetail() throws Exception { + when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + when(projects.create( + 10L, + new ProjectCreateCommand( + "Intern Portal Refresh", + "Refresh portal", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + 20L))) + .thenReturn(30L); + + mvc.perform(post("/projects") + .with(csrf()) + .param("name", "Intern Portal Refresh") + .param("description", "Refresh portal") + .param("startDate", "2026-08-15") + .param("endDate", "2026-09-30") + .param("initialLeaderUserId", "20")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/30")); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception { + mvc.perform(post("/projects") + .with(csrf()) + .param("name", " ") + .param("startDate", "2026-09-30") + .param("endDate", "2026-08-15") + .param("initialLeaderUserId", "0")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/form")) + .andExpect(model().attributeHasFieldErrors( + "projectForm", "name", "initialLeaderUserId")); + + verify(projects, never()).create(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void stateChangingRoutesRequireCsrf() throws Exception { + mvc.perform(post("/projects")) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java b/src/test/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntityTest.java similarity index 70% rename from src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java rename to src/test/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntityTest.java index 8583541..16f1a2d 100644 --- a/src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntityTest.java @@ -1,21 +1,25 @@ -package com.lab.labtimesheet.projects.domain; +package com.lab.labtimesheet.feature.project.model.entity; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; +import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility; +import com.lab.labtimesheet.feature.project.model.ProjectStatus; import java.time.Instant; import java.time.LocalDate; import org.junit.jupiter.api.Test; -class ProjectTest { +class ProjectEntityTest { private static final Instant CREATED_AT = Instant.parse("2026-08-14T02:00:00Z"); @Test void planningCreatesTheInitialLeaderMembershipAndTermTogether() { - var project = Project.plan( + var project = ProjectEntity.plan( 10L, " Intern Portal Refresh ", " Refresh the portal ", @@ -36,7 +40,7 @@ class ProjectTest { @Test void planningRejectsAnIneligibleInitialLeaderAndInvalidDates() { - assertThrows(ProjectRuleViolation.class, () -> Project.plan( + assertThrows(ProjectRuleViolationException.class, () -> ProjectEntity.plan( 10L, "Project", null, @@ -44,13 +48,13 @@ class ProjectTest { LocalDate.of(2026, 8, 31), activeIntern(20L), CREATED_AT)); - assertThrows(ProjectRuleViolation.class, () -> Project.plan( + assertThrows(ProjectRuleViolationException.class, () -> ProjectEntity.plan( 10L, "Project", null, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), - new EligibleIntern(20L, false, true), + new ProjectInternEligibility(20L, false), CREATED_AT)); } @@ -62,16 +66,16 @@ class ProjectTest { assertEquals(2, project.memberships().size()); assertTrue(project.hasCurrentMember(21L)); - assertThrows(ProjectRuleViolation.class, + assertThrows(ProjectRuleViolationException.class, () -> project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(120))); - assertThrows(ProjectAccessDenied.class, + assertThrows(ProjectAccessDeniedException.class, () -> project.addMember(11L, activeIntern(22L), CREATED_AT.plusSeconds(120))); } @Test void theSameInternCanBelongToSeparateProjects() { var first = plannedProject(); - var second = Project.plan( + var second = ProjectEntity.plan( 11L, "Second", null, @@ -91,38 +95,39 @@ class ProjectTest { var project = plannedProject(); project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(60)); - project.changeLeader(10L, activeIntern(21L), CREATED_AT.plusSeconds(120)); + var change = project.prepareLeaderChange(10L, activeIntern(21L), CREATED_AT.plusSeconds(120)); + project.completeLeaderChange(10L, change); assertEquals(2, project.memberships().size()); assertEquals(2, project.leadershipTerms().size()); - assertEquals(1, project.leadershipTerms().stream().filter(LeadershipTerm::isCurrent).count()); + assertEquals(1, project.leadershipTerms().stream().filter(ProjectLeadershipTermEntity::isCurrent).count()); assertEquals(21L, project.currentLeader().internUserId()); assertFalse(project.leadershipTerms().getFirst().isCurrent()); - assertThrows(ProjectRuleViolation.class, - () -> project.changeLeader(10L, activeIntern(21L), CREATED_AT.plusSeconds(180))); - assertThrows(ProjectRuleViolation.class, - () -> project.changeLeader(10L, activeIntern(22L), CREATED_AT.plusSeconds(180))); + assertThrows(ProjectRuleViolationException.class, + () -> project.prepareLeaderChange(10L, activeIntern(21L), CREATED_AT.plusSeconds(180))); + assertThrows(ProjectRuleViolationException.class, + () -> project.prepareLeaderChange(10L, activeIntern(22L), CREATED_AT.plusSeconds(180))); } @Test void activationRequiresOwnerAndValidCurrentTaskAssignees() { var project = plannedProject(); - assertThrows(ProjectAccessDenied.class, + assertThrows(ProjectAccessDeniedException.class, () -> project.activate(11L, true, CREATED_AT.plusSeconds(60))); - assertThrows(ProjectRuleViolation.class, + assertThrows(ProjectRuleViolationException.class, () -> project.activate(10L, false, CREATED_AT.plusSeconds(60))); project.activate(10L, true, CREATED_AT.plusSeconds(60)); assertEquals(ProjectStatus.ACTIVE, project.status()); assertEquals(CREATED_AT.plusSeconds(60), project.activatedAt()); - assertThrows(ProjectRuleViolation.class, + assertThrows(ProjectRuleViolationException.class, () -> project.activate(10L, true, CREATED_AT.plusSeconds(120))); } - private static Project plannedProject() { - return Project.plan( + private static ProjectEntity plannedProject() { + return ProjectEntity.plan( 10L, "Project", null, @@ -132,7 +137,7 @@ class ProjectTest { CREATED_AT); } - private static EligibleIntern activeIntern(long userId) { - return new EligibleIntern(userId, true, true); + private static ProjectInternEligibility activeIntern(long userId) { + return new ProjectInternEligibility(userId, true); } } diff --git a/src/test/java/com/lab/labtimesheet/feature/project/repository/ProjectPersistenceStructureTest.java b/src/test/java/com/lab/labtimesheet/feature/project/repository/ProjectPersistenceStructureTest.java new file mode 100644 index 0000000..a3bab65 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/project/repository/ProjectPersistenceStructureTest.java @@ -0,0 +1,31 @@ +package com.lab.labtimesheet.feature.project.repository; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; +import com.lab.labtimesheet.feature.project.service.ProjectService; +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.jdbc.core.JdbcOperations; + +class ProjectPersistenceStructureTest { + + @Test + void projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa() { + assertTrue(ProjectEntity.class.getPackageName().startsWith("com.lab.labtimesheet.feature.project.model.entity")); + assertTrue(ProjectService.class.getPackageName().startsWith("com.lab.labtimesheet.feature.project.service")); + assertTrue(JpaRepository.class.isAssignableFrom(ProjectRepository.class)); + assertTrue(ProjectRepository.class.getInterfaces().length > 0); + assertTrue(ProjectEntity.class.isAnnotationPresent(jakarta.persistence.Entity.class)); + assertFalse(Arrays.stream(ProjectService.class.getDeclaredFields()) + .map(field -> field.getType()) + .anyMatch(JdbcOperations.class::isAssignableFrom)); + assertThrows(ClassNotFoundException.class, + () -> Class.forName("com.lab.labtimesheet.feature.project.model.entity.ProjectUserEntity")); + assertThrows(ClassNotFoundException.class, + () -> Class.forName("com.lab.labtimesheet.feature.project.model.entity.ProjectTaskEntity")); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java new file mode 100644 index 0000000..8db027a --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java @@ -0,0 +1,231 @@ +package com.lab.labtimesheet.feature.project.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.lab.labtimesheet.config.TestcontainersConfiguration; +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; +import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class ProjectServiceIntegrationTest { + + private static final Instant NOW = Instant.parse("2026-08-14T00:00:00Z"); + + @Autowired + private ProjectService projectService; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private ProjectQueryService projectPages; + + @PersistenceContext + private EntityManager entityManager; + + @Test + void createsProjectMembershipAndLeadershipInOneTransaction() { + long mentorId = user("mentor-create@example.test", "MENTOR"); + long leaderId = intern("leader-create@example.test", "I001"); + + long projectId = projectService.create( + mentorId, + new ProjectCreateCommand( + "Intern Portal Refresh", + "Refresh the portal", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + leaderId)); + + assertEquals("PLANNED", text("select status from projects where id = ?", projectId)); + assertEquals(1, count("select count(*) from project_memberships where project_id = ? and left_at is null", projectId)); + assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is null", projectId)); + assertEquals(leaderId, number(""" + select membership.intern_user_id + from project_leadership_terms leadership + join project_memberships membership on membership.id = leadership.membership_id + where leadership.project_id = ? and leadership.ended_at is null + """, projectId)); + + long nonMentorId = intern("not-mentor@example.test", "I002"); + assertThrows(ProjectAccessDeniedException.class, () -> projectService.create( + nonMentorId, + new ProjectCreateCommand( + "Denied", + null, + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 8, 31), + leaderId))); + assertEquals(0, count("select count(*) from projects where name = 'Denied'")); + } + + @Test + void ownerAddsEligibleMemberAndDuplicateCurrentMembershipIsRejected() { + long mentorId = user("mentor-add@example.test", "MENTOR"); + long leaderId = intern("leader-add@example.test", "I003"); + long memberId = intern("member-add@example.test", "I004"); + long projectId = createProject(mentorId, leaderId, "Membership"); + long otherProjectId = createProject(mentorId, memberId, "Concurrent membership"); + + projectService.addMember(mentorId, projectId, memberId); + + assertEquals(1, count(""" + select count(*) from project_memberships + where project_id = ? and intern_user_id = ? and left_at is null + """, projectId, memberId)); + assertEquals(1, count(""" + select count(*) from project_memberships + where project_id = ? and intern_user_id = ? and left_at is null + """, otherProjectId, memberId)); + assertThrows(ProjectRuleViolationException.class, + () -> projectService.addMember(mentorId, projectId, memberId)); + assertThrows(ProjectAccessDeniedException.class, + () -> projectService.addMember( + user("other-mentor@example.test", "MENTOR"), projectId, Long.MAX_VALUE)); + } + + @Test + void leaderChangeClosesOneTermAndDoesNotMoveTaskAssignments() { + long mentorId = user("mentor-leader@example.test", "MENTOR"); + long firstLeaderId = intern("leader-one@example.test", "I005"); + long nextLeaderId = intern("leader-two@example.test", "I006"); + long projectId = createProject(mentorId, firstLeaderId, "Leadership"); + projectService.addMember(mentorId, projectId, nextLeaderId); + long firstMembershipId = membershipId(projectId, firstLeaderId); + jdbc.update(""" + insert into tasks ( + project_id, assignee_membership_id, title, + created_by_membership_id, assigned_by_membership_id) + values (?, ?, 'Keep assignee', ?, ?) + """, projectId, firstMembershipId, firstMembershipId, firstMembershipId); + + projectService.changeLeader(mentorId, projectId, nextLeaderId); + + assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is null", projectId)); + assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is not null", projectId)); + assertEquals(firstMembershipId, number("select assignee_membership_id from tasks where project_id = ?", projectId)); + } + + @Test + void listAndDetailQueriesEnforceRoleOwnershipAndMembershipWithoutIdDisclosure() { + long adminId = user("admin-view@example.test", "ADMIN"); + long mentorId = user("mentor-view@example.test", "MENTOR"); + long otherMentorId = user("other-mentor-view@example.test", "MENTOR"); + long leaderId = intern("leader-view@example.test", "I009"); + long memberId = intern("member-view@example.test", "I010"); + long unrelatedId = intern("unrelated-view@example.test", "I011"); + long projectId = createProject(mentorId, leaderId, "Visible project"); + projectService.addMember(mentorId, projectId, memberId); + + assertEquals(List.of(projectId), projectPages.listVisible(adminId).stream().map(summary -> summary.id()).toList()); + assertEquals(List.of(projectId), projectPages.listVisible(mentorId).stream().map(summary -> summary.id()).toList()); + assertEquals(List.of(), projectPages.listVisible(otherMentorId)); + assertEquals(List.of(projectId), projectPages.listVisible(memberId).stream().map(summary -> summary.id()).toList()); + assertEquals(List.of(), projectPages.listVisible(unrelatedId)); + assertEquals(projectId, projectPages.detail(memberId, projectId).id()); + assertEquals("INTERN", projectPages.authenticatedActor("member-view@example.test").role()); + var taskContext = projectPages.taskContext(memberId, projectId); + assertEquals(mentorId, taskContext.mentorUserId()); + assertEquals("PLANNED", taskContext.status()); + assertEquals(2, taskContext.activeMembers().size()); + assertEquals(membershipId(projectId, leaderId), taskContext.currentLeaderMembershipId()); + jdbc.update(""" + update projects set status = 'ACTIVE', activated_at = ?, updated_at = ? where id = ? + """, dbTime(NOW.plusSeconds(30)), dbTime(NOW.plusSeconds(30)), projectId); + entityManager.clear(); + assertEquals(1, projectPages.dashboardSummary(adminId).activeProjectCount()); + assertEquals(1, projectPages.dashboardSummary(mentorId).activeProjectCount()); + assertEquals(2, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount()); + assertEquals(1, projectPages.dashboardSummary(memberId).activeProjectCount()); + assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(otherMentorId, projectId)); + assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(unrelatedId, projectId)); + assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(unrelatedId, Long.MAX_VALUE)); + + jdbc.update(""" + update project_memberships + set left_at = ?, removed_by_mentor_user_id = ? + where project_id = ? and intern_user_id = ? + """, dbTime(NOW.plusSeconds(60)), mentorId, projectId, memberId); + entityManager.clear(); + + assertEquals(projectId, projectPages.detail(memberId, projectId).id()); + assertTrue(projectPages.taskContext(memberId, projectId).activeMembers().stream() + .noneMatch(member -> member.userId() == memberId)); + assertEquals(0, projectPages.dashboardSummary(memberId).activeProjectCount()); + assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount()); + } + + private long createProject(long mentorId, long leaderId, String name) { + return projectService.create( + mentorId, + new ProjectCreateCommand( + name, + null, + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + leaderId)); + } + + private long user(String email, String role) { + return jdbc.queryForObject(""" + insert into app_users ( + email, display_name, password_hash, global_role, account_status, activated_at) + values (?, ?, '{noop}password-password', ?, 'ACTIVE', ?) + returning id + """, Long.class, email, email, role, dbTime(NOW)); + } + + private long intern(String email, String studentCode) { + long userId = user(email, "INTERN"); + jdbc.update(""" + insert into intern_profiles ( + user_id, student_code, internship_start_date, internship_end_date, + internship_status, activated_at) + values (?, ?, date '2026-08-01', date '2026-12-31', 'ACTIVE', ?) + """, userId, studentCode, dbTime(NOW)); + return userId; + } + + private long membershipId(long projectId, long internUserId) { + return number(""" + select id from project_memberships + where project_id = ? and intern_user_id = ? and left_at is null + """, projectId, internUserId); + } + + private int count(String sql, Object... arguments) { + return jdbc.queryForObject(sql, Integer.class, arguments); + } + + private long number(String sql, Object... arguments) { + return jdbc.queryForObject(sql, Long.class, arguments); + } + + private String text(String sql, Object... arguments) { + return jdbc.queryForObject(sql, String.class, arguments); + } + + private OffsetDateTime dbTime(Instant instant) { + return instant.atOffset(ZoneOffset.UTC); + } +} From 7acd5252058ddf9c6c9b71693a49a440e02a3500 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:27:35 +0700 Subject: [PATCH 14/62] docs(project): record feature JPA milestone evidence --- docs/tests/integration/projects-workflows.md | 75 ++++++++++++++++++++ docs/tests/unit/projects-domain.md | 10 +-- docs/tests/unit/projects-layer-structure.md | 75 ++++++++++++++++++++ docs/tests/web/projects-pages.md | 73 +++++++++++++++++++ 4 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 docs/tests/integration/projects-workflows.md create mode 100644 docs/tests/unit/projects-layer-structure.md create mode 100644 docs/tests/web/projects-pages.md diff --git a/docs/tests/integration/projects-workflows.md b/docs/tests/integration/projects-workflows.md new file mode 100644 index 0000000..dd985a1 --- /dev/null +++ b/docs/tests/integration/projects-workflows.md @@ -0,0 +1,75 @@ +# Test Evidence: Atomic Project workflows + +- **Test type:** Integration +- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-017`, `AUTH-001`–`AUTH-004`, `DB-003`, `DB-007` +- **Scenario IDs:** `AC-PRJ-001`–`AC-PRJ-003`, `AC-PRJ-009` +- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest` +- **Implementation commit:** `25a855e` + +## Protected behavior + +PostgreSQL transactions persist a planned Project with its initial membership and leadership term, reject unauthorized or duplicate direct additions, change exactly one Leader without moving Task assignments, and enforce role/membership visibility without ID disclosure. + +## Test method + +A Spring Boot integration test uses the platform-owned PostgreSQL 18.4 Testcontainer and Flyway V1 schema. It calls the public Project service and verifies committed-shape rows and negative-case non-mutation with independent SQL. + +## Hand-derived expected result + +Creation yields one Project, one active membership, and one current leadership term. Direct addition yields one membership per Project/Intern pair while allowing the same Intern in a second Project. Leader change yields one closed and one current term while the Task assignee ID remains unchanged. Admin, owner, and historical member visibility is allowed; unrelated IDs are denied uniformly. + +## 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=ProjectServiceIntegrationTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class CreateProjectCommand +[ERROR] cannot find symbol: class ProjectService +[INFO] BUILD FAILURE +``` + +## 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=ProjectServiceIntegrationTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## 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 test + +[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, real concurrent transaction races, Iteration 2 invitations/removals/completion, or Task-module business rules beyond preserving stored assignment IDs. `I1-PRJ-04` remains `IN_PROGRESS`: the activation Task-assignee guard will be implemented only after the Task feature exposes its concrete query service. diff --git a/docs/tests/unit/projects-domain.md b/docs/tests/unit/projects-domain.md index 18e5512..27467e4 100644 --- a/docs/tests/unit/projects-domain.md +++ b/docs/tests/unit/projects-domain.md @@ -3,8 +3,8 @@ - **Test type:** Unit - **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004` - **Scenario IDs:** `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009` -- **Test class/method:** `com.lab.labtimesheet.projects.domain.ProjectTest` -- **Implementation commit:** `3483347` +- **Test class/method:** `com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest` +- **Implementation commit:** `25a855e` ## Protected behavior @@ -43,13 +43,13 @@ export PATH="$JAVA_HOME/bin:$PATH" ```text export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:$PATH" -./mvnw -Dtest=ProjectTest test +./mvnw -Dtest=ProjectEntityTest test ``` **Observed result** ```text -[INFO] Running com.lab.labtimesheet.projects.domain.ProjectTest +[INFO] Running com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest [INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -61,7 +61,7 @@ export PATH="$JAVA_HOME/bin:$PATH" ```text export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:$PATH" -./mvnw -Dtest=ProjectTest test +./mvnw -Dtest=ProjectEntityTest test [INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS diff --git a/docs/tests/unit/projects-layer-structure.md b/docs/tests/unit/projects-layer-structure.md new file mode 100644 index 0000000..e27ad70 --- /dev/null +++ b/docs/tests/unit/projects-layer-structure.md @@ -0,0 +1,75 @@ +# Test Evidence: Project layer and JPA structure + +- **Test type:** Unit +- **Requirement IDs:** `ARC-002`, `ARC-005`–`ARC-007`, `OPS-018`–`OPS-020`, `TST-001`–`TST-010` +- **Scenario IDs:** `I1-PRJ-01`–`I1-PRJ-05` +- **Test class/method:** `com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest#projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa` +- **Implementation commit:** `25a855e` + +## Protected behavior + +Project-owned production code follows the authoritative feature-first package layout, persists aggregate entities through Spring Data JPA, keeps JDBC operations out of Project business services, and does not shadow Account or Task persistence. + +## Test method + +Plain JUnit inspects the public Project entity, repository, and service types. It verifies their exact feature/layer packages, the entity's JPA mapping, the repository's `JpaRepository` contract, the absence of JDBC service dependencies, and the absence of foreign-table Account/Task shadow entities. + +## Hand-derived expected result + +The Project aggregate is under `feature.project.model.entity`, persistence under `feature.project.repository`, business logic under `feature.project.service`, the service has zero JDBC collaborators, and Account/Task persistence remains owned by those features. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectPersistenceStructureTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class ProjectUserRepository +[ERROR] cannot find symbol: class ProjectInternProfileRepository +[ERROR] cannot find symbol: class ProjectTaskRepository +[INFO] BUILD FAILURE +``` + +The RED was observed after removing Project-owned shadow mappings of Account and Task tables. It proves the service still required cross-feature dependencies and could not be made green by retaining forbidden repositories. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectPersistenceStructureTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=LayerStructureTest,ProjectPersistenceStructureTest,ProjectEntityTest test + +[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This check does not prove database mappings, transaction behavior, MVC routing, or runtime authorization; those remain covered by PostgreSQL and MockMvc tests. diff --git a/docs/tests/web/projects-pages.md b/docs/tests/web/projects-pages.md new file mode 100644 index 0000000..df25b1f --- /dev/null +++ b/docs/tests/web/projects-pages.md @@ -0,0 +1,73 @@ +# Test Evidence: Authorized Project pages + +- **Test type:** Web +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `SEC-001`, `ERR-001` +- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `I1-PRJ-05` +- **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest` +- **Implementation commit:** `25a855e` + +## Protected behavior + +Authenticated users receive only authorized Project routes; guessed IDs return a non-disclosing not-found response; valid Mentor create requests use the authenticated identity; invalid forms do not mutate; state changes require CSRF. + +## Test method + +MockMvc exercises the real controller, binding, Bean Validation, exception mapping, view selection, redirect, Spring Security authentication, and CSRF filter. Only application/query services are mocked. + +## Hand-derived expected result + +An authorized list request renders `projects/list`. An unauthorized direct ID returns 404. Member and leadership routes authorize through actor plus Project ID. A valid create redirects to the created detail ID; a blank name and zero Leader ID render field errors and make no service call. POST without CSRF returns 403. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectControllerTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class ProjectController +[ERROR] cannot find symbol: class ProjectPageService +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectControllerTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.controller.ProjectControllerTest +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## 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 test + +[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, browser accessibility, or Iteration 2 invitation/exit/completion pages. The activation route remains deferred with `I1-PRJ-04` until the Task feature query dependency is available. From 334374563239f9974d58364964f5f702bffe538c Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:30:01 +0700 Subject: [PATCH 15/62] fix(ui): enforce accessible theme contrast --- docs/tests/web/theme-token-contrast.md | 75 +++++++++++++++++++ src/main/frontend/app.css | 14 ++-- src/main/resources/static/assets/app.css | 2 +- .../labtimesheet/ui/UiContractWebTest.java | 68 +++++++++++++++++ 4 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 docs/tests/web/theme-token-contrast.md diff --git a/docs/tests/web/theme-token-contrast.md b/docs/tests/web/theme-token-contrast.md new file mode 100644 index 0000000..715ca4f --- /dev/null +++ b/docs/tests/web/theme-token-contrast.md @@ -0,0 +1,75 @@ +# Test Evidence: theme token contrast + +- **Test type:** Web +- **Requirement IDs:** `UI-005`, `UI-006`, `UI-010`, `UI-018`, `I1-UI-02` +- **Scenario IDs:** `AC-UI-003`, `AC-UI-005` +- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast` +- **Implementation commit:** `pending` + +## Protected behavior + +The committed light and dark CSS tokens provide at least 4.5:1 contrast for normal text and 3:1 for focus indicators and meaningful panel/control boundaries against their adjacent surfaces. + +## Test method + +The web test reads the generated classpath CSS, extracts the production light and dark custom-property values, converts sRGB colors to relative luminance, and checks WCAG contrast ratios for ink, muted/subtle text, neutral boundaries, and focus tokens. + +## Hand-derived expected result + +Both themes must keep normal text at or above 4.5:1. Borders and focus tokens must be at or above 3:1 against the panel, sidebar, or canvas on which they are used. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast test +``` + +**Observed result** + +```text +border / canvas contrast 1.2206621853850066 is below 3.0 +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +npm run build +./mvnw -Dtest=UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast test +``` + +**Observed result** + +```text +Tailwind CSS v4.3.3: Done in 73ms +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 26.385 s +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=UiContractWebTest,DashboardTemplateWebTest,ReportingArchitectureTest,LayerStructureTest test + +Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 27.072 s +``` + +## External-test boundaries + +This deterministic check proves the declared theme-token ratios used by the shared shell. It does not replace browser inspection for antialiasing, authored colors outside the token set, image contrast, zoom, high-contrast modes, or viewport-specific focus clipping. diff --git a/src/main/frontend/app.css b/src/main/frontend/app.css index 279580a..9643fd6 100644 --- a/src/main/frontend/app.css +++ b/src/main/frontend/app.css @@ -7,8 +7,8 @@ --color-sidebar: #f0f1f2; --color-panel: #ffffff; --color-panel-muted: #f7f8f9; - --color-border: #dfe1e5; - --color-border-strong: #c9cdd3; + --color-border: #858c96; + --color-border-strong: #747d89; --color-muted: #626a75; --color-accent: #3157e7; --color-success: #087a48; @@ -23,10 +23,10 @@ --sidebar: #f0f1f2; --panel: #ffffff; --panel-muted: #f7f8f9; - --border: #dfe1e5; - --border-strong: #c9cdd3; + --border: #858c96; + --border-strong: #747d89; --muted: #626a75; - --subtle: #818894; + --subtle: #626a75; --accent: #3157e7; --focus: #3157e7; --success: #087a48; @@ -41,8 +41,8 @@ --sidebar: #111317; --panel: #17191e; --panel-muted: #1d2026; - --border: #30343d; - --border-strong: #454b57; + --border: #626b78; + --border-strong: #707987; --muted: #b2b7c0; --subtle: #969da8; --accent: #8ca4ff; diff --git a/src/main/resources/static/assets/app.css b/src/main/resources/static/assets/app.css index cac788f..f7a5021 100644 --- a/src/main/resources/static/assets/app.css +++ b/src/main/resources/static/assets/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.static{position:static}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#dfe1e5;--border-strong:#c9cdd3;--muted:#626a75;--subtle:#818894;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#30343d;--border-strong:#454b57;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.static{position:static}.border{border-style:var(--tw-border-style);border-width:1px}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java b/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java index f1717df..14a690f 100644 --- a/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java +++ b/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java @@ -6,6 +6,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -70,6 +72,28 @@ class UiContractWebTest { assertTrue(themeBootstrap.contains("matchMedia('(prefers-color-scheme: dark)')")); } + @Test + void themeTokensMeetTextFocusAndMeaningfulBoundaryContrast() throws Exception { + String css = new ClassPathResource("static/assets/app.css") + .getContentAsString(StandardCharsets.UTF_8); + String light = section(css, ":root\\{color-scheme:light;([^}]*)}"); + String dark = section(css, ":root\\[data-theme=dark]\\{color-scheme:dark;([^}]*)}"); + + assertContrast(light, "ink", "canvas", 4.5); + assertContrast(light, "muted", "panel", 4.5); + assertContrast(light, "subtle", "sidebar", 4.5); + assertContrast(light, "border", "canvas", 3.0); + assertContrast(light, "border-strong", "panel", 3.0); + assertContrast(light, "focus", "canvas", 3.0); + + assertContrast(dark, "ink", "canvas", 4.5); + assertContrast(dark, "muted", "panel", 4.5); + assertContrast(dark, "subtle", "sidebar", 4.5); + assertContrast(dark, "border", "panel", 3.0); + assertContrast(dark, "border-strong", "panel", 3.0); + assertContrast(dark, "focus", "panel", 3.0); + } + @Test @WithMockUser(username = "admin@example.test", roles = "ADMIN") void sharedComponentsExposeAccessibleFormsStatusAndEmptyState() throws Exception { @@ -100,4 +124,48 @@ class UiContractWebTest { return "test/components-consumer"; } } + + private static String section(String css, String expression) { + Matcher matcher = Pattern.compile(expression).matcher(css); + assertTrue(matcher.find(), () -> "Missing CSS token section: " + expression); + return matcher.group(1); + } + + private static void assertContrast(String section, String foreground, String background, double minimum) { + double ratio = contrast(color(section, foreground), color(section, background)); + assertTrue(ratio >= minimum, + () -> foreground + " / " + background + " contrast " + ratio + " is below " + minimum); + } + + private static String color(String section, String name) { + Matcher matcher = Pattern.compile("--" + Pattern.quote(name) + ":(#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?)") + .matcher(section); + assertTrue(matcher.find(), () -> "Missing CSS color token: " + name); + String value = matcher.group(1); + if (value.length() == 4) { + return "#" + value.charAt(1) + value.charAt(1) + + value.charAt(2) + value.charAt(2) + + value.charAt(3) + value.charAt(3); + } + return value; + } + + private static double contrast(String first, String second) { + double lighter = Math.max(luminance(first), luminance(second)); + double darker = Math.min(luminance(first), luminance(second)); + return (lighter + 0.05) / (darker + 0.05); + } + + private static double luminance(String hex) { + double red = linear(Integer.parseInt(hex.substring(1, 3), 16) / 255.0); + double green = linear(Integer.parseInt(hex.substring(3, 5), 16) / 255.0); + double blue = linear(Integer.parseInt(hex.substring(5, 7), 16) / 255.0); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; + } + + private static double linear(double component) { + return component <= 0.04045 + ? component / 12.92 + : Math.pow((component + 0.055) / 1.055, 2.4); + } } From a9ee99a6fef0a12483ee0d6ad3390fa1e8708d35 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:30:22 +0700 Subject: [PATCH 16/62] fix(project): hide mentor-only controls --- .../project/controller/ProjectController.java | 6 ++++- .../project/model/dto/ProjectDetail.java | 3 ++- .../project/service/ProjectQueryService.java | 3 ++- .../templates/projects/leadership.html | 2 +- .../resources/templates/projects/members.html | 2 +- .../controller/ProjectControllerTest.java | 24 ++++++++++++++++--- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java index 16c4758..79cfd10 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -1,5 +1,6 @@ package com.lab.labtimesheet.feature.project.controller; +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm; import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm; import com.lab.labtimesheet.feature.project.service.ProjectQueryService; @@ -34,7 +35,10 @@ public class ProjectController { } @GetMapping("/new") - public String createForm(Model model) { + public String createForm(Principal principal, Model model) { + if (!"MENTOR".equals(pages.authenticatedActor(principal.getName()).role())) { + throw new ProjectAccessDeniedException(); + } model.addAttribute("projectForm", new ProjectCreateForm()); return "projects/form"; } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java index 4af0e54..72a13a0 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java @@ -10,5 +10,6 @@ public record ProjectDetail( LocalDate startDate, LocalDate endDate, String mentorName, - String leaderName) { + String leaderName, + boolean canManage) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java index eadff93..593bfc9 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -64,7 +64,8 @@ public class ProjectQueryService { project.startDate(), project.endDate(), displayName(project.mentorUserId()), - displayName(project.currentLeader().internUserId())); + displayName(project.currentLeader().internUserId()), + project.mentorUserId() == actorUserId); } @Transactional(readOnly = true) diff --git a/src/main/resources/templates/projects/leadership.html b/src/main/resources/templates/projects/leadership.html index 378a69c..abe41e9 100644 --- a/src/main/resources/templates/projects/leadership.html +++ b/src/main/resources/templates/projects/leadership.html @@ -7,7 +7,7 @@
Leadership history
LeaderStartedEnded
-
+
diff --git a/src/main/resources/templates/projects/members.html b/src/main/resources/templates/projects/members.html index e0238eb..8ee0e9f 100644 --- a/src/main/resources/templates/projects/members.html +++ b/src/main/resources/templates/projects/members.html @@ -7,7 +7,7 @@
Membership history
InternJoinedLeftRole
-
+
diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java index ed3ef9f..4a13cec 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -3,6 +3,8 @@ package com.lab.labtimesheet.feature.project.controller; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -13,6 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; 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; import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary; import com.lab.labtimesheet.feature.project.service.ProjectQueryService; @@ -79,16 +82,31 @@ class ProjectControllerTest { LocalDate.of(2026, 8, 15), LocalDate.of(2026, 9, 30), "Mentor", - "Leader")); + "Leader", + false)); when(pages.members(20L, 30L)).thenReturn(List.of()); when(pages.leadership(20L, 30L)).thenReturn(List.of()); mvc.perform(get("/projects/30/members")) .andExpect(status().isOk()) - .andExpect(view().name("projects/members")); + .andExpect(view().name("projects/members")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString("Add member")))); mvc.perform(get("/projects/30/leadership")) .andExpect(status().isOk()) - .andExpect(view().name("projects/leadership")); + .andExpect(view().name("projects/leadership")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString("Change Leader")))); + } + + @Test + @WithMockUser(username = "member@example.test") + void nonMentorCannotOpenProjectCreationForm() throws Exception { + when(pages.authenticatedActor("member@example.test")) + .thenReturn(new ProjectActorView(20L, "INTERN")); + + mvc.perform(get("/projects/new")) + .andExpect(status().isNotFound()); } @Test From 98a52a1ac23591fa1cd30b7b175da81ec607e521 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:34:32 +0700 Subject: [PATCH 17/62] feat(account): add SMTP-gated activation lifecycle --- docs/tests/integration/account-activation.md | 68 ++++++ .../feature/account/model/TokenPurpose.java | 6 + .../account/model/dto/AccountCreation.java | 4 + .../account/model/dto/AccountSummary.java | 4 + .../model/dto/CreateAccountCommand.java | 14 ++ .../feature/account/model/entity/AppUser.java | 20 ++ .../account/model/entity/InternProfile.java | 37 ++++ .../account/model/entity/UserActionToken.java | 112 ++++++++++ .../account/repository/AppUserRepository.java | 8 + .../repository/InternProfileRepository.java | 10 + .../repository/UserActionTokenRepository.java | 22 ++ .../account/service/AccountService.java | 204 +++++++++++++++++- .../service/MailDeliveryService.java | 46 ++++ .../service/SmtpConfigurationService.java | 20 +- src/main/resources/application-dev.yaml | 1 + .../AccountActivationIntegrationTest.java | 184 ++++++++++++++++ src/test/resources/application-test.yaml | 1 + 17 files changed, 748 insertions(+), 13 deletions(-) create mode 100644 docs/tests/integration/account-activation.md create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/account/service/AccountActivationIntegrationTest.java diff --git a/docs/tests/integration/account-activation.md b/docs/tests/integration/account-activation.md new file mode 100644 index 0000000..cec2e4e --- /dev/null +++ b/docs/tests/integration/account-activation.md @@ -0,0 +1,68 @@ +# Test Evidence: SMTP-gated account creation and activation + +- **Test type:** Integration +- **Requirement IDs:** `ACC-008`–`ACC-014`, `ACC-019`, `ACC-020`, `NOT-008`, `SEC-005`, `SEC-007` +- **Scenario IDs:** `AC-ACC-001`, `AC-ACC-002`, `AC-ACC-003`, `AC-ACC-007` +- **Test class/method:** `com.lab.labtimesheet.feature.account.service.AccountActivationIntegrationTest#smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory` +- **Implementation commit:** `this milestone commit` + +## Protected behavior + +An active Admin can create pending Mentor/Intern accounts only while a tested SMTP revision is active. The raw activation secret exists only in the immediate email, PostgreSQL stores only its SHA-256 hash, activation is single-use, and a failed initial delivery keeps history while invalidating that token. Activating an Intern's lifecycle separately makes the account eligible only inside its inclusive internship dates. Reporting reads account counts through the Account service boundary. + +## Test method + +The PostgreSQL 18.4 integration test bootstraps the first Admin, proves creation is blocked before SMTP activation, activates a recorded SMTP boundary, and exercises production account creation/activation. It independently hashes the captured raw link token, inspects persisted state through platform-owned repositories, simulates delivery failure, activates an Intern lifecycle, checks date boundaries, and checks the service-level summary used by reporting. + +## Hand-derived expected result + +The first non-bootstrap creation attempt adds zero rows. A delivered Mentor is pending with no password until one successful activation; replay fails. A failed Intern delivery leaves one pending account and one invalidated token. After the successful Intern is activated at both account and internship levels, the final state has three active accounts (Admin, Mentor, Intern), one pending account, and one active internship. + +## RED + +**Command** + +```text +JAVA_HOME=/opt/homebrew/opt/openjdk@25 DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=AccountActivationIntegrationTest test +``` + +**Observed result** + +```text +BUILD FAILURE. Test compilation reported five missing account-activation API/model symbols, including CreateAccountCommand, TokenPurpose, and UserActionTokenRepository. No test ran. +``` + +After the first GREEN implementation, the exact-expiry assertion was added and observed RED before exposing the persisted expiry: + +```text +BUILD FAILURE. AccountActivationIntegrationTest could not compile because UserActionToken#getExpiresAt() did not exist. +``` + +## GREEN + +**Command** + +```text +JAVA_HOME=/opt/homebrew/opt/openjdk@25 DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=AccountActivationIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +JAVA_HOME=/opt/homebrew/opt/openjdk@25 DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test +Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## External-test boundaries + +The recording SMTP boundary proves the exact in-memory handoff but not Mailpit/network delivery or a browser following the link. MVC activation forms, resend, password reset, session invalidation, lock/deactivation, and production origin/readiness hardening remain separate Iteration 1 or later slices. diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java b/src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java new file mode 100644 index 0000000..a1a9f5f --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java @@ -0,0 +1,6 @@ +package com.lab.labtimesheet.feature.account.model; + +public enum TokenPurpose { + ACTIVATION, + PASSWORD_RESET +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java new file mode 100644 index 0000000..1797583 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java @@ -0,0 +1,4 @@ +package com.lab.labtimesheet.feature.account.model.dto; + +public record AccountCreation(long userId, boolean deliverySucceeded) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java new file mode 100644 index 0000000..946f03e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java @@ -0,0 +1,4 @@ +package com.lab.labtimesheet.feature.account.model.dto; + +public record AccountSummary(long activeAccounts, long pendingActivations, long activeInternships) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java new file mode 100644 index 0000000..8e0b384 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.feature.account.model.dto; + +import java.time.LocalDate; + +import com.lab.labtimesheet.feature.account.model.GlobalRole; + +public record CreateAccountCommand( + String email, + String displayName, + GlobalRole role, + String studentCode, + LocalDate internshipStart, + LocalDate internshipEnd) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java index c7f5e9a..656dd47 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java @@ -77,6 +77,22 @@ public class AppUser { return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now); } + public static AppUser pending( + String email, String displayName, GlobalRole globalRole, AppUser createdBy, Instant now) { + return new AppUser( + email, displayName, null, globalRole, AccountStatus.PENDING_ACTIVATION, null, createdBy, now); + } + + public void activate(String encodedPassword, Instant now) { + if (accountStatus != AccountStatus.PENDING_ACTIVATION) { + throw new IllegalStateException("Only a pending account can activate"); + } + passwordHash = encodedPassword; + accountStatus = AccountStatus.ACTIVE; + activatedAt = now; + updatedAt = now; + } + public Long getId() { return id; } @@ -100,4 +116,8 @@ public class AppUser { public AccountStatus getAccountStatus() { return accountStatus; } + + public Instant getActivatedAt() { + return activatedAt; + } } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java index 611116b..c2011dc 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java @@ -58,4 +58,41 @@ public class InternProfile { protected InternProfile() { } + + private InternProfile( + long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) { + this.userId = userId; + this.studentCode = studentCode; + this.internshipStartDate = internshipStartDate; + this.internshipEndDate = internshipEndDate; + this.internshipStatus = InternshipStatus.NOT_STARTED; + this.createdAt = now; + this.updatedAt = now; + } + + public static InternProfile notStarted( + long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) { + return new InternProfile(userId, studentCode, internshipStartDate, internshipEndDate, now); + } + + public void activate(Instant now) { + if (internshipStatus != InternshipStatus.NOT_STARTED) { + throw new IllegalStateException("Only a not-started internship can activate"); + } + internshipStatus = InternshipStatus.ACTIVE; + activatedAt = now; + updatedAt = now; + } + + public InternshipStatus getInternshipStatus() { + return internshipStatus; + } + + public LocalDate getInternshipStartDate() { + return internshipStartDate; + } + + public LocalDate getInternshipEndDate() { + return internshipEndDate; + } } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java new file mode 100644 index 0000000..3e4d6fd --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java @@ -0,0 +1,112 @@ +package com.lab.labtimesheet.feature.account.model.entity; + +import java.time.Instant; +import java.util.Arrays; + +import com.lab.labtimesheet.feature.account.model.TokenPurpose; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "user_action_tokens") +public class UserActionToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 24) + private TokenPurpose purpose; + + @Column(name = "token_hash", nullable = false, columnDefinition = "bytea") + private byte[] tokenHash; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "used_at") + private Instant usedAt; + + @Column(name = "invalidated_at") + private Instant invalidatedAt; + + @Column(name = "issued_by_user_id") + private Long issuedByUserId; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected UserActionToken() { + } + + private UserActionToken(long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) { + this.userId = userId; + this.purpose = TokenPurpose.ACTIVATION; + this.tokenHash = Arrays.copyOf(tokenHash, tokenHash.length); + this.expiresAt = expiresAt; + this.issuedByUserId = issuedByUserId; + this.createdAt = now; + } + + public static UserActionToken activation( + long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) { + return new UserActionToken(userId, tokenHash, expiresAt, issuedByUserId, now); + } + + public boolean isUsableAt(Instant now) { + return usedAt == null && invalidatedAt == null && now.isBefore(expiresAt); + } + + public void markUsed(Instant now) { + if (!isUsableAt(now)) { + throw new IllegalStateException("Activation token is not usable"); + } + usedAt = now; + } + + public void invalidate(Instant now) { + if (usedAt != null) { + throw new IllegalStateException("A used token cannot be invalidated"); + } + if (invalidatedAt == null) { + invalidatedAt = now; + } + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public TokenPurpose getPurpose() { + return purpose; + } + + public byte[] getTokenHash() { + return Arrays.copyOf(tokenHash, tokenHash.length); + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public Instant getUsedAt() { + return usedAt; + } + + public Instant getInvalidatedAt() { + return invalidatedAt; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java index 8097d42..7d13a81 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java @@ -5,7 +5,9 @@ import java.util.Optional; import com.lab.labtimesheet.feature.account.model.AccountStatus; import com.lab.labtimesheet.feature.account.model.entity.AppUser; import com.lab.labtimesheet.feature.account.model.GlobalRole; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -13,5 +15,11 @@ public interface AppUserRepository extends JpaRepository { @Query("select u from AppUser u where lower(trim(u.email)) = :email") Optional findByNormalizedEmail(@Param("email") String email); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from AppUser u where u.id = :id") + Optional findForUpdateById(@Param("id") Long id); + long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status); + + long countByAccountStatus(AccountStatus status); } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java index b6e11e6..25aeffc 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java @@ -4,11 +4,21 @@ import java.time.LocalDate; import com.lab.labtimesheet.feature.account.model.InternshipStatus; import com.lab.labtimesheet.feature.account.model.entity.InternProfile; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface InternProfileRepository extends JpaRepository { boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status); boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual( Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate); + + long countByInternshipStatus(InternshipStatus status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from InternProfile p where p.userId = :userId") + java.util.Optional findForUpdateByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java new file mode 100644 index 0000000..ab932bb --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java @@ -0,0 +1,22 @@ +package com.lab.labtimesheet.feature.account.repository; + +import java.util.Optional; + +import com.lab.labtimesheet.feature.account.model.TokenPurpose; +import com.lab.labtimesheet.feature.account.model.entity.UserActionToken; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface UserActionTokenRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from UserActionToken t where t.tokenHash = :hash and t.purpose = :purpose") + Optional findForUpdateByHashAndPurpose( + @Param("hash") byte[] hash, @Param("purpose") TokenPurpose purpose); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from UserActionToken t where t.id = :id") + Optional findForUpdateById(@Param("id") Long id); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java index 1227365..4c776a9 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java @@ -1,25 +1,140 @@ package com.lab.labtimesheet.feature.account.service; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; import java.time.LocalDate; +import java.util.Base64; import com.lab.labtimesheet.feature.account.model.AccountStatus; import com.lab.labtimesheet.feature.account.model.GlobalRole; import com.lab.labtimesheet.feature.account.model.InternshipStatus; +import com.lab.labtimesheet.feature.account.model.TokenPurpose; +import com.lab.labtimesheet.feature.account.model.dto.AccountCreation; import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity; +import com.lab.labtimesheet.feature.account.model.dto.AccountSummary; +import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand; import com.lab.labtimesheet.feature.account.model.entity.AppUser; +import com.lab.labtimesheet.feature.account.model.entity.InternProfile; +import com.lab.labtimesheet.feature.account.model.entity.UserActionToken; import com.lab.labtimesheet.feature.account.repository.AppUserRepository; import com.lab.labtimesheet.feature.account.repository.InternProfileRepository; +import com.lab.labtimesheet.feature.account.repository.UserActionTokenRepository; +import com.lab.labtimesheet.feature.integration.service.MailDeliveryService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; @Service public class AccountService { + private static final Duration ACTIVATION_LIFETIME = Duration.ofHours(24); + private static final SecureRandom TOKEN_RANDOM = new SecureRandom(); + private final AppUserRepository users; private final InternProfileRepository internProfiles; + private final UserActionTokenRepository tokens; + private final MailDeliveryService mailDelivery; + private final PasswordEncoder passwords; + private final Clock clock; + private final TransactionTemplate transactions; + private final String publicOrigin; - AccountService(AppUserRepository users, InternProfileRepository internProfiles) { + AccountService( + AppUserRepository users, + InternProfileRepository internProfiles, + UserActionTokenRepository tokens, + MailDeliveryService mailDelivery, + PasswordEncoder passwords, + Clock clock, + TransactionTemplate transactions, + @Value("${lab.public-origin}") String publicOrigin) { this.users = users; this.internProfiles = internProfiles; + this.tokens = tokens; + this.mailDelivery = mailDelivery; + this.passwords = passwords; + this.clock = clock; + this.transactions = transactions; + this.publicOrigin = normalizeOrigin(publicOrigin); + } + + public AccountCreation create(CreateAccountCommand command, long adminId) { + ValidatedAccount account = validate(command); + if (!mailDelivery.isAvailable()) { + throw new IllegalStateException("Active SMTP configuration is required for account creation"); + } + + String rawToken = newRawToken(); + byte[] tokenHash = sha256(rawToken); + PendingActivation pending = transactions.execute(status -> createPending(account, adminId, tokenHash)); + if (pending == null) { + throw new IllegalStateException("Account creation did not complete"); + } + + try { + mailDelivery.send( + account.email(), + "Activate your Lab Timesheet account", + "Activate your account using this single-use link:\n" + activationLink(rawToken)); + return new AccountCreation(pending.userId(), true); + } catch (RuntimeException deliveryFailure) { + transactions.executeWithoutResult(status -> tokens.findForUpdateById(pending.tokenId()) + .orElseThrow(() -> new IllegalStateException("Activation token is missing")) + .invalidate(clock.instant())); + return new AccountCreation(pending.userId(), false); + } + } + + @Transactional + public boolean activate(String rawToken, String password) { + BootstrapService.requirePassword(password); + if (rawToken == null || rawToken.isBlank()) { + return false; + } + + UserActionToken token = tokens.findForUpdateByHashAndPurpose(sha256(rawToken), TokenPurpose.ACTIVATION) + .orElse(null); + var now = clock.instant(); + if (token == null || !token.isUsableAt(now)) { + return false; + } + + AppUser user = users.findForUpdateById(token.getUserId()).orElse(null); + if (user == null || user.getAccountStatus() != AccountStatus.PENDING_ACTIVATION) { + return false; + } + user.activate(passwords.encode(password), now); + token.markUsed(now); + return true; + } + + @Transactional + public void activateInternship(long internUserId, long adminId) { + AppUser admin = users.findById(adminId) + .orElseThrow(() -> new IllegalArgumentException("Admin not found")); + requireActiveAdmin(admin); + + AppUser intern = users.findForUpdateById(internUserId) + .orElseThrow(() -> new IllegalArgumentException("Intern not found")); + if (intern.getGlobalRole() != GlobalRole.INTERN || intern.getAccountStatus() != AccountStatus.ACTIVE) { + throw new IllegalArgumentException("An active Intern account is required"); + } + internProfiles.findForUpdateByUserId(internUserId) + .orElseThrow(() -> new IllegalArgumentException("Intern profile not found")) + .activate(clock.instant()); + } + + @Transactional(readOnly = true) + public AccountSummary summary() { + return new AccountSummary( + users.countByAccountStatus(AccountStatus.ACTIVE), + users.countByAccountStatus(AccountStatus.PENDING_ACTIVATION), + internProfiles.countByInternshipStatus(InternshipStatus.ACTIVE)); } @Transactional(readOnly = true) @@ -91,4 +206,91 @@ public class AccountService { return new AccountIdentity( user.getId(), user.getEmail(), user.getDisplayName(), user.getGlobalRole(), user.getAccountStatus()); } + + private PendingActivation createPending(ValidatedAccount account, long adminId, byte[] tokenHash) { + AppUser admin = users.findForUpdateById(adminId) + .orElseThrow(() -> new IllegalArgumentException("Admin not found")); + requireActiveAdmin(admin); + + var now = clock.instant(); + AppUser user = users.save(AppUser.pending( + account.email(), account.displayName(), account.role(), admin, now)); + if (account.role() == GlobalRole.INTERN) { + internProfiles.save(InternProfile.notStarted( + user.getId(), account.studentCode(), account.internshipStart(), account.internshipEnd(), now)); + } + UserActionToken token = tokens.save(UserActionToken.activation( + user.getId(), tokenHash, now.plus(ACTIVATION_LIFETIME), admin.getId(), now)); + return new PendingActivation(user.getId(), token.getId()); + } + + private String activationLink(String rawToken) { + return publicOrigin + "/activate?token=" + rawToken; + } + + private static ValidatedAccount validate(CreateAccountCommand command) { + if (command == null || command.role() == null) { + throw new IllegalArgumentException("Account role is required"); + } + String email = BootstrapService.normalizeEmail(command.email()); + String displayName = requireText(command.displayName(), "Display name"); + if (command.role() != GlobalRole.INTERN) { + if (command.studentCode() != null || command.internshipStart() != null || command.internshipEnd() != null) { + throw new IllegalArgumentException("Internship fields are allowed only for Intern accounts"); + } + return new ValidatedAccount(email, displayName, command.role(), null, null, null); + } + + String studentCode = requireText(command.studentCode(), "Student code"); + if (command.internshipStart() == null || command.internshipEnd() == null + || command.internshipEnd().isBefore(command.internshipStart())) { + throw new IllegalArgumentException("A valid internship date range is required"); + } + return new ValidatedAccount( + email, displayName, command.role(), studentCode, command.internshipStart(), command.internshipEnd()); + } + + private static String requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + return value.trim(); + } + + private static String normalizeOrigin(String value) { + String origin = requireText(value, "Public origin"); + while (origin.endsWith("/")) { + origin = origin.substring(0, origin.length() - 1); + } + if (!origin.startsWith("http://") && !origin.startsWith("https://")) { + throw new IllegalArgumentException("Public origin must use HTTP or HTTPS"); + } + return origin; + } + + private static String newRawToken() { + byte[] bytes = new byte[32]; + TOKEN_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static byte[] sha256(String value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private record ValidatedAccount( + String email, + String displayName, + GlobalRole role, + String studentCode, + LocalDate internshipStart, + LocalDate internshipEnd) { + } + + private record PendingActivation(long userId, long tokenId) { + } } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java new file mode 100644 index 0000000..36a0ac7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java @@ -0,0 +1,46 @@ +package com.lab.labtimesheet.feature.integration.service; + +import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; +import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration; +import com.lab.labtimesheet.feature.integration.model.SmtpStatus; +import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class MailDeliveryService { + private final SmtpConfigurationRepository configurations; + private final SecretCipher secrets; + private final SmtpProbe probe; + + MailDeliveryService(SmtpConfigurationRepository configurations, SecretCipher secrets, SmtpProbe probe) { + this.configurations = configurations; + this.secrets = secrets; + this.probe = probe; + } + + @Transactional(readOnly = true) + public boolean isAvailable() { + return configurations.existsByStatus(SmtpStatus.ACTIVE); + } + + public void send(String recipient, String subject, String body) { + probe.send(activeConnection(), recipient, subject, body); + } + + @Transactional(readOnly = true) + public SmtpConnection activeConnection() { + return configurations.findByStatus(SmtpStatus.ACTIVE) + .map(this::connection) + .orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required")); + } + + SmtpConnection connection(SmtpConfiguration configuration) { + byte[] ciphertext = configuration.getPasswordCiphertext(); + return new SmtpConnection( + configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(), + configuration.getUsername(), + ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()), + configuration.getFromAddress(), configuration.getFromName()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java index d3f7e71..dc584c9 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java @@ -23,15 +23,18 @@ public class SmtpConfigurationService { private final SmtpProbe probe; private final Environment environment; private final Clock clock; + private final MailDeliveryService mailDelivery; SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts, - SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock) { + SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock, + MailDeliveryService mailDelivery) { this.configurations = configurations; this.accounts = accounts; this.secrets = secrets; this.probe = probe; this.environment = environment; this.clock = clock; + this.mailDelivery = mailDelivery; } @Transactional @@ -72,27 +75,20 @@ public class SmtpConfigurationService { @Transactional(readOnly = true) public boolean hasActiveConfiguration() { - return configurations.existsByStatus(SmtpStatus.ACTIVE); + return mailDelivery.isAvailable(); } @Transactional(readOnly = true) public SmtpConnection activeConnection() { - return configurations.findByStatus(SmtpStatus.ACTIVE) - .map(this::connection) - .orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required")); + return mailDelivery.activeConnection(); } public void sendWithActiveConfiguration(String recipient, String subject, String body) { - probe.send(activeConnection(), recipient, subject, body); + mailDelivery.send(recipient, subject, body); } private SmtpConnection connection(SmtpConfiguration configuration) { - byte[] ciphertext = configuration.getPasswordCiphertext(); - return new SmtpConnection( - configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(), - configuration.getUsername(), - ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()), - configuration.getFromAddress(), configuration.getFromName()); + return mailDelivery.connection(configuration); } private void validate(SmtpDraft draft) { diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 062ef03..63f3551 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -7,6 +7,7 @@ spring: host: ${LAB_SMTP_HOST:localhost} port: ${LAB_SMTP_PORT:1025} lab: + public-origin: ${LAB_PUBLIC_ORIGIN:http://localhost:8080} security: # Explicit non-production key; production must supply its own 256-bit key. master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= diff --git a/src/test/java/com/lab/labtimesheet/feature/account/service/AccountActivationIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/AccountActivationIntegrationTest.java new file mode 100644 index 0000000..59237cc --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/AccountActivationIntegrationTest.java @@ -0,0 +1,184 @@ +package com.lab.labtimesheet.feature.account.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +import com.lab.labtimesheet.config.TestcontainersConfiguration; +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import com.lab.labtimesheet.feature.account.model.InternshipStatus; +import com.lab.labtimesheet.feature.account.model.TokenPurpose; +import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand; +import com.lab.labtimesheet.feature.account.repository.AppUserRepository; +import com.lab.labtimesheet.feature.account.repository.InternProfileRepository; +import com.lab.labtimesheet.feature.account.repository.UserActionTokenRepository; +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 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.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class}) +@SpringBootTest +@ActiveProfiles("test") +class AccountActivationIntegrationTest { + + @Autowired + private BootstrapService bootstrap; + + @Autowired + private AccountService accounts; + + @Autowired + private SmtpConfigurationService smtp; + + @Autowired + private RecordingSmtpProbe mail; + + @Autowired + private AppUserRepository users; + + @Autowired + private InternProfileRepository internProfiles; + + @Autowired + private UserActionTokenRepository tokens; + + @Autowired + private PasswordEncoder passwords; + + @Test + void smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory() throws Exception { + bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); + long adminId = accounts.requireActiveAdminId("admin@example.com"); + + var mentor = new CreateAccountCommand( + " MENTOR@EXAMPLE.COM ", " Mentor One ", GlobalRole.MENTOR, null, null, null); + assertThatThrownBy(() -> accounts.create(mentor, adminId)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("SMTP"); + assertThat(users.count()).isEqualTo(1); + + activateSmtp(adminId); + mail.messages.clear(); + + var mentorCreation = accounts.create(mentor, adminId); + assertThat(mentorCreation.deliverySucceeded()).isTrue(); + var pendingMentor = users.findById(mentorCreation.userId()).orElseThrow(); + assertThat(pendingMentor.getEmail()).isEqualTo("mentor@example.com"); + assertThat(pendingMentor.getDisplayName()).isEqualTo("Mentor One"); + assertThat(pendingMentor.getGlobalRole()).isEqualTo(GlobalRole.MENTOR); + assertThat(pendingMentor.getAccountStatus()).isEqualTo(AccountStatus.PENDING_ACTIVATION); + assertThat(pendingMentor.getPasswordHash()).isNull(); + + String rawMentorToken = mail.onlyActivationToken(); + var mentorToken = tokens.findAll().stream() + .filter(token -> token.getUserId().equals(mentorCreation.userId())) + .findFirst() + .orElseThrow(); + assertThat(mentorToken.getPurpose()).isEqualTo(TokenPurpose.ACTIVATION); + assertThat(mentorToken.getTokenHash()).containsExactly(sha256(rawMentorToken)); + assertThat(mentorToken.getExpiresAt()).isEqualTo(Instant.parse("2026-08-15T00:00:00Z")); + assertThat(mentorToken.isUsableAt(mentorToken.getExpiresAt())).isFalse(); + assertThat(HexFormat.of().formatHex(mentorToken.getTokenHash())).doesNotContain(rawMentorToken); + + assertThat(accounts.activate("not-the-token", "new secure mentor password")).isFalse(); + assertThat(accounts.activate(rawMentorToken, "new secure mentor password")).isTrue(); + assertThat(accounts.activate(rawMentorToken, "another secure password")).isFalse(); + var activeMentor = users.findById(mentorCreation.userId()).orElseThrow(); + assertThat(activeMentor.getAccountStatus()).isEqualTo(AccountStatus.ACTIVE); + assertThat(passwords.matches("new secure mentor password", activeMentor.getPasswordHash())).isTrue(); + assertThat(tokens.findById(mentorToken.getId()).orElseThrow().getUsedAt()).isNotNull(); + + mail.fail = true; + var failedIntern = accounts.create(new CreateAccountCommand( + "intern-failed@example.com", "Failed Intern", GlobalRole.INTERN, "STU-FAIL", + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId); + assertThat(failedIntern.deliverySucceeded()).isFalse(); + assertThat(users.findById(failedIntern.userId()).orElseThrow().getAccountStatus()) + .isEqualTo(AccountStatus.PENDING_ACTIVATION); + assertThat(tokens.findAll().stream() + .filter(token -> token.getUserId().equals(failedIntern.userId())) + .findFirst().orElseThrow().getInvalidatedAt()).isNotNull(); + + mail.fail = false; + mail.messages.clear(); + var activeInternCreation = accounts.create(new CreateAccountCommand( + "intern@example.com", "Active Intern", GlobalRole.INTERN, "STU-001", + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId); + assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue(); + accounts.activateInternship(activeInternCreation.userId(), adminId); + + var profile = internProfiles.findById(activeInternCreation.userId()).orElseThrow(); + assertThat(profile.getInternshipStatus()).isEqualTo(InternshipStatus.ACTIVE); + assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 8, 1))).isTrue(); + assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 12, 31))).isTrue(); + assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2027, 1, 1))).isFalse(); + + var summary = accounts.summary(); + assertThat(summary.activeAccounts()).isEqualTo(3); + assertThat(summary.pendingActivations()).isEqualTo(1); + assertThat(summary.activeInternships()).isEqualTo(1); + } + + private void activateSmtp(long adminId) { + long draftId = smtp.saveDraft(adminId, new SmtpDraft( + "mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet")); + smtp.testDraft(draftId, adminId, "admin@example.com"); + smtp.activate(draftId, adminId); + } + + private static byte[] sha256(String value) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + } + + @TestConfiguration(proxyBeanMethods = false) + static class MailProbeConfiguration { + @Bean + @Primary + RecordingSmtpProbe recordingSmtpProbe() { + return new RecordingSmtpProbe(); + } + } + + static final class RecordingSmtpProbe implements SmtpProbe { + private final List messages = new ArrayList<>(); + private boolean fail; + + @Override + public void send(SmtpConnection connection, String recipient, String subject, String body) { + if (fail) { + throw new IllegalStateException("simulated SMTP failure"); + } + messages.add(new Message(recipient, subject, body)); + } + + String onlyActivationToken() { + assertThat(messages).hasSize(1); + String body = messages.getFirst().body(); + int tokenStart = body.indexOf("token="); + assertThat(tokenStart).isGreaterThanOrEqualTo(0); + return body.substring(tokenStart + "token=".length()).trim(); + } + } + + record Message(String recipient, String subject, String body) { + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 9408fb4..5bbe123 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -3,5 +3,6 @@ spring: compose: enabled: false lab: + public-origin: http://localhost:8080 security: master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= From 2f2573159c34833b2e70a6c6a07f433da308af7b Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:38:03 +0700 Subject: [PATCH 18/62] fix(project): hide unavailable create action --- docs/tests/web/projects-pages.md | 14 ++++++++++++- .../project/controller/ProjectController.java | 4 +++- .../resources/templates/projects/list.html | 2 +- .../controller/ProjectControllerTest.java | 20 +++++++++++++++++-- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/tests/web/projects-pages.md b/docs/tests/web/projects-pages.md index df25b1f..905adb2 100644 --- a/docs/tests/web/projects-pages.md +++ b/docs/tests/web/projects-pages.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `SEC-001`, `ERR-001` - **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `I1-PRJ-05` - **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest` -- **Implementation commit:** `25a855e` +- **Implementation commits:** `25a855e`, `a9ee99a` ## Protected behavior @@ -68,6 +68,18 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock [INFO] BUILD SUCCESS ``` +## Mentor-only control regression + +**RED:** the focused MockMvc run reported two expected failures: `GET /projects/new` returned `200` for an Intern instead of non-disclosing `404`, and the member page rendered the `Add member` form for a non-owner. + +**GREEN:** rerunning `./mvnw -Dtest=ProjectControllerTest test` after the controller/DTO/template correction passed 7 tests with zero failures, errors, or skips. + +## Role-aware Project-list action regression + +**RED:** the focused MockMvc run reported two expected failures after adding the list-action regression: the controller still resolved only a user ID, so the Mentor fixture was queried as user `0`, and an Intern-facing Project list rendered the `Create Project` link. + +**GREEN:** rerunning `./mvnw -Dtest=ProjectControllerTest test` after resolving the public actor view and conditionally rendering the link passed 8 tests with zero failures, errors, or skips. + ## External-test boundaries This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, browser accessibility, or Iteration 2 invitation/exit/completion pages. The activation route remains deferred with `I1-PRJ-04` until the Task feature query dependency is available. diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java index 79cfd10..113b50a 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -30,7 +30,9 @@ public class ProjectController { @GetMapping public String list(Principal principal, Model model) { - model.addAttribute("projects", pages.listVisible(actorId(principal))); + var actor = pages.authenticatedActor(principal.getName()); + model.addAttribute("projects", pages.listVisible(actor.userId())); + model.addAttribute("canCreateProject", "MENTOR".equals(actor.role())); return "projects/list"; } diff --git a/src/main/resources/templates/projects/list.html b/src/main/resources/templates/projects/list.html index 9379ac8..67ec7ac 100644 --- a/src/main/resources/templates/projects/list.html +++ b/src/main/resources/templates/projects/list.html @@ -4,7 +4,7 @@

Projects

- Create Project + Create Project

No authorized Projects.

diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java index 4a13cec..2a26916 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -44,7 +44,8 @@ class ProjectControllerTest { @Test @WithMockUser(username = "mentor@example.test") void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception { - when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + when(pages.authenticatedActor("mentor@example.test")) + .thenReturn(new ProjectActorView(10L, "MENTOR")); when(pages.listVisible(10L)).thenReturn(List.of(new ProjectSummary( 30L, "Intern Portal Refresh", @@ -55,11 +56,26 @@ class ProjectControllerTest { mvc.perform(get("/projects")) .andExpect(status().isOk()) .andExpect(view().name("projects/list")) - .andExpect(model().attributeExists("projects")); + .andExpect(model().attributeExists("projects")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("Create Project"))); verify(pages).listVisible(10L); } + @Test + @WithMockUser(username = "member@example.test") + void nonMentorProjectListOmitsTheCreateLink() throws Exception { + when(pages.authenticatedActor("member@example.test")) + .thenReturn(new ProjectActorView(20L, "INTERN")); + when(pages.listVisible(20L)).thenReturn(List.of()); + + mvc.perform(get("/projects")) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString("Create Project")))); + } + @Test @WithMockUser(username = "member@example.test") void guessedProjectIdReturnsTheSameNotFoundResponseAsAMissingProject() throws Exception { From 8b48e281f7e860af435ae35b16c4edeb139286dc Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:43:21 +0700 Subject: [PATCH 19/62] feat: add iteration one attendance workflows --- .../integration/attendance-persistence.md | 99 ++++++ docs/tests/unit/attendance-current-state.md | 81 +++++ .../unit/attendance-feature-structure.md | 101 ++++++ docs/tests/unit/attendance-policy.md | 6 +- .../tests/unit/attendance-punch-boundaries.md | 6 +- docs/tests/web/attendance-web.md | 85 ++++++ .../AttendanceDayContextProvider.java | 9 - .../attendance/AttendanceRepository.java | 11 - .../attendance/AttendanceService.java | 62 ---- .../controller/AttendanceController.java | 103 +++++++ .../controller/CalendarController.java | 81 +++++ .../exception}/AttendanceException.java | 2 +- .../exception}/AttendanceRejection.java | 2 +- .../exception/CalendarException.java | 8 + .../attendance/model/AttendanceActor.java | 10 + .../model}/AttendanceDayContext.java | 2 +- .../attendance/model}/AttendancePolicy.java | 2 +- .../attendance/model}/AttendanceRecord.java | 5 +- .../attendance/model/AttendanceRole.java | 7 + .../model}/AttendanceViolations.java | 2 +- .../model/dto/AttendanceCurrentState.java | 7 + .../model/dto/AttendanceHistoryItem.java | 14 + .../model/dto/GlobalCalendarEvent.java | 5 + .../model/entity/AttendancePolicyEntity.java | 83 +++++ .../model/entity/AttendanceRecordEntity.java | 70 +++++ .../entity/GlobalCalendarEventEntity.java | 72 +++++ .../model/entity/LeaveRequestEntity.java | 32 ++ .../AttendancePolicyRepository.java | 10 + .../repository/AttendanceQueryRepository.java | 19 ++ .../AttendanceRecordRepository.java | 15 + .../GlobalCalendarEventRepository.java | 14 + .../service/AttendanceApplicationService.java | 143 +++++++++ .../service/AttendanceCurrentUserService.java | 38 +++ .../service}/AttendancePolicyTimeline.java | 3 +- .../attendance/service/AttendanceService.java | 51 ++++ .../service/CalendarApplicationService.java | 105 +++++++ .../templates/attendance/calendar.html | 42 +++ .../templates/attendance/history.html | 55 ++++ .../AttendanceLayerStructureTest.java | 57 ++++ .../controller/AttendanceControllerTest.java | 161 ++++++++++ .../model}/AttendancePolicyTest.java | 3 +- .../AttendanceApplicationServiceTest.java | 87 ++++++ .../AttendancePersistenceIntegrationTest.java | 289 ++++++++++++++++++ .../service}/AttendanceServiceTest.java | 129 ++++---- 44 files changed, 2019 insertions(+), 169 deletions(-) create mode 100644 docs/tests/integration/attendance-persistence.md create mode 100644 docs/tests/unit/attendance-current-state.md create mode 100644 docs/tests/unit/attendance-feature-structure.md create mode 100644 docs/tests/web/attendance-web.md delete mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java delete mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java delete mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java rename src/main/java/com/lab/labtimesheet/{attendance => feature/attendance/exception}/AttendanceException.java (85%) rename src/main/java/com/lab/labtimesheet/{attendance => feature/attendance/exception}/AttendanceRejection.java (78%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java rename src/main/java/com/lab/labtimesheet/{attendance => feature/attendance/model}/AttendanceDayContext.java (65%) rename src/main/java/com/lab/labtimesheet/{attendance => feature/attendance/model}/AttendancePolicy.java (97%) rename src/main/java/com/lab/labtimesheet/{attendance => feature/attendance/model}/AttendanceRecord.java (89%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java rename src/main/java/com/lab/labtimesheet/{attendance => feature/attendance/model}/AttendanceViolations.java (64%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java rename src/main/java/com/lab/labtimesheet/{attendance => feature/attendance/service}/AttendancePolicyTimeline.java (91%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java create mode 100644 src/main/resources/templates/attendance/calendar.html create mode 100644 src/main/resources/templates/attendance/history.html create mode 100644 src/test/java/com/lab/labtimesheet/architecture/AttendanceLayerStructureTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java rename src/test/java/com/lab/labtimesheet/{attendance => feature/attendance/model}/AttendancePolicyTest.java (94%) create mode 100644 src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java rename src/test/java/com/lab/labtimesheet/{attendance => feature/attendance/service}/AttendanceServiceTest.java (50%) diff --git a/docs/tests/integration/attendance-persistence.md b/docs/tests/integration/attendance-persistence.md new file mode 100644 index 0000000..9d1ef55 --- /dev/null +++ b/docs/tests/integration/attendance-persistence.md @@ -0,0 +1,99 @@ +# Test Evidence: Attendance PostgreSQL persistence and calendar rules + +- **Test type:** Integration +- **Requirement IDs:** `ATT-002`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-010`, `CAL-001`, `CAL-006`, `CAL-007`, `CAL-009`, `AUTH-003`, `RPT-004` +- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-CAL-003`, `AC-CAL-004` +- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendancePersistenceIntegrationTest` +- **Implementation commit:** `pending (committed with this evidence)` + +## Protected behavior + +PostgreSQL stores server-time punches with the seeded applied-policy foreign key, +enforces one row per Intern/date, and returns the attached policy in history. +Admin-only manual calendar changes affect check-in, past events are immutable, +stale edits are rejected, and Mentor/Admin/own-history scopes are enforced. + +## Test method + +A Spring Boot integration test migrates a real PostgreSQL 18.4 Testcontainer, +creates and activates a valid Intern exclusively through public account and SMTP +service/DTO boundaries, invokes the transactional attendance services, and +asserts persisted rows and denied state transitions. + +## Hand-derived expected result + +The 1970 seed has ID 1 and a 30-minute checkout grace. An event created for +2026-08-14 while server business date is 2026-08-13 blocks check-in on that +date. After business date advances to 2026-08-15, that event cannot change. +An update from version 0 advances the row, so a second version-0 edit is stale. + +## 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=AttendancePersistenceIntegrationTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class AttendanceApplicationService +[ERROR] cannot find symbol: class CalendarApplicationService +[INFO] 8 errors +[INFO] BUILD FAILURE +Process exited 1 before Testcontainers startup because the required persistence/application services did not exist. +``` + +The optimistic-edit assertion was separately observed RED: + +```text +./mvnw -Dtest=AttendancePersistenceIntegrationTest test +[ERROR] method updateManual ... actual and formal argument lists differ in length +[INFO] 4 errors +[INFO] BUILD FAILURE +Process exited 1 because update did not yet accept an expected version. +``` + +## 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=AttendancePersistenceIntegrationTest,AttendanceControllerTest test +``` + +**Observed result** + +```text +PostgreSQL 18.4 container started and Flyway applied V1. +AttendancePersistenceIntegrationTest: Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## 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='*Attendance*Test' test +Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +This test does not prove cross-request check-in races, production authentication +configuration, shared-shell integration, HolidayAPI, leave creation/decision, +corrections, schedulers, or later policy scheduling. diff --git a/docs/tests/unit/attendance-current-state.md b/docs/tests/unit/attendance-current-state.md new file mode 100644 index 0000000..2e089a8 --- /dev/null +++ b/docs/tests/unit/attendance-current-state.md @@ -0,0 +1,81 @@ +# Test Evidence: Current business-date attendance state + +- **Test type:** Unit +- **Requirement IDs:** `ATT-005`, `I1-UI-03` +- **Scenario IDs:** `I1-ATT-03`, `I1-ATT-04` +- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServiceTest` +- **Implementation commit:** `pending (committed with this evidence)` + +## Protected behavior + +The public attendance service reports an eligible Intern's current business-date +state as not checked in, checked in, or checked out without exposing attendance +repositories/entities to dashboard consumers. Ineligible Interns are rejected. + +## Test method + +A fixed Clock, seeded policy, and mocked Spring Data/account boundaries drive the +real application service through all three persisted-record shapes. A separate +case makes account eligibility false and asserts the attendance rejection. + +## Hand-derived expected result + +No record means `NOT_CHECKED_IN`; a record without checkout means `CHECKED_IN`; +a record with checkout means `CHECKED_OUT`. An ineligible user produces +`INACTIVE_INTERN` instead of a state. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendancePersistenceIntegrationTest test +``` + +**Observed result** + +```text +cannot find symbol: class AttendanceCurrentState +Tests did not run because the requested public DTO/service behavior did not exist. +BUILD FAILURE +Process exited 1. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceApplicationServiceTest test +``` + +**Observed result** + +```text +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## 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='*Attendance*Test' test +Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +The unit test does not prove PostgreSQL persistence, account fixture creation, +Spring transaction behavior, MVC rendering, or dashboard composition. diff --git a/docs/tests/unit/attendance-feature-structure.md b/docs/tests/unit/attendance-feature-structure.md new file mode 100644 index 0000000..6ea1119 --- /dev/null +++ b/docs/tests/unit/attendance-feature-structure.md @@ -0,0 +1,101 @@ +# Test Evidence: Attendance feature package and JPA boundaries + +- **Test type:** Unit +- **Requirement IDs:** `ARC-005`, `OPS-020` +- **Scenario IDs:** `I1-ATT-01` through `I1-ATT-05` structural gate +- **Test class/method:** `com.lab.labtimesheet.architecture.AttendanceLayerStructureTest` +- **Implementation commit:** `pending (committed with this evidence)` + +## Protected behavior + +Attendance/calendar code lives under one `feature.attendance` boundary with +controller, model, model.dto, model.entity, repository, service, and exception +layers. The superseded feature-first and global-layer classes are absent, and +application services do not depend on `JdbcTemplate`. +Attendance does not map or expose the account feature's `app_users` or +`intern_profiles` tables. + +## Test method + +Plain JUnit loads the required public classes by authoritative package name, +proves superseded class names are absent, verifies the query repository is a +Spring Data repository, reflects over application-service dependencies, and +proves that attendance-owned account entities/repositories cannot be loaded. + +## Hand-derived expected result + +Seven representative classes load from `feature.attendance` internal layers; +the old `attendance.AttendanceService` and global `controller.AttendanceController` +do not load; query access implements Spring Data `Repository`; no checked +application service has a `JdbcTemplate` field. +The four forbidden attendance-owned account entity/repository class names do +not load. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceLayerStructureTest test +``` + +**Observed result** + +```text +ClassNotFoundException: com.lab.labtimesheet.feature.attendance.controller.AttendanceController +ClassNotFoundException: com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository +Tests run: 2, Failures: 0, Errors: 2, Skipped: 0 +BUILD FAILURE +Process exited 1 because the implementation still used the superseded package layout. +``` + +The account-boundary assertion was separately observed RED after the final +feature package move: + +```text +./mvnw -Dtest=AttendanceLayerStructureTest test +AttendanceLayerStructureTest.attendanceDoesNotMapOrExposeAccountFeatureTables: +Expecting code to raise a throwable. +Tests run: 3, Failures: 1, Errors: 0, Skipped: 0 +BUILD FAILURE +Process exited 1 because attendance still owned shadow AppUser/InternProfile entity and repository types. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceLayerStructureTest test +``` + +**Observed result** + +```text +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## 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='*Attendance*Test' test +Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +This test proves source/package and dependency shape, not Spring context startup, +PostgreSQL queries, MVC behavior, or the final platform account-service wiring. diff --git a/docs/tests/unit/attendance-policy.md b/docs/tests/unit/attendance-policy.md index 03bc78d..edfab27 100644 --- a/docs/tests/unit/attendance-policy.md +++ b/docs/tests/unit/attendance-policy.md @@ -3,7 +3,7 @@ - **Test type:** Unit - **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004` - **Scenario IDs:** `AC-ATT-001` -- **Test class/method:** `com.lab.labtimesheet.attendance.AttendancePolicyTest` +- **Test class/method:** `com.lab.labtimesheet.feature.attendance.model.AttendancePolicyTest` - **Implementation commit:** `pending (committed with this evidence)` ## Protected behavior @@ -65,8 +65,8 @@ Process exited 0. **Command and result** ```text -./mvnw -Dtest='Attendance*Test' test -Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +./mvnw -Dtest='*Attendance*Test' test +Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/unit/attendance-punch-boundaries.md b/docs/tests/unit/attendance-punch-boundaries.md index 85b00a2..fb99a25 100644 --- a/docs/tests/unit/attendance-punch-boundaries.md +++ b/docs/tests/unit/attendance-punch-boundaries.md @@ -3,7 +3,7 @@ - **Test type:** Unit - **Requirement IDs:** `GOV-011`, `GOV-012`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-009`, `ATT-010`, `ATT-011`, `ATT-012`, `ATT-016` - **Scenario IDs:** `AC-ATT-002`, `AC-ATT-003`, `AC-ATT-004`, `AC-ATT-005` -- **Test class/method:** `com.lab.labtimesheet.attendance.AttendanceServiceTest` +- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceServiceTest` - **Implementation commit:** `pending (committed with this evidence)` ## Protected behavior @@ -70,8 +70,8 @@ Process exited 0. **Command and result** ```text -./mvnw -Dtest='Attendance*Test' test -Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +./mvnw -Dtest='*Attendance*Test' test +Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/web/attendance-web.md b/docs/tests/web/attendance-web.md new file mode 100644 index 0000000..3578f80 --- /dev/null +++ b/docs/tests/web/attendance-web.md @@ -0,0 +1,85 @@ +# Test Evidence: Attendance and global-calendar web authorization + +- **Test type:** Web +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-003`, `ATT-007`, `ATT-010`, `CAL-001`, `CAL-007`, `RPT-004` +- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-CAL-004` +- **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.AttendanceControllerTest` +- **Implementation commit:** `pending (committed with this evidence)` + +## Protected behavior + +Authenticated Intern punch routes use the server-resolved user ID, own history +renders attached policy details, Mentor inspection routes preserve the target +scope, and calendar management rejects non-Admin access. Calendar updates carry +the submitted optimistic version. + +## Test method + +`@WebMvcTest` runs Spring Security filters, CSRF protection, MVC binding, route +selection, controller authorization, Thymeleaf rendering, and service-call +arguments while mocking only application-service and current-user boundaries. + +## Hand-derived expected result + +An Intern authenticated as user 42 can punch only ID 42. A Mentor can inspect +target 42 but receives HTTP 403 for Admin calendar management. Attached policy +grace renders as `30 min`. An event form with version 3 calls update with 3. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceControllerTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class AttendanceCurrentUserService +[ERROR] cannot find symbol: class AttendanceController +[ERROR] cannot find symbol: class CalendarController +[INFO] 3 errors +[INFO] BUILD FAILURE +Process exited 1 because the required authenticated web endpoints did not exist. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceControllerTest test +``` + +**Observed result** + +```text +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## 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='*Attendance*Test' test +Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +This MVC slice does not prove the platform's production login/session setup, +shared shell and navigation, browser layout, or accessibility beyond semantic +labels, table headers, status roles, CSRF, and route authorization. diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java deleted file mode 100644 index 2ded1fa..0000000 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.lab.labtimesheet.attendance; - -import java.time.LocalDate; - -@FunctionalInterface -public interface AttendanceDayContextProvider { - - AttendanceDayContext get(long internId, LocalDate workDate); -} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java deleted file mode 100644 index 1091fe9..0000000 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.lab.labtimesheet.attendance; - -import java.time.LocalDate; -import java.util.Optional; - -public interface AttendanceRepository { - - Optional find(long internId, LocalDate workDate); - - AttendanceRecord save(AttendanceRecord record); -} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java deleted file mode 100644 index ac0fe59..0000000 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.lab.labtimesheet.attendance; - -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDate; -import java.util.Objects; - -public final class AttendanceService { - - private final Clock clock; - private final AttendancePolicyTimeline policies; - private final AttendanceRepository records; - private final AttendanceDayContextProvider dayContexts; - - public AttendanceService( - Clock clock, - AttendancePolicyTimeline policies, - AttendanceRepository records, - AttendanceDayContextProvider dayContexts) { - this.clock = Objects.requireNonNull(clock, "clock"); - this.policies = Objects.requireNonNull(policies, "policies"); - this.records = Objects.requireNonNull(records, "records"); - this.dayContexts = Objects.requireNonNull(dayContexts, "dayContexts"); - } - - public AttendanceRecord checkIn(long internId) { - Instant now = clock.instant(); - AttendancePolicy policy = policies.resolve(now); - LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate(); - AttendanceDayContext context = dayContexts.get(internId, workDate); - requireEligible(policy, workDate, context); - if (records.find(internId, workDate).isPresent()) { - throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN); - } - return records.save(new AttendanceRecord(internId, workDate, policy, now, null)); - } - - public AttendanceRecord checkOut(long internId) { - Instant now = clock.instant(); - AttendancePolicy currentPolicy = policies.resolve(now); - LocalDate workDate = now.atZone(currentPolicy.zoneId()).toLocalDate(); - AttendanceRecord record = records.find(internId, workDate) - .orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD)); - return records.save(record.checkOut(now)); - } - - private static void requireEligible( - AttendancePolicy policy, LocalDate workDate, AttendanceDayContext context) { - if (!context.activeIntern()) { - throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN); - } - if (!policy.isWorkday(workDate)) { - throw new AttendanceException(AttendanceRejection.NON_WORKDAY); - } - if (context.globalDayOff()) { - throw new AttendanceException(AttendanceRejection.GLOBAL_DAY_OFF); - } - if (context.approvedLeave()) { - throw new AttendanceException(AttendanceRejection.APPROVED_LEAVE); - } - } -} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java new file mode 100644 index 0000000..74159f8 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java @@ -0,0 +1,103 @@ +package com.lab.labtimesheet.feature.attendance.controller; + +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService; +import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService; +import java.security.Principal; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequestMapping("/attendance") +public class AttendanceController { + + private final AttendanceApplicationService attendance; + private final AttendanceCurrentUserService currentUsers; + + AttendanceController( + AttendanceApplicationService attendance, AttendanceCurrentUserService currentUsers) { + this.attendance = attendance; + this.currentUsers = currentUsers; + } + + @GetMapping + public String ownHistory( + Principal principal, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + Model model) { + AttendanceActor actor = requireIntern(currentUsers.actor(principal)); + return history(actor, actor.userId(), from, to, model); + } + + @GetMapping("/interns/{internId}") + public String inspectHistory( + Principal principal, + @org.springframework.web.bind.annotation.PathVariable long internId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + Model model) { + AttendanceActor actor = currentUsers.actor(principal); + if (actor.role() == AttendanceRole.INTERN) { + throw new AccessDeniedException("Intern inspection is not allowed"); + } + return history(actor, internId, from, to, model); + } + + @PostMapping("/check-in") + public String checkIn(Principal principal, RedirectAttributes redirectAttributes) { + AttendanceActor actor = requireIntern(currentUsers.actor(principal)); + try { + attendance.checkIn(actor.userId()); + redirectAttributes.addFlashAttribute("message", "Checked in"); + } catch (AttendanceException exception) { + redirectAttributes.addFlashAttribute("error", exception.rejection().name()); + } + return "redirect:/attendance"; + } + + @PostMapping("/check-out") + public String checkOut(Principal principal, RedirectAttributes redirectAttributes) { + AttendanceActor actor = requireIntern(currentUsers.actor(principal)); + try { + attendance.checkOut(actor.userId()); + redirectAttributes.addFlashAttribute("message", "Checked out"); + } catch (AttendanceException exception) { + redirectAttributes.addFlashAttribute("error", exception.rejection().name()); + } + return "redirect:/attendance"; + } + + private String history( + AttendanceActor actor, + long internId, + LocalDate from, + LocalDate to, + Model model) { + LocalDate effectiveTo = to == null ? attendance.currentBusinessDate() : to; + LocalDate effectiveFrom = from == null ? effectiveTo.withDayOfMonth(1) : from; + model.addAttribute("items", attendance.history(actor, internId, effectiveFrom, effectiveTo)); + model.addAttribute("targetInternId", internId); + model.addAttribute("from", effectiveFrom); + model.addAttribute("to", effectiveTo); + model.addAttribute("ownHistory", actor.userId() == internId); + return "attendance/history"; + } + + private static AttendanceActor requireIntern(AttendanceActor actor) { + if (actor.role() != AttendanceRole.INTERN) { + throw new AccessDeniedException("Only Interns may punch attendance"); + } + return actor; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java new file mode 100644 index 0000000..cde6cd7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java @@ -0,0 +1,81 @@ +package com.lab.labtimesheet.feature.attendance.controller; + +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService; +import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService; +import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService; +import java.security.Principal; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequestMapping("/attendance/calendar") +public class CalendarController { + + private final CalendarApplicationService calendar; + private final AttendanceApplicationService attendance; + private final AttendanceCurrentUserService currentUsers; + + CalendarController( + CalendarApplicationService calendar, + AttendanceApplicationService attendance, + AttendanceCurrentUserService currentUsers) { + this.calendar = calendar; + this.attendance = attendance; + this.currentUsers = currentUsers; + } + + @GetMapping + public String calendar(Principal principal, Model model) { + requireAdmin(currentUsers.actor(principal)); + LocalDate today = attendance.currentBusinessDate(); + model.addAttribute("events", calendar.list(today, today.plusYears(1))); + model.addAttribute("today", today); + return "attendance/calendar"; + } + + @PostMapping + public String create( + Principal principal, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam String name, + @RequestParam(defaultValue = "false") boolean dayOff, + RedirectAttributes redirectAttributes) { + AttendanceActor actor = requireAdmin(currentUsers.actor(principal)); + calendar.createManual(actor, date, name, dayOff); + redirectAttributes.addFlashAttribute("message", "Calendar event created"); + return "redirect:/attendance/calendar"; + } + + @PostMapping("/{eventId}") + public String update( + Principal principal, + @PathVariable long eventId, + @RequestParam long version, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam String name, + @RequestParam(defaultValue = "false") boolean dayOff, + RedirectAttributes redirectAttributes) { + AttendanceActor actor = requireAdmin(currentUsers.actor(principal)); + calendar.updateManual(actor, eventId, version, date, name, dayOff); + redirectAttributes.addFlashAttribute("message", "Calendar event updated"); + return "redirect:/attendance/calendar"; + } + + private static AttendanceActor requireAdmin(AttendanceActor actor) { + if (actor.role() != AttendanceRole.ADMIN) { + throw new AccessDeniedException("Only Admin may manage the global calendar"); + } + return actor; + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java similarity index 85% rename from src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java index fbae49a..5afc11e 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.exception; public final class AttendanceException extends RuntimeException { diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java similarity index 78% rename from src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java index 4cb1f6a..a860d9b 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.exception; public enum AttendanceRejection { INACTIVE_INTERN, diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java new file mode 100644 index 0000000..abd0c84 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.feature.attendance.exception; + +public final class CalendarException extends RuntimeException { + + public CalendarException(String message) { + super(message); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java new file mode 100644 index 0000000..08907c7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.feature.attendance.model; + +import java.util.Objects; + +public record AttendanceActor(long userId, AttendanceRole role) { + + public AttendanceActor { + Objects.requireNonNull(role, "role"); + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java similarity index 65% rename from src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java index 4ad222e..460a3c0 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java @@ -1,3 +1,3 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.model; public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java similarity index 97% rename from src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java index 1fe1bf4..5646aa8 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.model; import java.math.BigDecimal; import java.time.DayOfWeek; diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java similarity index 89% rename from src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java index a58425c..724900e 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java @@ -1,4 +1,7 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.model; + +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; import java.time.Instant; import java.time.LocalDate; diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java new file mode 100644 index 0000000..bcea83e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.attendance.model; + +public enum AttendanceRole { + ADMIN, + MENTOR, + INTERN +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java similarity index 64% rename from src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java index 521aa62..ee72e91 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java @@ -1,3 +1,3 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.model; public record AttendanceViolations(boolean late, boolean earlyDeparture, boolean missingCheckout) {} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java new file mode 100644 index 0000000..2fd7033 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.attendance.model.dto; + +public enum AttendanceCurrentState { + NOT_CHECKED_IN, + CHECKED_IN, + CHECKED_OUT +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java new file mode 100644 index 0000000..b406b90 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.feature.attendance.model.dto; + +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; + +import java.time.Instant; +import java.time.LocalDate; + +public record AttendanceHistoryItem( + LocalDate workDate, + Instant checkInAt, + Instant checkOutAt, + AttendancePolicy policy, + AttendanceViolations violations) {} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java new file mode 100644 index 0000000..6875491 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java @@ -0,0 +1,5 @@ +package com.lab.labtimesheet.feature.attendance.model.dto; + +import java.time.LocalDate; + +public record GlobalCalendarEvent(long id, LocalDate date, String name, boolean dayOff, long version) {} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java new file mode 100644 index 0000000..e341613 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java @@ -0,0 +1,83 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Set; +import java.util.stream.Collectors; + +@Entity +@Table(name = "attendance_policy_versions") +public class AttendancePolicyEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "effective_from", nullable = false) + private LocalDate effectiveFrom; + + @Column(name = "timezone_name", nullable = false) + private String timezoneName; + + @Column(name = "scheduled_start", nullable = false) + private LocalTime scheduledStart; + + @Column(name = "scheduled_end", nullable = false) + private LocalTime scheduledEnd; + + @Column(name = "check_in_grace_minutes", nullable = false) + private int checkInGraceMinutes; + + @Column(name = "checkout_grace_minutes", nullable = false) + private int checkoutGraceMinutes; + + @Column(name = "monthly_leave_quota", nullable = false) + private int monthlyLeaveQuota; + + @Column(name = "violation_penalty", nullable = false) + private BigDecimal violationPenalty; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "attendance_policy_workdays", + joinColumns = @JoinColumn(name = "policy_version_id")) + @Column(name = "iso_weekday", nullable = false) + private Set isoWeekdays; + + @Version + private long version; + + protected AttendancePolicyEntity() {} + + public AttendancePolicy toDomain() { + Set workdays = isoWeekdays.stream() + .map(day -> DayOfWeek.of(day.intValue())) + .collect(Collectors.toUnmodifiableSet()); + return new AttendancePolicy( + id, + effectiveFrom, + ZoneId.of(timezoneName), + scheduledStart, + scheduledEnd, + checkInGraceMinutes, + checkoutGraceMinutes, + monthlyLeaveQuota, + violationPenalty, + workdays); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java new file mode 100644 index 0000000..dcf01ab --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java @@ -0,0 +1,70 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "attendance_records") +public class AttendanceRecordEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "intern_user_id", nullable = false) + private long internUserId; + + @Column(name = "work_date", nullable = false) + private LocalDate workDate; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "policy_version_id", nullable = false) + private AttendancePolicyEntity policy; + + @Column(name = "check_in_at", nullable = false) + private Instant checkInAt; + + @Column(name = "check_out_at") + private Instant checkOutAt; + + @Version + private long version; + + protected AttendanceRecordEntity() {} + + public AttendanceRecordEntity( + long internUserId, + LocalDate workDate, + AttendancePolicyEntity policy, + Instant checkInAt, + Instant checkOutAt) { + this.internUserId = internUserId; + this.workDate = workDate; + this.policy = policy; + this.checkInAt = checkInAt; + this.checkOutAt = checkOutAt; + } + + public AttendanceRecord toDomain() { + return new AttendanceRecord(internUserId, workDate, policy.toDomain(), checkInAt, checkOutAt); + } + + public void setCheckOutAt(Instant checkOutAt) { + this.checkOutAt = checkOutAt; + } + + public LocalDate workDate() { + return workDate; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java new file mode 100644 index 0000000..3bd5224 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java @@ -0,0 +1,72 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.LocalDate; + +@Entity +@Table(name = "global_calendar_events") +public class GlobalCalendarEventEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "calendar_date", nullable = false) + private LocalDate calendarDate; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String source; + + @Column(name = "is_day_off", nullable = false) + private boolean dayOff; + + @Column(name = "created_by_user_id", nullable = false, updatable = false) + private long createdByUserId; + + @Column(name = "updated_by_user_id", nullable = false) + private long updatedByUserId; + + @Version + private long version; + + protected GlobalCalendarEventEntity() {} + + public GlobalCalendarEventEntity(LocalDate date, String name, boolean dayOff, long actorUserId) { + this.calendarDate = date; + this.name = name; + this.source = "CUSTOM"; + this.dayOff = dayOff; + this.createdByUserId = actorUserId; + this.updatedByUserId = actorUserId; + } + + public void update(LocalDate date, String name, boolean dayOff, long actorUserId) { + this.calendarDate = date; + this.name = name; + this.dayOff = dayOff; + this.updatedByUserId = actorUserId; + } + + public GlobalCalendarEvent toDomain() { + return new GlobalCalendarEvent(id, calendarDate, name, dayOff, version); + } + + public LocalDate calendarDate() { + return calendarDate; + } + + public long version() { + return version; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java new file mode 100644 index 0000000..91fa7e1 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java @@ -0,0 +1,32 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDate; + +@Entity +@Table(name = "leave_requests") +public class LeaveRequestEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "intern_user_id", nullable = false) + private long internUserId; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + @Column(nullable = false) + private String status; + + protected LeaveRequestEntity() {} +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java new file mode 100644 index 0000000..bdeba3e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.feature.attendance.repository; + +import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttendancePolicyRepository extends JpaRepository { + + List findAllByOrderByEffectiveFromAsc(); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java new file mode 100644 index 0000000..086984e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java @@ -0,0 +1,19 @@ +package com.lab.labtimesheet.feature.attendance.repository; + +import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity; +import java.time.LocalDate; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +public interface AttendanceQueryRepository extends Repository { + + @Query(""" + select count(request) > 0 + from LeaveRequestEntity request + where request.internUserId = :internId and request.status = 'APPROVED' + and :workDate between request.startDate and request.endDate + """) + boolean hasApprovedLeave( + @Param("internId") long internId, @Param("workDate") LocalDate workDate); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java new file mode 100644 index 0000000..e2954c3 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java @@ -0,0 +1,15 @@ +package com.lab.labtimesheet.feature.attendance.repository; + +import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttendanceRecordRepository extends JpaRepository { + + Optional findByInternUserIdAndWorkDate(long internUserId, LocalDate workDate); + + List findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc( + long internUserId, LocalDate from, LocalDate to); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java new file mode 100644 index 0000000..2d86432 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.feature.attendance.repository; + +import com.lab.labtimesheet.feature.attendance.model.entity.GlobalCalendarEventEntity; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GlobalCalendarEventRepository extends JpaRepository { + + boolean existsByCalendarDateAndDayOffTrue(LocalDate date); + + List findByCalendarDateBetweenOrderByCalendarDateAscIdAsc( + LocalDate from, LocalDate to); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java new file mode 100644 index 0000000..423d91d --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java @@ -0,0 +1,143 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity; +import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository; +import com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository; +import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AttendanceApplicationService { + + private final Clock clock; + private final AttendancePolicyRepository policyEntities; + private final AttendanceRecordRepository recordEntities; + private final AttendanceQueryRepository queries; + private final AccountService accounts; + private final CalendarApplicationService calendar; + private final AttendanceService attendance; + + AttendanceApplicationService( + Clock clock, + AttendancePolicyRepository policyEntities, + AttendanceRecordRepository recordEntities, + AttendanceQueryRepository queries, + AccountService accounts, + CalendarApplicationService calendar, + AttendanceService attendance) { + this.clock = clock; + this.policyEntities = policyEntities; + this.recordEntities = recordEntities; + this.queries = queries; + this.accounts = accounts; + this.calendar = calendar; + this.attendance = attendance; + } + + @Transactional + public AttendanceRecord checkIn(long internId) { + Instant now = clock.instant(); + AttendancePolicy policy = timeline().resolve(now); + LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate(); + Optional existing = recordEntities + .findByInternUserIdAndWorkDate(internId, workDate) + .map(AttendanceRecordEntity::toDomain); + AttendanceRecord record = attendance.checkIn( + internId, now, policy, dayContext(internId, workDate), existing); + return recordEntities.saveAndFlush(new AttendanceRecordEntity( + record.internId(), + record.workDate(), + policyEntities.getReferenceById(record.policy().id()), + record.checkInAt(), + record.checkOutAt())) + .toDomain(); + } + + @Transactional + public AttendanceRecord checkOut(long internId) { + Instant now = clock.instant(); + AttendancePolicy currentPolicy = timeline().resolve(now); + LocalDate workDate = now.atZone(currentPolicy.zoneId()).toLocalDate(); + Optional entity = recordEntities.findByInternUserIdAndWorkDate(internId, workDate); + AttendanceRecord checkedOut = attendance.checkOut(entity.map(AttendanceRecordEntity::toDomain), now); + AttendanceRecordEntity persisted = entity.orElseThrow(); + persisted.setCheckOutAt(checkedOut.checkOutAt()); + return recordEntities.saveAndFlush(persisted).toDomain(); + } + + @Transactional(readOnly = true) + public AttendanceCurrentState currentState(long internId) { + Instant now = clock.instant(); + AttendancePolicy policy = timeline().resolve(now); + LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate(); + if (!accounts.isEligibleIntern(internId, workDate)) { + throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN); + } + return recordEntities.findByInternUserIdAndWorkDate(internId, workDate) + .map(AttendanceRecordEntity::toDomain) + .map(record -> record.checkOutAt() == null + ? AttendanceCurrentState.CHECKED_IN + : AttendanceCurrentState.CHECKED_OUT) + .orElse(AttendanceCurrentState.NOT_CHECKED_IN); + } + + @Transactional(readOnly = true) + public List history( + AttendanceActor actor, long internId, LocalDate from, LocalDate to) { + if (actor.role() == AttendanceRole.INTERN && actor.userId() != internId) { + throw new AccessDeniedException("Interns may view only their own attendance"); + } + if (from.isAfter(to)) { + throw new IllegalArgumentException("from must not be after to"); + } + return recordEntities.findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc(internId, from, to) + .stream() + .map(AttendanceRecordEntity::toDomain) + .map(record -> new AttendanceHistoryItem( + record.workDate(), + record.checkInAt(), + record.checkOutAt(), + record.policy(), + record.violations(clock.instant()))) + .toList(); + } + + @Transactional(readOnly = true) + public LocalDate currentBusinessDate() { + AttendancePolicy policy = timeline().resolve(clock.instant()); + return clock.instant().atZone(policy.zoneId()).toLocalDate(); + } + + private AttendancePolicyTimeline timeline() { + return new AttendancePolicyTimeline(policyEntities + .findAllByOrderByEffectiveFromAsc() + .stream() + .map(AttendancePolicyEntity::toDomain) + .toList()); + } + + private AttendanceDayContext dayContext(long internId, LocalDate workDate) { + return new AttendanceDayContext( + accounts.isEligibleIntern(internId, workDate), + calendar.isGlobalDayOff(workDate), + queries.hasApprovedLeave(internId, workDate)); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java new file mode 100644 index 0000000..47e7aeb --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java @@ -0,0 +1,38 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import java.security.Principal; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; + +@Service +public class AttendanceCurrentUserService { + + private final AccountService accounts; + + AttendanceCurrentUserService(AccountService accounts) { + this.accounts = accounts; + } + + public AttendanceActor actor(Principal principal) { + if (principal == null || principal.getName() == null) { + throw new AccessDeniedException("Authentication is required"); + } + AccountIdentity identity; + try { + identity = accounts.requireIdentityByEmail(principal.getName()); + } catch (IllegalArgumentException exception) { + throw new AccessDeniedException( + "No active application user matches the authenticated identity", exception); + } + if (identity.status() != AccountStatus.ACTIVE) { + throw new AccessDeniedException( + "No active application user matches the authenticated identity"); + } + return new AttendanceActor(identity.id(), AttendanceRole.valueOf(identity.role().name())); + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java similarity index 91% rename from src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java index 662f8a6..1f45b3c 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java @@ -1,5 +1,6 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.service; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; import java.time.LocalDate; import java.time.Instant; import java.util.Collection; diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java new file mode 100644 index 0000000..4e285bd --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java @@ -0,0 +1,51 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; +import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Optional; +import org.springframework.stereotype.Service; + +@Service +public final class AttendanceService { + + public AttendanceRecord checkIn( + long internId, + Instant now, + AttendancePolicy policy, + AttendanceDayContext context, + Optional existingRecord) { + LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate(); + requireEligible(policy, workDate, context); + if (existingRecord.isPresent()) { + throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN); + } + return new AttendanceRecord(internId, workDate, policy, now, null); + } + + public AttendanceRecord checkOut(Optional record, Instant now) { + return record + .orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD)) + .checkOut(now); + } + + private static void requireEligible( + AttendancePolicy policy, LocalDate workDate, AttendanceDayContext context) { + if (!context.activeIntern()) { + throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN); + } + if (!policy.isWorkday(workDate)) { + throw new AttendanceException(AttendanceRejection.NON_WORKDAY); + } + if (context.globalDayOff()) { + throw new AttendanceException(AttendanceRejection.GLOBAL_DAY_OFF); + } + if (context.approvedLeave()) { + throw new AttendanceException(AttendanceRejection.APPROVED_LEAVE); + } + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java new file mode 100644 index 0000000..525fa8c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java @@ -0,0 +1,105 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import com.lab.labtimesheet.feature.attendance.exception.CalendarException; +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.GlobalCalendarEventEntity; +import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository; +import com.lab.labtimesheet.feature.attendance.repository.GlobalCalendarEventRepository; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CalendarApplicationService { + + private final Clock clock; + private final AttendancePolicyRepository policies; + private final GlobalCalendarEventRepository events; + + CalendarApplicationService( + Clock clock, + AttendancePolicyRepository policies, + GlobalCalendarEventRepository events) { + this.clock = clock; + this.policies = policies; + this.events = events; + } + + @Transactional + public GlobalCalendarEvent createManual( + AttendanceActor actor, LocalDate date, String name, boolean dayOff) { + requireAdmin(actor); + requireMutableDate(date); + return events.saveAndFlush(new GlobalCalendarEventEntity(date, requireName(name), dayOff, actor.userId())) + .toDomain(); + } + + @Transactional + public GlobalCalendarEvent updateManual( + AttendanceActor actor, + long eventId, + long expectedVersion, + LocalDate date, + String name, + boolean dayOff) { + requireAdmin(actor); + GlobalCalendarEventEntity event = events.findById(eventId) + .orElseThrow(() -> new CalendarException("Calendar event not found")); + requireMutableDate(event.calendarDate()); + requireMutableDate(date); + if (event.version() != expectedVersion) { + throw new CalendarException("Calendar event was changed by another request"); + } + event.update(date, requireName(name), dayOff, actor.userId()); + return events.saveAndFlush(event).toDomain(); + } + + @Transactional(readOnly = true) + public List list(LocalDate from, LocalDate to) { + if (from.isAfter(to)) { + throw new IllegalArgumentException("from must not be after to"); + } + return events.findByCalendarDateBetweenOrderByCalendarDateAscIdAsc(from, to) + .stream() + .map(GlobalCalendarEventEntity::toDomain) + .toList(); + } + + @Transactional(readOnly = true) + public boolean isGlobalDayOff(LocalDate date) { + return events.existsByCalendarDateAndDayOffTrue(date); + } + + private void requireMutableDate(LocalDate date) { + AttendancePolicy policy = new AttendancePolicyTimeline(policies + .findAllByOrderByEffectiveFromAsc() + .stream() + .map(AttendancePolicyEntity::toDomain) + .toList()) + .resolve(clock.instant()); + LocalDate today = clock.instant().atZone(policy.zoneId()).toLocalDate(); + if (date.isBefore(today)) { + throw new CalendarException("Past calendar events are immutable"); + } + } + + private static void requireAdmin(AttendanceActor actor) { + if (actor.role() != AttendanceRole.ADMIN) { + throw new AccessDeniedException("Only Admin may manage the global calendar"); + } + } + + private static String requireName(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + return name.strip(); + } +} diff --git a/src/main/resources/templates/attendance/calendar.html b/src/main/resources/templates/attendance/calendar.html new file mode 100644 index 0000000..9098c0f --- /dev/null +++ b/src/main/resources/templates/attendance/calendar.html @@ -0,0 +1,42 @@ + + + + + + Global calendar + + +
+

Global calendar

+

+ +
+ + + + + + + + +

No upcoming calendar events.

+
Authorized Projects
+ + + + + + + + + + +
Upcoming global events
DateNameDay offSave
+
+ + +
+
+
+ + diff --git a/src/main/resources/templates/attendance/history.html b/src/main/resources/templates/attendance/history.html new file mode 100644 index 0000000..5cd6782 --- /dev/null +++ b/src/main/resources/templates/attendance/history.html @@ -0,0 +1,55 @@ + + + + + + Attendance history + + +
+

Attendance

+

+

+ +
+ +
+
+ +
+ +
+ + + + + +
+ +

No attendance records in this period.

+ + + + + + + + + + + + + + + + + + + + + + +
Attendance records and applied policy
DateCheck inCheck outApplied scheduleGraceResult
+
+ + diff --git a/src/test/java/com/lab/labtimesheet/architecture/AttendanceLayerStructureTest.java b/src/test/java/com/lab/labtimesheet/architecture/AttendanceLayerStructureTest.java new file mode 100644 index 0000000..31d7019 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/architecture/AttendanceLayerStructureTest.java @@ -0,0 +1,57 @@ +package com.lab.labtimesheet.architecture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.repository.Repository; +import org.springframework.jdbc.core.JdbcTemplate; + +class AttendanceLayerStructureTest { + + @Test + void attendanceUsesAuthoritativeLayerPackagesWithoutLegacyFeaturePackage() throws Exception { + for (String className : List.of( + "com.lab.labtimesheet.feature.attendance.controller.AttendanceController", + "com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem", + "com.lab.labtimesheet.feature.attendance.exception.AttendanceException", + "com.lab.labtimesheet.feature.attendance.model.AttendancePolicy", + "com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity", + "com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository", + "com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService")) { + assertThat(Class.forName(className)).isNotNull(); + } + + assertThatThrownBy(() -> Class.forName("com.lab.labtimesheet.attendance.AttendanceService")) + .isInstanceOf(ClassNotFoundException.class); + assertThatThrownBy(() -> Class.forName("com.lab.labtimesheet.controller.AttendanceController")) + .isInstanceOf(ClassNotFoundException.class); + } + + @Test + void attendanceQueriesUseSpringDataJpaRatherThanJdbcTemplate() throws Exception { + Class queryRepository = Class.forName( + "com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository"); + assertThat(Repository.class).isAssignableFrom(queryRepository); + + for (String serviceName : List.of( + "com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService", + "com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService")) { + assertThat(Class.forName(serviceName).getDeclaredFields()) + .allSatisfy(field -> assertThat(field.getType()).isNotEqualTo(JdbcTemplate.class)); + } + } + + @Test + void attendanceDoesNotMapOrExposeAccountFeatureTables() { + for (String className : List.of( + "com.lab.labtimesheet.feature.attendance.model.entity.AppUserEntity", + "com.lab.labtimesheet.feature.attendance.model.entity.InternProfileEntity", + "com.lab.labtimesheet.feature.attendance.repository.AppUserRepository", + "com.lab.labtimesheet.feature.attendance.repository.InternProfileRepository")) { + assertThatThrownBy(() -> Class.forName(className)) + .isInstanceOf(ClassNotFoundException.class); + } + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java new file mode 100644 index 0000000..0a7c787 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java @@ -0,0 +1,161 @@ +package com.lab.labtimesheet.feature.attendance.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; +import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent; +import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService; +import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService; +import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest({AttendanceController.class, CalendarController.class}) +class AttendanceControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AttendanceApplicationService attendance; + + @MockitoBean + private CalendarApplicationService calendar; + + @MockitoBean + private AttendanceCurrentUserService currentUsers; + + @Test + void internPunchesOnlyForAuthenticatedSelf() throws Exception { + AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN); + when(currentUsers.actor(any())).thenReturn(actor); + + mockMvc.perform(post("/attendance/check-in") + .with(user("intern@example.test").roles("INTERN")) + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/attendance")); + + verify(attendance).checkIn(42L); + } + + @Test + void ownHistoryRendersAttachedHistoricalPolicy() throws Exception { + AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN); + when(currentUsers.actor(any())).thenReturn(actor); + when(attendance.history(eq(actor), eq(42L), any(), any())).thenReturn(List.of(new AttendanceHistoryItem( + LocalDate.of(2026, 8, 14), + Instant.parse("2026-08-14T02:00:00Z"), + Instant.parse("2026-08-14T09:00:00Z"), + AttendancePolicy.seeded(1L), + new AttendanceViolations(false, false, false)))); + + mockMvc.perform(get("/attendance") + .with(user("intern@example.test").roles("INTERN")) + .param("from", "2026-08-01") + .param("to", "2026-08-31")) + .andExpect(status().isOk()) + .andExpect(view().name("attendance/history")) + .andExpect(model().attribute("targetInternId", 42L)) + .andExpect(content().string(org.hamcrest.Matchers.containsString("30 min"))); + } + + @Test + void mentorCanInspectInternHistory() throws Exception { + AttendanceActor mentor = new AttendanceActor(7L, AttendanceRole.MENTOR); + when(currentUsers.actor(any())).thenReturn(mentor); + when(attendance.currentBusinessDate()).thenReturn(LocalDate.of(2026, 8, 14)); + when(attendance.history(eq(mentor), eq(42L), any(), any())).thenReturn(List.of()); + + mockMvc.perform(get("/attendance/interns/42") + .with(user("mentor@example.test").roles("MENTOR"))) + .andExpect(status().isOk()) + .andExpect(view().name("attendance/history")); + + verify(attendance).history( + mentor, 42L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 14)); + } + + @Test + void onlyAdminCanOpenCalendarManagement() throws Exception { + when(currentUsers.actor(any())).thenReturn(new AttendanceActor(7L, AttendanceRole.MENTOR)); + + mockMvc.perform(get("/attendance/calendar") + .with(user("mentor@example.test").roles("MENTOR"))) + .andExpect(status().isForbidden()); + } + + @Test + void adminCreatesManualDayOffFromServerAuthorizedIdentity() throws Exception { + AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN); + when(currentUsers.actor(any())).thenReturn(admin); + + mockMvc.perform(post("/attendance/calendar") + .with(user("admin@example.test").roles("ADMIN")) + .with(csrf()) + .param("date", "2026-08-20") + .param("name", "Lab closure") + .param("dayOff", "true")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/attendance/calendar")); + + verify(calendar).createManual(admin, LocalDate.of(2026, 8, 20), "Lab closure", true); + } + + @Test + void adminCalendarRendersEditableVersionedEvents() throws Exception { + AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN); + when(currentUsers.actor(any())).thenReturn(admin); + when(attendance.currentBusinessDate()).thenReturn(LocalDate.of(2026, 8, 14)); + when(calendar.list(LocalDate.of(2026, 8, 14), LocalDate.of(2027, 8, 14))) + .thenReturn(List.of(new GlobalCalendarEvent( + 9L, LocalDate.of(2026, 8, 20), "Lab closure", true, 3L))); + + mockMvc.perform(get("/attendance/calendar") + .with(user("admin@example.test").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(view().name("attendance/calendar")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Lab closure"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("value=\"3\""))); + } + + @Test + void adminUpdateCarriesOptimisticVersion() throws Exception { + AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN); + when(currentUsers.actor(any())).thenReturn(admin); + + mockMvc.perform(post("/attendance/calendar/9") + .with(user("admin@example.test").roles("ADMIN")) + .with(csrf()) + .param("version", "3") + .param("date", "2026-08-20") + .param("name", "Lab closure") + .param("dayOff", "true")) + .andExpect(status().is3xxRedirection()); + + verify(calendar).updateManual( + admin, 9L, 3L, LocalDate.of(2026, 8, 20), "Lab closure", true); + } +} diff --git a/src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java similarity index 94% rename from src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java rename to src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java index 5ea8d91..103f81f 100644 --- a/src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java @@ -1,5 +1,6 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.model; +import com.lab.labtimesheet.feature.attendance.service.AttendancePolicyTimeline; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java new file mode 100644 index 0000000..aff3d25 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java @@ -0,0 +1,87 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity; +import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository; +import com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository; +import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class AttendanceApplicationServiceTest { + + private static final long INTERN_ID = 42L; + private static final Instant NOW = Instant.parse("2026-08-14T02:00:00Z"); + private static final LocalDate WORK_DATE = LocalDate.of(2026, 8, 14); + + private final AttendancePolicyRepository policies = mock(AttendancePolicyRepository.class); + private final AttendanceRecordRepository records = mock(AttendanceRecordRepository.class); + private final AccountService accounts = mock(AccountService.class); + private AttendanceApplicationService attendance; + + @BeforeEach + void setUp() { + AttendancePolicyEntity policyEntity = mock(AttendancePolicyEntity.class); + when(policyEntity.toDomain()).thenReturn(AttendancePolicy.seeded(1L)); + when(policies.findAllByOrderByEffectiveFromAsc()).thenReturn(List.of(policyEntity)); + when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(true); + attendance = new AttendanceApplicationService( + Clock.fixed(NOW, ZoneOffset.UTC), + policies, + records, + mock(AttendanceQueryRepository.class), + accounts, + mock(CalendarApplicationService.class), + new AttendanceService()); + } + + @Test + void reportsCurrentBusinessDatePunchStateWithoutExposingPersistenceTypes() { + when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(entityFor(null))) + .thenReturn(Optional.of(entityFor(NOW.plusSeconds(60)))); + + assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.NOT_CHECKED_IN); + assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.CHECKED_IN); + assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.CHECKED_OUT); + } + + @Test + void rejectsCurrentStateLookupForIneligibleIntern() { + when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(false); + + assertThatThrownBy(() -> attendance.currentState(INTERN_ID)) + .isInstanceOfSatisfying(AttendanceException.class, + exception -> assertThat(exception.rejection()) + .isEqualTo(AttendanceRejection.INACTIVE_INTERN)); + } + + private static AttendanceRecordEntity entityFor(Instant checkOutAt) { + AttendanceRecordEntity entity = mock(AttendanceRecordEntity.class); + when(entity.toDomain()).thenReturn(new AttendanceRecord( + INTERN_ID, + WORK_DATE, + AttendancePolicy.seeded(1L), + NOW, + checkOutAt)); + return entity; + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java new file mode 100644 index 0000000..347882d --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java @@ -0,0 +1,289 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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.account.service.BootstrapService; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.exception.CalendarException; +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; +import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository; +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.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +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.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +@Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class) +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class AttendancePersistenceIntegrationTest { + + @Autowired + private AttendanceApplicationService attendance; + + @Autowired + private CalendarApplicationService calendar; + + @Autowired + private AttendanceCurrentUserService currentUsers; + + @Autowired + private BootstrapService bootstrap; + + @Autowired + private AccountService accounts; + + @Autowired + private SmtpConfigurationService smtp; + + @Autowired + private RecordingSmtpProbe mail; + + @Autowired + private AttendanceRecordRepository records; + + @Autowired + private MutableClock clock; + + private long internId; + private long adminId; + private long mentorId; + + @BeforeEach + void seedUsers() { + clock.set(Instant.parse("2026-08-14T00:00:00Z")); + bootstrap.bootstrap("admin@example.test", "Admin", "correct horse battery staple"); + adminId = accounts.requireActiveAdminId("admin@example.test"); + long draftId = smtp.saveDraft(adminId, new SmtpDraft( + "mailpit", + 1025, + SecurityMode.NONE, + null, + null, + "admin@example.test", + "Lab Timesheet")); + smtp.testDraft(draftId, adminId, "admin@example.test"); + smtp.activate(draftId, adminId); + mail.clear(); + + var creation = accounts.create(new CreateAccountCommand( + "intern@example.test", + "Intern", + GlobalRole.INTERN, + "INT-001", + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 12, 31)), adminId); + assertThat(creation.deliverySucceeded()).isTrue(); + assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue(); + accounts.activateInternship(creation.userId(), adminId); + + mentorId = adminId + 1; + internId = creation.userId(); + } + + @Test + void storesServerPunchesWithSeededPolicyAndHistoricalPolicyDetails() { + clock.set(Instant.parse("2026-08-14T02:00:00Z")); + + assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.NOT_CHECKED_IN); + attendance.checkIn(internId); + assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.CHECKED_IN); + + var persisted = records.findByInternUserIdAndWorkDate(internId, LocalDate.of(2026, 8, 14)) + .orElseThrow() + .toDomain(); + assertThat(persisted.checkInAt()).isEqualTo(clock.instant()); + assertThat(persisted.policy().id()).isEqualTo(1L); + assertThatThrownBy(() -> attendance.checkIn(internId)).isInstanceOf(AttendanceException.class); + + clock.set(Instant.parse("2026-08-14T09:00:00Z")); + attendance.checkOut(internId); + assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.CHECKED_OUT); + + AttendanceHistoryItem item = attendance.history( + new AttendanceActor(internId, AttendanceRole.INTERN), + internId, + LocalDate.of(2026, 8, 14), + LocalDate.of(2026, 8, 14)) + .getFirst(); + assertThat(item.policy().id()).isEqualTo(1L); + assertThat(item.policy().checkoutGraceMinutes()).isEqualTo(30); + assertThat(item.checkOutAt()).isEqualTo(clock.instant()); + assertThat(item.violations().missingCheckout()).isFalse(); + } + + @Test + void calendarDayOffBlocksCheckInAndPastEventsAreImmutable() { + AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN); + AttendanceActor intern = new AttendanceActor(internId, AttendanceRole.INTERN); + LocalDate workDate = LocalDate.of(2026, 8, 14); + clock.set(Instant.parse("2026-08-13T02:00:00Z")); + + assertThatThrownBy(() -> calendar.createManual(intern, workDate, "Blocked", true)) + .isInstanceOf(AccessDeniedException.class); + var event = calendar.createManual(admin, workDate, "Team holiday", true); + + clock.set(Instant.parse("2026-08-14T02:00:00Z")); + assertThatThrownBy(() -> attendance.checkIn(internId)).isInstanceOf(AttendanceException.class); + + clock.set(Instant.parse("2026-08-15T02:00:00Z")); + assertThatThrownBy(() -> calendar.updateManual( + admin, event.id(), event.version(), workDate, "Changed", false)) + .isInstanceOf(CalendarException.class); + } + + @Test + void calendarRejectsStaleOptimisticVersion() { + AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN); + LocalDate date = LocalDate.of(2026, 8, 20); + var event = calendar.createManual(admin, date, "Lab closure", true); + + calendar.updateManual(admin, event.id(), event.version(), date, "Lab open", false); + + assertThatThrownBy(() -> calendar.updateManual( + admin, event.id(), event.version(), date, "Stale edit", true)) + .isInstanceOf(CalendarException.class); + } + + @Test + void publicCalendarServiceReportsAuthoritativeDayOff() { + AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN); + LocalDate date = LocalDate.of(2026, 8, 20); + var event = calendar.createManual(admin, date, "Observance", false); + + assertThat(calendar.isGlobalDayOff(date)).isFalse(); + + calendar.updateManual(admin, event.id(), event.version(), date, "Lab closure", true); + assertThat(calendar.isGlobalDayOff(date)).isTrue(); + } + + @Test + void ownHistoryAndMentorAdminInspectionAreAuthorized() { + clock.set(Instant.parse("2026-08-14T02:00:00Z")); + attendance.checkIn(internId); + LocalDate date = LocalDate.of(2026, 8, 14); + + assertThat(attendance.history( + new AttendanceActor(internId, AttendanceRole.INTERN), internId, date, date)) + .hasSize(1); + assertThat(attendance.history( + new AttendanceActor(mentorId, AttendanceRole.MENTOR), internId, date, date)) + .hasSize(1); + assertThat(attendance.history( + new AttendanceActor(adminId, AttendanceRole.ADMIN), internId, date, date)) + .hasSize(1); + assertThatThrownBy(() -> attendance.history( + new AttendanceActor(internId + 100, AttendanceRole.INTERN), internId, date, date)) + .isInstanceOf(AccessDeniedException.class); + } + + @Test + void currentActorComesFromActiveNormalizedAccountServiceIdentity() { + assertThat(currentUsers.actor(() -> " INTERN@EXAMPLE.TEST ")) + .isEqualTo(new AttendanceActor(internId, AttendanceRole.INTERN)); + + assertThatThrownBy(() -> currentUsers.actor(() -> "missing@example.test")) + .isInstanceOf(AccessDeniedException.class); + } + + @TestConfiguration(proxyBeanMethods = false) + static class IntegrationConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer(DockerImageName.parse("postgres:18.4")); + } + + @Bean + @Primary + MutableClock mutableClock() { + return new MutableClock(Instant.parse("2026-08-14T00:00:00Z")); + } + + @Bean + @Primary + RecordingSmtpProbe recordingSmtpProbe() { + return new RecordingSmtpProbe(); + } + } + + static final class RecordingSmtpProbe implements SmtpProbe { + + private final List messages = new ArrayList<>(); + + @Override + public void send(SmtpConnection connection, String recipient, String subject, String body) { + messages.add(body); + } + + void clear() { + messages.clear(); + } + + String onlyActivationToken() { + assertThat(messages).hasSize(1); + String body = messages.getFirst(); + int tokenStart = body.indexOf("token="); + assertThat(tokenStart).isGreaterThanOrEqualTo(0); + return body.substring(tokenStart + "token=".length()).trim(); + } + } + + static final class MutableClock extends Clock { + + private Instant instant; + + MutableClock(Instant instant) { + this.instant = instant; + } + + void set(Instant instant) { + this.instant = instant; + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +} diff --git a/src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java similarity index 50% rename from src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java rename to src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java index 57ad526..537eceb 100644 --- a/src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java @@ -1,28 +1,30 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.service; -import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_IN; -import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_OUT; -import static com.lab.labtimesheet.attendance.AttendanceRejection.APPROVED_LEAVE; -import static com.lab.labtimesheet.attendance.AttendanceRejection.CHECKOUT_CUTOFF_PASSED; -import static com.lab.labtimesheet.attendance.AttendanceRejection.GLOBAL_DAY_OFF; -import static com.lab.labtimesheet.attendance.AttendanceRejection.INACTIVE_INTERN; -import static com.lab.labtimesheet.attendance.AttendanceRejection.NON_WORKDAY; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.ALREADY_CHECKED_IN; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.ALREADY_CHECKED_OUT; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.APPROVED_LEAVE; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.CHECKOUT_CUTOFF_PASSED; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.GLOBAL_DAY_OFF; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.INACTIVE_INTERN; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.NON_WORKDAY; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; +import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; +import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; import java.math.BigDecimal; -import java.time.Clock; import java.time.DayOfWeek; import java.time.Instant; import java.time.LocalDate; import java.time.LocalTime; import java.time.ZoneId; -import java.time.ZoneOffset; -import java.util.HashMap; -import java.util.Map; import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.Test; @@ -52,39 +54,48 @@ class AttendanceServiceTest { AttendancePolicy weekendOnly = policy(30, Set.of(DayOfWeek.SATURDAY)); assertCheckInRejected(NON_WORKDAY, activeDay(), weekendOnly); - InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository(); - AttendanceService service = serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), seededPolicy()); - service.checkIn(INTERN_ID); + AttendanceService service = new AttendanceService(); + AttendanceRecord existing = checkInAt("2026-08-14T01:30:00Z", activeDay(), seededPolicy()); - AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkIn(INTERN_ID)); + AttendanceException exception = assertThrows( + AttendanceException.class, + () -> service.checkIn( + INTERN_ID, + at("2026-08-14T01:30:00Z"), + seededPolicy(), + activeDay(), + Optional.of(existing))); assertEquals(ALREADY_CHECKED_IN, exception.rejection()); - assertEquals(1, repository.records.size()); } @Test void checkoutIsInclusiveAtCutoffAndCannotBeOverwritten() { - InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy()); - AttendanceService atCutoff = serviceAt("2026-08-14T09:00:00Z", repository, activeDay(), seededPolicy()); + AttendanceService service = new AttendanceService(); + AttendanceRecord checkedIn = checkedInRecord(seededPolicy()); - AttendanceRecord checkedOut = atCutoff.checkOut(INTERN_ID); + AttendanceRecord checkedOut = service.checkOut( + Optional.of(checkedIn), at("2026-08-14T09:00:00Z")); assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt()); - AttendanceService later = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy()); - AttendanceException repeated = assertThrows(AttendanceException.class, () -> later.checkOut(INTERN_ID)); + AttendanceException repeated = assertThrows( + AttendanceException.class, + () -> service.checkOut(Optional.of(checkedOut), at("2026-08-14T09:00:00.001Z"))); assertEquals(ALREADY_CHECKED_OUT, repeated.rejection()); - assertEquals(at("2026-08-14T09:00:00Z"), repository.record().checkOutAt()); + assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt()); } @Test void firstInstantAfterCheckoutCutoffIsRejectedWithoutRawCheckout() { - InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy()); - AttendanceService service = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy()); + AttendanceRecord checkedIn = checkedInRecord(seededPolicy()); + AttendanceService service = new AttendanceService(); - AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkOut(INTERN_ID)); + AttendanceException exception = assertThrows( + AttendanceException.class, + () -> service.checkOut(Optional.of(checkedIn), at("2026-08-14T09:00:00.001Z"))); assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection()); - assertNull(repository.record().checkOutAt()); - AttendanceViolations violations = repository.record().violations(at("2026-08-14T09:00:00.001Z")); + assertNull(checkedIn.checkOutAt()); + AttendanceViolations violations = checkedIn.violations(at("2026-08-14T09:00:00.001Z")); assertTrue(violations.missingCheckout()); assertFalse(violations.earlyDeparture()); } @@ -99,25 +110,26 @@ class AttendanceServiceTest { DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)); - InMemoryAttendanceRepository repository = checkedInRepository(zeroGrace); + AttendanceService service = new AttendanceService(); + AttendanceRecord checkedIn = checkedInRecord(zeroGrace); - AttendanceRecord checkedOut = serviceAt("2026-08-14T08:30:00Z", repository, activeDay(), zeroGrace) - .checkOut(INTERN_ID); + AttendanceRecord checkedOut = service.checkOut( + Optional.of(checkedIn), at("2026-08-14T08:30:00Z")); assertEquals(at("2026-08-14T08:30:00Z"), checkedOut.checkOutAt()); - InMemoryAttendanceRepository lateRepository = checkedInRepository(zeroGrace); + AttendanceRecord lateRecord = checkedInRecord(zeroGrace); AttendanceException exception = assertThrows( AttendanceException.class, - () -> serviceAt("2026-08-14T08:30:00.001Z", lateRepository, activeDay(), zeroGrace) - .checkOut(INTERN_ID)); + () -> service.checkOut(Optional.of(lateRecord), at("2026-08-14T08:30:00.001Z"))); assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection()); - assertNull(lateRepository.record().checkOutAt()); + assertNull(lateRecord.checkOutAt()); } private static AttendanceRecord checkInAt( String instant, AttendanceDayContext context, AttendancePolicy policy) { - return serviceAt(instant, new InMemoryAttendanceRepository(), context, policy).checkIn(INTERN_ID); + return new AttendanceService().checkIn( + INTERN_ID, at(instant), policy, context, Optional.empty()); } private static void assertCheckInRejected(AttendanceRejection rejection, AttendanceDayContext context) { @@ -128,27 +140,17 @@ class AttendanceServiceTest { AttendanceRejection rejection, AttendanceDayContext context, AttendancePolicy policy) { AttendanceException exception = assertThrows( AttendanceException.class, - () -> serviceAt("2026-08-14T01:30:00Z", new InMemoryAttendanceRepository(), context, policy) - .checkIn(INTERN_ID)); + () -> new AttendanceService().checkIn( + INTERN_ID, + at("2026-08-14T01:30:00Z"), + policy, + context, + Optional.empty())); assertEquals(rejection, exception.rejection()); } - private static AttendanceService serviceAt( - String instant, - InMemoryAttendanceRepository repository, - AttendanceDayContext context, - AttendancePolicy policy) { - return new AttendanceService( - Clock.fixed(at(instant), ZoneOffset.UTC), - new AttendancePolicyTimeline(Set.of(policy)), - repository, - (internId, date) -> context); - } - - private static InMemoryAttendanceRepository checkedInRepository(AttendancePolicy policy) { - InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository(); - serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), policy).checkIn(INTERN_ID); - return repository; + private static AttendanceRecord checkedInRecord(AttendancePolicy policy) { + return checkInAt("2026-08-14T01:30:00Z", activeDay(), policy); } private static AttendanceDayContext activeDay() { @@ -177,23 +179,4 @@ class AttendanceServiceTest { return Instant.parse(instant); } - private static final class InMemoryAttendanceRepository implements AttendanceRepository { - - private final Map records = new HashMap<>(); - - @Override - public Optional find(long internId, LocalDate workDate) { - return Optional.ofNullable(records.get(workDate)); - } - - @Override - public AttendanceRecord save(AttendanceRecord record) { - records.put(record.workDate(), record); - return record; - } - - private AttendanceRecord record() { - return records.get(WORKDAY); - } - } } From 8e786ba37ba7fcff09cf88d5951acb21fbb36ea8 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:45:34 +0700 Subject: [PATCH 20/62] feat(account): add activation and authentication web flow --- docs/tests/integration/account-activation.md | 2 +- docs/tests/web/account-activation-flow.md | 85 +++++++++ .../account/controller/AccountController.java | 85 +++++++++ .../templates/accounts/activate.html | 16 ++ .../resources/templates/accounts/new.html | 30 +++ src/main/resources/templates/home.html | 9 +- .../controller/AccountWebIntegrationTest.java | 173 ++++++++++++++++++ 7 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 docs/tests/web/account-activation-flow.md create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java create mode 100644 src/main/resources/templates/accounts/activate.html create mode 100644 src/main/resources/templates/accounts/new.html create mode 100644 src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java diff --git a/docs/tests/integration/account-activation.md b/docs/tests/integration/account-activation.md index cec2e4e..951613e 100644 --- a/docs/tests/integration/account-activation.md +++ b/docs/tests/integration/account-activation.md @@ -65,4 +65,4 @@ BUILD SUCCESS ## External-test boundaries -The recording SMTP boundary proves the exact in-memory handoff but not Mailpit/network delivery or a browser following the link. MVC activation forms, resend, password reset, session invalidation, lock/deactivation, and production origin/readiness hardening remain separate Iteration 1 or later slices. +The recording SMTP boundary proves the exact in-memory handoff but not Mailpit/network delivery. MVC creation, activation, login, role denial, and logout are covered separately by `AccountWebIntegrationTest`; resend, password reset, session invalidation after credential/state changes, lock/deactivation, and production origin/readiness hardening remain separate slices. diff --git a/docs/tests/web/account-activation-flow.md b/docs/tests/web/account-activation-flow.md new file mode 100644 index 0000000..924e103 --- /dev/null +++ b/docs/tests/web/account-activation-flow.md @@ -0,0 +1,85 @@ +# Test Evidence: Account creation, activation, authentication, and logout + +- **Test type:** Web +- **Requirement IDs:** `ACC-008–ACC-011, ACC-014, ACC-019, AUTH-001–AUTH-002, SEC-002–SEC-004` +- **Scenario IDs:** `AC-ACC-005, AC-ACC-007, AC-AUTH-001` +- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AccountWebIntegrationTest.adminCreatesMentorAndInternThenMentorActivatesAuthenticatesAndLogsOut` +- **Implementation commit:** `this milestone commit` + +## Protected behavior + +An authenticated Admin can use the account form to create pending Mentor and Intern accounts, the intended recipient can follow the emailed activation link and set a first password, normalized email login succeeds, a Mentor is denied the Admin account route, and logout clears authentication. Browser-submitted blank Intern fields do not prevent Mentor creation. + +## Test method + +MockMvc drives the production controllers, Thymeleaf templates, CSRF protection, Spring Security login/logout handlers, JPA services, and PostgreSQL 18.4. SMTP is replaced only at the network boundary by an in-memory recording probe. The test extracts the activation token from that immediate test message without logging or persisting the raw value, then exercises the public activation form. + +## Hand-derived expected result + +The Admin form returns 200. Mentor and Intern submissions redirect to `?created` and persist their immutable roles as pending accounts. Activation redirects to `/login?activated`; login with a case/whitespace variant authenticates the normalized Mentor identity. That session receives 403 at the Admin form and becomes unauthenticated after POST `/logout`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=AccountWebIntegrationTest test +``` + +**Observed result** + +```text +GET /admin/accounts/new resolved to ResourceHttpRequestHandler +Status expected:<200> but was:<404> +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +BUILD FAILURE +``` + +After the MVC boundary first reached GREEN, the test was tightened to submit blank Intern controls exactly as the browser form does and observed a second RED: + +```text +POST /admin/accounts returned accounts/new with +"Internship fields are allowed only for Intern accounts" +Range for response status value 200 expected: but was: +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=AccountWebIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## External-test boundaries + +This test does not contact Mailpit or an external SMTP server and is not a real browser/accessibility test. It does not cover activation resend, password reset, account lock/deactivation, session invalidation after credential/state changes, production origin configuration, containerization, CI, or deployment. diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java new file mode 100644 index 0000000..621eccf --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java @@ -0,0 +1,85 @@ +package com.lab.labtimesheet.feature.account.controller; + +import java.security.Principal; +import java.time.LocalDate; + +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 org.springframework.format.annotation.DateTimeFormat; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +class AccountController { + private final AccountService accounts; + + AccountController(AccountService accounts) { + this.accounts = accounts; + } + + @GetMapping("/admin/accounts/new") + String newAccount() { + return "accounts/new"; + } + + @PostMapping("/admin/accounts") + String create( + @RequestParam String email, + @RequestParam String displayName, + @RequestParam GlobalRole role, + @RequestParam(required = false) String studentCode, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipStart, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipEnd, + Principal principal, + Model model) { + try { + var result = accounts.create( + new CreateAccountCommand( + email, displayName, role, clean(studentCode), internshipStart, internshipEnd), + accounts.requireActiveAdminId(principal.getName())); + return result.deliverySucceeded() + ? "redirect:/admin/accounts/new?created" + : "redirect:/admin/accounts/new?deliveryFailed"; + } catch (IllegalArgumentException | IllegalStateException exception) { + model.addAttribute("error", exception.getMessage()); + return "accounts/new"; + } + } + + private static String clean(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + @GetMapping("/activate") + String activationForm(@RequestParam String token, Model model) { + model.addAttribute("token", token); + return "accounts/activate"; + } + + @PostMapping("/activate") + String activate( + @RequestParam String token, + @RequestParam String password, + @RequestParam String confirmPassword, + Model model) { + if (!password.equals(confirmPassword)) { + model.addAttribute("token", token); + model.addAttribute("error", "Passwords do not match"); + return "accounts/activate"; + } + try { + if (accounts.activate(token, password)) { + return "redirect:/login?activated"; + } + model.addAttribute("error", "This activation link is invalid or no longer usable"); + } catch (IllegalArgumentException exception) { + model.addAttribute("error", exception.getMessage()); + } + model.addAttribute("token", token); + return "accounts/activate"; + } +} diff --git a/src/main/resources/templates/accounts/activate.html b/src/main/resources/templates/accounts/activate.html new file mode 100644 index 0000000..69c785f --- /dev/null +++ b/src/main/resources/templates/accounts/activate.html @@ -0,0 +1,16 @@ + + +Activate account + +
+

Choose your password

+

+
+ + + + +
+
+ + diff --git a/src/main/resources/templates/accounts/new.html b/src/main/resources/templates/accounts/new.html new file mode 100644 index 0000000..8595aa5 --- /dev/null +++ b/src/main/resources/templates/accounts/new.html @@ -0,0 +1,30 @@ + + +Create account + +
+

Create account

+

Account created and activation email sent.

+

Account created, but activation delivery failed.

+

+
+ + + +
+ Intern details + + + +
+ +
+
+ + diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index 8f23b78..f0d1514 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -1,5 +1,10 @@ - + Lab Timesheet -

Lab Timesheet

+ +
+

Lab Timesheet

+
+
+ diff --git a/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java new file mode 100644 index 0000000..ba040d3 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java @@ -0,0 +1,173 @@ +package com.lab.labtimesheet.feature.account.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.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import java.util.ArrayList; +import java.util.List; + +import com.lab.labtimesheet.config.TestcontainersConfiguration; +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.account.service.BootstrapService; +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 jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.BeforeEach; +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.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@Import({TestcontainersConfiguration.class, AccountWebIntegrationTest.MailProbeConfiguration.class}) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AccountWebIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private BootstrapService bootstrap; + + @Autowired + private AccountService accounts; + + @Autowired + private SmtpConfigurationService smtp; + + @Autowired + private RecordingSmtpProbe mail; + + @BeforeEach + void initializeAdminAndSmtp() { + bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); + long adminId = accounts.requireActiveAdminId("admin@example.com"); + long draftId = smtp.saveDraft(adminId, new SmtpDraft( + "mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet")); + smtp.testDraft(draftId, adminId, "admin@example.com"); + smtp.activate(draftId, adminId); + mail.messages.clear(); + } + + @Test + void adminCreatesMentorAndInternThenMentorActivatesAuthenticatesAndLogsOut() throws Exception { + mockMvc.perform(get("/admin/accounts/new").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(view().name("accounts/new")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Internship start"))); + + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", " MENTOR@EXAMPLE.COM ") + .param("displayName", "Mentor One") + .param("role", "MENTOR") + .param("studentCode", "") + .param("internshipStart", "") + .param("internshipEnd", "")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/accounts/new?created")); + + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", "intern@example.com") + .param("displayName", "Intern One") + .param("role", "INTERN") + .param("studentCode", "STU-001") + .param("internshipStart", "2026-08-01") + .param("internshipEnd", "2026-12-31")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/accounts/new?created")); + + var pendingMentor = accounts.requireIdentityByEmail("mentor@example.com"); + assertThat(pendingMentor.role()).isEqualTo(GlobalRole.MENTOR); + assertThat(pendingMentor.status()).isEqualTo(AccountStatus.PENDING_ACTIVATION); + assertThat(accounts.requireIdentityByEmail("intern@example.com").role()).isEqualTo(GlobalRole.INTERN); + + String rawToken = mail.activationTokenFor("mentor@example.com"); + mockMvc.perform(get("/activate").param("token", rawToken)) + .andExpect(status().isOk()) + .andExpect(view().name("accounts/activate")); + mockMvc.perform(post("/activate") + .with(csrf()) + .param("token", rawToken) + .param("password", "new secure mentor password") + .param("confirmPassword", "new secure mentor password")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?activated")); + + var login = mockMvc.perform(post("/login") + .with(csrf()) + .param("username", " MENTOR@EXAMPLE.COM ") + .param("password", "new secure mentor password")) + .andExpect(status().is3xxRedirection()) + .andExpect(authenticated().withUsername("mentor@example.com")) + .andReturn(); + HttpSession session = login.getRequest().getSession(false); + assertThat(session).isNotNull(); + + mockMvc.perform(get("/admin/accounts/new").session((org.springframework.mock.web.MockHttpSession) session)) + .andExpect(status().isForbidden()); + mockMvc.perform(post("/logout") + .session((org.springframework.mock.web.MockHttpSession) session) + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?logout")) + .andExpect(unauthenticated()); + } + + @TestConfiguration(proxyBeanMethods = false) + static class MailProbeConfiguration { + @Bean + @Primary + RecordingSmtpProbe recordingSmtpProbe() { + return new RecordingSmtpProbe(); + } + } + + static final class RecordingSmtpProbe implements SmtpProbe { + private final List messages = new ArrayList<>(); + + @Override + public void send(SmtpConnection connection, String recipient, String subject, String body) { + messages.add(new Message(recipient, body)); + } + + 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) { + } +} From 7dd61b9dd40b8db6f29a3a15080bb0696cae5992 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:53:45 +0700 Subject: [PATCH 21/62] feat(ui): integrate account pages with shared shell --- docs/tests/web/account-shell-integration.md | 79 +++++++++++++++++++ src/main/frontend/app.css | 14 ++++ src/main/resources/static/assets/app.css | 2 +- .../templates/accounts/activate.html | 19 +++-- .../resources/templates/accounts/new.html | 43 ++++++---- .../resources/templates/dashboard/admin.html | 2 +- .../templates/fragments/auth-layout.html | 37 +++++++++ .../resources/templates/fragments/layout.html | 2 +- .../AccountTemplateIntegrationTest.java | 66 ++++++++++++++++ 9 files changed, 236 insertions(+), 28 deletions(-) create mode 100644 docs/tests/web/account-shell-integration.md create mode 100644 src/main/resources/templates/fragments/auth-layout.html create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/controller/AccountTemplateIntegrationTest.java diff --git a/docs/tests/web/account-shell-integration.md b/docs/tests/web/account-shell-integration.md new file mode 100644 index 0000000..c406e72 --- /dev/null +++ b/docs/tests/web/account-shell-integration.md @@ -0,0 +1,79 @@ +# Test Evidence: account shell integration + +- **Test type:** Web +- **Requirement IDs:** `UI-001`, `UI-002`, `UI-004`, `UI-009`, `I1-PLAT-06`, `I1-UI-04` +- **Scenario IDs:** `AC-UI-001`, `AC-UI-002`, `AC-UI-005` +- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AccountTemplateIntegrationTest` +- **Implementation commit:** `pending` + +## Protected behavior + +The authenticated account-creation page consumes the shared role-aware desktop shell and posts to the real account endpoint. The public activation page consumes the local themed authentication shell while preserving its single-use raw-token form contract. The Admin dashboard and navigation link to the implemented `/admin/accounts/new` route. + +## Test method + +A focused MockMvc slice renders both production account templates through a test-only controller. It asserts the authenticated and public shell markers, local pre-paint theme and CSS assets, real form actions, activation token retention, and the real account-creation URL. The existing PostgreSQL Account web flow then exercises account creation, activation, authentication, authorization, and logout through the production controller and services. + +## Hand-derived expected result + +The account-creation response contains `app-shell`, posts to `/admin/accounts`, and exposes `/admin/accounts/new` as the account navigation target. The activation response contains `auth-shell`, posts to `/activate`, retains `raw-token`, and loads `/assets/theme.js` before `/assets/app.css`. Existing account lifecycle and Admin dashboard requests remain successful on PostgreSQL. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AccountTemplateIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 2, Failures: 2, Errors: 0, Skipped: 0 +AccountTemplateIntegrationTest.accountCreationUsesAuthenticatedShellAndRealAccountRoute expected class="app-shell" +AccountTemplateIntegrationTest.activationUsesPublicAuthShellAndLocalAssets expected class="auth-shell" +BUILD FAILURE +Total time: 4.763 s +``` + +Both production templates were standalone documents and did not consume either shared layout. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AccountTemplateIntegrationTest,DashboardTemplateWebTest,UiContractWebTest test +``` + +**Observed result** + +```text +Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 3.710 s +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=AccountWebIntegrationTest,AdminDashboardWebTest test + +PostgreSQL 18.4 +Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 12.219 s +``` + +## External-test boundaries + +The checks prove server rendering, local asset wiring, security-aware account navigation, and the complete account lifecycle through MockMvc/PostgreSQL. They do not replace a real-browser visual check of theme paint timing, password-manager behavior, or desktop overflow. diff --git a/src/main/frontend/app.css b/src/main/frontend/app.css index 9643fd6..d789ab5 100644 --- a/src/main/frontend/app.css +++ b/src/main/frontend/app.css @@ -64,6 +64,13 @@ @layer components { .app-shell { display: grid; grid-template-columns: 16rem minmax(0, 1fr); min-height: 100vh; } + .auth-shell { min-height: 100vh; } + .auth-header { display: flex; min-height: 4rem; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); padding: .75rem 1.25rem; } + .auth-theme { width: 9rem; } + .auth-main { display: grid; min-height: calc(100vh - 4rem); place-items: center; padding: 2rem; } + .auth-card { width: min(100%, 28rem); border: 1px solid var(--border); border-radius: .85rem; background: var(--panel); padding: 1.5rem; box-shadow: 0 16px 42px rgb(20 25 35 / .08); } + .auth-eyebrow { margin: 0 0 .35rem; color: var(--muted); font-size: .72rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } + .auth-form { margin-top: 1.25rem; } [data-sidebar-collapsed="true"] .app-shell { grid-template-columns: 4rem minmax(0, 1fr); } .sidebar { position: sticky; top: 0; display: flex; height: 100vh; flex-direction: column; border-right: 1px solid var(--border); background: var(--sidebar); padding: 1rem .75rem; } .brand, .account { display: flex; align-items: center; gap: .7rem; min-width: 0; padding: .25rem .4rem; } @@ -104,6 +111,13 @@ .metric-value { margin-top: .35rem; font-size: 1.4rem; font-weight: 700; font-variant-numeric: tabular-nums; } .metric-detail { margin-top: .18rem; color: var(--muted); font-size: .78rem; } .field { display: grid; gap: .35rem; } + .form-panel { margin-top: 1rem; padding: 1rem; } + .form-grid { display: grid; gap: 1rem; } + .form-grid-three { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .form-section { border: 1px solid var(--border); border-radius: .65rem; padding: 1rem; } + .form-section legend { padding: 0 .35rem; font-weight: 700; } + .field-help { margin: 0 0 .8rem; color: var(--muted); font-size: .78rem; } + .form-actions { display: flex; justify-content: flex-end; gap: .6rem; } .field-label { font-size: .78rem; font-weight: 650; } .control { min-height: 2.45rem; width: 100%; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .55rem .65rem; } .control[aria-invalid="true"] { border-color: var(--danger); } diff --git a/src/main/resources/static/assets/app.css b/src/main/resources/static/assets/app.css index f7a5021..0af1fb7 100644 --- a/src/main/resources/static/assets/app.css +++ b/src/main/resources/static/assets/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.static{position:static}.border{border-style:var(--tw-border-style);border-width:1px}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.hidden{display:none}.table{display:table}.border{border-style:var(--tw-border-style);border-width:1px}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/src/main/resources/templates/accounts/activate.html b/src/main/resources/templates/accounts/activate.html index 69c785f..0a36661 100644 --- a/src/main/resources/templates/accounts/activate.html +++ b/src/main/resources/templates/accounts/activate.html @@ -1,15 +1,18 @@ - -Activate account +
-

Choose your password

-

-
+

Use at least 12 characters. This activation link can be used once.

+ + - - - +
+
+
diff --git a/src/main/resources/templates/accounts/new.html b/src/main/resources/templates/accounts/new.html index 8595aa5..7b4475b 100644 --- a/src/main/resources/templates/accounts/new.html +++ b/src/main/resources/templates/accounts/new.html @@ -1,29 +1,38 @@ - -Create account + +Back to overview
-

Create account

-

Account created and activation email sent.

-

Account created, but activation delivery failed.

-

-
- - -
+
+
+ - -
+
+
Intern details - - - +

Required only when the selected role is Intern.

+
+
+
+
+
- +
Cancel
diff --git a/src/main/resources/templates/dashboard/admin.html b/src/main/resources/templates/dashboard/admin.html index 95ec5c4..5f9df4d 100644 --- a/src/main/resources/templates/dashboard/admin.html +++ b/src/main/resources/templates/dashboard/admin.html @@ -7,7 +7,7 @@ primaryAction=~{::#primary-action}, content=~{::main})}"> -Create account +Create account

Account readiness, internship activity, and active Project work.

diff --git a/src/main/resources/templates/fragments/auth-layout.html b/src/main/resources/templates/fragments/auth-layout.html new file mode 100644 index 0000000..711bec1 --- /dev/null +++ b/src/main/resources/templates/fragments/auth-layout.html @@ -0,0 +1,37 @@ + + + + + + Lab Timesheet + + + + + +
+
+ + + Lab Timesheet + +
+ + +
+
+
+
+

Account access

+

Page

+ +
+
+
+ + diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 27acb9e..c4fae17 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -21,7 +21,7 @@ diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java new file mode 100644 index 0000000..feb3ab8 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java @@ -0,0 +1,73 @@ +package com.lab.labtimesheet.feature.reporting.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@WebMvcTest(AttendanceTemplateIntegrationTest.TemplateController.class) +@Import(AttendanceTemplateIntegrationTest.TemplateController.class) +class AttendanceTemplateIntegrationTest { + + private final MockMvc mvc; + + @Autowired + AttendanceTemplateIntegrationTest(MockMvc mvc) { + this.mvc = mvc; + } + + @Test + void internHistoryUsesSharedShellAndPreservesPunchActions() throws Exception { + mvc.perform(get("/template-contract/attendance/history") + .with(user("intern@example.test").roles("INTERN"))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("class=\"app-shell\""))) + .andExpect(content().string(containsString("action=\"/attendance/check-in\""))) + .andExpect(content().string(containsString("action=\"/attendance/check-out\""))) + .andExpect(content().string(containsString("No attendance records in this period"))) + .andExpect(content().string(containsString("src=\"/assets/theme.js\""))); + } + + @Test + void adminCalendarUsesSharedShellAndPreservesEventForm() throws Exception { + mvc.perform(get("/template-contract/attendance/calendar") + .with(user("admin@example.test").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("class=\"app-shell\""))) + .andExpect(content().string(containsString("action=\"/attendance/calendar\""))) + .andExpect(content().string(containsString("No upcoming calendar events"))) + .andExpect(content().string(containsString("src=\"/assets/theme.js\""))); + } + + @Controller + public static class TemplateController { + + @GetMapping("/template-contract/attendance/history") + String history(Model model) { + model.addAttribute("ownHistory", true); + model.addAttribute("from", LocalDate.of(2026, 8, 1)); + model.addAttribute("to", LocalDate.of(2026, 8, 31)); + model.addAttribute("items", List.of()); + return "attendance/history"; + } + + @GetMapping("/template-contract/attendance/calendar") + String calendar(Model model) { + model.addAttribute("today", LocalDate.of(2026, 8, 15)); + model.addAttribute("events", List.of()); + return "attendance/calendar"; + } + } +} From 3d954dd512761546c4213762cc696fe0ab59022d Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:18:19 +0700 Subject: [PATCH 29/62] fix(project): preserve completed task history --- .../project/service/ProjectQueryService.java | 18 ++++++++- .../ProjectServiceIntegrationTest.java | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java index 1867eda..3a3cf45 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -71,7 +71,9 @@ public class ProjectQueryService { @Transactional(readOnly = true) public List members(long actorUserId, long projectId) { var project = visibleProject(actorUserId, projectId); - var leaderUserId = project.currentLeader().internUserId(); + Long leaderUserId = project.status() == ProjectStatus.COMPLETED + ? null + : project.currentLeader().internUserId(); return project.memberships().stream() .map(membership -> new ProjectMemberView( membership.id(), @@ -79,7 +81,9 @@ public class ProjectQueryService { displayName(membership.internUserId()), membership.joinedAt(), membership.leftAt(), - membership.isCurrent() && membership.internUserId() == leaderUserId)) + membership.isCurrent() + && leaderUserId != null + && membership.internUserId() == leaderUserId)) .toList(); } @@ -103,6 +107,16 @@ public class ProjectQueryService { ProjectTaskContext taskContext(long actorUserId, ProjectEntity project) { requireVisibleProject(actorUserId, project); + if (project.status() == ProjectStatus.COMPLETED) { + return new ProjectTaskContext( + project.id(), + project.mentorUserId(), + project.status().name(), + project.startDate(), + project.endDate(), + null, + List.of()); + } var activeMembers = project.memberships().stream() .filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId())) .map(membership -> new ProjectTaskMemberView( diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java index a353e45..d98b3f7 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java @@ -1,6 +1,7 @@ package com.lab.labtimesheet.feature.project.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -178,6 +179,42 @@ class ProjectServiceIntegrationTest { assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount()); } + @Test + void completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader() { + long mentorId = user("mentor-history@example.test", "MENTOR"); + long leaderId = intern("leader-history@example.test", "I012"); + long memberId = intern("member-history@example.test", "I013"); + long projectId = createProject(mentorId, leaderId, "Completed history"); + projectService.addMember(mentorId, projectId, memberId); + var activatedAt = dbTime(NOW.plusSeconds(30)); + var completedAt = dbTime(NOW.plusSeconds(60)); + jdbc.update(""" + update project_leadership_terms + set ended_at = ?, ended_by_mentor_user_id = ? + where project_id = ? and ended_at is null + """, completedAt, mentorId, projectId); + jdbc.update(""" + update project_memberships + set left_at = ?, removed_by_mentor_user_id = ?, updated_at = ? + where project_id = ? and left_at is null + """, completedAt, mentorId, completedAt, projectId); + jdbc.update(""" + update projects + set status = 'COMPLETED', activated_at = ?, completed_at = ?, updated_at = ? + where id = ? + """, activatedAt, completedAt, completedAt, projectId); + entityManager.clear(); + + var taskContext = projectPages.taskContext(memberId, projectId); + assertEquals("COMPLETED", taskContext.status()); + assertNull(taskContext.currentLeaderMembershipId()); + assertEquals(List.of(), taskContext.activeMembers()); + var members = projectPages.members(memberId, projectId); + assertEquals(2, members.size()); + assertTrue(members.stream().allMatch(member -> member.leftAt() != null)); + assertTrue(members.stream().noneMatch(member -> member.currentLeader())); + } + private long createProject(long mentorId, long leaderId, String name) { return projectService.create( mentorId, From e1aa8eb062e32a46b1b6e7afcfc99e446ca16711 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:19:00 +0700 Subject: [PATCH 30/62] docs(project): record completed history evidence --- .../integration/projects-completed-history.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/tests/integration/projects-completed-history.md diff --git a/docs/tests/integration/projects-completed-history.md b/docs/tests/integration/projects-completed-history.md new file mode 100644 index 0000000..8586b76 --- /dev/null +++ b/docs/tests/integration/projects-completed-history.md @@ -0,0 +1,77 @@ +# Test Evidence: Completed Project member and Task history + +- **Test type:** Integration +- **Requirement IDs:** `AUTH-006`, `PRJ-014` +- **Scenario IDs:** `AC-AUTH-007` +- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader` +- **Implementation commit:** `3d954dd` + +## Protected behavior + +After Project completion closes every membership and leadership interval, a historical member can still retrieve read-only Task context and member history. The Task context reports no current Leader and no active members, and every historical member row reports `currentLeader=false`. + +## Test method + +The Spring Boot integration test creates a Project and second member through public Project services, then uses direct SQL only as a fixture to reproduce the Iteration 2 completion result: all leadership and membership intervals are closed and the Project is marked `COMPLETED`. After clearing the persistence context, it calls the public Project query APIs as the former member and checks the DTOs. + +## Hand-derived expected result + +A completed Project cannot have a current Leader or active member. Therefore `ProjectTaskContext.currentLeaderMembershipId` is `null`, `activeMembers` is empty, both membership-history rows remain visible, both have leave timestamps, and neither is marked as current Leader. + +## 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=ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader test +``` + +**Observed result** + +```text +[ERROR] ProjectRuleViolationException: Project has no current Leader + at com.lab.labtimesheet.feature.project.model.entity.ProjectEntity.currentLeader(ProjectEntity.java:232) + at com.lab.labtimesheet.feature.project.service.ProjectQueryService.taskContext(ProjectQueryService.java:113) +[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 +[INFO] BUILD FAILURE +``` + +## 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=ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## 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='Project*Test' test + +[INFO] Tests run: 21, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This regression proves completed-state DTO behavior against PostgreSQL 18.4. It does not implement or test the future Project-completion mutation itself, browser rendering, or Task-owned authorization and presentation; direct SQL is confined to constructing the completed aggregate fixture. From c4656a88806a92cb59b2e588035a4124854feb92 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:22:21 +0700 Subject: [PATCH 31/62] fix(account): route login landing to dashboard --- .../web/authenticated-dashboard-landing.md | 78 +++++++++++++++++++ .../account/controller/HomeController.java | 2 +- src/main/resources/templates/home.html | 10 --- .../AuthenticationWebIntegrationTest.java | 4 + 4 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 docs/tests/web/authenticated-dashboard-landing.md delete mode 100644 src/main/resources/templates/home.html diff --git a/docs/tests/web/authenticated-dashboard-landing.md b/docs/tests/web/authenticated-dashboard-landing.md new file mode 100644 index 0000000..31c4a27 --- /dev/null +++ b/docs/tests/web/authenticated-dashboard-landing.md @@ -0,0 +1,78 @@ +# Test Evidence: Authenticated dashboard landing + +- **Test type:** Web +- **Requirement IDs:** `I1-UI-03, I1-UI-04` +- **Scenario IDs:** `I1-UI-04 authentication integration follow-up` +- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AuthenticationWebIntegrationTest.projectLoginPageSupportsFailureNormalizedSuccessAndLogout` +- **Implementation commit:** `this milestone commit` + +## Protected behavior + +Successful database authentication retains the established `/` success target, and an authenticated GET `/` immediately redirects to the shared role-dashboard route `/dashboard` instead of rendering a standalone dead-end page. Login failure, normalized-email authentication, CSRF, and logout remain covered by the same production-shaped flow. + +## Test method + +MockMvc logs in through the production Spring Security filter chain using a case-and-whitespace variant of the bootstrapped Admin email. It reuses the resulting authenticated session for GET `/` and asserts the redirect target. The same test continues through the production logout handler. PostgreSQL 18.4 backs the account and session authentication setup. + +## Hand-derived expected result + +The successful form login redirects to `/`. Following that landing URL with the authenticated session returns a 3xx response whose location is `/dashboard`; it does not resolve `home.html`. Logout still redirects to `/login?logout` and clears authentication. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=AuthenticationWebIntegrationTest test +``` + +**Observed result** + +```text +Authenticated GET / invoked HomeController#home and rendered view "home". +Response status was 200; expected a 3xx redirect to /dashboard. +AuthenticationWebIntegrationTest.java:76 expected: but was: +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=AuthenticationWebIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +Tests run: 11, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## External-test boundaries + +The `/dashboard` endpoint and its role-specific content remain owned and tested by Reporting. This test proves only the authenticated platform handoff to that route. It is not a real-browser/accessibility test and does not change dashboard styling, authorization, account activation, or email delivery. diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java index ac6a4e5..053a38f 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java @@ -7,6 +7,6 @@ import org.springframework.web.bind.annotation.GetMapping; class HomeController { @GetMapping("/") String home() { - return "home"; + return "redirect:/dashboard"; } } diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html deleted file mode 100644 index f0d1514..0000000 --- a/src/main/resources/templates/home.html +++ /dev/null @@ -1,10 +0,0 @@ - - -Lab Timesheet - -
-

Lab Timesheet

-
-
- - diff --git a/src/test/java/com/lab/labtimesheet/feature/account/controller/AuthenticationWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/controller/AuthenticationWebIntegrationTest.java index f2f4537..627a999 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/controller/AuthenticationWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/controller/AuthenticationWebIntegrationTest.java @@ -72,6 +72,10 @@ class AuthenticationWebIntegrationTest { .andReturn(); var session = (MockHttpSession) login.getRequest().getSession(false); + mockMvc.perform(get("/").session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dashboard")); + mockMvc.perform(post("/logout").session(session).with(csrf())) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/login?logout")) From 511ee81a91a79a61cc6afb00097e1b38577c1968 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:22:49 +0700 Subject: [PATCH 32/62] feat(task): adopt JPA feature boundaries --- docs/tests/integration/task-workflow.md | 27 +- docs/tests/unit/task-dashboard-query.md | 74 +++ docs/tests/unit/task-mutation-boundary.md | 77 +++ docs/tests/unit/task-persistence-structure.md | 88 ++++ .../unit/task-project-activation-query.md | 74 +++ docs/tests/unit/task-status-progress.md | 4 +- docs/tests/web/task-pages.md | 14 +- .../task/controller}/TaskController.java | 9 +- .../exception}/TaskNotFoundException.java | 2 +- .../exception}/TaskValidationException.java | 2 +- .../task/model}/TaskProgress.java | 2 +- .../task/model}/TaskStatus.java | 2 +- .../task/model/dto}/CreateTaskCommand.java | 2 +- .../task/model/dto}/TaskAssigneeChoice.java | 2 +- .../task/model/dto}/TaskCommentView.java | 2 +- .../task/model/dto}/TaskCreateForm.java | 2 +- .../task/model/dto/TaskDashboardView.java | 13 + .../feature/task/model/dto/TaskDetails.java | 14 + .../task/model/dto}/TaskListView.java | 5 +- .../task/model/dto/TaskPriorityView.java | 10 + .../task/model/dto}/TaskView.java | 4 +- .../feature/task/model/entity/Task.java | 145 ++++++ .../task/model/entity/TaskComment.java | 59 +++ .../repository/TaskCommentRepository.java | 10 + .../task/repository/TaskRepository.java | 56 +++ .../task/service/TaskDashboardService.java | 86 ++++ .../task/service/TaskQueryService.java | 24 + .../feature/task/service/TaskService.java | 323 +++++++++++++ .../lab/labtimesheet/tasks/TaskDetails.java | 10 - .../lab/labtimesheet/tasks/TaskService.java | 438 ------------------ .../resources/templates/tasks/detail.html | 5 +- src/main/resources/templates/tasks/list.html | 5 +- .../task/controller}/TaskControllerTest.java | 56 ++- .../task/model}/TaskDomainRulesTest.java | 2 +- .../TaskPersistenceStructureTest.java | 70 +++ .../service}/TaskCreationIntegrationTest.java | 214 ++++++++- .../service/TaskDashboardServiceTest.java | 108 +++++ .../service/TaskMutationBoundaryTest.java | 135 ++++++ .../task/service/TaskQueryServiceTest.java | 41 ++ 39 files changed, 1722 insertions(+), 494 deletions(-) create mode 100644 docs/tests/unit/task-dashboard-query.md create mode 100644 docs/tests/unit/task-mutation-boundary.md create mode 100644 docs/tests/unit/task-persistence-structure.md create mode 100644 docs/tests/unit/task-project-activation-query.md rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/controller}/TaskController.java (89%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/exception}/TaskNotFoundException.java (85%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/exception}/TaskValidationException.java (85%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model}/TaskProgress.java (95%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model}/TaskStatus.java (89%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/CreateTaskCommand.java (79%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/TaskAssigneeChoice.java (58%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/TaskCommentView.java (71%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/TaskCreateForm.java (89%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/TaskListView.java (52%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/TaskView.java (73%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java delete mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java delete mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskService.java rename src/test/java/com/lab/labtimesheet/{tasks => feature/task/controller}/TaskControllerTest.java (67%) rename src/test/java/com/lab/labtimesheet/{tasks => feature/task/model}/TaskDomainRulesTest.java (97%) create mode 100644 src/test/java/com/lab/labtimesheet/feature/task/repository/TaskPersistenceStructureTest.java rename src/test/java/com/lab/labtimesheet/{ => feature/task/service}/TaskCreationIntegrationTest.java (64%) create mode 100644 src/test/java/com/lab/labtimesheet/feature/task/service/TaskDashboardServiceTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/task/service/TaskMutationBoundaryTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/task/service/TaskQueryServiceTest.java diff --git a/docs/tests/integration/task-workflow.md b/docs/tests/integration/task-workflow.md index 0ac76ea..6d87de9 100644 --- a/docs/tests/integration/task-workflow.md +++ b/docs/tests/integration/task-workflow.md @@ -1,23 +1,25 @@ # Test Evidence: Iteration 1 Task persistence and authorization - **Test type:** Integration -- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-007`–`AUTH-009`, `AUTH-011`, `PRJ-013`, `PRJ-015`, `PRJ-016`, `TSK-001`–`TSK-005`, `TSK-007`, `TSK-008`, `TSK-011`, `TSK-012`, `TSK-018` -- **Scenario IDs:** `I1-TSK-01`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-003`–`AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` -- **Test class/method:** `com.lab.labtimesheet.TaskCreationIntegrationTest` +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`–`AUTH-009`, `AUTH-011`, `PRJ-013`, `PRJ-015`, `PRJ-016`, `TSK-001`–`TSK-005`, `TSK-007`, `TSK-008`, `TSK-011`, `TSK-012`, `TSK-018` +- **Scenario IDs:** `I1-TSK-01`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-003`–`AC-AUTH-007`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` +- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskCreationIntegrationTest` - **Implementation commit:** `pending` ## Protected behavior -PostgreSQL-backed Task operations preserve generic same-Project membership actors, limit ordinary members to self-Task creation, allow current Leaders to assign active same-Project members, validate due dates, restrict status changes to the active current assignee, append authorized comments, exclude deleted Tasks from current reads/progress, render empty progress as absent, and deny guessed/cross-Project identifiers without writes. +PostgreSQL-backed Task operations preserve generic same-Project membership actors, limit ordinary members to self-Task creation, allow current Leaders to assign active same-Project members, validate due dates, restrict status changes to the active current assignee, append authorized comments, exclude deleted Tasks from current reads/progress, render assignee names and empty progress, deny guessed/cross-Project identifiers without writes, give former members read-only access only after completion, and execute the Project-activation and dashboard Task queries. ## Test method -Nine transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. +Thirteen transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. ## Hand-derived expected result A self-Task stores one membership in creator, assigner, and assignee fields. A Leader-created Task retains the Leader membership as creator/assigner and the selected member as assignee. Project start/end due dates are valid; dates before, after, or on a current global day off are invalid. Only an active Project's current assignee can traverse an allowed status edge. Authorized member/Mentor comments append two rows. Four current Tasks with one in each status produce 25% and four unit counts; a deleted fifth Task is absent; zero Tasks has no percentage. +A former member cannot read Task data while the Project remains planned or active, but can read the completed Project history. List/detail views resolve the assignee display name from the Project service boundary, and their capability flags match current membership, assignment, role, and Project lifecycle. + ## RED **Command** @@ -51,6 +53,17 @@ After creation reached GREEN, the next cohesive workflow increment was separatel [INFO] BUILD FAILURE ``` +The review-hardening increment was also observed RED before its implementation. The former-member PostgreSQL regression reached the old list behavior instead of throwing, and the view-contract tests could not compile because `assigneeName`, `canCreate`, `canChangeStatus`, and `canComment` did not exist. + +The second review then made the completed-history fixture production-shaped by closing the current leadership term and all memberships. That focused test was observed RED because the Project read boundary still required `currentLeader()` for a completed Project: + +```text +[ERROR] TaskCreationIntegrationTest.formerMemberReadsOnlyCompletedProjectTaskHistory + » TaskNotFound Task or Project was not found +[INFO] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 +[INFO] BUILD FAILURE +``` + ## GREEN **Command** @@ -65,7 +78,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -79,7 +92,7 @@ export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 107, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` diff --git a/docs/tests/unit/task-dashboard-query.md b/docs/tests/unit/task-dashboard-query.md new file mode 100644 index 0000000..ec7dc52 --- /dev/null +++ b/docs/tests/unit/task-dashboard-query.md @@ -0,0 +1,74 @@ +# Test Evidence: Role-correct Task dashboard query + +- **Test type:** Unit +- **Requirement IDs:** `AUTH-003`–`AUTH-005`, `AUTH-009`, `TSK-001`, `TSK-002`, `TSK-004`, `PRJ-016` +- **Scenario IDs:** `I1-UI-03` +- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskDashboardServiceTest` +- **Implementation commit:** `pending` + +## Protected behavior + +The public Task dashboard service reports blocked Tasks only for a Mentor's active owned Projects. For an Intern, it excludes former/completed memberships, counts current assigned Tasks, and returns at most five priority Tasks ordered by due date with null dates last and Task ID as the stable tie-breaker. + +## Test method + +Two focused Mockito tests provide Project service DTOs and verify the Task service result. The repository remains mocked so the test isolates role/project/member filtering and the Task-owned dashboard DTO boundary; PostgreSQL query ordering is verified by the affected integration suite. + +## Hand-derived expected result + +A Mentor with one active and one planned Project receives the active Project's four blocked Tasks only. An Intern with one current active membership, one former membership, and one completed Project receives six assigned Tasks and the due-first Task from the current Project. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskDashboardServiceTest test +``` + +**Observed result** + +```text +[ERROR] TaskDashboardServiceTest.java:[34,13] cannot find symbol + symbol: class TaskDashboardService +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskDashboardServiceTest test +``` + +Run with approved sandbox escalation for Mockito Java 25 self-attach. + +**Observed result** + +```text +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## 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=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test + +[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This test does not prove the shared dashboard controller/template, which belongs to `work/reports-ui`. PostgreSQL ordering, soft-delete filtering, and repository query syntax remain integration-test concerns. diff --git a/docs/tests/unit/task-mutation-boundary.md b/docs/tests/unit/task-mutation-boundary.md new file mode 100644 index 0000000..c2a787e --- /dev/null +++ b/docs/tests/unit/task-mutation-boundary.md @@ -0,0 +1,77 @@ +# Test Evidence: Task mutation authorization and locking boundary + +- **Test type:** Unit +- **Requirement IDs:** `AUTH-011`, `TSK-003`, `TSK-007`, `TSK-012`, `TSK-018` +- **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`, `I1-TSK-04`, `AC-AUTH-010`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` +- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskMutationBoundaryTest` +- **Implementation commit:** `pending` + +## Protected behavior + +Every Task create/status/comment mutation first asks the concrete Project service for a current authorization context while holding the Project row lock. Status and comment mutations then load the Task with `PESSIMISTIC_WRITE` before checking or changing Task state. + +## Test method + +Three focused Mockito tests verify call order for create, status, and comment. They prove the Project mutation context precedes the Task write, the unlocked Project query is not used for create, and status/comment use the locked Task lookup before mutation. `TaskPersistenceStructureTest` separately inspects the real repository method's lock annotation, while the PostgreSQL workflow suite executes the query. + +## Hand-derived expected result + +Create calls `ProjectService.taskMutationContext(5, 10)` before saving. Status and comment call that same Project boundary, then `TaskRepository.findLockedByIdAndProjectIdAndDeletedAtIsNull(25, 10)`, before changing status or appending the comment. The Project service joins the outer Task transaction, so both locks remain through commit or rollback. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskMutationBoundaryTest test +``` + +**Observed result** + +```text +[ERROR] constructor TaskService ... cannot be applied to given types + required: TaskRepository,TaskCommentRepository,ProjectQueryService,CalendarApplicationService,Clock + found: TaskRepository,TaskCommentRepository,ProjectQueryService,ProjectService,CalendarApplicationService,Clock +[ERROR] cannot find symbol + symbol: method findLockedByIdAndProjectIdAndDeletedAtIsNull(long,long) +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskMutationBoundaryTest test +``` + +Run with approved sandbox escalation for Mockito Java 25 self-attach. + +**Observed result** + +```text +[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## 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=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test + +[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +These unit tests establish service call order and repository lock metadata; they do not simulate two concurrent database transactions. PostgreSQL execution of the locked Task lookup is covered by `TaskCreationIntegrationTest`, and the producing Project feature separately proves its locked DTO boundary. Broader concurrency stress remains the explicit Iteration 3 hardening scope. diff --git a/docs/tests/unit/task-persistence-structure.md b/docs/tests/unit/task-persistence-structure.md new file mode 100644 index 0000000..bd0c930 --- /dev/null +++ b/docs/tests/unit/task-persistence-structure.md @@ -0,0 +1,88 @@ +# Test Evidence: Task feature persistence structure + +- **Test type:** Unit +- **Requirement IDs:** `TSK-001`–`TSK-005`, `TSK-007`, `TSK-011`, `TSK-012` +- **Scenario IDs:** `I1-TSK-01`–`I1-TSK-04` +- **Test class/method:** `com.lab.labtimesheet.feature.task.repository.TaskPersistenceStructureTest#taskPersistenceUsesJpaEntitiesAndSpringDataRepositories` +- **Implementation commit:** `pending` + +## Protected behavior + +Task persistence uses JPA entities in `feature.task.model.entity` and Spring Data repositories in `feature.task.repository`. Status/comment mutation lookup is protected by `PESSIMISTIC_WRITE`. This prevents a regression to business-level JDBC access, unlocked mutation reads, or a global layer package. + +## Test method + +Four focused tests load the production `Task` and `TaskComment` classes, verify their `@Entity` annotations, verify that both production repository interfaces extend `JpaRepository`, reject direct JDBC imports in Task business code, and inspect the locked lookup's `@Lock(PESSIMISTIC_WRITE)` annotation. + +## Hand-derived expected result + +Exactly two Task-owned persisted aggregates are required for Iteration 1: `Task` and append-only `TaskComment`. Each must be a JPA entity, and each repository must be a Spring Data JPA repository under the Task feature package. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskPersistenceStructureTest test +``` + +**Observed result** + +```text +[ERROR] TaskPersistenceStructureTest.java:[5,54] package com.lab.labtimesheet.feature.task.model.entity does not exist +[ERROR] TaskPersistenceStructureTest.java:[6,54] package com.lab.labtimesheet.feature.task.model.entity does not exist +[INFO] BUILD FAILURE +``` + +The final feature-first package contract did not yet exist. + +After that package move reached GREEN, the business-persistence boundary was tightened with a second test and separately observed RED: + +```text +[ERROR] Tests run: 3, Failures: 2, Errors: 0, Skipped: 0 +Expecting [org.springframework.jdbc.core.simple.JdbcClient] +to contain [TaskRepository, TaskCommentRepository] +Expecting empty but was: [src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java] +[INFO] BUILD FAILURE +``` + +The second failure proves that `TaskService` still depended on direct JDBC instead of the two Task-owned Spring Data repositories. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskPersistenceStructureTest test +``` + +**Observed result** + +```text +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## 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=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test + +[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +The suite ran with approved escalation for PostgreSQL 18.4 Testcontainers and Mockito Java 25 self-attach. + +## External-test boundaries + +This structure test does not prove persistence mappings against PostgreSQL, transactional authorization, cross-feature service contracts, or rendered behavior. Those remain protected by the Task integration and web evidence after the dependency foundations are merged. diff --git a/docs/tests/unit/task-project-activation-query.md b/docs/tests/unit/task-project-activation-query.md new file mode 100644 index 0000000..db0dc09 --- /dev/null +++ b/docs/tests/unit/task-project-activation-query.md @@ -0,0 +1,74 @@ +# Test Evidence: Project activation Task-assignment query + +- **Test type:** Unit +- **Requirement IDs:** `PRJ-012` +- **Scenario IDs:** `I1-PRJ-04`, `AC-PRJ-006` +- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskQueryServiceTest` +- **Implementation commit:** `pending` + +## Protected behavior + +The Project feature can ask the public Task service whether any current non-deleted Task is assigned outside the Project's active membership set, without accessing Task repositories or entities. + +## Test method + +Two focused Mockito tests exercise the concrete public service. An empty active-membership set counts every current Task without issuing an invalid `NOT IN ()` query. A non-empty set delegates to the filtered Spring Data repository query. + +## Hand-derived expected result + +With no active memberships, all three current Tasks are invalid assignments. With active memberships 7 and 9, the repository-derived count of assignments outside that set is two. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskQueryServiceTest test +``` + +**Observed result** + +```text +[ERROR] TaskQueryServiceTest.java:[22,13] cannot find symbol + symbol: class TaskQueryService +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskQueryServiceTest test +``` + +Run with approved sandbox escalation for Mockito Java 25 self-attach. + +**Observed result** + +```text +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## 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=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test + +[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This unit test does not prove the JPQL query against PostgreSQL or Project activation integration. The Task PostgreSQL suite and the Project feature's own integration tests cover those boundaries after dependency merge. diff --git a/docs/tests/unit/task-status-progress.md b/docs/tests/unit/task-status-progress.md index 9e31804..1b2691f 100644 --- a/docs/tests/unit/task-status-progress.md +++ b/docs/tests/unit/task-status-progress.md @@ -3,7 +3,7 @@ - **Test type:** Unit - **Requirement IDs:** `TSK-007`, `TSK-008`, `PRJ-015`, `PRJ-016` - **Scenario IDs:** `I1-TSK-03`, `I1-TSK-05`, `AC-TSK-003`, `AC-PRJ-008` -- **Test class/method:** `com.lab.labtimesheet.tasks.TaskDomainRulesTest` +- **Test class/method:** `com.lab.labtimesheet.feature.task.model.TaskDomainRulesTest` - **Implementation commit:** `17a3c5d` ## Protected behavior @@ -66,7 +66,7 @@ export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -[INFO] Tests run: 19, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 107, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` diff --git a/docs/tests/web/task-pages.md b/docs/tests/web/task-pages.md index d7db642..eaf24a3 100644 --- a/docs/tests/web/task-pages.md +++ b/docs/tests/web/task-pages.md @@ -3,21 +3,23 @@ - **Test type:** Web - **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-007`, `TSK-011`, `TSK-012` - **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` -- **Test class/method:** `com.lab.labtimesheet.tasks.TaskControllerTest` +- **Test class/method:** `com.lab.labtimesheet.feature.task.controller.TaskControllerTest` - **Implementation commit:** `pending` ## Protected behavior -Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, and show `N/A` for an empty Project. +Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, show `N/A` for an empty Project, display assignees, and expose create/status/comment controls only when the service-provided capability permits them. ## Test method -Six `@WebMvcTest` MockMvc tests render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, and exception-to-status mapping. Only the PostgreSQL-backed Task service is replaced at the controller boundary. +Nine `@WebMvcTest` MockMvc tests render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, exception-to-status mapping, assignee output, and capability-controlled actions. Only the PostgreSQL-backed Task service is replaced at the controller boundary. ## Hand-derived expected result Unauthenticated list access returns 401 under the current platform security baseline. An authorized empty list returns 200 and contains `N/A`. A denied guessed Task returns 404. A valid create request passes Project 10, assignee membership 7, the supplied fields, and the authenticated email to the service, then redirects to Task 25. Blank title stays on the form with a field error and no write. Valid status/comment posts redirect to Task 25. +When `canCreate`, `canChangeStatus`, or `canComment` is false, the corresponding control is absent. When true, it is rendered. Both list and detail output the assignee display name. + ## RED **Command** @@ -38,6 +40,8 @@ export PATH="$JAVA_HOME/bin:$PATH" The first sandboxed GREEN attempt then exposed an environment boundary, not an application failure: Mockito could not use Java 25 self-attach inside the restricted sandbox. The exact same command was rerun with approved escalation; one test expectation was corrected from a login redirect to the platform baseline's observed 401 response before the final GREEN run. +The later view-capability increment was observed RED at test compilation because the Task DTOs did not yet provide the required capability and assignee fields. + ## GREEN **Command** @@ -53,7 +57,7 @@ Run with approved sandbox escalation for Mockito Java 25 self-attach. **Observed result** ```text -[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -67,7 +71,7 @@ export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 107, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskController.java b/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java similarity index 89% rename from src/main/java/com/lab/labtimesheet/tasks/TaskController.java rename to src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java index 0188b1a..6838216 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskController.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java @@ -1,5 +1,12 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.controller; +import com.lab.labtimesheet.feature.task.model.TaskProgress; +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand; +import com.lab.labtimesheet.feature.task.model.dto.TaskCreateForm; +import com.lab.labtimesheet.feature.task.model.dto.TaskListView; +import com.lab.labtimesheet.feature.task.model.dto.TaskView; +import com.lab.labtimesheet.feature.task.service.TaskService; import jakarta.validation.Valid; import java.util.Locale; import org.springframework.security.core.Authentication; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java similarity index 85% rename from src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java rename to src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java index dde94dd..00a77bc 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java similarity index 85% rename from src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java rename to src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java index c32968b..0c2b66e 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java similarity index 95% rename from src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java index 632d8e7..80fefbe 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model; import java.util.Collection; import java.util.OptionalDouble; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java similarity index 89% rename from src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java index 5aff97a..363accf 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model; public enum TaskStatus { TODO, diff --git a/src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java similarity index 79% rename from src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java index d10fc00..7ebbd28 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; import java.time.LocalDate; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java similarity index 58% rename from src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java index 6e09bb1..d67e530 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java @@ -1,3 +1,3 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; public record TaskAssigneeChoice(long membershipId, String displayName) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java similarity index 71% rename from src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java index 25be01e..1d3f800 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; import java.time.Instant; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java similarity index 89% rename from src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java index fb0a92d..efe9815 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java new file mode 100644 index 0000000..d7ac141 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java @@ -0,0 +1,13 @@ +package com.lab.labtimesheet.feature.task.model.dto; + +import java.util.List; + +public record TaskDashboardView( + long blockedTaskCount, + long assignedTaskCount, + List priorityTasks) { + + public TaskDashboardView { + priorityTasks = List.copyOf(priorityTasks); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java new file mode 100644 index 0000000..9f9a91d --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.feature.task.model.dto; + +import java.util.List; + +public record TaskDetails( + TaskView task, + List comments, + boolean canChangeStatus, + boolean canComment) { + + public TaskDetails { + comments = List.copyOf(comments); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskListView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java similarity index 52% rename from src/main/java/com/lab/labtimesheet/tasks/TaskListView.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java index 1acfed2..8049554 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskListView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java @@ -1,8 +1,9 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; +import com.lab.labtimesheet.feature.task.model.TaskProgress; import java.util.List; -public record TaskListView(List tasks, TaskProgress progress) { +public record TaskListView(List tasks, TaskProgress progress, boolean canCreate) { public TaskListView { tasks = List.copyOf(tasks); diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java new file mode 100644 index 0000000..30d09a7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.feature.task.model.dto; + +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import java.time.LocalDate; + +public record TaskPriorityView( + String title, + String projectName, + TaskStatus status, + LocalDate dueDate) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java similarity index 73% rename from src/main/java/com/lab/labtimesheet/tasks/TaskView.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java index 4b0fb48..a466eee 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java @@ -1,5 +1,6 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; +import com.lab.labtimesheet.feature.task.model.TaskStatus; import java.time.Instant; import java.time.LocalDate; @@ -7,6 +8,7 @@ public record TaskView( long id, long projectId, long assigneeMembershipId, + String assigneeName, String title, String description, TaskStatus status, diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java new file mode 100644 index 0000000..59564d3 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java @@ -0,0 +1,145 @@ +package com.lab.labtimesheet.feature.task.model.entity; + +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "tasks") +public class Task { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "project_id", nullable = false) + private long projectId; + + @Column(name = "assignee_membership_id", nullable = false) + private long assigneeMembershipId; + + @Column(nullable = false, length = 200) + private String title; + + @Column(columnDefinition = "text") + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 24) + private TaskStatus status; + + @Column(name = "due_date") + private LocalDate dueDate; + + @Column(name = "assigned_at", nullable = false) + private Instant assignedAt; + + @Column(name = "created_by_membership_id", nullable = false) + private long creatorMembershipId; + + @Column(name = "assigned_by_membership_id", nullable = false) + private long assignerMembershipId; + + @Column(name = "deleted_at") + private Instant deletedAt; + + @Column(name = "deleted_by_membership_id") + private Long deletedByMembershipId; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + private long version; + + protected Task() {} + + public Task( + long projectId, + long assigneeMembershipId, + String title, + String description, + LocalDate dueDate, + long actorMembershipId, + Instant now) { + this.projectId = projectId; + this.assigneeMembershipId = assigneeMembershipId; + this.title = title; + this.description = description; + this.status = TaskStatus.TODO; + this.dueDate = dueDate; + this.assignedAt = now; + this.creatorMembershipId = actorMembershipId; + this.assignerMembershipId = actorMembershipId; + this.createdAt = now; + this.updatedAt = now; + } + + public void changeStatus(TaskStatus target, Instant now) { + if (!status.canTransitionTo(target)) { + throw new IllegalArgumentException("Task status transition is not allowed"); + } + status = target; + updatedAt = now; + } + + public Long getId() { + return id; + } + + public long getProjectId() { + return projectId; + } + + public long getAssigneeMembershipId() { + return assigneeMembershipId; + } + + public String getTitle() { + return title; + } + + public String getDescription() { + return description; + } + + public TaskStatus getStatus() { + return status; + } + + public LocalDate getDueDate() { + return dueDate; + } + + public Instant getAssignedAt() { + return assignedAt; + } + + public long getCreatorMembershipId() { + return creatorMembershipId; + } + + public long getAssignerMembershipId() { + return assignerMembershipId; + } + + public Instant getDeletedAt() { + return deletedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java new file mode 100644 index 0000000..6e3b15e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java @@ -0,0 +1,59 @@ +package com.lab.labtimesheet.feature.task.model.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "task_comments") +public class TaskComment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "task_id", nullable = false) + private long taskId; + + @Column(name = "author_user_id", nullable = false) + private long authorUserId; + + @Column(nullable = false, columnDefinition = "text") + private String body; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected TaskComment() {} + + public TaskComment(long taskId, long authorUserId, String body, Instant createdAt) { + this.taskId = taskId; + this.authorUserId = authorUserId; + this.body = body; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public long getTaskId() { + return taskId; + } + + public long getAuthorUserId() { + return authorUserId; + } + + public String getBody() { + return body; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java new file mode 100644 index 0000000..f0073fc --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.feature.task.repository; + +import com.lab.labtimesheet.feature.task.model.entity.TaskComment; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TaskCommentRepository extends JpaRepository { + + List findAllByTaskIdOrderByCreatedAtAscIdAsc(long taskId); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java new file mode 100644 index 0000000..1a8708e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java @@ -0,0 +1,56 @@ +package com.lab.labtimesheet.feature.task.repository; + +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import com.lab.labtimesheet.feature.task.model.entity.Task; +import jakarta.persistence.LockModeType; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface TaskRepository extends JpaRepository { + + Optional findByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findLockedByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId); + + List findAllByProjectIdAndDeletedAtIsNullOrderById(long projectId); + + long countByProjectIdAndDeletedAtIsNull(long projectId); + + long countByProjectIdInAndStatusAndDeletedAtIsNull(List projectIds, TaskStatus status); + + long countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull( + List projectIds, List assigneeMembershipIds); + + @Query(""" + select task + from Task task + where task.projectId in :projectIds + and task.assigneeMembershipId in :assigneeMembershipIds + and task.deletedAt is null + order by case when task.dueDate is null then 1 else 0 end, + task.dueDate, + task.id + """) + List findPriorityTasks( + @Param("projectIds") List projectIds, + @Param("assigneeMembershipIds") List assigneeMembershipIds, + Pageable pageable); + + @Query(""" + select count(task) + from Task task + where task.projectId = :projectId + and task.deletedAt is null + and task.assigneeMembershipId not in :activeMembershipIds + """) + long countCurrentTasksAssignedOutside( + @Param("projectId") long projectId, + @Param("activeMembershipIds") Set activeMembershipIds); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java new file mode 100644 index 0000000..9540de2 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java @@ -0,0 +1,86 @@ +package com.lab.labtimesheet.feature.task.service; + +import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView; +import com.lab.labtimesheet.feature.project.service.ProjectQueryService; +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import com.lab.labtimesheet.feature.task.model.dto.TaskDashboardView; +import com.lab.labtimesheet.feature.task.model.dto.TaskPriorityView; +import com.lab.labtimesheet.feature.task.model.entity.Task; +import com.lab.labtimesheet.feature.task.repository.TaskRepository; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class TaskDashboardService { + + private static final TaskDashboardView EMPTY_DASHBOARD = new TaskDashboardView(0, 0, List.of()); + + private final TaskRepository tasks; + private final ProjectQueryService projects; + + public TaskDashboardService(TaskRepository tasks, ProjectQueryService projects) { + this.tasks = tasks; + this.projects = projects; + } + + @Transactional(readOnly = true) + public TaskDashboardView dashboard(String actorEmail) { + var actor = projects.authenticatedActor(actorEmail); + List activeProjects = projects.listVisible(actor.userId()).stream() + .filter(project -> "ACTIVE".equals(project.status())) + .toList(); + if ("MENTOR".equals(actor.role())) { + return mentorDashboard(activeProjects); + } + if ("INTERN".equals(actor.role())) { + return internDashboard(actor.userId(), activeProjects); + } + return EMPTY_DASHBOARD; + } + + private TaskDashboardView mentorDashboard(List activeProjects) { + List projectIds = activeProjects.stream().map(ProjectSummary::id).toList(); + long blocked = projectIds.isEmpty() + ? 0 + : tasks.countByProjectIdInAndStatusAndDeletedAtIsNull(projectIds, TaskStatus.BLOCKED); + return new TaskDashboardView(blocked, 0, List.of()); + } + + private TaskDashboardView internDashboard(long actorUserId, List activeProjects) { + Map currentProjects = new LinkedHashMap<>(); + Map currentMemberships = new LinkedHashMap<>(); + for (ProjectSummary project : activeProjects) { + projects.taskContext(actorUserId, project.id()).activeMembers().stream() + .filter(member -> member.userId() == actorUserId) + .map(ProjectTaskMemberView::membershipId) + .findFirst() + .ifPresent(membershipId -> { + currentProjects.put(project.id(), project); + currentMemberships.put(project.id(), membershipId); + }); + } + List projectIds = List.copyOf(currentProjects.keySet()); + List membershipIds = List.copyOf(currentMemberships.values()); + if (projectIds.isEmpty()) { + return EMPTY_DASHBOARD; + } + + long assigned = tasks.countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull( + projectIds, membershipIds); + List priority = tasks.findPriorityTasks( + projectIds, membershipIds, PageRequest.of(0, 5)) + .stream() + .map(task -> priorityView(task, currentProjects.get(task.getProjectId()).name())) + .toList(); + return new TaskDashboardView(0, assigned, priority); + } + + private static TaskPriorityView priorityView(Task task, String projectName) { + return new TaskPriorityView(task.getTitle(), projectName, task.getStatus(), task.getDueDate()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java new file mode 100644 index 0000000..e469cf3 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java @@ -0,0 +1,24 @@ +package com.lab.labtimesheet.feature.task.service; + +import com.lab.labtimesheet.feature.task.repository.TaskRepository; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class TaskQueryService { + + private final TaskRepository tasks; + + public TaskQueryService(TaskRepository tasks) { + this.tasks = tasks; + } + + @Transactional(readOnly = true) + public long countCurrentTasksAssignedOutside(long projectId, Set activeMembershipIds) { + if (activeMembershipIds.isEmpty()) { + return tasks.countByProjectIdAndDeletedAtIsNull(projectId); + } + return tasks.countCurrentTasksAssignedOutside(projectId, Set.copyOf(activeMembershipIds)); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java new file mode 100644 index 0000000..c8a44a8 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java @@ -0,0 +1,323 @@ +package com.lab.labtimesheet.feature.task.service; + +import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService; +import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; +import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView; +import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView; +import com.lab.labtimesheet.feature.project.service.ProjectQueryService; +import com.lab.labtimesheet.feature.project.service.ProjectService; +import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException; +import com.lab.labtimesheet.feature.task.exception.TaskValidationException; +import com.lab.labtimesheet.feature.task.model.TaskProgress; +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand; +import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice; +import com.lab.labtimesheet.feature.task.model.dto.TaskCommentView; +import com.lab.labtimesheet.feature.task.model.dto.TaskDetails; +import com.lab.labtimesheet.feature.task.model.dto.TaskListView; +import com.lab.labtimesheet.feature.task.model.dto.TaskView; +import com.lab.labtimesheet.feature.task.model.entity.Task; +import com.lab.labtimesheet.feature.task.model.entity.TaskComment; +import com.lab.labtimesheet.feature.task.repository.TaskCommentRepository; +import com.lab.labtimesheet.feature.task.repository.TaskRepository; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class TaskService { + + private final TaskRepository tasks; + private final TaskCommentRepository comments; + private final ProjectQueryService projects; + private final ProjectService projectMutations; + private final CalendarApplicationService calendar; + private final Clock clock; + + public TaskService( + TaskRepository tasks, + TaskCommentRepository comments, + ProjectQueryService projects, + ProjectService projectMutations, + CalendarApplicationService calendar, + Clock clock) { + this.tasks = tasks; + this.comments = comments; + this.projects = projects; + this.projectMutations = projectMutations; + this.calendar = calendar; + this.clock = clock; + } + + @Transactional + public TaskView create(String actorEmail, CreateTaskCommand command) { + String title = requireTitle(command.title()); + TaskAccess access = requireMutationAccess(actorEmail, command.projectId()); + requireOpenProject(access.project()); + ProjectTaskMemberView actorMembership = requireActorMembership( + access.project(), access.actor().userId()); + ProjectTaskMemberView assignee = requireAssigneeMembership( + access.project(), command.assigneeMembershipId()); + if (actorMembership.membershipId() != assignee.membershipId() + && !Objects.equals(access.project().currentLeaderMembershipId(), actorMembership.membershipId())) { + throw new TaskNotFoundException(); + } + validateDueDate(access.project(), command.dueDate()); + + Task task = new Task( + access.project().projectId(), + assignee.membershipId(), + title, + trimToNull(command.description()), + command.dueDate(), + actorMembership.membershipId(), + clock.instant()); + return view(tasks.saveAndFlush(task), assignee.displayName()); + } + + @Transactional + public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) { + TaskAccess access = requireMutationAccess(actorEmail, projectId); + if (!"ACTIVE".equals(access.project().status())) { + throw new TaskNotFoundException(); + } + ProjectTaskMemberView actorMembership = requireActorMembership( + access.project(), access.actor().userId()); + Task task = requireLockedTask(projectId, taskId); + if (task.getAssigneeMembershipId() != actorMembership.membershipId()) { + throw new TaskNotFoundException(); + } + if (!task.getStatus().canTransitionTo(target)) { + throw new TaskValidationException("Task status transition is not allowed"); + } + task.changeStatus(target, clock.instant()); + return view(tasks.saveAndFlush(task), actorMembership.displayName()); + } + + @Transactional + public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) { + String normalizedBody = requireCommentBody(body); + TaskAccess access = requireMutationAccess(actorEmail, projectId); + if ("COMPLETED".equals(access.project().status())) { + throw new TaskNotFoundException(); + } + boolean owningMentor = access.actor().userId() == access.project().mentorUserId(); + boolean activeMember = access.project().activeMembers().stream() + .anyMatch(member -> member.userId() == access.actor().userId()); + if (!owningMentor && !activeMember) { + throw new TaskNotFoundException(); + } + requireLockedTask(projectId, taskId); + + TaskComment comment = new TaskComment( + taskId, access.actor().userId(), normalizedBody, clock.instant()); + return view(comments.saveAndFlush(comment)); + } + + @Transactional(readOnly = true) + public TaskListView list(String actorEmail, long projectId) { + TaskAccess access = requireReadableProject(actorEmail, projectId); + Map members = projectMembers(access); + List projectTasks = tasks.findAllByProjectIdAndDeletedAtIsNullOrderById(projectId) + .stream() + .map(task -> view(task, requireAssigneeName(members, task.getAssigneeMembershipId()))) + .toList(); + return new TaskListView( + projectTasks, + TaskProgress.from(projectTasks.stream().map(TaskView::status).toList()), + isOpen(access.project()) && activeMembership(access) != null); + } + + @Transactional(readOnly = true) + public TaskDetails details(String actorEmail, long projectId, long taskId) { + TaskAccess access = requireReadableProject(actorEmail, projectId); + ProjectTaskMemberView actorMembership = activeMembership(access); + Task persistedTask = requireTask(projectId, taskId); + TaskView task = view( + persistedTask, + requireAssigneeName(projectMembers(access), persistedTask.getAssigneeMembershipId())); + List taskComments = comments.findAllByTaskIdOrderByCreatedAtAscIdAsc(taskId) + .stream() + .map(TaskService::view) + .toList(); + boolean canChangeStatus = "ACTIVE".equals(access.project().status()) + && actorMembership != null + && persistedTask.getAssigneeMembershipId() == actorMembership.membershipId(); + boolean canComment = !"COMPLETED".equals(access.project().status()) + && (access.actor().userId() == access.project().mentorUserId() || actorMembership != null); + return new TaskDetails(task, taskComments, canChangeStatus, canComment); + } + + @Transactional(readOnly = true) + public List assignmentChoices(String actorEmail, long projectId) { + TaskAccess access = requireProjectAccess(actorEmail, projectId); + requireOpenProject(access.project()); + ProjectTaskMemberView actorMembership = requireActorMembership( + access.project(), access.actor().userId()); + if (Objects.equals(access.project().currentLeaderMembershipId(), actorMembership.membershipId())) { + return access.project().activeMembers().stream() + .map(member -> new TaskAssigneeChoice(member.membershipId(), member.displayName())) + .toList(); + } + return List.of(new TaskAssigneeChoice( + actorMembership.membershipId(), actorMembership.displayName())); + } + + private TaskAccess requireProjectAccess(String actorEmail, long projectId) { + try { + ProjectActorView actor = projects.authenticatedActor(actorEmail); + return new TaskAccess(actor, projects.taskContext(actor.userId(), projectId)); + } catch (ProjectAccessDeniedException | ProjectRuleViolationException exception) { + throw new TaskNotFoundException(); + } + } + + private TaskAccess requireMutationAccess(String actorEmail, long projectId) { + try { + ProjectActorView actor = projects.authenticatedActor(actorEmail); + return new TaskAccess(actor, projectMutations.taskMutationContext(actor.userId(), projectId)); + } catch (ProjectAccessDeniedException | ProjectRuleViolationException exception) { + throw new TaskNotFoundException(); + } + } + + private TaskAccess requireReadableProject(String actorEmail, long projectId) { + TaskAccess access = requireProjectAccess(actorEmail, projectId); + boolean historicalIntern = "INTERN".equals(access.actor().role()) + && access.project().activeMembers().stream() + .noneMatch(member -> member.userId() == access.actor().userId()); + if (historicalIntern && !"COMPLETED".equals(access.project().status())) { + throw new TaskNotFoundException(); + } + return access; + } + + private Map projectMembers(TaskAccess access) { + try { + return projects.members(access.actor().userId(), access.project().projectId()).stream() + .collect(Collectors.toUnmodifiableMap(ProjectMemberView::membershipId, Function.identity())); + } catch (ProjectAccessDeniedException | ProjectRuleViolationException exception) { + throw new TaskNotFoundException(); + } + } + + private static String requireAssigneeName(Map members, long membershipId) { + ProjectMemberView member = members.get(membershipId); + if (member == null) { + throw new TaskNotFoundException(); + } + return member.displayName(); + } + + private static ProjectTaskMemberView activeMembership(TaskAccess access) { + return access.project().activeMembers().stream() + .filter(member -> member.userId() == access.actor().userId()) + .findFirst() + .orElse(null); + } + + private Task requireTask(long projectId, long taskId) { + return tasks.findByIdAndProjectIdAndDeletedAtIsNull(taskId, projectId) + .orElseThrow(TaskNotFoundException::new); + } + + private Task requireLockedTask(long projectId, long taskId) { + return tasks.findLockedByIdAndProjectIdAndDeletedAtIsNull(taskId, projectId) + .orElseThrow(TaskNotFoundException::new); + } + + private static void requireOpenProject(ProjectTaskContext project) { + if (!isOpen(project)) { + throw new TaskNotFoundException(); + } + } + + private static boolean isOpen(ProjectTaskContext project) { + return "PLANNED".equals(project.status()) || "ACTIVE".equals(project.status()); + } + + private static ProjectTaskMemberView requireActorMembership(ProjectTaskContext project, long userId) { + return project.activeMembers().stream() + .filter(member -> member.userId() == userId) + .findFirst() + .orElseThrow(TaskNotFoundException::new); + } + + private static ProjectTaskMemberView requireAssigneeMembership(ProjectTaskContext project, long membershipId) { + return project.activeMembers().stream() + .filter(member -> member.membershipId() == membershipId) + .findFirst() + .orElseThrow(TaskNotFoundException::new); + } + + private void validateDueDate(ProjectTaskContext project, LocalDate dueDate) { + if (dueDate == null) { + return; + } + if (dueDate.isBefore(project.startDate()) || dueDate.isAfter(project.endDate())) { + throw new TaskValidationException("Due date must be within Project dates"); + } + if (calendar.isGlobalDayOff(dueDate)) { + throw new TaskValidationException("Due date cannot be a current global day off"); + } + } + + private static TaskView view(Task task, String assigneeName) { + return new TaskView( + task.getId(), + task.getProjectId(), + task.getAssigneeMembershipId(), + assigneeName, + task.getTitle(), + task.getDescription(), + task.getStatus(), + task.getDueDate(), + task.getCreatorMembershipId(), + task.getAssignerMembershipId(), + task.getAssignedAt(), + task.getCreatedAt()); + } + + private static TaskCommentView view(TaskComment comment) { + return new TaskCommentView( + comment.getId(), + comment.getTaskId(), + comment.getAuthorUserId(), + comment.getBody(), + comment.getCreatedAt()); + } + + private static String requireTitle(String title) { + String trimmed = trimToNull(title); + if (trimmed == null || trimmed.length() > 200) { + throw new TaskValidationException("Title is required and must not exceed 200 characters"); + } + return trimmed; + } + + private static String requireCommentBody(String body) { + String trimmed = trimToNull(body); + if (trimmed == null) { + throw new TaskValidationException("Comment body is required"); + } + return trimmed; + } + + private static String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private record TaskAccess(ProjectActorView actor, ProjectTaskContext project) {} +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java b/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java deleted file mode 100644 index 2cacfe7..0000000 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.lab.labtimesheet.tasks; - -import java.util.List; - -public record TaskDetails(TaskView task, List comments) { - - public TaskDetails { - comments = List.copyOf(comments); - } -} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java deleted file mode 100644 index 03fbe68..0000000 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java +++ /dev/null @@ -1,438 +0,0 @@ -package com.lab.labtimesheet.tasks; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.LocalDate; -import java.util.List; -import org.springframework.jdbc.core.simple.JdbcClient; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -public class TaskService { - - private final JdbcClient jdbc; - - public TaskService(JdbcClient jdbc) { - this.jdbc = jdbc; - } - - @Transactional - public TaskView create(String actorEmail, CreateTaskCommand command) { - String title = requireTitle(command.title()); - Actor actor = requireActiveActor(actorEmail); - Project project = requireOpenProject(command.projectId()); - long actorMembershipId = requireActorMembership(project.id(), actor.id()); - requireAssigneeMembership(project.id(), command.assigneeMembershipId()); - - if (actorMembershipId != command.assigneeMembershipId() - && !isCurrentLeader(project.id(), actorMembershipId)) { - throw new TaskNotFoundException(); - } - validateDueDate(project, command.dueDate()); - - long taskId = jdbc.sql(""" - insert into tasks - (project_id, assignee_membership_id, title, description, due_date, - created_by_membership_id, assigned_by_membership_id) - values (:projectId, :assigneeId, :title, :description, :dueDate, - :actorMembershipId, :actorMembershipId) - returning id - """) - .param("projectId", project.id()) - .param("assigneeId", command.assigneeMembershipId()) - .param("title", title) - .param("description", trimToNull(command.description())) - .param("dueDate", command.dueDate()) - .param("actorMembershipId", actorMembershipId) - .query(Long.class) - .single(); - return task(taskId); - } - - @Transactional - public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) { - Actor actor = requireActiveActor(actorEmail); - TaskView task = jdbc.sql(""" - select t.id, t.project_id, t.assignee_membership_id, t.title, t.description, - t.status, t.due_date, t.created_by_membership_id, - t.assigned_by_membership_id, t.assigned_at, t.created_at - from tasks t - join projects p on p.id = t.project_id - join project_memberships m on m.id = t.assignee_membership_id - and m.project_id = t.project_id - where t.id = :taskId - and t.project_id = :projectId - and t.deleted_at is null - and p.status = 'ACTIVE' - and m.intern_user_id = :actorId - and m.left_at is null - """) - .param("taskId", taskId) - .param("projectId", projectId) - .param("actorId", actor.id()) - .query(TaskService::mapTask) - .optional() - .orElseThrow(TaskNotFoundException::new); - if (!task.status().canTransitionTo(target)) { - throw new TaskValidationException("Task status transition is not allowed"); - } - jdbc.sql("update tasks set status = :status, updated_at = current_timestamp where id = :taskId") - .param("status", target.name()) - .param("taskId", taskId) - .update(); - return task(taskId); - } - - @Transactional - public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) { - String normalizedBody = requireCommentBody(body); - Actor actor = requireActiveActor(actorEmail); - ProjectAccess project = requireProjectAccess(projectId); - if ("COMPLETED".equals(project.status()) || !taskExists(projectId, taskId)) { - throw new TaskNotFoundException(); - } - if (actor.id() != project.mentorUserId() && !hasActiveMembership(projectId, actor.id())) { - throw new TaskNotFoundException(); - } - - long commentId = jdbc.sql(""" - insert into task_comments (task_id, author_user_id, body) - values (:taskId, :actorId, :body) - returning id - """) - .param("taskId", taskId) - .param("actorId", actor.id()) - .param("body", normalizedBody) - .query(Long.class) - .single(); - return comment(commentId); - } - - @Transactional(readOnly = true) - public TaskListView list(String actorEmail, long projectId) { - requireViewAccess(actorEmail, projectId); - List tasks = jdbc.sql(""" - select id, project_id, assignee_membership_id, title, description, status, - due_date, created_by_membership_id, assigned_by_membership_id, - assigned_at, created_at - from tasks - where project_id = :projectId and deleted_at is null - order by id - """) - .param("projectId", projectId) - .query(TaskService::mapTask) - .list(); - return new TaskListView(tasks, TaskProgress.from(tasks.stream().map(TaskView::status).toList())); - } - - @Transactional(readOnly = true) - public TaskDetails details(String actorEmail, long projectId, long taskId) { - requireViewAccess(actorEmail, projectId); - TaskView task = jdbc.sql(""" - select id, project_id, assignee_membership_id, title, description, status, - due_date, created_by_membership_id, assigned_by_membership_id, - assigned_at, created_at - from tasks - where id = :taskId and project_id = :projectId and deleted_at is null - """) - .param("taskId", taskId) - .param("projectId", projectId) - .query(TaskService::mapTask) - .optional() - .orElseThrow(TaskNotFoundException::new); - List comments = jdbc.sql(""" - select id, task_id, author_user_id, body, created_at - from task_comments - where task_id = :taskId - order by created_at, id - """) - .param("taskId", taskId) - .query(TaskService::mapComment) - .list(); - return new TaskDetails(task, comments); - } - - @Transactional(readOnly = true) - public List assignmentChoices(String actorEmail, long projectId) { - Actor actor = requireActiveActor(actorEmail); - requireOpenProject(projectId); - long actorMembershipId = requireActorMembership(projectId, actor.id()); - boolean leader = isCurrentLeader(projectId, actorMembershipId); - return jdbc.sql(""" - select m.id, u.display_name - from project_memberships m - join app_users u on u.id = m.intern_user_id - join intern_profiles i on i.user_id = m.intern_user_id - where m.project_id = :projectId - and m.left_at is null - and u.account_status = 'ACTIVE' - and i.internship_status = 'ACTIVE' - and (:leader or m.id = :actorMembershipId) - order by m.id - """) - .param("projectId", projectId) - .param("leader", leader) - .param("actorMembershipId", actorMembershipId) - .query((rs, rowNum) -> new TaskAssigneeChoice( - rs.getLong("id"), rs.getString("display_name"))) - .list(); - } - - private Actor requireActiveActor(String email) { - Actor actor = requireReadableActor(email); - if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) { - throw new TaskNotFoundException(); - } - return actor; - } - - private Actor requireReadableActor(String email) { - return jdbc.sql(""" - select u.id, u.global_role, i.internship_status - from app_users u - left join intern_profiles i on i.user_id = u.id - where lower(btrim(u.email)) = lower(btrim(:email)) - and u.account_status = 'ACTIVE' - """) - .param("email", email) - .query((rs, rowNum) -> new Actor( - rs.getLong("id"), - rs.getString("global_role"), - rs.getString("internship_status"))) - .optional() - .orElseThrow(TaskNotFoundException::new); - } - - private Project requireOpenProject(long projectId) { - return jdbc.sql(""" - select id, status, start_date, end_date - from projects - where id = :projectId and status in ('PLANNED', 'ACTIVE') - """) - .param("projectId", projectId) - .query((rs, rowNum) -> new Project( - rs.getLong("id"), - rs.getString("status"), - rs.getObject("start_date", LocalDate.class), - rs.getObject("end_date", LocalDate.class))) - .optional() - .orElseThrow(TaskNotFoundException::new); - } - - private long requireActorMembership(long projectId, long userId) { - return jdbc.sql(""" - select m.id - from project_memberships m - join app_users u on u.id = m.intern_user_id - join intern_profiles i on i.user_id = m.intern_user_id - where m.project_id = :projectId - and m.intern_user_id = :userId - and m.left_at is null - and u.account_status = 'ACTIVE' - and i.internship_status = 'ACTIVE' - """) - .param("projectId", projectId) - .param("userId", userId) - .query(Long.class) - .optional() - .orElseThrow(TaskNotFoundException::new); - } - - private void requireAssigneeMembership(long projectId, long membershipId) { - boolean exists = jdbc.sql(""" - select exists ( - select 1 - from project_memberships m - join app_users u on u.id = m.intern_user_id - join intern_profiles i on i.user_id = m.intern_user_id - where m.id = :membershipId - and m.project_id = :projectId - and m.left_at is null - and u.account_status = 'ACTIVE' - and i.internship_status = 'ACTIVE' - ) - """) - .param("membershipId", membershipId) - .param("projectId", projectId) - .query(Boolean.class) - .single(); - if (!exists) { - throw new TaskNotFoundException(); - } - } - - private boolean isCurrentLeader(long projectId, long membershipId) { - return jdbc.sql(""" - select exists ( - select 1 from project_leadership_terms - where project_id = :projectId - and membership_id = :membershipId - and ended_at is null - ) - """) - .param("projectId", projectId) - .param("membershipId", membershipId) - .query(Boolean.class) - .single(); - } - - private void requireViewAccess(String actorEmail, long projectId) { - Actor actor = requireReadableActor(actorEmail); - ProjectAccess project = requireProjectAccess(projectId); - if ("ADMIN".equals(actor.role()) || actor.id() == project.mentorUserId()) { - return; - } - boolean member = jdbc.sql(""" - select exists ( - select 1 from project_memberships - where project_id = :projectId - and intern_user_id = :actorId - and (:completed or left_at is null) - ) - """) - .param("projectId", projectId) - .param("actorId", actor.id()) - .param("completed", "COMPLETED".equals(project.status())) - .query(Boolean.class) - .single(); - if (!member) { - throw new TaskNotFoundException(); - } - } - - private ProjectAccess requireProjectAccess(long projectId) { - return jdbc.sql("select status, mentor_user_id from projects where id = :projectId") - .param("projectId", projectId) - .query((rs, rowNum) -> new ProjectAccess( - rs.getString("status"), rs.getLong("mentor_user_id"))) - .optional() - .orElseThrow(TaskNotFoundException::new); - } - - private boolean hasActiveMembership(long projectId, long actorId) { - return jdbc.sql(""" - select exists ( - select 1 from project_memberships - where project_id = :projectId - and intern_user_id = :actorId - and left_at is null - ) - """) - .param("projectId", projectId) - .param("actorId", actorId) - .query(Boolean.class) - .single(); - } - - private boolean taskExists(long projectId, long taskId) { - return jdbc.sql(""" - select exists ( - select 1 from tasks - where id = :taskId and project_id = :projectId and deleted_at is null - ) - """) - .param("taskId", taskId) - .param("projectId", projectId) - .query(Boolean.class) - .single(); - } - - private void validateDueDate(Project project, LocalDate dueDate) { - if (dueDate == null) { - return; - } - if (dueDate.isBefore(project.startDate()) || dueDate.isAfter(project.endDate())) { - throw new TaskValidationException("Due date must be within Project dates"); - } - boolean dayOff = jdbc.sql(""" - select exists ( - select 1 from global_calendar_events - where calendar_date = :dueDate and is_day_off = true - ) - """) - .param("dueDate", dueDate) - .query(Boolean.class) - .single(); - if (dayOff) { - throw new TaskValidationException("Due date cannot be a current global day off"); - } - } - - private TaskView task(long taskId) { - return jdbc.sql(""" - select id, project_id, assignee_membership_id, title, description, status, - due_date, created_by_membership_id, assigned_by_membership_id, - assigned_at, created_at - from tasks - where id = :taskId - """) - .param("taskId", taskId) - .query(TaskService::mapTask) - .single(); - } - - private TaskCommentView comment(long commentId) { - return jdbc.sql(""" - select id, task_id, author_user_id, body, created_at - from task_comments - where id = :commentId - """) - .param("commentId", commentId) - .query(TaskService::mapComment) - .single(); - } - - private static TaskView mapTask(ResultSet rs, int rowNum) throws SQLException { - return new TaskView( - rs.getLong("id"), - rs.getLong("project_id"), - rs.getLong("assignee_membership_id"), - rs.getString("title"), - rs.getString("description"), - TaskStatus.valueOf(rs.getString("status")), - rs.getObject("due_date", LocalDate.class), - rs.getLong("created_by_membership_id"), - rs.getLong("assigned_by_membership_id"), - rs.getTimestamp("assigned_at").toInstant(), - rs.getTimestamp("created_at").toInstant()); - } - - private static TaskCommentView mapComment(ResultSet rs, int rowNum) throws SQLException { - return new TaskCommentView( - rs.getLong("id"), - rs.getLong("task_id"), - rs.getLong("author_user_id"), - rs.getString("body"), - rs.getTimestamp("created_at").toInstant()); - } - - private static String requireTitle(String title) { - String trimmed = trimToNull(title); - if (trimmed == null || trimmed.length() > 200) { - throw new TaskValidationException("Title is required and must not exceed 200 characters"); - } - return trimmed; - } - - private static String trimToNull(String value) { - if (value == null || value.isBlank()) { - return null; - } - return value.trim(); - } - - private static String requireCommentBody(String body) { - String trimmed = trimToNull(body); - if (trimmed == null) { - throw new TaskValidationException("Comment body is required"); - } - return trimmed; - } - - private record Actor(long id, String role, String internshipStatus) {} - - private record Project(long id, String status, LocalDate startDate, LocalDate endDate) {} - - private record ProjectAccess(String status, long mentorUserId) {} -} diff --git a/src/main/resources/templates/tasks/detail.html b/src/main/resources/templates/tasks/detail.html index 1e9ba7d..07d2417 100644 --- a/src/main/resources/templates/tasks/detail.html +++ b/src/main/resources/templates/tasks/detail.html @@ -9,10 +9,11 @@

Task

No description

+

Assignee: Assignee

Status: TODO

Due date:

-
+ diff --git a/src/main/resources/templates/tasks/list.html b/src/main/resources/templates/tasks/list.html index 7b830e0..a4d468a 100644 --- a/src/main/resources/templates/tasks/list.html +++ b/src/main/resources/templates/tasks/list.html @@ -15,13 +15,14 @@
BLOCKED
0
DONE
0
-

Create Task

+

Create Task

- + + diff --git a/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java similarity index 67% rename from src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java rename to src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java index 6f69df5..c3ec13e 100644 --- a/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.controller; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -15,6 +15,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; +import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException; +import com.lab.labtimesheet.feature.task.model.TaskProgress; +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand; +import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice; +import com.lab.labtimesheet.feature.task.model.dto.TaskCommentView; +import com.lab.labtimesheet.feature.task.model.dto.TaskDetails; +import com.lab.labtimesheet.feature.task.model.dto.TaskListView; +import com.lab.labtimesheet.feature.task.model.dto.TaskView; +import com.lab.labtimesheet.feature.task.service.TaskService; import java.time.Instant; import java.time.LocalDate; import java.util.List; @@ -47,12 +57,25 @@ class TaskControllerTest { @Test void emptyTaskListRendersNotApplicableProgress() throws Exception { given(taskService.list(ACTOR_EMAIL, 10L)) - .willReturn(new TaskListView(List.of(), TaskProgress.from(List.of()))); + .willReturn(new TaskListView(List.of(), TaskProgress.from(List.of()), false)); mockMvc.perform(get("/projects/10/tasks").with(user(ACTOR_EMAIL))) .andExpect(status().isOk()) .andExpect(view().name("tasks/list")) - .andExpect(content().string(org.hamcrest.Matchers.containsString("N/A"))); + .andExpect(content().string(org.hamcrest.Matchers.containsString("N/A"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("Create Task")))); + } + + @Test + void taskListShowsAssigneeAndCreateActionOnlyWhenAllowed() throws Exception { + given(taskService.list(ACTOR_EMAIL, 10L)).willReturn(new TaskListView( + List.of(task(25L)), TaskProgress.from(List.of(TaskStatus.TODO)), true)); + + mockMvc.perform(get("/projects/10/tasks").with(user(ACTOR_EMAIL))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Member Name"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Create Task"))); } @Test @@ -123,10 +146,35 @@ class TaskControllerTest { .andExpect(redirectedUrl("/projects/10/tasks/25")); } + @Test + void taskDetailsHideUnavailableActionsAndShowAssignee() throws Exception { + given(taskService.details(ACTOR_EMAIL, 10L, 25L)) + .willReturn(new TaskDetails(task(25L), List.of(), false, false)); + + mockMvc.perform(get("/projects/10/tasks/25").with(user(ACTOR_EMAIL))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Member Name"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("Change status")))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("Add comment")))); + } + + @Test + void taskDetailsRenderAvailableActions() throws Exception { + given(taskService.details(ACTOR_EMAIL, 10L, 25L)) + .willReturn(new TaskDetails(task(25L), List.of(), true, true)); + + mockMvc.perform(get("/projects/10/tasks/25").with(user(ACTOR_EMAIL))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Change status"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Add comment"))); + } + private static TaskView task(long id) { Instant instant = Instant.parse("2026-08-14T10:00:00Z"); return new TaskView( - id, 10L, 7L, "Draft", "Notes", TaskStatus.TODO, + id, 10L, 7L, "Member Name", "Draft", "Notes", TaskStatus.TODO, LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant); } } diff --git a/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java b/src/test/java/com/lab/labtimesheet/feature/task/model/TaskDomainRulesTest.java similarity index 97% rename from src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java rename to src/test/java/com/lab/labtimesheet/feature/task/model/TaskDomainRulesTest.java index af42eaf..f5f6988 100644 --- a/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/task/model/TaskDomainRulesTest.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/com/lab/labtimesheet/feature/task/repository/TaskPersistenceStructureTest.java b/src/test/java/com/lab/labtimesheet/feature/task/repository/TaskPersistenceStructureTest.java new file mode 100644 index 0000000..658eeb6 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/task/repository/TaskPersistenceStructureTest.java @@ -0,0 +1,70 @@ +package com.lab.labtimesheet.feature.task.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.lab.labtimesheet.feature.task.model.entity.Task; +import com.lab.labtimesheet.feature.task.model.entity.TaskComment; +import com.lab.labtimesheet.feature.task.service.TaskService; +import jakarta.persistence.Entity; +import jakarta.persistence.LockModeType; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.jdbc.core.simple.JdbcClient; + +class TaskPersistenceStructureTest { + + @Test + void taskPersistenceUsesJpaEntitiesAndSpringDataRepositories() { + assertThat(Task.class).hasAnnotation(Entity.class); + assertThat(TaskComment.class).hasAnnotation(Entity.class); + assertThat(JpaRepository.class).isAssignableFrom(TaskRepository.class); + assertThat(JpaRepository.class).isAssignableFrom(TaskCommentRepository.class); + } + + @Test + void taskServiceUsesTaskRepositoriesInsteadOfDirectJdbcAccess() { + var constructorTypes = Arrays.stream(TaskService.class.getDeclaredConstructors()) + .flatMap(constructor -> Arrays.stream(constructor.getParameterTypes())) + .toList(); + + assertThat(constructorTypes) + .contains(TaskRepository.class, TaskCommentRepository.class) + .doesNotContain(JdbcClient.class); + } + + @Test + void taskMutationLookupUsesAPessimisticWriteLock() throws NoSuchMethodException { + var method = TaskRepository.class.getMethod( + "findLockedByIdAndProjectIdAndDeletedAtIsNull", long.class, long.class); + + Lock lock = method.getAnnotation(Lock.class); + assertThat(lock).isNotNull(); + assertThat(lock.value()).isEqualTo(LockModeType.PESSIMISTIC_WRITE); + } + + @Test + void taskBusinessCodeContainsNoDirectJdbcOrSqlImports() throws IOException { + Path taskSource = Path.of("src/main/java/com/lab/labtimesheet/feature/task"); + try (var sources = Files.walk(taskSource)) { + var directSqlSources = sources + .filter(path -> path.toString().endsWith(".java")) + .filter(path -> { + try { + String source = Files.readString(path); + return source.contains("import org.springframework.jdbc") + || source.contains("import java.sql"); + } catch (IOException exception) { + throw new IllegalStateException("Cannot inspect " + path, exception); + } + }) + .toList(); + + assertThat(directSqlSources).isEmpty(); + } + } +} diff --git a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskCreationIntegrationTest.java similarity index 64% rename from src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java rename to src/test/java/com/lab/labtimesheet/feature/task/service/TaskCreationIntegrationTest.java index ff52682..b4ffd70 100644 --- a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskCreationIntegrationTest.java @@ -1,19 +1,21 @@ -package com.lab.labtimesheet; +package com.lab.labtimesheet.feature.task.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.lab.labtimesheet.tasks.CreateTaskCommand; -import com.lab.labtimesheet.tasks.TaskCommentView; -import com.lab.labtimesheet.tasks.TaskDetails; -import com.lab.labtimesheet.tasks.TaskListView; -import com.lab.labtimesheet.tasks.TaskAssigneeChoice; -import com.lab.labtimesheet.tasks.TaskNotFoundException; -import com.lab.labtimesheet.tasks.TaskService; -import com.lab.labtimesheet.tasks.TaskStatus; -import com.lab.labtimesheet.tasks.TaskValidationException; -import com.lab.labtimesheet.tasks.TaskView; +import com.lab.labtimesheet.config.TestcontainersConfiguration; +import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException; +import com.lab.labtimesheet.feature.task.exception.TaskValidationException; +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand; +import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice; +import com.lab.labtimesheet.feature.task.model.dto.TaskCommentView; +import com.lab.labtimesheet.feature.task.model.dto.TaskDetails; +import com.lab.labtimesheet.feature.task.model.dto.TaskListView; +import com.lab.labtimesheet.feature.task.model.dto.TaskView; +import jakarta.persistence.EntityManager; import java.time.LocalDate; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -35,9 +37,18 @@ class TaskCreationIntegrationTest { @Autowired private JdbcClient jdbc; + @Autowired + private EntityManager entityManager; + @Autowired private TaskService taskService; + @Autowired + private TaskQueryService taskQueries; + + @Autowired + private TaskDashboardService taskDashboard; + private long projectId; private long leaderMembershipId; private long memberMembershipId; @@ -73,6 +84,7 @@ class TaskCreationIntegrationTest { assertThat(task.creatorMembershipId()).isEqualTo(memberMembershipId); assertThat(task.assignerMembershipId()).isEqualTo(memberMembershipId); assertThat(task.assigneeMembershipId()).isEqualTo(memberMembershipId); + assertThat(task.assigneeName()).isEqualTo("member@example.test"); assertThatThrownBy(() -> taskService.create( "member@example.test", @@ -208,6 +220,9 @@ class TaskCreationIntegrationTest { assertThat(list.tasks()).extracting(TaskView::title) .containsExactly("Todo", "Active", "Blocked", "Done"); + assertThat(list.tasks()).extracting(TaskView::assigneeName) + .containsOnly("member@example.test"); + assertThat(list.canCreate()).isTrue(); assertThat(list.progress().total()).isEqualTo(4); assertThat(list.progress().count(TaskStatus.TODO)).isEqualTo(1); assertThat(list.progress().count(TaskStatus.IN_PROGRESS)).isEqualTo(1); @@ -215,8 +230,24 @@ class TaskCreationIntegrationTest { assertThat(list.progress().count(TaskStatus.DONE)).isEqualTo(1); assertThat(list.progress().completionPercentage()).hasValue(25.0); assertThat(details.comments()).extracting(TaskCommentView::body).containsExactly("Visible comment"); + assertThat(details.task().assigneeName()).isEqualTo("member@example.test"); + assertThat(details.canChangeStatus()).isFalse(); + assertThat(details.canComment()).isTrue(); long emptyProjectId = insertProject(userId("mentor@example.test"), "PLANNED"); + long emptyLeaderMembershipId = insertMembership( + emptyProjectId, + userId("leader@example.test"), + userId("mentor@example.test")); + jdbc.sql(""" + insert into project_leadership_terms + (project_id, membership_id, appointed_by_mentor_user_id) + values (:projectId, :membershipId, :mentorId) + """) + .param("projectId", emptyProjectId) + .param("membershipId", emptyLeaderMembershipId) + .param("mentorId", userId("mentor@example.test")) + .update(); assertThat(taskService.list("mentor@example.test", emptyProjectId).progress().completionPercentage()) .isEmpty(); } @@ -234,6 +265,60 @@ class TaskCreationIntegrationTest { .isInstanceOf(TaskNotFoundException.class); } + @Test + void formerMemberReadsOnlyCompletedProjectTaskHistory() { + TaskView task = createMemberTask("Historical task"); + closeMembership(memberMembershipId); + + assertThatThrownBy(() -> taskService.list("member@example.test", projectId)) + .isInstanceOf(TaskNotFoundException.class); + assertThatThrownBy(() -> taskService.details("member@example.test", projectId, task.id())) + .isInstanceOf(TaskNotFoundException.class); + + completeProject(); + + assertThat(currentLeadershipCount()).isZero(); + assertThat(currentMembershipCount()).isZero(); + + assertThat(taskService.list("member@example.test", projectId).tasks()) + .extracting(TaskView::title) + .containsExactly("Historical task"); + assertThat(taskService.details("member@example.test", projectId, task.id()).task().title()) + .isEqualTo("Historical task"); + } + + @Test + void viewCapabilitiesFollowCurrentMembershipAssignmentAndProjectLifecycle() { + TaskView task = createMemberTask("Capability task"); + + assertThat(taskService.list("member@example.test", projectId).canCreate()).isTrue(); + assertThat(taskService.list("mentor@example.test", projectId).canCreate()).isFalse(); + assertThat(taskService.details("member@example.test", projectId, task.id())) + .satisfies(details -> { + assertThat(details.canChangeStatus()).isFalse(); + assertThat(details.canComment()).isTrue(); + }); + + activateProject(); + + assertThat(taskService.details("member@example.test", projectId, task.id())) + .satisfies(details -> { + assertThat(details.canChangeStatus()).isTrue(); + assertThat(details.canComment()).isTrue(); + }); + assertThat(taskService.details("leader@example.test", projectId, task.id()).canChangeStatus()) + .isFalse(); + + completeProject(); + + assertThat(taskService.list("member@example.test", projectId).canCreate()).isFalse(); + assertThat(taskService.details("member@example.test", projectId, task.id())) + .satisfies(details -> { + assertThat(details.canChangeStatus()).isFalse(); + assertThat(details.canComment()).isFalse(); + }); + } + @Test void createFormChoicesAreSelfOnlyForMembersAndAllActiveMembersForLeader() { assertThat(taskService.assignmentChoices("member@example.test", projectId)) @@ -244,6 +329,45 @@ class TaskCreationIntegrationTest { .containsExactly(leaderMembershipId, memberMembershipId); } + @Test + void projectActivationQueryCountsOnlyCurrentTasksOutsideActiveMemberships() { + createMemberTask("Member task"); + TaskView leaderTask = taskService.create( + "leader@example.test", + new CreateTaskCommand(projectId, leaderMembershipId, "Leader task", null, null)); + + assertThat(taskQueries.countCurrentTasksAssignedOutside(projectId, Set.of(memberMembershipId))) + .isEqualTo(1L); + + softDelete(leaderTask.id()); + assertThat(taskQueries.countCurrentTasksAssignedOutside(projectId, Set.of(memberMembershipId))) + .isZero(); + } + + @Test + void internDashboardCountsAssignmentsAndOrdersFivePriorityTasks() { + createMemberTask("Late"); + taskService.create("member@example.test", new CreateTaskCommand( + projectId, memberMembershipId, "No due date", null, null)); + taskService.create("member@example.test", new CreateTaskCommand( + projectId, memberMembershipId, "Earliest A", null, LocalDate.of(2026, 8, 10))); + taskService.create("member@example.test", new CreateTaskCommand( + projectId, memberMembershipId, "Earliest B", null, LocalDate.of(2026, 8, 10))); + taskService.create("member@example.test", new CreateTaskCommand( + projectId, memberMembershipId, "Middle", null, LocalDate.of(2026, 8, 11))); + taskService.create("member@example.test", new CreateTaskCommand( + projectId, memberMembershipId, "Next", null, LocalDate.of(2026, 8, 13))); + setDueDateForTitle("Late", LocalDate.of(2026, 8, 12)); + activateProject(); + + var dashboard = taskDashboard.dashboard("member@example.test"); + + assertThat(dashboard.assignedTaskCount()).isEqualTo(6L); + assertThat(dashboard.priorityTasks()) + .extracting(task -> task.title()) + .containsExactly("Earliest A", "Earliest B", "Middle", "Late", "Next"); + } + private long insertUser(String email, String role) { return jdbc.sql(""" insert into app_users @@ -328,6 +452,26 @@ class TaskCreationIntegrationTest { return jdbc.sql("select count(*) from task_comments").query(Long.class).single(); } + private long currentLeadershipCount() { + return jdbc.sql(""" + select count(*) from project_leadership_terms + where project_id = :projectId and ended_at is null + """) + .param("projectId", projectId) + .query(Long.class) + .single(); + } + + private long currentMembershipCount() { + return jdbc.sql(""" + select count(*) from project_memberships + where project_id = :projectId and left_at is null + """) + .param("projectId", projectId) + .query(Long.class) + .single(); + } + private TaskView createMemberTask(String title) { return taskService.create( "member@example.test", @@ -338,9 +482,34 @@ class TaskCreationIntegrationTest { jdbc.sql("update projects set status = 'ACTIVE', activated_at = current_timestamp where id = :id") .param("id", projectId) .update(); + entityManager.clear(); } private void completeProject() { + long mentorId = userId("mentor@example.test"); + jdbc.sql(""" + update tasks + set status = 'DONE' + where project_id = :id and deleted_at is null + """) + .param("id", projectId) + .update(); + jdbc.sql(""" + update project_leadership_terms + set ended_at = started_at + interval '1 second', ended_by_mentor_user_id = :mentorId + where project_id = :id and ended_at is null + """) + .param("id", projectId) + .param("mentorId", mentorId) + .update(); + jdbc.sql(""" + update project_memberships + set left_at = joined_at + interval '1 second', removed_by_mentor_user_id = :mentorId + where project_id = :id and left_at is null + """) + .param("id", projectId) + .param("mentorId", mentorId) + .update(); jdbc.sql(""" update projects set status = 'COMPLETED', activated_at = current_timestamp, @@ -349,6 +518,7 @@ class TaskCreationIntegrationTest { """) .param("id", projectId) .update(); + entityManager.clear(); } private void setStatus(long taskId, TaskStatus status) { @@ -356,6 +526,7 @@ class TaskCreationIntegrationTest { .param("status", status.name()) .param("id", taskId) .update(); + entityManager.clear(); } private void softDelete(long taskId) { @@ -367,5 +538,26 @@ class TaskCreationIntegrationTest { .param("membershipId", memberMembershipId) .param("id", taskId) .update(); + entityManager.clear(); + } + + private void closeMembership(long membershipId) { + jdbc.sql(""" + update project_memberships + set left_at = joined_at + interval '1 second', removed_by_mentor_user_id = :mentorId + where id = :id + """) + .param("mentorId", userId("mentor@example.test")) + .param("id", membershipId) + .update(); + entityManager.clear(); + } + + private void setDueDateForTitle(String title, LocalDate dueDate) { + jdbc.sql("update tasks set due_date = :dueDate where title = :title") + .param("dueDate", dueDate) + .param("title", title) + .update(); + entityManager.clear(); } } diff --git a/src/test/java/com/lab/labtimesheet/feature/task/service/TaskDashboardServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskDashboardServiceTest.java new file mode 100644 index 0000000..fafe9bb --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskDashboardServiceTest.java @@ -0,0 +1,108 @@ +package com.lab.labtimesheet.feature.task.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView; +import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView; +import com.lab.labtimesheet.feature.project.service.ProjectQueryService; +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import com.lab.labtimesheet.feature.task.model.entity.Task; +import com.lab.labtimesheet.feature.task.repository.TaskRepository; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +class TaskDashboardServiceTest { + + @Mock + private TaskRepository tasks; + + @Mock + private ProjectQueryService projects; + + @InjectMocks + private TaskDashboardService dashboardService; + + @Test + void mentorDashboardCountsBlockedTasksOnlyInOwnedActiveProjects() { + given(projects.authenticatedActor("mentor@example.test")) + .willReturn(new ProjectActorView(3L, "MENTOR")); + given(projects.listVisible(3L)).willReturn(List.of( + summary(10L, "Active", "ACTIVE"), + summary(11L, "Planned", "PLANNED"))); + given(tasks.countByProjectIdInAndStatusAndDeletedAtIsNull(List.of(10L), TaskStatus.BLOCKED)) + .willReturn(4L); + + var dashboard = dashboardService.dashboard("mentor@example.test"); + + assertThat(dashboard.blockedTaskCount()).isEqualTo(4L); + assertThat(dashboard.assignedTaskCount()).isZero(); + assertThat(dashboard.priorityTasks()).isEmpty(); + } + + @Test + void internDashboardExcludesFormerMembershipsAndReturnsFiveDueDatePriorities() { + given(projects.authenticatedActor("intern@example.test")) + .willReturn(new ProjectActorView(5L, "INTERN")); + given(projects.listVisible(5L)).willReturn(List.of( + summary(10L, "Current", "ACTIVE"), + summary(11L, "Former", "ACTIVE"), + summary(12L, "Completed", "COMPLETED"))); + given(projects.taskContext(5L, 10L)).willReturn(context( + 10L, List.of(new ProjectTaskMemberView(70L, 5L, "Intern")))); + given(projects.taskContext(5L, 11L)).willReturn(context(11L, List.of())); + given(tasks.countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull( + List.of(10L), List.of(70L))) + .willReturn(6L); + var priority = new Task( + 10L, + 70L, + "Due first", + null, + LocalDate.of(2026, 8, 16), + 70L, + Instant.parse("2026-08-15T00:00:00Z")); + given(tasks.findPriorityTasks( + org.mockito.ArgumentMatchers.eq(List.of(10L)), + org.mockito.ArgumentMatchers.eq(List.of(70L)), + org.mockito.ArgumentMatchers.any(Pageable.class))) + .willReturn(List.of(priority)); + + var dashboard = dashboardService.dashboard("intern@example.test"); + + assertThat(dashboard.blockedTaskCount()).isZero(); + assertThat(dashboard.assignedTaskCount()).isEqualTo(6L); + assertThat(dashboard.priorityTasks()).singleElement().satisfies(task -> { + assertThat(task.title()).isEqualTo("Due first"); + assertThat(task.projectName()).isEqualTo("Current"); + assertThat(task.status()).isEqualTo(TaskStatus.TODO); + assertThat(task.dueDate()).isEqualTo(LocalDate.of(2026, 8, 16)); + }); + } + + private static ProjectSummary summary(long id, String name, String status) { + return new ProjectSummary( + id, name, status, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31)); + } + + private static ProjectTaskContext context(long id, List members) { + return new ProjectTaskContext( + id, + 3L, + "ACTIVE", + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 8, 31), + null, + members); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/task/service/TaskMutationBoundaryTest.java b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskMutationBoundaryTest.java new file mode 100644 index 0000000..34e95fc --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskMutationBoundaryTest.java @@ -0,0 +1,135 @@ +package com.lab.labtimesheet.feature.task.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService; +import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext; +import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView; +import com.lab.labtimesheet.feature.project.service.ProjectQueryService; +import com.lab.labtimesheet.feature.project.service.ProjectService; +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand; +import com.lab.labtimesheet.feature.task.model.entity.Task; +import com.lab.labtimesheet.feature.task.model.entity.TaskComment; +import com.lab.labtimesheet.feature.task.repository.TaskCommentRepository; +import com.lab.labtimesheet.feature.task.repository.TaskRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TaskMutationBoundaryTest { + + private static final Instant NOW = Instant.parse("2026-08-15T00:00:00Z"); + + @Mock private TaskRepository tasks; + @Mock private TaskCommentRepository comments; + @Mock private ProjectQueryService projectQueries; + @Mock private ProjectService projectMutations; + @Mock private CalendarApplicationService calendar; + + private TaskService service; + private ProjectTaskContext context; + + @BeforeEach + void setUp() { + service = new TaskService( + tasks, + comments, + projectQueries, + projectMutations, + calendar, + Clock.fixed(NOW, ZoneOffset.UTC)); + context = new ProjectTaskContext( + 10L, + 3L, + "ACTIVE", + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 8, 31), + 70L, + List.of(new ProjectTaskMemberView(70L, 5L, "Member"))); + when(projectQueries.authenticatedActor("member@example.test")) + .thenReturn(new ProjectActorView(5L, "INTERN")); + when(projectMutations.taskMutationContext(5L, 10L)).thenReturn(context); + } + + @Test + void createLocksAndRechecksProjectBeforeWriting() { + Task saved = taskForView(TaskStatus.TODO); + when(tasks.saveAndFlush(any(Task.class))).thenReturn(saved); + + service.create( + "member@example.test", + new CreateTaskCommand(10L, 70L, "Task", null, null)); + + InOrder order = inOrder(projectMutations, tasks); + order.verify(projectMutations).taskMutationContext(5L, 10L); + order.verify(tasks).saveAndFlush(any(Task.class)); + verify(projectQueries, never()).taskContext(5L, 10L); + } + + @Test + void statusChangeLocksProjectThenTaskBeforeMutation() { + Task task = taskForView(TaskStatus.TODO); + when(tasks.findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L)) + .thenReturn(Optional.of(task)); + when(tasks.saveAndFlush(task)).thenReturn(task); + + service.changeStatus("member@example.test", 10L, 25L, TaskStatus.IN_PROGRESS); + + InOrder order = inOrder(projectMutations, tasks, task); + order.verify(projectMutations).taskMutationContext(5L, 10L); + order.verify(tasks).findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L); + order.verify(task).changeStatus(TaskStatus.IN_PROGRESS, NOW); + } + + @Test + void commentLocksProjectThenTaskBeforeWriting() { + Task task = mock(Task.class); + TaskComment saved = mock(TaskComment.class); + when(tasks.findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L)) + .thenReturn(Optional.of(task)); + when(comments.saveAndFlush(any(TaskComment.class))).thenReturn(saved); + when(saved.getId()).thenReturn(4L); + when(saved.getTaskId()).thenReturn(25L); + when(saved.getAuthorUserId()).thenReturn(5L); + when(saved.getBody()).thenReturn("Comment"); + when(saved.getCreatedAt()).thenReturn(NOW); + + service.addComment("member@example.test", 10L, 25L, "Comment"); + + InOrder order = inOrder(projectMutations, tasks, comments); + order.verify(projectMutations).taskMutationContext(5L, 10L); + order.verify(tasks).findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L); + order.verify(comments).saveAndFlush(any(TaskComment.class)); + } + + private static Task taskForView(TaskStatus status) { + Task task = mock(Task.class); + when(task.getId()).thenReturn(25L); + when(task.getProjectId()).thenReturn(10L); + when(task.getAssigneeMembershipId()).thenReturn(70L); + when(task.getTitle()).thenReturn("Task"); + when(task.getStatus()).thenReturn(status); + when(task.getCreatorMembershipId()).thenReturn(70L); + when(task.getAssignerMembershipId()).thenReturn(70L); + when(task.getAssignedAt()).thenReturn(NOW); + when(task.getCreatedAt()).thenReturn(NOW); + return task; + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/task/service/TaskQueryServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskQueryServiceTest.java new file mode 100644 index 0000000..abfb347 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskQueryServiceTest.java @@ -0,0 +1,41 @@ +package com.lab.labtimesheet.feature.task.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.lab.labtimesheet.feature.task.repository.TaskRepository; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TaskQueryServiceTest { + + @Mock + private TaskRepository tasks; + + @InjectMocks + private TaskQueryService taskQueries; + + @Test + void countsEveryCurrentTaskWhenProjectHasNoActiveMemberships() { + given(tasks.countByProjectIdAndDeletedAtIsNull(42L)).willReturn(3L); + + assertThat(taskQueries.countCurrentTasksAssignedOutside(42L, Set.of())).isEqualTo(3L); + + verify(tasks).countByProjectIdAndDeletedAtIsNull(42L); + } + + @Test + void countsCurrentTasksWhoseAssigneeIsOutsideActiveMemberships() { + given(tasks.countCurrentTasksAssignedOutside(42L, Set.of(7L, 9L))).willReturn(2L); + + assertThat(taskQueries.countCurrentTasksAssignedOutside(42L, Set.of(7L, 9L))).isEqualTo(2L); + + verify(tasks).countCurrentTasksAssignedOutside(42L, Set.of(7L, 9L)); + } +} From 213a889c8f0a475abfdb06082065320379d9bc7a Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:23:25 +0700 Subject: [PATCH 33/62] docs(tests): finalize task evidence --- docs/tests/integration/task-workflow.md | 2 +- docs/tests/unit/task-dashboard-query.md | 2 +- docs/tests/unit/task-mutation-boundary.md | 2 +- docs/tests/unit/task-persistence-structure.md | 2 +- docs/tests/unit/task-project-activation-query.md | 2 +- docs/tests/web/task-pages.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/tests/integration/task-workflow.md b/docs/tests/integration/task-workflow.md index 6d87de9..7cb7c5b 100644 --- a/docs/tests/integration/task-workflow.md +++ b/docs/tests/integration/task-workflow.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`–`AUTH-009`, `AUTH-011`, `PRJ-013`, `PRJ-015`, `PRJ-016`, `TSK-001`–`TSK-005`, `TSK-007`, `TSK-008`, `TSK-011`, `TSK-012`, `TSK-018` - **Scenario IDs:** `I1-TSK-01`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-003`–`AC-AUTH-007`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` - **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskCreationIntegrationTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior diff --git a/docs/tests/unit/task-dashboard-query.md b/docs/tests/unit/task-dashboard-query.md index ec7dc52..95d9bb7 100644 --- a/docs/tests/unit/task-dashboard-query.md +++ b/docs/tests/unit/task-dashboard-query.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-003`–`AUTH-005`, `AUTH-009`, `TSK-001`, `TSK-002`, `TSK-004`, `PRJ-016` - **Scenario IDs:** `I1-UI-03` - **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskDashboardServiceTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior diff --git a/docs/tests/unit/task-mutation-boundary.md b/docs/tests/unit/task-mutation-boundary.md index c2a787e..986b73f 100644 --- a/docs/tests/unit/task-mutation-boundary.md +++ b/docs/tests/unit/task-mutation-boundary.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-011`, `TSK-003`, `TSK-007`, `TSK-012`, `TSK-018` - **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`, `I1-TSK-04`, `AC-AUTH-010`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` - **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskMutationBoundaryTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior diff --git a/docs/tests/unit/task-persistence-structure.md b/docs/tests/unit/task-persistence-structure.md index bd0c930..1ede20e 100644 --- a/docs/tests/unit/task-persistence-structure.md +++ b/docs/tests/unit/task-persistence-structure.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `TSK-001`–`TSK-005`, `TSK-007`, `TSK-011`, `TSK-012` - **Scenario IDs:** `I1-TSK-01`–`I1-TSK-04` - **Test class/method:** `com.lab.labtimesheet.feature.task.repository.TaskPersistenceStructureTest#taskPersistenceUsesJpaEntitiesAndSpringDataRepositories` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior diff --git a/docs/tests/unit/task-project-activation-query.md b/docs/tests/unit/task-project-activation-query.md index db0dc09..ccd9498 100644 --- a/docs/tests/unit/task-project-activation-query.md +++ b/docs/tests/unit/task-project-activation-query.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `PRJ-012` - **Scenario IDs:** `I1-PRJ-04`, `AC-PRJ-006` - **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskQueryServiceTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior diff --git a/docs/tests/web/task-pages.md b/docs/tests/web/task-pages.md index eaf24a3..0bd59ae 100644 --- a/docs/tests/web/task-pages.md +++ b/docs/tests/web/task-pages.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-007`, `TSK-011`, `TSK-012` - **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` - **Test class/method:** `com.lab.labtimesheet.feature.task.controller.TaskControllerTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior From dbf12023c202a3aabd0dd0ad7f804c4a8e3ee2df Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:29:53 +0700 Subject: [PATCH 34/62] feat(project): enforce activation guards --- .../project/controller/ProjectController.java | 6 +++ .../project/model/entity/ProjectEntity.java | 17 +++++-- .../project/service/ProjectService.java | 27 +++++++++++ .../resources/templates/projects/detail.html | 3 ++ .../controller/ProjectControllerTest.java | 48 +++++++++++++++++++ .../model/entity/ProjectEntityTest.java | 13 +++-- .../ProjectServiceIntegrationTest.java | 40 ++++++++++++++++ .../ProjectTaskMutationContextTest.java | 6 ++- 8 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java index 113b50a..7d3aeda 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -63,6 +63,12 @@ public class ProjectController { return "projects/detail"; } + @PostMapping("/{projectId}/activate") + public String activate(Principal principal, @PathVariable long projectId) { + projects.activate(actorId(principal), projectId); + return "redirect:/projects/" + projectId; + } + @GetMapping("/{projectId}/members") public String members(Principal principal, @PathVariable long projectId, Model model) { long actorId = actorId(principal); diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java index eabaefc..850fe03 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java @@ -21,6 +21,7 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Set; @Entity @Table(name = "projects") @@ -157,15 +158,23 @@ public class ProjectEntity { this, change.replacement(), change.effectiveAt(), actorMentorUserId)); } - public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) { + public void activate( + long actorMentorUserId, + Set activeInternUserIds, + boolean allTaskAssigneesAreCurrent, + Instant at) { requireOwner(actorMentorUserId); + Objects.requireNonNull(activeInternUserIds, "activeInternUserIds"); Objects.requireNonNull(at, "at"); if (status != ProjectStatus.PLANNED) { throw new ProjectRuleViolationException("Only a planned Project can be activated"); } - if (memberships.stream().noneMatch(ProjectMembershipEntity::isCurrent) - || leadershipTerms.stream().noneMatch(ProjectLeadershipTermEntity::isCurrent)) { - throw new ProjectRuleViolationException("Project requires a current member and Leader"); + if (memberships.stream().noneMatch(membership -> membership.isCurrent() + && activeInternUserIds.contains(membership.internUserId()))) { + throw new ProjectRuleViolationException("Project requires an active member"); + } + if (!activeInternUserIds.contains(currentLeader().internUserId())) { + throw new ProjectRuleViolationException("Project Leader must be an active member"); } if (!allTaskAssigneesAreCurrent) { throw new ProjectRuleViolationException("Every current Task assignee must be an active Project member"); diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java index 946b7c3..1dcac12 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java @@ -7,7 +7,10 @@ import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand; import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext; 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.util.Set; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,16 +20,19 @@ public class ProjectService { private final ProjectRepository projects; private final AccountService accounts; private final ProjectQueryService queries; + private final TaskQueryService taskQueries; private final Clock clock; public ProjectService( ProjectRepository projects, AccountService accounts, ProjectQueryService queries, + TaskQueryService taskQueries, Clock clock) { this.projects = projects; this.accounts = accounts; this.queries = queries; + this.taskQueries = taskQueries; this.clock = clock; } @@ -70,6 +76,27 @@ public class ProjectService { return queries.taskContext(actorUserId, lockedProject(projectId)); } + @Transactional + public void activate(long actorUserId, long projectId) { + var project = lockedProject(projectId); + project.authorizeOwner(actorUserId); + var activeMemberships = project.memberships().stream() + .filter(membership -> membership.isCurrent() + && accounts.isEligibleIntern(membership.internUserId())) + .toList(); + var activeMembershipIds = activeMemberships.stream() + .map(membership -> membership.id()) + .collect(Collectors.toUnmodifiableSet()); + Set activeInternUserIds = activeMemberships.stream() + .map(membership -> membership.internUserId()) + .collect(Collectors.toUnmodifiableSet()); + var allTaskAssigneesAreCurrent = taskQueries.countCurrentTasksAssignedOutside( + projectId, activeMembershipIds) == 0; + + project.activate(actorUserId, activeInternUserIds, allTaskAssigneesAreCurrent, clock.instant()); + projects.flush(); + } + private ProjectEntity lockedProject(long projectId) { return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new); } diff --git a/src/main/resources/templates/projects/detail.html b/src/main/resources/templates/projects/detail.html index 5dd745e..9588814 100644 --- a/src/main/resources/templates/projects/detail.html +++ b/src/main/resources/templates/projects/detail.html @@ -6,6 +6,9 @@

Project

Status
Mentor
Leader
+ + + diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java index 2a26916..b4682b1 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -150,6 +150,54 @@ class ProjectControllerTest { .andExpect(redirectedUrl("/projects/30")); } + @Test + @WithMockUser(username = "mentor@example.test") + void owningMentorCanActivateAPlannedProject() throws Exception { + when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + + mvc.perform(post("/projects/30/activate").with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/30")); + + verify(projects).activate(10L, 30L); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void plannedProjectDetailShowsActivationOnlyToTheOwningMentor() throws Exception { + when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + when(pages.detail(10L, 30L)).thenReturn(new ProjectDetail( + 30L, + "Intern Portal Refresh", + null, + "PLANNED", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + "Mentor", + "Leader", + true)); + + mvc.perform(get("/projects/30")) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString(">Activate<"))); + + when(pages.detail(10L, 30L)).thenReturn(new ProjectDetail( + 30L, + "Intern Portal Refresh", + null, + "PLANNED", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + "Mentor", + "Leader", + false)); + mvc.perform(get("/projects/30")) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString(">Activate<")))); + } + @Test @WithMockUser(username = "mentor@example.test") void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception { diff --git a/src/test/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntityTest.java b/src/test/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntityTest.java index 16f1a2d..6fd6826 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntityTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntityTest.java @@ -11,6 +11,7 @@ import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility; import com.lab.labtimesheet.feature.project.model.ProjectStatus; import java.time.Instant; import java.time.LocalDate; +import java.util.Set; import org.junit.jupiter.api.Test; class ProjectEntityTest { @@ -114,16 +115,20 @@ class ProjectEntityTest { var project = plannedProject(); assertThrows(ProjectAccessDeniedException.class, - () -> project.activate(11L, true, CREATED_AT.plusSeconds(60))); + () -> project.activate(11L, Set.of(20L), true, CREATED_AT.plusSeconds(60))); assertThrows(ProjectRuleViolationException.class, - () -> project.activate(10L, false, CREATED_AT.plusSeconds(60))); + () -> project.activate(10L, Set.of(), true, CREATED_AT.plusSeconds(60))); + assertThrows(ProjectRuleViolationException.class, + () -> project.activate(10L, Set.of(21L), true, CREATED_AT.plusSeconds(60))); + assertThrows(ProjectRuleViolationException.class, + () -> project.activate(10L, Set.of(20L), false, CREATED_AT.plusSeconds(60))); - project.activate(10L, true, CREATED_AT.plusSeconds(60)); + project.activate(10L, Set.of(20L), true, CREATED_AT.plusSeconds(60)); assertEquals(ProjectStatus.ACTIVE, project.status()); assertEquals(CREATED_AT.plusSeconds(60), project.activatedAt()); assertThrows(ProjectRuleViolationException.class, - () -> project.activate(10L, true, CREATED_AT.plusSeconds(120))); + () -> project.activate(10L, Set.of(20L), true, CREATED_AT.plusSeconds(120))); } private static ProjectEntity plannedProject() { diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java index d98b3f7..a860e9a 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java @@ -215,6 +215,46 @@ class ProjectServiceIntegrationTest { assertTrue(members.stream().noneMatch(member -> member.currentLeader())); } + @Test + void ownerActivatesAPlannedProjectWhenCurrentMemberAndTaskAssigneeGuardsPass() { + long mentorId = user("mentor-activate@example.test", "MENTOR"); + long leaderId = intern("leader-activate@example.test", "I014"); + long projectId = createProject(mentorId, leaderId, "Ready to activate"); + + projectService.activate(mentorId, projectId); + + assertEquals("ACTIVE", text("select status from projects where id = ?", projectId)); + assertEquals(1, count("select count(*) from projects where id = ? and activated_at is not null", projectId)); + } + + @Test + void activationRejectsATaskAssignedToAFormerMemberWithoutPartialMutation() { + long mentorId = user("mentor-guard@example.test", "MENTOR"); + long leaderId = intern("leader-guard@example.test", "I015"); + long formerMemberId = intern("former-assignee@example.test", "I016"); + long projectId = createProject(mentorId, leaderId, "Assignee guard"); + projectService.addMember(mentorId, projectId, formerMemberId); + long leaderMembershipId = membershipId(projectId, leaderId); + long formerMembershipId = membershipId(projectId, formerMemberId); + jdbc.update(""" + insert into tasks ( + project_id, assignee_membership_id, title, + created_by_membership_id, assigned_by_membership_id) + values (?, ?, 'Former assignee', ?, ?) + """, projectId, formerMembershipId, leaderMembershipId, leaderMembershipId); + jdbc.update(""" + update project_memberships + set left_at = ?, removed_by_mentor_user_id = ?, updated_at = ? + where id = ? + """, dbTime(NOW.plusSeconds(60)), mentorId, dbTime(NOW.plusSeconds(60)), formerMembershipId); + entityManager.clear(); + + assertThrows(ProjectRuleViolationException.class, () -> projectService.activate(mentorId, projectId)); + + assertEquals("PLANNED", text("select status from projects where id = ?", projectId)); + assertEquals(1, count("select count(*) from tasks where project_id = ? and deleted_at is null", projectId)); + } + private long createProject(long mentorId, long leaderId, String name) { return projectService.create( mentorId, diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java index 8f31971..5556f85 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java @@ -9,6 +9,7 @@ import com.lab.labtimesheet.feature.account.service.AccountService; import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext; 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.List; @@ -30,6 +31,9 @@ class ProjectTaskMutationContextTest { @Mock private ProjectQueryService queries; + @Mock + private TaskQueryService taskQueries; + @Mock private ProjectEntity project; @@ -45,7 +49,7 @@ class ProjectTaskMutationContextTest { LocalDate.of(2026, 9, 30), 40L, List.of()); - var service = new ProjectService(projects, accounts, queries, Clock.systemUTC()); + var service = new ProjectService(projects, accounts, queries, taskQueries, Clock.systemUTC()); when(projects.findLockedById(projectId)).thenReturn(Optional.of(project)); when(queries.taskContext(actorUserId, project)).thenReturn(expected); From b1c6b170d0a37d0c31a403e971f3a9cef51b0de7 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:30:18 +0700 Subject: [PATCH 35/62] feat(reporting): compose role dashboards from feature services --- docs/tests/web/dashboard-template-contract.md | 4 +- docs/tests/web/role-dashboard-routing.md | 81 +++++++++++ .../controller/DashboardController.java | 40 ++++++ .../DashboardAccessDeniedException.java | 13 ++ .../reporting/service/DashboardService.java | 97 +++++++++++++ .../reporting/ReportingArchitectureTest.java | 16 +++ .../controller/AdminDashboardWebTest.java | 74 ++++++++++ .../DashboardControllerWebTest.java | 85 +++++++++++ .../service/DashboardServiceTest.java | 132 ++++++++++++++++++ 9 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 docs/tests/web/role-dashboard-routing.md create mode 100644 src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/controller/AdminDashboardWebTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/service/DashboardServiceTest.java diff --git a/docs/tests/web/dashboard-template-contract.md b/docs/tests/web/dashboard-template-contract.md index 78e617b..d260ead 100644 --- a/docs/tests/web/dashboard-template-contract.md +++ b/docs/tests/web/dashboard-template-contract.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-003`, `UI-003`, `UI-013`, `I1-UI-03` - **Scenario IDs:** `AC-AUTH-002`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.feature.reporting.ReportingArchitectureTest`, `com.lab.labtimesheet.feature.reporting.controller.DashboardTemplateWebTest` -- **Implementation commit:** `5638286`, `pending` +- **Implementation commit:** `5638286`, `f8db8a3` ## Protected behavior @@ -101,4 +101,4 @@ Total time: 24.823 s ## External-test boundaries -These tests prove package and template contracts with controlled view DTOs. They deliberately do not claim that `/dashboard` is connected to real account, Project, Task, attendance, or notification data; that integration remains gated on the platform's cross-feature service APIs and will require a PostgreSQL/Testcontainers test after the pinned platform structure is merged. Browser viewport, contrast, and pre-paint behavior remain integrated UI gates. +These tests prove package and template contracts with controlled view DTOs. The separate role-dashboard routing evidence covers the now-complete cross-feature service composition and PostgreSQL-backed Admin route. Browser viewport, contrast, and pre-paint behavior remain integrated UI gates. diff --git a/docs/tests/web/role-dashboard-routing.md b/docs/tests/web/role-dashboard-routing.md new file mode 100644 index 0000000..b749805 --- /dev/null +++ b/docs/tests/web/role-dashboard-routing.md @@ -0,0 +1,81 @@ +# Test Evidence: role dashboard routing and service composition + +- **Test type:** Web and unit +- **Requirement IDs:** `AUTH-003`, `UI-003`, `UI-013`, `I1-UI-03` +- **Scenario IDs:** `AC-AUTH-002`, `AC-UI-005` +- **Test class/method:** `com.lab.labtimesheet.feature.reporting.service.DashboardServiceTest`, `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AdminDashboardWebTest`, `com.lab.labtimesheet.feature.reporting.ReportingArchitectureTest` +- **Implementation commit:** `pending` + +## Protected behavior + +`/dashboard` selects exactly one role template from the authenticated authority, while all displayed data is authorized again from the persisted account identity. Reporting composes public Account, Project, Task, and Attendance service DTOs; it owns no shadow account entity, repository, direct SQL, or business date calculation. + +## Test method + +The unit test supplies mocked concrete public feature services to the reporting coordinator and independently checks the exact Admin, Mentor, and Intern view DTOs, including Task-status and attendance-state translation. Negative cases prove that a forged authority, locked account, missing account, or inactive internship cannot produce a dashboard. The MVC slice proves role-to-template routing and authentication. The PostgreSQL web test bootstraps a real Admin through `BootstrapService` and exercises the complete authenticated route without SQL fixtures. + +## Hand-derived expected result + +An active Admin sees account totals plus active Project count. An active Mentor sees their display name, visible active Project count, distinct active eligible member count, and blocked Task count. An eligible Intern sees the server-authoritative attendance state, active Project count, assigned Task count, and the Task service's ordered priority list. Unsupported roles and identities that do not satisfy the persisted role/lifecycle checks receive HTTP 403. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=DashboardServiceTest,DashboardControllerWebTest test +``` + +**Observed result** + +```text +DashboardService constructor required DashboardRepository and did not accept TaskDashboardService or AttendanceApplicationService. +DashboardService.intern required a caller-supplied LocalDate instead of using AttendanceApplicationService.currentState. +Tests failed during compilation with 5 errors. +BUILD FAILURE +Total time: 6.645 s +``` + +The focused contract could not compile against the temporary reporting-owned persistence implementation, which is the intended missing behavior. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=DashboardServiceTest,DashboardControllerWebTest,ReportingArchitectureTest test +``` + +**Observed result** + +```text +Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 6.145 s +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=DashboardServiceTest,DashboardControllerWebTest,AdminDashboardWebTest,ReportingArchitectureTest,DashboardTemplateWebTest test + +PostgreSQL 18.4 via Testcontainers +Tests run: 18, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 11.409 s +``` + +## External-test boundaries + +The focused tests prove reporting composition, route selection, denial behavior, and one production-shaped Admin journey. Feature-owned suites separately prove the Project, Task, Attendance, and Account query semantics. Browser viewport behavior remains an integrated UI gate. diff --git a/src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java b/src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java new file mode 100644 index 0000000..18f5506 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java @@ -0,0 +1,40 @@ +package com.lab.labtimesheet.feature.reporting.controller; + +import com.lab.labtimesheet.feature.reporting.exception.DashboardAccessDeniedException; +import com.lab.labtimesheet.feature.reporting.service.DashboardService; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class DashboardController { + + private final DashboardService dashboardService; + + public DashboardController(DashboardService dashboardService) { + this.dashboardService = dashboardService; + } + + @GetMapping("/dashboard") + public String dashboard(Authentication authentication, Model model) { + String email = authentication.getName(); + if (hasRole(authentication, "ROLE_ADMIN")) { + model.addAttribute("dashboard", dashboardService.admin(email)); + return "dashboard/admin"; + } + if (hasRole(authentication, "ROLE_MENTOR")) { + model.addAttribute("dashboard", dashboardService.mentor(email)); + return "dashboard/mentor"; + } + if (hasRole(authentication, "ROLE_INTERN")) { + model.addAttribute("dashboard", dashboardService.intern(email)); + return "dashboard/intern"; + } + throw new DashboardAccessDeniedException("Dashboard access requires a supported global role"); + } + + private boolean hasRole(Authentication authentication, String role) { + return authentication.getAuthorities().stream().anyMatch(authority -> authority.getAuthority().equals(role)); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java b/src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java new file mode 100644 index 0000000..3173389 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java @@ -0,0 +1,13 @@ +package com.lab.labtimesheet.feature.reporting.exception; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.FORBIDDEN) +public class DashboardAccessDeniedException extends AccessDeniedException { + + public DashboardAccessDeniedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java b/src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java new file mode 100644 index 0000000..0a8283e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java @@ -0,0 +1,97 @@ +package com.lab.labtimesheet.feature.reporting.service; + +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity; +import com.lab.labtimesheet.feature.account.model.dto.AccountSummary; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; +import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService; +import com.lab.labtimesheet.feature.reporting.exception.DashboardAccessDeniedException; +import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView; +import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView.AssignedTask; +import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView.AttendanceState; +import com.lab.labtimesheet.feature.project.model.dto.ProjectDashboardSummary; +import com.lab.labtimesheet.feature.project.service.ProjectQueryService; +import com.lab.labtimesheet.feature.task.model.dto.TaskDashboardView; +import com.lab.labtimesheet.feature.task.service.TaskDashboardService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class DashboardService { + + private final AccountService accounts; + private final ProjectQueryService projects; + private final TaskDashboardService tasks; + private final AttendanceApplicationService attendance; + + public DashboardService( + AccountService accounts, + ProjectQueryService projects, + TaskDashboardService tasks, + AttendanceApplicationService attendance) { + this.accounts = accounts; + this.projects = projects; + this.tasks = tasks; + this.attendance = attendance; + } + + public DashboardView.Admin admin(String email) { + AccountIdentity admin = activeAccount(email, GlobalRole.ADMIN); + AccountSummary accountSummary = accounts.summary(); + ProjectDashboardSummary projectSummary = projects.dashboardSummary(admin.id()); + return new DashboardView.Admin( + accountSummary.activeAccounts(), + accountSummary.pendingActivations(), + accountSummary.activeInternships(), + projectSummary.activeProjectCount()); + } + + public DashboardView.Mentor mentor(String email) { + AccountIdentity mentor = activeAccount(email, GlobalRole.MENTOR); + ProjectDashboardSummary projectSummary = projects.dashboardSummary(mentor.id()); + TaskDashboardView taskSummary = tasks.dashboard(email); + return new DashboardView.Mentor( + mentor.displayName(), + projectSummary.activeProjectCount(), + projectSummary.distinctActiveMemberCount(), + taskSummary.blockedTaskCount()); + } + + public DashboardView.Intern intern(String email) { + AccountIdentity intern = activeAccount(email, GlobalRole.INTERN); + AttendanceCurrentState attendanceState; + try { + attendanceState = attendance.currentState(intern.id()); + } catch (AttendanceException exception) { + throw new DashboardAccessDeniedException("Active Intern account and internship required"); + } + ProjectDashboardSummary projectSummary = projects.dashboardSummary(intern.id()); + TaskDashboardView taskSummary = tasks.dashboard(email); + return new DashboardView.Intern( + intern.displayName(), + AttendanceState.valueOf(attendanceState.name()), + projectSummary.activeProjectCount(), + taskSummary.assignedTaskCount(), + taskSummary.priorityTasks().stream() + .map(task -> new AssignedTask( + task.title(), task.projectName(), task.status().name(), task.dueDate())) + .toList()); + } + + private AccountIdentity activeAccount(String email, GlobalRole role) { + AccountIdentity account; + try { + account = accounts.requireIdentityByEmail(email); + } catch (IllegalArgumentException exception) { + throw new DashboardAccessDeniedException("Active " + role + " account required"); + } + if (account.status() != AccountStatus.ACTIVE || account.role() != role) { + throw new DashboardAccessDeniedException("Active " + role + " account required"); + } + return account; + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java index 8eed62b..677d889 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java @@ -1,18 +1,25 @@ package com.lab.labtimesheet.feature.reporting; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Arrays; import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; class ReportingArchitectureTest { @Test void reportingUsesFeaturePackageWithoutGlobalLayersOrPlaceholderBoundary() throws Exception { Class view = Class.forName("com.lab.labtimesheet.feature.reporting.model.dto.DashboardView"); + Class controller = Class.forName("com.lab.labtimesheet.feature.reporting.controller.DashboardController"); + Class service = Class.forName("com.lab.labtimesheet.feature.reporting.service.DashboardService"); + Class exception = Class.forName("com.lab.labtimesheet.feature.reporting.exception.DashboardAccessDeniedException"); assertTrue(view.isSealed()); assertFalse(view.getPackageName().startsWith("com.lab.labtimesheet.model")); + assertFalse(usesJdbcTemplate(controller, service, exception)); assertMissing("com.lab.labtimesheet.controller.DashboardController"); assertMissing("com.lab.labtimesheet.dto.DashboardView"); @@ -22,6 +29,15 @@ class ReportingArchitectureTest { assertMissing("com.lab.labtimesheet.service.DashboardService"); assertMissing("com.lab.labtimesheet.reporting.ModuleBoundary"); assertMissing("com.lab.labtimesheet.feature.reporting.ModuleBoundary"); + assertAll( + () -> assertMissing("com.lab.labtimesheet.feature.reporting.model.DashboardAccount"), + () -> assertMissing("com.lab.labtimesheet.feature.reporting.repository.DashboardRepository")); + } + + private boolean usesJdbcTemplate(Class... types) { + return Arrays.stream(types) + .flatMap(type -> Arrays.stream(type.getDeclaredFields())) + .anyMatch(field -> field.getType().equals(JdbcTemplate.class)); } private void assertMissing(String className) { diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AdminDashboardWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AdminDashboardWebTest.java new file mode 100644 index 0000000..b3e2628 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AdminDashboardWebTest.java @@ -0,0 +1,74 @@ +package com.lab.labtimesheet.feature.reporting.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.lab.labtimesheet.config.TestcontainersConfiguration; +import com.lab.labtimesheet.feature.account.service.BootstrapService; +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.annotation.Import; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class AdminDashboardWebTest { + + private final MockMvc mvc; + private final BootstrapService bootstrap; + + @Autowired + AdminDashboardWebTest(MockMvc mvc, BootstrapService bootstrap) { + this.mvc = mvc; + this.bootstrap = bootstrap; + } + + @Test + @WithMockUser(username = "admin@example.test", roles = "ADMIN") + void adminDashboardUsesAccountAndProjectServiceSummaries() throws Exception { + bootstrap(); + + mvc.perform(get("/dashboard")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("System overview"))) + .andExpect(content().string(containsString("Active accounts
1"))) + .andExpect(content().string(containsString("Pending activation
0"))) + .andExpect(content().string(containsString("Active internships
0"))) + .andExpect(content().string(containsString("Active Projects
0"))) + .andExpect(content().string(containsString("Create account"))) + .andExpect(content().string(containsString("No pending activations"))) + .andExpect(content().string(not(containsString("Create Project")))) + .andExpect(content().string(not(containsString("Check in")))); + } + + @Test + @WithMockUser(username = "intern@example.test", roles = "ADMIN") + void adminAuthorityDoesNotAuthorizeUnknownAccount() throws Exception { + bootstrap(); + + mvc.perform(get("/dashboard")) + .andExpect(status().isForbidden()); + } + + @Test + void dashboardRequiresAuthentication() throws Exception { + bootstrap(); + mvc.perform(get("/dashboard")) + .andExpect(status().is3xxRedirection()); + } + + private void bootstrap() { + bootstrap.bootstrap("admin@example.test", "An Admin", "correct-horse-battery-staple"); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java new file mode 100644 index 0000000..7df5aac --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java @@ -0,0 +1,85 @@ +package com.lab.labtimesheet.feature.reporting.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView; +import com.lab.labtimesheet.feature.reporting.service.DashboardService; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(DashboardController.class) +class DashboardControllerWebTest { + + @Autowired + private MockMvc mvc; + + @MockitoBean + private DashboardService dashboards; + + @Test + void adminRendersAdminDashboardForAuthenticatedIdentity() throws Exception { + var dashboard = new DashboardView.Admin(2, 1, 1, 3); + given(dashboards.admin("admin@example.test")).willReturn(dashboard); + + mvc.perform(get("/dashboard").with(user("admin@example.test").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(view().name("dashboard/admin")) + .andExpect(model().attribute("dashboard", dashboard)); + + verify(dashboards).admin("admin@example.test"); + } + + @Test + void mentorRendersMentorDashboardForAuthenticatedIdentity() throws Exception { + var dashboard = new DashboardView.Mentor("Mentor", 2, 4, 1); + given(dashboards.mentor("mentor@example.test")).willReturn(dashboard); + + mvc.perform(get("/dashboard").with(user("mentor@example.test").roles("MENTOR"))) + .andExpect(status().isOk()) + .andExpect(view().name("dashboard/mentor")) + .andExpect(model().attribute("dashboard", dashboard)); + + verify(dashboards).mentor("mentor@example.test"); + } + + @Test + void internRendersInternDashboardWithoutClientSuppliedBusinessDate() throws Exception { + var dashboard = new DashboardView.Intern( + "Intern", DashboardView.AttendanceState.NOT_CHECKED_IN, 1, 0, List.of()); + given(dashboards.intern("intern@example.test")).willReturn(dashboard); + + mvc.perform(get("/dashboard").with(user("intern@example.test").roles("INTERN"))) + .andExpect(status().isOk()) + .andExpect(view().name("dashboard/intern")) + .andExpect(model().attribute("dashboard", dashboard)); + + verify(dashboards).intern("intern@example.test"); + } + + @Test + void unsupportedRoleIsForbiddenWithoutCallingDashboardServices() throws Exception { + mvc.perform(get("/dashboard").with(user("user@example.test").roles("USER"))) + .andExpect(status().isForbidden()); + + verifyNoInteractions(dashboards); + } + + @Test + void dashboardRequiresAuthentication() throws Exception { + mvc.perform(get("/dashboard")) + .andExpect(status().isUnauthorized()); + + verifyNoInteractions(dashboards); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/service/DashboardServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/service/DashboardServiceTest.java new file mode 100644 index 0000000..6113dd1 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/service/DashboardServiceTest.java @@ -0,0 +1,132 @@ +package com.lab.labtimesheet.feature.reporting.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity; +import com.lab.labtimesheet.feature.account.model.dto.AccountSummary; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; +import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService; +import com.lab.labtimesheet.feature.project.model.dto.ProjectDashboardSummary; +import com.lab.labtimesheet.feature.project.service.ProjectQueryService; +import com.lab.labtimesheet.feature.reporting.exception.DashboardAccessDeniedException; +import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView; +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import com.lab.labtimesheet.feature.task.model.dto.TaskDashboardView; +import com.lab.labtimesheet.feature.task.model.dto.TaskPriorityView; +import com.lab.labtimesheet.feature.task.service.TaskDashboardService; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DashboardServiceTest { + + private final AccountService accounts = mock(AccountService.class); + private final ProjectQueryService projects = mock(ProjectQueryService.class); + private final TaskDashboardService tasks = mock(TaskDashboardService.class); + private final AttendanceApplicationService attendance = mock(AttendanceApplicationService.class); + private DashboardService dashboards; + + @BeforeEach + void setUp() { + dashboards = new DashboardService(accounts, projects, tasks, attendance); + } + + @Test + void adminDashboardCombinesAccountAndProjectSummaries() { + given(accounts.requireIdentityByEmail("admin@example.test")) + .willReturn(identity(1L, "Admin", GlobalRole.ADMIN, AccountStatus.ACTIVE)); + given(accounts.summary()).willReturn(new AccountSummary(8, 2, 3)); + given(projects.dashboardSummary(1L)).willReturn(new ProjectDashboardSummary(4, 0)); + + assertThat(dashboards.admin("admin@example.test")) + .isEqualTo(new DashboardView.Admin(8, 2, 3, 4)); + + verifyNoInteractions(tasks, attendance); + } + + @Test + void mentorDashboardCombinesOwnedProjectAndTaskSummaries() { + given(accounts.requireIdentityByEmail("mentor@example.test")) + .willReturn(identity(2L, "Minh Mentor", GlobalRole.MENTOR, AccountStatus.ACTIVE)); + given(projects.dashboardSummary(2L)).willReturn(new ProjectDashboardSummary(3, 7)); + given(tasks.dashboard("mentor@example.test")).willReturn(new TaskDashboardView(5, 0, List.of())); + + assertThat(dashboards.mentor("mentor@example.test")) + .isEqualTo(new DashboardView.Mentor("Minh Mentor", 3, 7, 5)); + + verifyNoInteractions(attendance); + } + + @Test + void internDashboardCombinesAttendanceProjectAndTaskViews() { + var dueDate = LocalDate.of(2026, 8, 20); + given(accounts.requireIdentityByEmail("intern@example.test")) + .willReturn(identity(3L, "Mai Intern", GlobalRole.INTERN, AccountStatus.ACTIVE)); + given(attendance.currentState(3L)).willReturn(AttendanceCurrentState.CHECKED_IN); + given(projects.dashboardSummary(3L)).willReturn(new ProjectDashboardSummary(2, 0)); + given(tasks.dashboard("intern@example.test")).willReturn(new TaskDashboardView( + 0, 6, List.of(new TaskPriorityView("Draft report", "Portal", TaskStatus.IN_PROGRESS, dueDate)))); + + assertThat(dashboards.intern("intern@example.test")) + .isEqualTo(new DashboardView.Intern( + "Mai Intern", + DashboardView.AttendanceState.CHECKED_IN, + 2, + 6, + List.of(new DashboardView.AssignedTask("Draft report", "Portal", "IN_PROGRESS", dueDate)))); + } + + @Test + void roleAndActiveStatusComeFromTheAccountServiceRatherThanGrantedAuthorities() { + given(accounts.requireIdentityByEmail("intern@example.test")) + .willReturn(identity(3L, "Mai Intern", GlobalRole.INTERN, AccountStatus.ACTIVE)); + given(accounts.requireIdentityByEmail("locked@example.test")) + .willReturn(identity(4L, "Locked Mentor", GlobalRole.MENTOR, AccountStatus.LOCKED)); + + assertThatThrownBy(() -> dashboards.admin("intern@example.test")) + .isInstanceOf(DashboardAccessDeniedException.class); + assertThatThrownBy(() -> dashboards.mentor("locked@example.test")) + .isInstanceOf(DashboardAccessDeniedException.class); + + verifyNoInteractions(projects, tasks, attendance); + } + + @Test + void missingAccountIsReportedAsDashboardAccessDenied() { + given(accounts.requireIdentityByEmail("missing@example.test")) + .willThrow(new IllegalArgumentException("Account not found")); + + assertThatThrownBy(() -> dashboards.intern("missing@example.test")) + .isInstanceOf(DashboardAccessDeniedException.class); + + verifyNoInteractions(projects, tasks, attendance); + } + + @Test + void ineligibleInternIsReportedAsDashboardAccessDenied() { + given(accounts.requireIdentityByEmail("intern@example.test")) + .willReturn(identity(3L, "Mai Intern", GlobalRole.INTERN, AccountStatus.ACTIVE)); + given(attendance.currentState(3L)) + .willThrow(new AttendanceException(AttendanceRejection.INACTIVE_INTERN)); + + assertThatThrownBy(() -> dashboards.intern("intern@example.test")) + .isInstanceOf(DashboardAccessDeniedException.class); + + verifyNoInteractions(projects, tasks); + } + + private static AccountIdentity identity( + long id, String displayName, GlobalRole role, AccountStatus status) { + return new AccountIdentity(id, role.name().toLowerCase() + "@example.test", displayName, role, status); + } +} From 2a9a1495203830ed0434649c153ee75e812ffe51 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:32:51 +0700 Subject: [PATCH 36/62] docs(project): record activation evidence --- docs/tests/integration/projects-workflows.md | 22 +++++++++++++------- docs/tests/unit/projects-domain.md | 19 +++++++++++------ docs/tests/web/projects-pages.md | 22 +++++++++++++------- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/docs/tests/integration/projects-workflows.md b/docs/tests/integration/projects-workflows.md index dd985a1..98bf180 100644 --- a/docs/tests/integration/projects-workflows.md +++ b/docs/tests/integration/projects-workflows.md @@ -1,14 +1,14 @@ # Test Evidence: Atomic Project workflows - **Test type:** Integration -- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-017`, `AUTH-001`–`AUTH-004`, `DB-003`, `DB-007` -- **Scenario IDs:** `AC-PRJ-001`–`AC-PRJ-003`, `AC-PRJ-009` +- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004`, `AUTH-011`, `DB-003`, `DB-007` +- **Scenario IDs:** `AC-AUTH-010`, `AC-PRJ-001`–`AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009` - **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest` -- **Implementation commit:** `25a855e` +- **Implementation commits:** `25a855e`, `dbf1202` ## Protected behavior -PostgreSQL transactions persist a planned Project with its initial membership and leadership term, reject unauthorized or duplicate direct additions, change exactly one Leader without moving Task assignments, and enforce role/membership visibility without ID disclosure. +PostgreSQL transactions persist a planned Project with its initial membership and leadership term, reject unauthorized or duplicate direct additions, change exactly one Leader without moving Task assignments, enforce role/membership visibility without ID disclosure, and activate only when current eligible membership/leadership and live-Task assignee guards pass. ## Test method @@ -16,7 +16,7 @@ A Spring Boot integration test uses the platform-owned PostgreSQL 18.4 Testconta ## Hand-derived expected result -Creation yields one Project, one active membership, and one current leadership term. Direct addition yields one membership per Project/Intern pair while allowing the same Intern in a second Project. Leader change yields one closed and one current term while the Task assignee ID remains unchanged. Admin, owner, and historical member visibility is allowed; unrelated IDs are denied uniformly. +Creation yields one Project, one active membership, and one current leadership term. Direct addition yields one membership per Project/Intern pair while allowing the same Intern in a second Project. Leader change yields one closed and one current term while the Task assignee ID remains unchanged. Activation persists `ACTIVE` and `activated_at` when every live Task is assigned to a current eligible membership; a live Task assigned to a closed membership leaves the Project `PLANNED` and the Task intact. Admin, owner, and historical member visibility is allowed; unrelated IDs are denied uniformly. ## RED @@ -52,10 +52,16 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ```text [INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest -[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` +## Activation transaction regression + +**RED:** the focused PostgreSQL activation tests failed at test compilation because `ProjectService.activate(long, long)` did not exist. + +**GREEN:** after wiring the locked Project aggregate to Account eligibility and `TaskQueryService.countCurrentTasksAssignedOutside`, both focused activation tests passed. The valid Project became `ACTIVE`; the former-member assignee case threw `ProjectRuleViolationException`, retained `PLANNED`, and preserved its live Task. + ## Affected suite **Command and result** @@ -66,10 +72,10 @@ export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 111, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` ## External-test boundaries -This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, real concurrent transaction races, Iteration 2 invitations/removals/completion, or Task-module business rules beyond preserving stored assignment IDs. `I1-PRJ-04` remains `IN_PROGRESS`: the activation Task-assignee guard will be implemented only after the Task feature exposes its concrete query service. +This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, a two-transaction lock race, or Iteration 2 invitations/removals/completion. Task query semantics have their own Task-owned unit evidence; this integration proves Project consumes that public service boundary atomically without importing Task persistence. diff --git a/docs/tests/unit/projects-domain.md b/docs/tests/unit/projects-domain.md index 27467e4..1d37f9a 100644 --- a/docs/tests/unit/projects-domain.md +++ b/docs/tests/unit/projects-domain.md @@ -4,11 +4,11 @@ - **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004` - **Scenario IDs:** `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009` - **Test class/method:** `com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest` -- **Implementation commit:** `25a855e` +- **Implementation commits:** `25a855e`, `dbf1202` ## Protected behavior -Project creation cannot produce an empty or leaderless aggregate; direct membership rejects ineligible or duplicate current members; leadership changes leave one current term; activation is owning-Mentor-only and rejects invalid Task assignees. +Project creation cannot produce an empty or leaderless aggregate; direct membership rejects ineligible or duplicate current members; leadership changes leave one current term; activation is owning-Mentor-only and requires an eligible active member, an eligible active current Leader, and valid current Task assignees. ## Test method @@ -16,7 +16,7 @@ Plain JUnit drives the aggregate through its public factory and mutation methods ## Hand-derived expected result -A planned Project starts with one current membership and one current leadership term. Adding a different eligible Intern yields two current memberships. Changing Leader closes one term and opens one term while retaining both memberships. Activation changes only `PLANNED` to `ACTIVE` when every supplied guard is true. +A planned Project starts with one current membership and one current leadership term. Adding a different eligible Intern yields two current memberships. Changing Leader closes one term and opens one term while retaining both memberships. Activation changes only `PLANNED` to `ACTIVE` when the owning Mentor acts, the supplied active-Intern set contains a current member and the current Leader, and every Task assignee guard passes. ## RED @@ -54,6 +54,12 @@ export PATH="$JAVA_HOME/bin:$PATH" [INFO] BUILD SUCCESS ``` +## Activation guard regression + +**RED:** after strengthening the aggregate test with the active-Intern set, compilation failed because `ProjectEntity.activate` still accepted only `(long, boolean, Instant)` and could not prove that the current Leader remained eligible and active. + +**GREEN:** after adding the active-Intern input and aggregate checks, `./mvnw -Dtest=ProjectEntityTest test` passed 6 tests with zero failures, errors, or skips. + ## Affected suite **Command and result** @@ -61,12 +67,13 @@ export PATH="$JAVA_HOME/bin:$PATH" ```text export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:$PATH" -./mvnw -Dtest=ProjectEntityTest test +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest='Project*Test' test -[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` ## External-test boundaries -This unit test does not prove JPA/Flyway mappings, PostgreSQL constraints or transaction concurrency, Spring Security routing, Task-module query integration, or browser rendering. +This unit test does not prove JPA/Flyway mappings, PostgreSQL constraints or transaction concurrency, Spring Security routing, the Task query implementation, or browser rendering; those are covered at their narrower integration and web layers. diff --git a/docs/tests/web/projects-pages.md b/docs/tests/web/projects-pages.md index 905adb2..44b9ffc 100644 --- a/docs/tests/web/projects-pages.md +++ b/docs/tests/web/projects-pages.md @@ -1,14 +1,14 @@ # Test Evidence: Authorized Project pages - **Test type:** Web -- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `SEC-001`, `ERR-001` -- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `I1-PRJ-05` +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `PRJ-012`, `SEC-001`, `ERR-001` +- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `AC-PRJ-006`, `I1-PRJ-04`, `I1-PRJ-05` - **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest` -- **Implementation commits:** `25a855e`, `a9ee99a` +- **Implementation commits:** `25a855e`, `a9ee99a`, `2f25731`, `dbf1202` ## Protected behavior -Authenticated users receive only authorized Project routes; guessed IDs return a non-disclosing not-found response; valid Mentor create requests use the authenticated identity; invalid forms do not mutate; state changes require CSRF. +Authenticated users receive only authorized Project routes; guessed IDs return a non-disclosing not-found response; valid Mentor create requests use the authenticated identity; invalid forms do not mutate; the planned-Project activation action is shown only to the owning Mentor; state changes require CSRF. ## Test method @@ -16,7 +16,7 @@ MockMvc exercises the real controller, binding, Bean Validation, exception mappi ## Hand-derived expected result -An authorized list request renders `projects/list`. An unauthorized direct ID returns 404. Member and leadership routes authorize through actor plus Project ID. A valid create redirects to the created detail ID; a blank name and zero Leader ID render field errors and make no service call. POST without CSRF returns 403. +An authorized list request renders `projects/list`. An unauthorized direct ID returns 404. Member and leadership routes authorize through actor plus Project ID. A valid create redirects to the created detail ID; a blank name and zero Leader ID render field errors and make no service call. An owning Mentor can submit activation and is redirected to detail; non-owners do not receive that control. POST without CSRF returns 403. ## RED @@ -50,7 +50,7 @@ export PATH="$JAVA_HOME/bin:$PATH" ```text [INFO] Running com.lab.labtimesheet.feature.project.controller.ProjectControllerTest -[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -64,7 +64,7 @@ export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 111, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -80,6 +80,12 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **GREEN:** rerunning `./mvnw -Dtest=ProjectControllerTest test` after resolving the public actor view and conditionally rendering the link passed 8 tests with zero failures, errors, or skips. +## Activation-route regression + +**RED:** the focused MockMvc run reported two expected failures: `POST /projects/30/activate` returned `404`, and the owning Mentor's planned-Project detail did not render the `Activate` action. + +**GREEN:** after adding the CSRF-protected POST route and owner/status-conditional Thymeleaf form, the two focused tests passed; the full `ProjectControllerTest` class passed 10 tests with zero failures, errors, or skips. + ## External-test boundaries -This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, browser accessibility, or Iteration 2 invitation/exit/completion pages. The activation route remains deferred with `I1-PRJ-04` until the Task feature query dependency is available. +This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, browser accessibility, or Iteration 2 invitation/exit/completion pages. Server-side activation authorization and Task-assignee atomicity are covered by Project domain and PostgreSQL integration tests. From 401f67671abe0bcfe9521e2bd201f89752432020 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:35:30 +0700 Subject: [PATCH 37/62] feat(ui): integrate project and task pages with shell --- .../web/project-task-shell-integration.md | 77 +++++++++++++++++++ src/main/resources/static/assets/app.css | 2 +- .../resources/templates/fragments/layout.html | 5 +- .../resources/templates/projects/detail.html | 27 +++++-- .../resources/templates/projects/form.html | 42 ++++++---- .../templates/projects/leadership.html | 30 ++++++-- .../resources/templates/projects/list.html | 40 ++++++---- .../resources/templates/projects/members.html | 30 ++++++-- .../resources/templates/tasks/detail.html | 50 +++++------- src/main/resources/templates/tasks/form.html | 44 ++++------- src/main/resources/templates/tasks/list.html | 48 +++++------- .../ProjectTaskShellContractTest.java | 38 +++++++++ 12 files changed, 297 insertions(+), 136 deletions(-) create mode 100644 docs/tests/web/project-task-shell-integration.md create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskShellContractTest.java diff --git a/docs/tests/web/project-task-shell-integration.md b/docs/tests/web/project-task-shell-integration.md new file mode 100644 index 0000000..b2f247a --- /dev/null +++ b/docs/tests/web/project-task-shell-integration.md @@ -0,0 +1,77 @@ +# Test Evidence: Project and Task shared-shell integration + +- **Test type:** Web +- **Requirement IDs:** `UI-003`, `UI-004`, `UI-007`, `UI-009`, `UI-013`, `I1-UI-04` +- **Scenario IDs:** `AC-UI-002`, `AC-UI-003`, `AC-UI-005` +- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskShellContractTest#projectAndTaskPageUsesSharedDesktopShell`, `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest`, `com.lab.labtimesheet.feature.task.controller.TaskControllerTest` +- **Implementation commit:** `pending` + +## Protected behavior + +Every Iteration 1 Project and Task page uses the same authenticated desktop shell, local assets, role-aware Project navigation, table containment, form controls, empty states, status badges, and `dd/MM/yyyy` date presentation. Existing capability-gated actions, server routes, validation, authentication, and CSRF contracts remain unchanged. + +## Test method + +The focused parameterized contract checks all five Project and three Task production templates for shared-shell composition and the active Project navigation marker. The affected Project and Task MVC slices then render the production templates through their real controllers while mocking only their feature service boundary, exercising route selection, authorization, form binding, validation, and action visibility. + +## Hand-derived expected result + +All eight templates reference `fragments/layout :: shell`, identify `projects` as the active navigation section, and contain no duplicate page ``. Mentor-only Project and Task creation controls remain capability-gated; Project members and leadership management stay hidden from non-managers; Task status/comment controls stay hidden when their capability flag is false. Empty Task progress remains `N/A`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectTaskShellContractTest test +``` + +**Observed result** + +```text +Tests run: 8, Failures: 8, Errors: 0, Skipped: 0 +Each standalone Project and Task template was missing "fragments/layout :: shell(". +BUILD FAILURE +Total time: 3.455 s +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +npm run build +./mvnw -Dtest=ProjectTaskShellContractTest test +``` + +**Observed result** + +```text +Tailwind CSS v4.3.3: Done in 63ms +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectTaskShellContractTest,ProjectControllerTest,TaskControllerTest test + +Tests run: 25, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 4.020 s +``` + +The first affected attempt additionally caught Thymeleaf trying to resolve a `null` action fragment on five pages. Replacing `null` with Thymeleaf's empty fragment token made the identical 25-test command green. + +## External-test boundaries + +The MVC slices verify server-rendered markup and security/control contracts but do not emulate a browser viewport or visually compare illustrative mockups. PostgreSQL query and mutation behavior remains covered by the feature-owned integration suites; final asset reproducibility and the full PostgreSQL suite are separate delivery gates. diff --git a/src/main/resources/static/assets/app.css b/src/main/resources/static/assets/app.css index ca7c7fd..cf183db 100644 --- a/src/main/resources/static/assets/app.css +++ b/src/main/resources/static/assets/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.hidden{display:none}.table{display:table}.border{border-style:var(--tw-border-style);border-width:1px}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.hidden{display:none}.table{display:table}.border{border-style:var(--tw-border-style);border-width:1px}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 7883668..8cc2a54 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -23,11 +23,10 @@ Overview
  • Accounts
  • Global calendar
  • -
  • Owned Projects
  • +
  • Owned Projects
  • Intern attendance
  • My attendance
  • -
  • My Projects
  • -
  • Assigned Tasks
  • +
  • My Projects
  • Current non-deleted Tasks
    TitleStatusDue date
    TitleAssigneeStatusDue date
    TaskAssignee TODO
    - -
    Leadership history
    LeaderStartedEnded
    -
    + +
    +
    +
    + +
    Leadership history
    LeaderStartedEnded
    LeaderStartedCurrent
    +
    +
    +
    + +
    diff --git a/src/main/resources/templates/projects/list.html b/src/main/resources/templates/projects/list.html index 67ec7ac..ff02c81 100644 --- a/src/main/resources/templates/projects/list.html +++ b/src/main/resources/templates/projects/list.html @@ -1,20 +1,32 @@ - -Projects + +Create Project
    -

    Projects

    - Create Project -

    No authorized Projects.

    - - - - - - - - -
    Authorized Projects
    NameStatusDates
    ProjectPLANNEDStartEnd
    +

    Projects visible to your current role and membership.

    +
    + +
    +
    +
    + + + + + + + + + +
    Authorized Projects
    NameStatusStartEnd
    ProjectPLANNED15/08/202630/09/2026
    +
    +
    diff --git a/src/main/resources/templates/projects/members.html b/src/main/resources/templates/projects/members.html index 8ee0e9f..e6319a2 100644 --- a/src/main/resources/templates/projects/members.html +++ b/src/main/resources/templates/projects/members.html @@ -1,13 +1,29 @@ - -Project members +
    -

    Project members

    - - -
    Membership history
    InternJoinedLeftRole
    -
    + +
    +
    +
    + +
    Membership history
    InternJoinedLeftRole
    InternJoinedCurrentMember
    +
    +
    +
    + +
    diff --git a/src/main/resources/templates/tasks/detail.html b/src/main/resources/templates/tasks/detail.html index 07d2417..ef682c4 100644 --- a/src/main/resources/templates/tasks/detail.html +++ b/src/main/resources/templates/tasks/detail.html @@ -1,36 +1,28 @@ - - - - - Task - +
    -

    Task

    -

    No description

    -

    Assignee: Assignee

    -

    Status: TODO

    -

    Due date:

    - -
    - - - +

    Task description.

    +
    +
    Assignee
    Assignee
    +
    Status
    TODO
    +
    Due date
    No due date
    +
    + +
    +
    - -
    -

    Comments

    -
      -
    1. Comment
    2. -
    -
    - - - -
    +
    +

    Comments

    +
    +
    Task comments
    CommentCreated
    CommentCreated
    +
    diff --git a/src/main/resources/templates/tasks/form.html b/src/main/resources/templates/tasks/form.html index c604e00..e526435 100644 --- a/src/main/resources/templates/tasks/form.html +++ b/src/main/resources/templates/tasks/form.html @@ -1,36 +1,22 @@ - - - - - Create Task - +
    -

    Create Task

    -
    -
    - - -

    Title error

    +

    Assign work to a current eligible Project member.

    + +
    +
    +
    +
    +
    -
    - - -
    -
    - - -

    Assignee error

    -
    -
    - - -
    - +
    Cancel
    diff --git a/src/main/resources/templates/tasks/list.html b/src/main/resources/templates/tasks/list.html index a4d468a..22da61f 100644 --- a/src/main/resources/templates/tasks/list.html +++ b/src/main/resources/templates/tasks/list.html @@ -1,33 +1,27 @@ - - - - - Project tasks - + +Create Task
    -

    Project tasks

    -

    Progress: N/A

    -
    -
    TODO
    0
    -
    IN_PROGRESS
    0
    -
    BLOCKED
    0
    -
    DONE
    0
    -
    -

    Create Task

    - - - - - - - - - - - -
    Current non-deleted Tasks
    TitleAssigneeStatusDue date
    TaskAssigneeTODO
    +

    Current non-deleted Tasks and Project completion progress.

    +
    +
    Progress
    N/A
    +
    TODO
    0
    +
    IN PROGRESS
    0
    +
    BLOCKED / DONE
    0 / 0
    +
    +
    +
    +
    + +
    Current non-deleted Tasks
    TitleAssigneeStatusDue date
    TaskAssigneeTODONo due date
    +
    diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskShellContractTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskShellContractTest.java new file mode 100644 index 0000000..59f652c --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskShellContractTest.java @@ -0,0 +1,38 @@ +package com.lab.labtimesheet.feature.reporting.controller; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class ProjectTaskShellContractTest { + + private static final Path TEMPLATES = Path.of("src/main/resources/templates"); + + @ParameterizedTest + @MethodSource("projectAndTaskTemplates") + void projectAndTaskPageUsesSharedDesktopShell(String relativeTemplate) throws IOException { + String template = Files.readString(TEMPLATES.resolve(relativeTemplate)); + + assertThat(template) + .contains("fragments/layout :: shell(") + .contains("activeNav='projects'") + .doesNotContain(""); + } + + private static Stream projectAndTaskTemplates() { + return Stream.of( + "projects/list.html", + "projects/form.html", + "projects/detail.html", + "projects/members.html", + "projects/leadership.html", + "tasks/list.html", + "tasks/form.html", + "tasks/detail.html"); + } +} From cbdbd8ee1303cfe98f59660414a11f2910898373 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:40:00 +0700 Subject: [PATCH 38/62] test(reporting): cover real role dashboard queries --- .../RoleDashboardWebIntegrationTest.java | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/controller/RoleDashboardWebIntegrationTest.java diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/RoleDashboardWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/RoleDashboardWebIntegrationTest.java new file mode 100644 index 0000000..9e9878e --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/RoleDashboardWebIntegrationTest.java @@ -0,0 +1,188 @@ +package com.lab.labtimesheet.feature.reporting.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import 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.account.service.BootstrapService; +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 com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand; +import com.lab.labtimesheet.feature.project.service.ProjectQueryService; +import com.lab.labtimesheet.feature.project.service.ProjectService; +import com.lab.labtimesheet.feature.task.model.TaskStatus; +import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand; +import com.lab.labtimesheet.feature.task.service.TaskService; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@Import({TestcontainersConfiguration.class, RoleDashboardWebIntegrationTest.MailProbeConfiguration.class}) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class RoleDashboardWebIntegrationTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private BootstrapService bootstrap; + + @Autowired + private AccountService accounts; + + @Autowired + private SmtpConfigurationService smtp; + + @Autowired + private RecordingSmtpProbe mail; + + @Autowired + private ProjectService projects; + + @Autowired + private ProjectQueryService projectQueries; + + @Autowired + private TaskService tasks; + + @Test + void mentorAndInternDashboardsRenderRealScopedProjectTaskAndAttendanceData() throws Exception { + long adminId = initializeAdminAndSmtp(); + long mentorId = createActiveAccount( + adminId, + new CreateAccountCommand( + "mentor@example.test", "Minh Mentor", GlobalRole.MENTOR, null, null, null)); + long internId = createActiveAccount( + adminId, + new CreateAccountCommand( + "intern@example.test", + "Mai Intern", + GlobalRole.INTERN, + "INT-001", + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 12, 31))); + accounts.activateInternship(internId, adminId); + + long projectId = projects.create( + mentorId, + new ProjectCreateCommand( + "Intern Portal", + "Portal refresh", + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 9, 30), + internId)); + projects.activate(mentorId, projectId); + long membershipId = projectQueries.taskContext(internId, projectId).currentLeaderMembershipId(); + var task = tasks.create( + "intern@example.test", + new CreateTaskCommand( + projectId, + membershipId, + "Resolve accessibility review", + null, + LocalDate.of(2026, 8, 20))); + tasks.changeStatus("intern@example.test", projectId, task.id(), TaskStatus.BLOCKED); + + mvc.perform(get("/dashboard").with(user("mentor@example.test").roles("MENTOR"))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Good morning, Minh Mentor"))) + .andExpect(content().string(containsString( + "Active owned Projects
    1"))) + .andExpect(content().string(containsString( + "Active members
    1"))) + .andExpect(content().string(containsString( + "Blocked Tasks
    1"))); + + mvc.perform(get("/dashboard").with(user("intern@example.test").roles("INTERN"))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Attendance, assigned work, and Project activity for Mai Intern."))) + .andExpect(content().string(containsString("Not checked in"))) + .andExpect(content().string(containsString( + "Active Projects
    1"))) + .andExpect(content().string(containsString( + "Assigned Tasks
    1"))) + .andExpect(content().string(containsString("Resolve accessibility review"))) + .andExpect(content().string(containsString("BLOCKED"))) + .andExpect(content().string(containsString("20/08/2026"))); + } + + private long initializeAdminAndSmtp() { + bootstrap.bootstrap("admin@example.test", "Admin", "correct horse battery staple"); + long adminId = accounts.requireActiveAdminId("admin@example.test"); + long draftId = smtp.saveDraft(adminId, new SmtpDraft( + "mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.test", "Lab Timesheet")); + smtp.testDraft(draftId, adminId, "admin@example.test"); + smtp.activate(draftId, adminId); + mail.clear(); + return adminId; + } + + private long createActiveAccount(long adminId, CreateAccountCommand command) { + var creation = accounts.create(command, adminId); + assertThat(creation.deliverySucceeded()).isTrue(); + assertThat(accounts.activate(mail.activationTokenFor(command.email()), "correct horse battery staple")) + .isTrue(); + return creation.userId(); + } + + @TestConfiguration(proxyBeanMethods = false) + static class MailProbeConfiguration { + + @Bean + @Primary + RecordingSmtpProbe recordingSmtpProbe() { + return new RecordingSmtpProbe(); + } + } + + static final class RecordingSmtpProbe implements SmtpProbe { + private final List 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) { + } +} From c5c143c3cd9aaf447b69ece11edb0dfb5a29c8a7 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:46:23 +0700 Subject: [PATCH 39/62] fix(ui): add project and task error summaries --- docs/tests/web/account-shell-integration.md | 4 +-- .../tests/web/attendance-shell-integration.md | 2 +- .../web/project-task-shell-integration.md | 34 +++++++++++++++++-- docs/tests/web/role-dashboard-routing.md | 16 ++++++--- docs/tests/web/theme-token-contrast.md | 2 +- src/main/resources/static/assets/app.css | 2 +- .../resources/templates/projects/form.html | 8 +++-- src/main/resources/templates/tasks/form.html | 3 +- .../ProjectTaskShellContractTest.java | 14 ++++++++ 9 files changed, 71 insertions(+), 14 deletions(-) diff --git a/docs/tests/web/account-shell-integration.md b/docs/tests/web/account-shell-integration.md index 12ad520..548b4ca 100644 --- a/docs/tests/web/account-shell-integration.md +++ b/docs/tests/web/account-shell-integration.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `UI-001`, `UI-002`, `UI-004`, `UI-009`, `I1-PLAT-06`, `I1-UI-04` - **Scenario IDs:** `AC-UI-001`, `AC-UI-002`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AccountTemplateIntegrationTest` -- **Implementation commit:** `pending` +- **Implementation commits:** `7dd61b9`, `f48fc63`, `f9ddef6` ## Protected behavior @@ -90,6 +90,7 @@ PostgreSQL 18.4 Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Total time: 18.361 s +``` Bootstrap-specific affected suite: @@ -100,7 +101,6 @@ Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Total time: 17.350 s ``` -``` ## External-test boundaries diff --git a/docs/tests/web/attendance-shell-integration.md b/docs/tests/web/attendance-shell-integration.md index 706450d..29741f5 100644 --- a/docs/tests/web/attendance-shell-integration.md +++ b/docs/tests/web/attendance-shell-integration.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `UI-001`, `UI-002`, `UI-003`, `UI-008`, `I1-ATT-03`, `I1-UI-04` - **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-UI-001`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `3064485` ## Protected behavior diff --git a/docs/tests/web/project-task-shell-integration.md b/docs/tests/web/project-task-shell-integration.md index b2f247a..8aa0773 100644 --- a/docs/tests/web/project-task-shell-integration.md +++ b/docs/tests/web/project-task-shell-integration.md @@ -4,11 +4,12 @@ - **Requirement IDs:** `UI-003`, `UI-004`, `UI-007`, `UI-009`, `UI-013`, `I1-UI-04` - **Scenario IDs:** `AC-UI-002`, `AC-UI-003`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskShellContractTest#projectAndTaskPageUsesSharedDesktopShell`, `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest`, `com.lab.labtimesheet.feature.task.controller.TaskControllerTest` -- **Implementation commit:** `pending` +- **Implementation and final-Project integration commits:** `401f676`, `4849e0b` ## Protected behavior Every Iteration 1 Project and Task page uses the same authenticated desktop shell, local assets, role-aware Project navigation, table containment, form controls, empty states, status badges, and `dd/MM/yyyy` date presentation. Existing capability-gated actions, server routes, validation, authentication, and CSRF contracts remain unchanged. +Project and Task forms provide both an error summary and inline field errors for failed server validation. ## Test method @@ -70,7 +71,36 @@ BUILD SUCCESS Total time: 4.020 s ``` -The first affected attempt additionally caught Thymeleaf trying to resolve a `null` action fragment on five pages. Replacing `null` with Thymeleaf's empty fragment token made the identical 25-test command green. +The first affected attempt additionally caught Thymeleaf trying to resolve a `null` action fragment on five pages. Replacing `null` with Thymeleaf's empty fragment token made the identical 25-test command green. After merging the final Project activation pin, the one overlapping detail template retained both the shell and the capability-gated activation form; the focused Project/shell set passed 25 tests. + +Final-review form-summary regression: + +```text +./mvnw -Dtest=ProjectTaskShellContractTest test +RED: Tests run: 10, Failures: 2, Errors: 0, Skipped: 0 +Both forms were missing #fields.hasAnyErrors() and #fields.allErrors(). + +./mvnw -Dtest=ProjectTaskShellContractTest,ProjectControllerTest,TaskControllerTest test +GREEN: Tests run: 29, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 4.214 s +``` + +## Final delivery gate + +```text +npm ci +added 34 packages; audited 35 packages; 0 vulnerabilities + +npm run build +Tailwind CSS v4.3.3: Done in 68ms + +./mvnw test +PostgreSQL 18.4 via Testcontainers +Tests run: 152, Failures: 0, Errors: 0, Skipped: 0 +36 Surefire reports +BUILD SUCCESS +``` ## External-test boundaries diff --git a/docs/tests/web/role-dashboard-routing.md b/docs/tests/web/role-dashboard-routing.md index b749805..1488d87 100644 --- a/docs/tests/web/role-dashboard-routing.md +++ b/docs/tests/web/role-dashboard-routing.md @@ -3,8 +3,8 @@ - **Test type:** Web and unit - **Requirement IDs:** `AUTH-003`, `UI-003`, `UI-013`, `I1-UI-03` - **Scenario IDs:** `AC-AUTH-002`, `AC-UI-005` -- **Test class/method:** `com.lab.labtimesheet.feature.reporting.service.DashboardServiceTest`, `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AdminDashboardWebTest`, `com.lab.labtimesheet.feature.reporting.ReportingArchitectureTest` -- **Implementation commit:** `pending` +- **Test class/method:** `com.lab.labtimesheet.feature.reporting.service.DashboardServiceTest`, `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AdminDashboardWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest`, `com.lab.labtimesheet.feature.reporting.ReportingArchitectureTest` +- **Implementation and integration-test commits:** `b1c6b17`, `cbdbd8e` ## Protected behavior @@ -12,7 +12,7 @@ ## Test method -The unit test supplies mocked concrete public feature services to the reporting coordinator and independently checks the exact Admin, Mentor, and Intern view DTOs, including Task-status and attendance-state translation. Negative cases prove that a forged authority, locked account, missing account, or inactive internship cannot produce a dashboard. The MVC slice proves role-to-template routing and authentication. The PostgreSQL web test bootstraps a real Admin through `BootstrapService` and exercises the complete authenticated route without SQL fixtures. +The unit test supplies mocked concrete public feature services to the reporting coordinator and independently checks the exact Admin, Mentor, and Intern view DTOs, including Task-status and attendance-state translation. Negative cases prove that a forged authority, locked account, missing account, or inactive internship cannot produce a dashboard. The MVC slice proves role-to-template routing and authentication. PostgreSQL web tests bootstrap a real Admin and create/activate Mentor and Intern identities, SMTP configuration, a Project, and a Task only through public application services; they then exercise all three authenticated dashboard roles without repository, entity, JDBC, or SQL fixtures. ## Hand-derived expected result @@ -74,8 +74,16 @@ PostgreSQL 18.4 via Testcontainers Tests run: 18, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Total time: 11.409 s + +Production-shaped Mentor/Intern query journey: + +./mvnw -Dtest=RoleDashboardWebIntegrationTest test +PostgreSQL 18.4 via Testcontainers +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 11.119 s ``` ## External-test boundaries -The focused tests prove reporting composition, route selection, denial behavior, and one production-shaped Admin journey. Feature-owned suites separately prove the Project, Task, Attendance, and Account query semantics. Browser viewport behavior remains an integrated UI gate. +The focused tests prove reporting composition, route selection, denial behavior, and production-shaped Admin, Mentor, and Intern journeys. Feature-owned suites separately prove additional Project, Task, Attendance, and Account query semantics. Browser viewport behavior remains an external UI boundary. diff --git a/docs/tests/web/theme-token-contrast.md b/docs/tests/web/theme-token-contrast.md index 715ca4f..c8267cc 100644 --- a/docs/tests/web/theme-token-contrast.md +++ b/docs/tests/web/theme-token-contrast.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `UI-005`, `UI-006`, `UI-010`, `UI-018`, `I1-UI-02` - **Scenario IDs:** `AC-UI-003`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast` -- **Implementation commit:** `pending` +- **Implementation commit:** `3343745` ## Protected behavior diff --git a/src/main/resources/static/assets/app.css b/src/main/resources/static/assets/app.css index cf183db..450c03d 100644 --- a/src/main/resources/static/assets/app.css +++ b/src/main/resources/static/assets/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.hidden{display:none}.table{display:table}.border{border-style:var(--tw-border-style);border-width:1px}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.hidden{display:none}.inline{display:inline}.table{display:table}.border{border-style:var(--tw-border-style);border-width:1px}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/src/main/resources/templates/projects/form.html b/src/main/resources/templates/projects/form.html index 0e754f2..3ad736b 100644 --- a/src/main/resources/templates/projects/form.html +++ b/src/main/resources/templates/projects/form.html @@ -10,6 +10,10 @@

    Define the initial Project window and Leader. Membership changes remain server-authorized.

    +
    @@ -20,8 +24,8 @@
    -
    -
    +
    +
    diff --git a/src/main/resources/templates/tasks/form.html b/src/main/resources/templates/tasks/form.html index e526435..45007ad 100644 --- a/src/main/resources/templates/tasks/form.html +++ b/src/main/resources/templates/tasks/form.html @@ -10,11 +10,12 @@

    Assign work to a current eligible Project member.

    +
    -
    +
    Cancel
    diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskShellContractTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskShellContractTest.java index 59f652c..efcd7e5 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskShellContractTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskShellContractTest.java @@ -24,6 +24,16 @@ class ProjectTaskShellContractTest { .doesNotContain(""); } + @ParameterizedTest + @MethodSource("projectAndTaskForms") + void projectAndTaskFormProvidesAnErrorSummary(String relativeTemplate) throws IOException { + String template = Files.readString(TEMPLATES.resolve(relativeTemplate)); + + assertThat(template) + .contains("#fields.hasAnyErrors()") + .contains("#fields.allErrors()"); + } + private static Stream projectAndTaskTemplates() { return Stream.of( "projects/list.html", @@ -35,4 +45,8 @@ class ProjectTaskShellContractTest { "tasks/form.html", "tasks/detail.html"); } + + private static Stream projectAndTaskForms() { + return Stream.of("projects/form.html", "tasks/form.html"); + } } From 6181984cf85f184be39513d6313f9cbe8267add5 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:15:48 +0700 Subject: [PATCH 40/62] fix platform security and SMTP boundaries --- .../config/SecurityConfiguration.java | 6 +- .../controller/BootstrapAccessFilter.java | 2 +- .../account/service/AccountService.java | 17 +++++- .../service/JavaMailSmtpProbe.java | 46 ++++++++++++--- .../SecurityResponseIntegrationTest.java | 55 ++++++++++++++++++ .../AccountActivationIntegrationTest.java | 21 +++++++ .../service/JavaMailSmtpProbeTest.java | 58 +++++++++++++++++++ .../resources/static/assets/review-test.css | 1 + 8 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 src/test/java/com/lab/labtimesheet/config/SecurityResponseIntegrationTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbeTest.java create mode 100644 src/test/resources/static/assets/review-test.css diff --git a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java index b4a4361..5fde489 100644 --- a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java @@ -9,6 +9,7 @@ import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy; @Configuration(proxyBeanMethods = false) class SecurityConfiguration { @@ -27,10 +28,13 @@ class SecurityConfiguration { throws Exception { return http .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/bootstrap/**", "/activate/**", "/login", "/error", "/actuator/health") + .requestMatchers( + "/bootstrap/**", "/activate/**", "/login", "/error", "/assets/**", + "/actuator/health") .permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) + .headers(headers -> headers.referrerPolicy(policy -> policy.policy(ReferrerPolicy.NO_REFERRER))) .formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", true)) .logout(logout -> logout.logoutSuccessUrl("/login?logout")) .addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class) diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java index a7afe85..c9472bc 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java @@ -29,7 +29,7 @@ public class BootstrapAccessFilter extends OncePerRequestFilter { private static boolean allowedBeforeBootstrap(String path) { return path.equals("/bootstrap") || path.startsWith("/bootstrap/") - || path.equals("/actuator/health") || path.startsWith("/bootstrap-assets/") + || path.equals("/actuator/health") || path.startsWith("/assets/") || path.equals("/error"); } } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java index 4c776a9..4a806bc 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java @@ -113,6 +113,14 @@ public class AccountService { return true; } + /** + * Moves an active Intern's internship from {@code NOT_STARTED} to {@code ACTIVE} once the configured + * internship start date has arrived in the application's business timezone. + * + * @param internUserId Intern account whose internship should start + * @param adminId active Admin authorizing the state transition + * @throws IllegalStateException when the internship start date has not arrived + */ @Transactional public void activateInternship(long internUserId, long adminId) { AppUser admin = users.findById(adminId) @@ -124,9 +132,12 @@ public class AccountService { if (intern.getGlobalRole() != GlobalRole.INTERN || intern.getAccountStatus() != AccountStatus.ACTIVE) { throw new IllegalArgumentException("An active Intern account is required"); } - internProfiles.findForUpdateByUserId(internUserId) - .orElseThrow(() -> new IllegalArgumentException("Intern profile not found")) - .activate(clock.instant()); + InternProfile profile = internProfiles.findForUpdateByUserId(internUserId) + .orElseThrow(() -> new IllegalArgumentException("Intern profile not found")); + if (LocalDate.now(clock).isBefore(profile.getInternshipStartDate())) { + throw new IllegalStateException("Internship cannot activate before its start date"); + } + profile.activate(clock.instant()); } @Transactional(readOnly = true) diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbe.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbe.java index 1783a39..c90e9a4 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbe.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbe.java @@ -1,18 +1,40 @@ package com.lab.labtimesheet.feature.integration.service; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; import java.util.Properties; +import java.util.function.Supplier; import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; import com.lab.labtimesheet.feature.integration.model.SecurityMode; -import org.springframework.mail.SimpleMailMessage; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Component; +/** + * Sends immediate SMTP messages through a freshly configured JavaMail client. + * Connections are bounded by finite network timeouts so an Admin test or + * activation delivery cannot block a request indefinitely. + */ @Component class JavaMailSmtpProbe implements SmtpProbe { + private static final String TIMEOUT_MILLIS = "5000"; + + private final Supplier senderFactory; + + JavaMailSmtpProbe() { + this(JavaMailSenderImpl::new); + } + + JavaMailSmtpProbe(Supplier senderFactory) { + this.senderFactory = senderFactory; + } + @Override public void send(SmtpConnection connection, String recipient, String subject, String body) { - JavaMailSenderImpl sender = new JavaMailSenderImpl(); + JavaMailSenderImpl sender = senderFactory.get(); sender.setHost(connection.host()); sender.setPort(connection.port()); sender.setUsername(connection.username()); @@ -24,11 +46,21 @@ class JavaMailSmtpProbe implements SmtpProbe { } else if (connection.securityMode() == SecurityMode.TLS) { sender.setProtocol("smtps"); } - SimpleMailMessage message = new SimpleMailMessage(); - message.setFrom(connection.fromAddress()); - message.setTo(recipient); - message.setSubject(subject); - message.setText(body); + String propertyPrefix = connection.securityMode() == SecurityMode.TLS ? "mail.smtps" : "mail.smtp"; + properties.setProperty(propertyPrefix + ".connectiontimeout", TIMEOUT_MILLIS); + properties.setProperty(propertyPrefix + ".timeout", TIMEOUT_MILLIS); + properties.setProperty(propertyPrefix + ".writetimeout", TIMEOUT_MILLIS); + + MimeMessage message = sender.createMimeMessage(); + try { + MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name()); + helper.setFrom(connection.fromAddress(), connection.fromName()); + helper.setTo(recipient); + helper.setSubject(subject); + helper.setText(body); + } catch (MessagingException | UnsupportedEncodingException exception) { + throw new IllegalStateException("Unable to construct SMTP message", exception); + } sender.send(message); } } diff --git a/src/test/java/com/lab/labtimesheet/config/SecurityResponseIntegrationTest.java b/src/test/java/com/lab/labtimesheet/config/SecurityResponseIntegrationTest.java new file mode 100644 index 0000000..3ce4323 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/config/SecurityResponseIntegrationTest.java @@ -0,0 +1,55 @@ +package com.lab.labtimesheet.config; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.lab.labtimesheet.feature.account.service.BootstrapService; +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.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class SecurityResponseIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private BootstrapService bootstrap; + + @Test + void assetsRemainPublicBeforeAndAfterBootstrap() throws Exception { + mockMvc.perform(get("/assets/review-test.css").with(anonymous())) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("asset"))); + + bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); + + mockMvc.perform(get("/assets/review-test.css").with(anonymous())) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("asset"))); + } + + @Test + void authenticationAndActivationResponsesDoNotSendReferrers() throws Exception { + bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); + + mockMvc.perform(get("/login")) + .andExpect(status().isOk()) + .andExpect(header().string("Referrer-Policy", "no-referrer")); + mockMvc.perform(get("/activate").param("token", "non-secret-test-fixture")) + .andExpect(status().isOk()) + .andExpect(header().string("Referrer-Policy", "no-referrer")); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/account/service/AccountActivationIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/AccountActivationIntegrationTest.java index 59237cc..2714969 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/service/AccountActivationIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/AccountActivationIntegrationTest.java @@ -33,11 +33,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Primary; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; @Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class}) @SpringBootTest @ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class AccountActivationIntegrationTest { @Autowired @@ -138,6 +140,25 @@ class AccountActivationIntegrationTest { assertThat(summary.activeInternships()).isEqualTo(1); } + @Test + void internshipCannotActivateBeforeItsBusinessStartDate() { + bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); + long adminId = accounts.requireActiveAdminId("admin@example.com"); + activateSmtp(adminId); + mail.messages.clear(); + + var creation = accounts.create(new CreateAccountCommand( + "future-intern@example.com", "Future Intern", GlobalRole.INTERN, "STU-FUTURE", + LocalDate.of(2026, 8, 15), LocalDate.of(2026, 12, 31)), adminId); + assertThat(accounts.activate(mail.onlyActivationToken(), "future secure password")).isTrue(); + + assertThatThrownBy(() -> accounts.activateInternship(creation.userId(), adminId)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("start date"); + assertThat(internProfiles.findById(creation.userId()).orElseThrow().getInternshipStatus()) + .isEqualTo(InternshipStatus.NOT_STARTED); + } + private void activateSmtp(long adminId) { long draftId = smtp.saveDraft(adminId, new SmtpDraft( "mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet")); diff --git a/src/test/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbeTest.java b/src/test/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbeTest.java new file mode 100644 index 0000000..6d78a44 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbeTest.java @@ -0,0 +1,58 @@ +package com.lab.labtimesheet.feature.integration.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.lab.labtimesheet.feature.integration.model.SecurityMode; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.Test; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +class JavaMailSmtpProbeTest { + @Test + void appliesFiniteTimeoutsAndConfiguredFromName() throws Exception { + var sender = new CapturingMailSender(); + var probe = new JavaMailSmtpProbe(() -> sender); + var connection = new SmtpConnection( + "smtp.example.com", 587, SecurityMode.STARTTLS, "user", "password", + "noreply@example.com", "Lab Timesheet"); + + probe.send(connection, "admin@example.com", "Subject", "Body"); + + assertThat(sender.getJavaMailProperties()) + .containsEntry("mail.smtp.connectiontimeout", "5000") + .containsEntry("mail.smtp.timeout", "5000") + .containsEntry("mail.smtp.writetimeout", "5000"); + var from = (InternetAddress) sender.message.getFrom()[0]; + assertThat(from.getAddress()).isEqualTo("noreply@example.com"); + assertThat(from.getPersonal()).isEqualTo("Lab Timesheet"); + } + + @Test + void appliesFiniteTimeoutsToImplicitTlsTransport() { + var sender = new CapturingMailSender(); + var probe = new JavaMailSmtpProbe(() -> sender); + var connection = new SmtpConnection( + "smtp.example.com", 465, SecurityMode.TLS, null, null, + "noreply@example.com", "Lab Timesheet"); + + probe.send(connection, "admin@example.com", "Subject", "Body"); + + assertThat(sender.getProtocol()).isEqualTo("smtps"); + assertThat(sender.getJavaMailProperties()) + .containsEntry("mail.smtps.connectiontimeout", "5000") + .containsEntry("mail.smtps.timeout", "5000") + .containsEntry("mail.smtps.writetimeout", "5000"); + } + + static final class CapturingMailSender extends JavaMailSenderImpl { + private MimeMessage message; + + @Override + public void send(MimeMessage... mimeMessages) { + assertThat(mimeMessages).hasSize(1); + message = mimeMessages[0]; + } + } +} diff --git a/src/test/resources/static/assets/review-test.css b/src/test/resources/static/assets/review-test.css new file mode 100644 index 0000000..2bd69c0 --- /dev/null +++ b/src/test/resources/static/assets/review-test.css @@ -0,0 +1 @@ +asset From 8388b4cc9bcad6fb756fd1a310f8c2871991d344 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:17:01 +0700 Subject: [PATCH 41/62] fix(ui): resolve round one shell findings --- .../tests/web/attendance-shell-integration.md | 6 +- .../web/project-task-shell-integration.md | 2 +- docs/tests/web/review-round-1-shared-ui.md | 93 +++++++++++++++ docs/tests/web/ui-shell-components.md | 4 +- .../controller/DashboardController.java | 20 ++++ .../DashboardAccessDeniedException.java | 10 ++ .../reporting/model/dto/DashboardView.java | 44 +++++++ .../reporting/service/DashboardService.java | 49 ++++++++ .../templates/attendance/history.html | 13 ++- .../resources/templates/error/generic.html | 19 +++ .../resources/templates/fragments/layout.html | 6 +- .../resources/templates/projects/form.html | 12 +- src/main/resources/templates/tasks/form.html | 6 +- .../AttendanceTemplateIntegrationTest.java | 39 +++++++ .../ProjectTaskFormAccessibilityWebTest.java | 108 ++++++++++++++++++ .../RoleDashboardWebIntegrationTest.java | 29 +++++ .../SharedErrorTemplateWebTest.java | 69 +++++++++++ .../labtimesheet/ui/UiContractWebTest.java | 21 +++- 18 files changed, 526 insertions(+), 24 deletions(-) create mode 100644 docs/tests/web/review-round-1-shared-ui.md create mode 100644 src/main/resources/templates/error/generic.html create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java diff --git a/docs/tests/web/attendance-shell-integration.md b/docs/tests/web/attendance-shell-integration.md index 29741f5..80f5634 100644 --- a/docs/tests/web/attendance-shell-integration.md +++ b/docs/tests/web/attendance-shell-integration.md @@ -1,18 +1,18 @@ # Test Evidence: attendance shell integration - **Test type:** Web -- **Requirement IDs:** `UI-001`, `UI-002`, `UI-003`, `UI-008`, `I1-ATT-03`, `I1-UI-04` +- **Requirement IDs:** `UI-001`, `UI-002`, `UI-003`, `UI-008`, `UI-013`, `I1-ATT-03`, `I1-UI-04` - **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-UI-001`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest` - **Implementation commit:** `3064485` ## Protected behavior -The Intern attendance-history and Admin global-calendar pages consume the role-aware shared shell while preserving their existing routes, CSRF-protected mutation forms, filter values, empty states, and local theme assets. +The Intern attendance-history and Admin global-calendar pages consume the role-aware shared shell while preserving their existing routes, CSRF-protected mutation forms, filter values, empty states, and local theme assets. Populated history presents `dd/MM/yyyy` dates and 24-hour times in the attached policy timezone and does not collapse simultaneous violations. ## Test method -A focused MockMvc slice supplies empty production-shaped models to the two production Attendance templates and renders them with role-specific Spring Security principals. The owning feature's `AttendanceControllerTest` remains the affected behavioral suite for authorization, punch actions, calendar mutation, and view selection. +A focused MockMvc slice supplies empty and populated production-shaped models to the two production Attendance templates and renders them with role-specific Spring Security principals. The populated fixture uses UTC instants, the attached `Asia/Ho_Chi_Minh` seeded policy, and late-plus-early and late-plus-missing combinations. The owning feature's `AttendanceControllerTest` remains the affected behavioral suite for authorization, punch actions, calendar mutation, and view selection. ## Hand-derived expected result diff --git a/docs/tests/web/project-task-shell-integration.md b/docs/tests/web/project-task-shell-integration.md index 8aa0773..e8a5ea9 100644 --- a/docs/tests/web/project-task-shell-integration.md +++ b/docs/tests/web/project-task-shell-integration.md @@ -9,7 +9,7 @@ ## Protected behavior Every Iteration 1 Project and Task page uses the same authenticated desktop shell, local assets, role-aware Project navigation, table containment, form controls, empty states, status badges, and `dd/MM/yyyy` date presentation. Existing capability-gated actions, server routes, validation, authentication, and CSRF contracts remain unchanged. -Project and Task forms provide both an error summary and inline field errors for failed server validation. +Project and Task forms provide both an error summary and inline field errors for failed server validation. Every inline error has a stable ID and every invalid control references that ID through `aria-describedby`. ## Test method diff --git a/docs/tests/web/review-round-1-shared-ui.md b/docs/tests/web/review-round-1-shared-ui.md new file mode 100644 index 0000000..74d74ce --- /dev/null +++ b/docs/tests/web/review-round-1-shared-ui.md @@ -0,0 +1,93 @@ +# Test Evidence: round-one shared UI corrections + +- **Test type:** Web +- **Requirement IDs:** `AUTH-002`, `UI-003`, `UI-004`, `UI-010`, `UI-013`, `UI-014`, `ERR-001`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04` +- **Scenario IDs:** `AC-AUTH-001`, `AC-UI-002`, `AC-UI-003`, `AC-UI-005` +- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest#populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation`, `com.lab.labtimesheet.feature.reporting.controller.SharedErrorTemplateWebTest`, `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskFormAccessibilityWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest#mentorAndInternDashboardsRenderRealScopedProjectTaskAndAttendanceData` +- **Implementation commit:** `pending` + +## Protected behavior + +The authenticated shell exposes only reachable role-authorized links. Intern attendance uses `/attendance`; Mentor attendance, profile, and notification links remain hidden until their authorized destination flows exist. Every rendered role-navigation link resolves through an actual authenticated GET. Attendance history uses the row's attached policy timezone for 24-hour times, formats business dates as `dd/MM/yyyy`, and renders every simultaneous violation. Project and Task field errors have stable IDs associated to invalid controls. Generic 404 and 409 pages use the shared shell and safe caller-supplied copy without rendering exception details. + +## Test method + +MockMvc renders the production shell and templates with real Spring Security principals and production-shaped Attendance DTOs. Project and Task invalid POSTs pass through their real controllers and validation, with only feature services replaced at the slice boundary. The full Spring/PostgreSQL role journey creates accounts, internship, Project, and Task through public services, renders each role's real dashboard, extracts every visible shell link, and performs an authenticated GET against each extracted path. + +## Hand-derived expected result + +Mentor navigation contains only overview and owned Projects; Intern navigation contains overview, `/attendance`, and Projects; Admin navigation contains overview, account creation, and global calendar. No role receives `/attendance/me`, `/profile`, `/notifications`, or a selector-less Mentor attendance destination. `2026-08-14T02:05:00Z` under `Asia/Ho_Chi_Minh` renders as `14/08/2026 09:05`; `09:00:00Z` renders as `16:00`. Late plus early-departure and late plus missing-checkout labels are both retained. Every rendered validation message has a stable referenced ID. Error pages expose only status and generic copy. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest test +./mvnw -Dtest=RoleDashboardWebIntegrationTest test +``` + +**Observed result** + +```text +Focused templates: Tests run: 13, Failures: 6, Errors: 2, Skipped: 0 +Navigation exposed /attendance/me, selector-less Mentor attendance, /profile, and /notifications. +Attendance rendered ISO dates/raw UTC instants and only one violation. +error/generic did not exist. +Invalid controls had no aria-describedby and inline errors had no stable IDs. + +PostgreSQL 18.4 role journey: Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +Following the Admin shell's visible /profile link returned 404 instead of 200. +BUILD FAILURE +``` + +The failures occurred after real template rendering and controller validation; they identify the missing reviewed behavior rather than fixture or environment failure. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest test +./mvnw -Dtest=RoleDashboardWebIntegrationTest test +``` + +**Observed result** + +```text +Focused templates: Tests run: 13, Failures: 0, Errors: 0, Skipped: 0 +PostgreSQL 18.4 role journey: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +npm run build +./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,AttendanceControllerTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest,ProjectControllerTest,TaskControllerTest,RoleDashboardWebIntegrationTest test +./mvnw -DskipTests compile +./mvnw -DskipTests -Ddoclint=all javadoc:javadoc + +Node v24.19.0; npm 11.17.0 +Tailwind CSS v4.3.3: Done in 68ms +PostgreSQL 18.4 via Testcontainers +Tests run: 40, Failures: 0, Errors: 0, Skipped: 0 +Compile: success +Javadoc/doclint: success +BUILD SUCCESS +``` + +## External-test boundaries + +The automated checks prove rendering, controller validation, role-scoped navigation targets, attached-policy formatting, and generic error copy. They do not prove first-paint timing, keyboard focus/tooltips, runtime `aria-expanded` synchronization, or viewport overflow; those remain mandatory live desktop browser gates after the corrected producer pins are merged. diff --git a/docs/tests/web/ui-shell-components.md b/docs/tests/web/ui-shell-components.md index 97afb28..3d381cc 100644 --- a/docs/tests/web/ui-shell-components.md +++ b/docs/tests/web/ui-shell-components.md @@ -8,7 +8,7 @@ ## Protected behavior -Domain-owned Thymeleaf pages can render inside one desktop shell with role-filtered navigation, accessible controls/states, pre-paint local theme loading, and committed local CSS/JavaScript/Lucide assets. The tests catch missing fragments, unauthorized navigation leakage, inaccessible shared form/status markup, remote icon references, or a theme bootstrap loaded after CSS. +Domain-owned Thymeleaf pages can render inside one desktop shell with role-filtered navigation, accessible controls/states, pre-paint local theme loading, and committed local CSS/JavaScript/Lucide assets. The tests catch missing fragments, unauthorized or dead navigation links, inaccessible shared form/status markup, remote icon references, or a theme bootstrap loaded after CSS. ## Test method @@ -16,7 +16,7 @@ A test-only domain page consumes the production layout fragment through MockMvc ## Hand-derived expected result -A Mentor sees `Owned Projects`, theme, profile identity, and logout, but not Admin `Accounts` or Intern `My attendance`. The theme script occurs before the stylesheet. Form label/control IDs match, errors use `role="alert"`, status includes a textual accessible name, confirmation copy is described, and the reduced sprite contains the selected symbols without remote resource references. +A Mentor sees `Owned Projects`, account identity, theme, and logout, but not Admin `Accounts`, Intern `My attendance`, or selector-less Intern attendance. An Intern's attendance link targets the real `/attendance` route. Unimplemented profile and notification destinations are not exposed. The theme script occurs before the stylesheet. Form label/control IDs match, errors use `role="alert"`, status includes a textual accessible name, confirmation copy is described, and the reduced sprite contains the selected symbols without remote resource references. ## RED diff --git a/src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java b/src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java index 18f5506..6d1710f 100644 --- a/src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java +++ b/src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java @@ -7,15 +7,35 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +/** + * Selects the dashboard view for the authenticated global authority. + * + *

    The authority selects only which role-specific flow to invoke. The reporting service then + * reloads and revalidates the persisted account role and lifecycle before returning any data. + */ @Controller public class DashboardController { private final DashboardService dashboardService; + /** + * Creates the dashboard endpoint backed by the reporting composition service. + * + * @param dashboardService service that authorizes and assembles role-scoped dashboard data + */ public DashboardController(DashboardService dashboardService) { this.dashboardService = dashboardService; } + /** + * Renders the dashboard permitted by the caller's authenticated global role. + * + * @param authentication authenticated caller whose name is the persisted account email + * @param model Thymeleaf model populated with the role-specific {@code dashboard} projection + * @return the Admin, Mentor, or Intern dashboard template name + * @throws DashboardAccessDeniedException when the authority is unsupported or does not match + * an active persisted account identity + */ @GetMapping("/dashboard") public String dashboard(Authentication authentication, Model model) { String email = authentication.getName(); diff --git a/src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java b/src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java index 3173389..a882394 100644 --- a/src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java +++ b/src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java @@ -4,9 +4,19 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; +/** + * Non-disclosing denial raised when an authenticated identity cannot access a role dashboard. + * + *

    Callers must not include protected record identifiers or lifecycle details in the message. + */ @ResponseStatus(HttpStatus.FORBIDDEN) public class DashboardAccessDeniedException extends AccessDeniedException { + /** + * Creates a safe dashboard denial. + * + * @param message generic reason suitable for server-side diagnosis without protected details + */ public DashboardAccessDeniedException(String message) { super(message); } diff --git a/src/main/java/com/lab/labtimesheet/feature/reporting/model/dto/DashboardView.java b/src/main/java/com/lab/labtimesheet/feature/reporting/model/dto/DashboardView.java index 2b41638..436e61e 100644 --- a/src/main/java/com/lab/labtimesheet/feature/reporting/model/dto/DashboardView.java +++ b/src/main/java/com/lab/labtimesheet/feature/reporting/model/dto/DashboardView.java @@ -3,23 +3,62 @@ package com.lab.labtimesheet.feature.reporting.model.dto; import java.time.LocalDate; import java.util.List; +/** + * Closed set of immutable, role-specific dashboard projections rendered by Reporting. + * + *

    Each projection contains only data authorized and calculated by its owning feature service. + */ public sealed interface DashboardView { + /** + * System-wide counts visible to an active Admin. + * + * @param activeAccounts active account count + * @param pendingActivations accounts awaiting activation + * @param activeInternships active internship count + * @param activeProjects active Projects visible to an Admin + */ record Admin(long activeAccounts, long pendingActivations, long activeInternships, long activeProjects) implements DashboardView { } + /** + * Owning-Mentor operational summary; empty authorized scopes are represented by zero counts. + * + * @param displayName persisted Mentor display name + * @param activeProjects active owned Project count + * @param activeMembers distinct eligible active members across owned Projects + * @param blockedTasks blocked Tasks visible within active owned Projects + */ record Mentor(String displayName, long activeProjects, long activeMembers, long blockedTasks) implements DashboardView { } + /** + * Eligible Intern summary for the Attendance policy's current business date. + * + * @param displayName persisted Intern display name + * @param attendanceState current policy-local attendance state + * @param activeProjects active Projects containing a current eligible membership + * @param assignedTasks current assigned Task count + * @param priorityTasks ordered Task-owned priority items; empty when none are assigned + */ record Intern(String displayName, AttendanceState attendanceState, long activeProjects, long assignedTasks, List priorityTasks) implements DashboardView { } + /** + * Compact Task row shown on an Intern dashboard. + * + * @param title Task title + * @param projectName owning Project name + * @param status Task status label supplied by the Task feature + * @param dueDate Task due date, or {@code null} when no due date is assigned + */ record AssignedTask(String title, String projectName, String status, LocalDate dueDate) { } + /** Current attendance state exposed to the Intern dashboard. */ enum AttendanceState { NOT_CHECKED_IN("Not checked in"), CHECKED_IN("Checked in"), @@ -31,6 +70,11 @@ public sealed interface DashboardView { this.label = label; } + /** + * Returns the English presentation label for this state. + * + * @return non-empty user-facing state label + */ public String label() { return label; } diff --git a/src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java b/src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java index 0a8283e..0bce619 100644 --- a/src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java +++ b/src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java @@ -19,6 +19,13 @@ import com.lab.labtimesheet.feature.task.service.TaskDashboardService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Composes authorized dashboard projections exclusively from public feature services and DTOs. + * + *

    This service owns no persistence mapping or business-date calculation. Account lifecycle and + * role are revalidated from persisted identity data, while Project, Task, and Attendance retain + * ownership of their query scope, ordering, and attendance-policy business date. + */ @Service @Transactional(readOnly = true) public class DashboardService { @@ -28,6 +35,14 @@ public class DashboardService { private final TaskDashboardService tasks; private final AttendanceApplicationService attendance; + /** + * Creates a reporting coordinator over the concrete feature query boundaries. + * + * @param accounts account identity and Admin summary boundary + * @param projects role-scoped Project summary boundary + * @param tasks role-scoped Task dashboard boundary + * @param attendance attendance state boundary using the active policy business date + */ public DashboardService( AccountService accounts, ProjectQueryService projects, @@ -39,6 +54,16 @@ public class DashboardService { this.attendance = attendance; } + /** + * Builds system-wide Admin counts after confirming an active persisted Admin identity. + * + *

    Counts are zero when the corresponding feature has no matching records. + * + * @param email authenticated account email + * @return account lifecycle counts and the Admin-visible active Project count + * @throws DashboardAccessDeniedException when the persisted account is missing, inactive, or + * not an Admin + */ public DashboardView.Admin admin(String email) { AccountIdentity admin = activeAccount(email, GlobalRole.ADMIN); AccountSummary accountSummary = accounts.summary(); @@ -50,6 +75,17 @@ public class DashboardService { projectSummary.activeProjectCount()); } + /** + * Builds the owning-Mentor dashboard after persisted-role revalidation. + * + *

    Project and member counts are scoped by the Project service; blocked Task count is scoped + * by the Task service. Each empty scope is represented by a zero count. + * + * @param email authenticated account email + * @return Mentor display name and role-scoped Project, member, and blocked-Task counts + * @throws DashboardAccessDeniedException when the persisted account is missing, inactive, or + * not a Mentor + */ public DashboardView.Mentor mentor(String email) { AccountIdentity mentor = activeAccount(email, GlobalRole.MENTOR); ProjectDashboardSummary projectSummary = projects.dashboardSummary(mentor.id()); @@ -61,6 +97,19 @@ public class DashboardService { taskSummary.blockedTaskCount()); } + /** + * Builds the eligible Intern dashboard after persisted-role revalidation. + * + *

    The Attendance feature determines today's state from its policy-owned business date. The + * Task feature owns assigned count and priority ordering; no matching Tasks produce an empty + * priority list. Attendance ineligibility is converted to the same non-disclosing dashboard + * denial as other invalid Intern lifecycle states. + * + * @param email authenticated account email + * @return Intern attendance state, scoped counts, and at most the Task service's priority items + * @throws DashboardAccessDeniedException when the persisted account or internship is not + * eligible for the Intern dashboard + */ public DashboardView.Intern intern(String email) { AccountIdentity intern = activeAccount(email, GlobalRole.INTERN); AttendanceCurrentState attendanceState; diff --git a/src/main/resources/templates/attendance/history.html b/src/main/resources/templates/attendance/history.html index af4cbb7..7644860 100644 --- a/src/main/resources/templates/attendance/history.html +++ b/src/main/resources/templates/attendance/history.html @@ -39,12 +39,17 @@ - - - + + + - + + On time + Late + Early departure + Missing checkout +

    diff --git a/src/main/resources/templates/error/generic.html b/src/main/resources/templates/error/generic.html new file mode 100644 index 0000000..41d68ab --- /dev/null +++ b/src/main/resources/templates/error/generic.html @@ -0,0 +1,19 @@ + + + +
    +
    +

    Error

    +

    Request could not be completed

    +

    The requested operation could not be completed.

    +

    Return to dashboard

    +
    +
    + + diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 8cc2a54..3e46c19 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -24,8 +24,7 @@
  • Accounts
  • Global calendar
  • Owned Projects
  • -
  • Intern attendance
  • -
  • My attendance
  • +
  • My attendance
  • My Projects
  • @@ -38,7 +37,7 @@
    - +
    @@ -46,7 +45,6 @@
    Section / Page
    -
    diff --git a/src/main/resources/templates/projects/form.html b/src/main/resources/templates/projects/form.html index 3ad736b..3252c08 100644 --- a/src/main/resources/templates/projects/form.html +++ b/src/main/resources/templates/projects/form.html @@ -16,20 +16,20 @@
    - - + +
    -
    -
    +
    +
    - - + +
    Cancel
    diff --git a/src/main/resources/templates/tasks/form.html b/src/main/resources/templates/tasks/form.html index 45007ad..25a2a97 100644 --- a/src/main/resources/templates/tasks/form.html +++ b/src/main/resources/templates/tasks/form.html @@ -11,11 +11,11 @@

    Assign work to a current eligible Project member.

    -
    +
    -
    -
    +
    +
    Cancel
    diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java index feb3ab8..e3566e1 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java @@ -6,6 +6,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; +import java.time.Instant; import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.Test; @@ -51,6 +55,20 @@ class AttendanceTemplateIntegrationTest { .andExpect(content().string(containsString("src=\"/assets/theme.js\""))); } + @Test + void populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation() throws Exception { + mvc.perform(get("/template-contract/attendance/history/populated") + .with(user("intern@example.test").roles("INTERN"))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("14/08/2026"))) + .andExpect(content().string(containsString("09:05"))) + .andExpect(content().string(containsString("16:00"))) + .andExpect(content().string(containsString("08:30–15:30 (Asia/Ho_Chi_Minh)"))) + .andExpect(content().string(containsString("Late"))) + .andExpect(content().string(containsString("Early departure"))) + .andExpect(content().string(containsString("Missing checkout"))); + } + @Controller public static class TemplateController { @@ -63,6 +81,27 @@ class AttendanceTemplateIntegrationTest { return "attendance/history"; } + @GetMapping("/template-contract/attendance/history/populated") + String populatedHistory(Model model) { + model.addAttribute("ownHistory", true); + model.addAttribute("from", LocalDate.of(2026, 8, 1)); + model.addAttribute("to", LocalDate.of(2026, 8, 31)); + model.addAttribute("items", List.of( + new AttendanceHistoryItem( + LocalDate.of(2026, 8, 14), + Instant.parse("2026-08-14T02:05:00Z"), + Instant.parse("2026-08-14T09:00:00Z"), + AttendancePolicy.seeded(1L), + new AttendanceViolations(true, true, false)), + new AttendanceHistoryItem( + LocalDate.of(2026, 8, 13), + Instant.parse("2026-08-13T01:30:00Z"), + null, + AttendancePolicy.seeded(1L), + new AttendanceViolations(true, false, true)))); + return "attendance/history"; + } + @GetMapping("/template-contract/attendance/calendar") String calendar(Model model) { model.addAttribute("today", LocalDate.of(2026, 8, 15)); diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java new file mode 100644 index 0000000..c07381c --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java @@ -0,0 +1,108 @@ +package com.lab.labtimesheet.feature.reporting.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.lab.labtimesheet.feature.project.controller.ProjectController; +import com.lab.labtimesheet.feature.project.service.ProjectQueryService; +import com.lab.labtimesheet.feature.project.service.ProjectService; +import com.lab.labtimesheet.feature.task.controller.TaskController; +import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice; +import com.lab.labtimesheet.feature.task.service.TaskService; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest({ProjectController.class, TaskController.class}) +class ProjectTaskFormAccessibilityWebTest { + + @Autowired + private MockMvc mvc; + + @MockitoBean + private ProjectQueryService projectQueries; + + @MockitoBean + private ProjectService projects; + + @MockitoBean + private TaskService tasks; + + @Test + void projectFieldErrorsHaveStableIdsAndInputAssociations() throws Exception { + mvc.perform(post("/projects") + .with(user("mentor@example.test").roles("MENTOR")) + .with(csrf()) + .param("name", " ") + .param("startDate", "2026-08-01") + .param("endDate", "") + .param("initialLeaderUserId", "0")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("aria-describedby=\"name-error\""))) + .andExpect(content().string(containsString("id=\"name-error\""))) + .andExpect(content().string(containsString("aria-describedby=\"endDate-error\""))) + .andExpect(content().string(containsString("id=\"endDate-error\""))) + .andExpect(content().string(containsString("aria-describedby=\"initialLeaderUserId-error\""))) + .andExpect(content().string(containsString("id=\"initialLeaderUserId-error\""))); + + mvc.perform(post("/projects") + .with(user("mentor@example.test").roles("MENTOR")) + .with(csrf()) + .param("name", "Project") + .param("startDate", "") + .param("endDate", "2026-08-31") + .param("initialLeaderUserId", "7")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("aria-describedby=\"startDate-error\""))) + .andExpect(content().string(containsString("id=\"startDate-error\""))); + } + + @Test + void projectDateRangeErrorIsAssociatedWithEndDate() throws Exception { + mvc.perform(post("/projects") + .with(user("mentor@example.test").roles("MENTOR")) + .with(csrf()) + .param("name", "Project") + .param("startDate", "2026-08-31") + .param("endDate", "2026-08-01") + .param("initialLeaderUserId", "7")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("aria-describedby=\"dateRangeValid-error\""))) + .andExpect(content().string(containsString("id=\"dateRangeValid-error\""))); + } + + @Test + void taskFieldErrorsHaveStableIdsAndControlAssociations() throws Exception { + given(tasks.assignmentChoices("leader@example.test", 10L)) + .willReturn(List.of(new TaskAssigneeChoice(7L, "Member"))); + + mvc.perform(post("/projects/10/tasks") + .with(user("leader@example.test").roles("INTERN")) + .with(csrf()) + .param("title", " ") + .param("dueDate", "2026-08-20")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("aria-describedby=\"title-error\""))) + .andExpect(content().string(containsString("id=\"title-error\""))) + .andExpect(content().string(containsString("aria-describedby=\"assigneeMembershipId-error\""))) + .andExpect(content().string(containsString("id=\"assigneeMembershipId-error\""))); + + mvc.perform(post("/projects/10/tasks") + .with(user("leader@example.test").roles("INTERN")) + .with(csrf()) + .param("title", "Task") + .param("assigneeMembershipId", "7") + .param("dueDate", "invalid")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("aria-describedby=\"dueDate-error\""))) + .andExpect(content().string(containsString("id=\"dueDate-error\""))); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/RoleDashboardWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/RoleDashboardWebIntegrationTest.java index 9e9878e..52c6d09 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/RoleDashboardWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/RoleDashboardWebIntegrationTest.java @@ -25,7 +25,11 @@ import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand; import com.lab.labtimesheet.feature.task.service.TaskService; import java.time.LocalDate; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -45,6 +49,9 @@ import org.springframework.transaction.annotation.Transactional; @Transactional class RoleDashboardWebIntegrationTest { + private static final Pattern NAVIGATION_LINK = Pattern.compile( + " paths = new LinkedHashSet<>(); + while (matcher.find()) { + paths.add(matcher.group(1)); + } + assertThat(paths).isNotEmpty(); + for (String path : paths) { + mvc.perform(get(path).with(user(email).roles(role))) + .andExpect(status().isOk()); + } } private long initializeAdminAndSmtp() { diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java new file mode 100644 index 0000000..86c0468 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java @@ -0,0 +1,69 @@ +package com.lab.labtimesheet.feature.reporting.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseStatus; + +@WebMvcTest(SharedErrorTemplateWebTest.ErrorTemplateController.class) +@Import(SharedErrorTemplateWebTest.ErrorTemplateController.class) +class SharedErrorTemplateWebTest { + + @Autowired + private MockMvc mvc; + + @Test + @WithMockUser(username = "intern@example.test", roles = "INTERN") + void notFoundPageUsesSharedShellWithoutDisclosingRecordDetails() throws Exception { + mvc.perform(get("/template-contract/error/404")) + .andExpect(status().isNotFound()) + .andExpect(content().string(containsString("class=\"app-shell\""))) + .andExpect(content().string(containsString("Page not found"))) + .andExpect(content().string(not(containsString("secret Project")))); + } + + @Test + @WithMockUser(username = "mentor@example.test", roles = "MENTOR") + void conflictPageUsesSharedShellWithoutRenderingExceptionDetails() throws Exception { + mvc.perform(get("/template-contract/error/409")) + .andExpect(status().isConflict()) + .andExpect(content().string(containsString("class=\"app-shell\""))) + .andExpect(content().string(containsString("Request could not be completed"))) + .andExpect(content().string(not(containsString("internal lifecycle detail")))); + } + + @Controller + static class ErrorTemplateController { + + @GetMapping("/template-contract/error/404") + @ResponseStatus(HttpStatus.NOT_FOUND) + String notFound(Model model) { + model.addAttribute("errorStatus", 404); + model.addAttribute("errorTitle", "Page not found"); + model.addAttribute("errorMessage", "The requested resource is unavailable or you may not have access."); + return "error/generic"; + } + + @GetMapping("/template-contract/error/409") + @ResponseStatus(HttpStatus.CONFLICT) + String conflict(Model model) { + model.addAttribute("errorStatus", 409); + model.addAttribute("errorTitle", "Request could not be completed"); + model.addAttribute("errorMessage", "The request conflicts with its current state. Review and try again."); + return "error/generic"; + } + } +} diff --git a/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java b/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java index 14a690f..876bea8 100644 --- a/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java +++ b/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java @@ -32,7 +32,7 @@ class UiContractWebTest { @Test @WithMockUser(username = "mentor@example.test", roles = "MENTOR") - void sharedShellRendersAuthorizedDesktopNavigationBeforeDomainPagesIntegrate() throws Exception { + void mentorShellRendersOnlyReachableAuthorizedNavigation() throws Exception { MvcResult result = mvc.perform(get("/ui-contract")) .andExpect(status().isOk()) .andReturn(); @@ -44,10 +44,29 @@ class UiContractWebTest { assertTrue(html.contains("Logout")); assertFalse(html.contains("Accounts")); assertFalse(html.contains("My attendance")); + assertFalse(html.contains("Intern attendance")); + assertFalse(html.contains("href=\"/attendance\"")); + assertFalse(html.contains("href=\"/profile\"")); + assertFalse(html.contains("href=\"/notifications\"")); assertTrue(html.indexOf("/assets/theme.js") < html.indexOf("/assets/app.css")); assertTrue(html.contains("href=\"/assets/icons.svg#panel-left\"")); } + @Test + @WithMockUser(username = "intern@example.test", roles = "INTERN") + void internShellLinksToTheReachableOwnAttendanceRoute() throws Exception { + String html = mvc.perform(get("/ui-contract")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + + assertTrue(html.contains("href=\"/attendance\"")); + assertFalse(html.contains("href=\"/attendance/me\"")); + assertFalse(html.contains("href=\"/profile\"")); + assertFalse(html.contains("href=\"/notifications\"")); + } + @Test void compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite() throws Exception { ClassPathResource css = new ClassPathResource("static/assets/app.css"); From 8347b9e3a6d3bbbb889accea39623b9d6e48c342 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:17:20 +0700 Subject: [PATCH 42/62] docs(ui): record round one fix evidence --- docs/tests/web/review-round-1-shared-ui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tests/web/review-round-1-shared-ui.md b/docs/tests/web/review-round-1-shared-ui.md index 74d74ce..40f7121 100644 --- a/docs/tests/web/review-round-1-shared-ui.md +++ b/docs/tests/web/review-round-1-shared-ui.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-002`, `UI-003`, `UI-004`, `UI-010`, `UI-013`, `UI-014`, `ERR-001`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04` - **Scenario IDs:** `AC-AUTH-001`, `AC-UI-002`, `AC-UI-003`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest#populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation`, `com.lab.labtimesheet.feature.reporting.controller.SharedErrorTemplateWebTest`, `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskFormAccessibilityWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest#mentorAndInternDashboardsRenderRealScopedProjectTaskAndAttendanceData` -- **Implementation commit:** `pending` +- **Implementation commit:** `8388b4c` ## Protected behavior From fb0ed7f12c9d89235c102b67f2b13f786011c9ee Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:17:26 +0700 Subject: [PATCH 43/62] fix(task): harden task forms and document contracts --- .../task/controller/TaskController.java | 46 +++++++-- .../task/exception/TaskNotFoundException.java | 7 ++ .../exception/TaskValidationException.java | 12 +++ .../feature/task/model/TaskProgress.java | 30 ++++++ .../feature/task/model/TaskStatus.java | 17 ++++ .../task/model/dto/CreateTaskCommand.java | 9 ++ .../task/model/dto/TaskAssigneeChoice.java | 6 ++ .../task/model/dto/TaskCommentView.java | 9 ++ .../task/model/dto/TaskCreateForm.java | 8 ++ .../task/model/dto/TaskDashboardView.java | 8 ++ .../feature/task/model/dto/TaskDetails.java | 9 ++ .../feature/task/model/dto/TaskListView.java | 8 ++ .../task/model/dto/TaskPriorityView.java | 8 ++ .../feature/task/model/dto/TaskView.java | 16 +++ .../feature/task/model/entity/Task.java | 86 ++++++++++++++++ .../task/model/entity/TaskComment.java | 40 ++++++++ .../repository/TaskCommentRepository.java | 7 ++ .../task/repository/TaskRepository.java | 68 +++++++++++++ .../task/service/TaskDashboardService.java | 23 +++++ .../task/service/TaskQueryService.java | 20 ++++ .../feature/task/service/TaskService.java | 99 +++++++++++++++++++ src/main/resources/templates/tasks/form.html | 1 + .../task/controller/TaskControllerTest.java | 70 ++++++++++++- 23 files changed, 596 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java b/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java index 6838216..572a623 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java @@ -1,13 +1,16 @@ package com.lab.labtimesheet.feature.task.controller; +import com.lab.labtimesheet.feature.task.exception.TaskValidationException; import com.lab.labtimesheet.feature.task.model.TaskProgress; import com.lab.labtimesheet.feature.task.model.TaskStatus; import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand; import com.lab.labtimesheet.feature.task.model.dto.TaskCreateForm; +import com.lab.labtimesheet.feature.task.model.dto.TaskDetails; import com.lab.labtimesheet.feature.task.model.dto.TaskListView; import com.lab.labtimesheet.feature.task.model.dto.TaskView; import com.lab.labtimesheet.feature.task.service.TaskService; import jakarta.validation.Valid; +import java.util.Arrays; import java.util.Locale; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; @@ -19,11 +22,24 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +/** + * Serves authenticated Task list, create, detail, status, and comment pages. + * + *

    The controller delegates record visibility and every mutation authorization decision to + * {@link TaskService}. A denied or guessed Project/Task identifier therefore retains the service's + * non-disclosing HTTP 404 contract. Bean and due-date validation failures return the create form; + * successful mutations use redirects to prevent duplicate submissions. + */ @Controller public class TaskController { private final TaskService taskService; + /** + * Creates the MVC adapter for the Task application service. + * + * @param taskService authorized Task use cases + */ public TaskController(TaskService taskService) { this.taskService = taskService; } @@ -55,14 +71,21 @@ public class TaskController { populateForm(authentication.getName(), projectId, model); return "tasks/form"; } - TaskView task = taskService.create( - authentication.getName(), - new CreateTaskCommand( - projectId, - form.assigneeMembershipId(), - form.title(), - form.description(), - form.dueDate())); + TaskView task; + try { + task = taskService.create( + authentication.getName(), + new CreateTaskCommand( + projectId, + form.assigneeMembershipId(), + form.title(), + form.description(), + form.dueDate())); + } catch (TaskValidationException exception) { + bindingResult.rejectValue("dueDate", "task.dueDate", exception.getMessage()); + populateForm(authentication.getName(), projectId, model); + return "tasks/form"; + } return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id()); } @@ -72,9 +95,12 @@ public class TaskController { @PathVariable long projectId, @PathVariable long taskId, Model model) { + TaskDetails details = taskService.details(authentication.getName(), projectId, taskId); model.addAttribute("projectId", projectId); - model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId)); - model.addAttribute("statuses", TaskStatus.values()); + model.addAttribute("details", details); + model.addAttribute("statuses", Arrays.stream(TaskStatus.values()) + .filter(details.task().status()::canTransitionTo) + .toList()); return "tasks/detail"; } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java index 00a77bc..db00e32 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java @@ -3,9 +3,16 @@ package com.lab.labtimesheet.feature.task.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; +/** + * Signals a non-disclosing Task or Project lookup/authorization failure. + * + *

    MVC maps this exception to HTTP 404 so guessed identifiers do not reveal whether the record + * exists or merely falls outside the authenticated actor's current or historical scope. + */ @ResponseStatus(HttpStatus.NOT_FOUND) public final class TaskNotFoundException extends RuntimeException { + /** Creates the fixed, non-identifying HTTP 404 failure. */ public TaskNotFoundException() { super("Task or Project was not found"); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java index 0c2b66e..0e73821 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java @@ -3,9 +3,21 @@ package com.lab.labtimesheet.feature.task.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; +/** + * Signals that an authorized Task request violates a Task business rule. + * + *

    Unadapted MVC uses map this exception to HTTP 400. The create-form controller handles this + * exact type locally to associate due-date failures with the field while allowing access failures + * to retain their separate HTTP 404 contract. + */ @ResponseStatus(HttpStatus.BAD_REQUEST) public final class TaskValidationException extends RuntimeException { + /** + * Creates a client-visible validation failure. + * + * @param message actionable rule violation without protected record details + */ public TaskValidationException(String message) { super(message); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java index 80fefbe..7b90f79 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java @@ -3,8 +3,22 @@ package com.lab.labtimesheet.feature.task.model; import java.util.Collection; import java.util.OptionalDouble; +/** + * Counts current non-deleted Tasks by fixed status for a single Project. + * + * @param todo Tasks not yet started + * @param inProgress Tasks actively in progress + * @param blocked Tasks currently blocked + * @param done completed Tasks + */ public record TaskProgress(int todo, int inProgress, int blocked, int done) { + /** + * Counts the supplied current Task statuses. + * + * @param statuses statuses already filtered to the caller's current Task scope + * @return immutable counts for all four statuses + */ public static TaskProgress from(Collection statuses) { int todo = 0; int inProgress = 0; @@ -21,10 +35,21 @@ public record TaskProgress(int todo, int inProgress, int blocked, int done) { return new TaskProgress(todo, inProgress, blocked, done); } + /** + * Returns the denominator used for Project completion progress. + * + * @return total number of counted Tasks + */ public int total() { return todo + inProgress + blocked + done; } + /** + * Returns the count for one fixed status. + * + * @param status status to inspect + * @return number of Tasks in that status + */ public int count(TaskStatus status) { return switch (status) { case TODO -> todo; @@ -34,6 +59,11 @@ public record TaskProgress(int todo, int inProgress, int blocked, int done) { }; } + /** + * Computes DONE Tasks as a percentage of all counted Tasks. + * + * @return an empty value when the Project has no current Tasks, otherwise a value from 0 to 100 + */ public OptionalDouble completionPercentage() { return total() == 0 ? OptionalDouble.empty() : OptionalDouble.of(done * 100.0 / total()); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java index 363accf..809ea9e 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java @@ -1,11 +1,28 @@ package com.lab.labtimesheet.feature.task.model; +/** + * Fixed v1 Task workflow states. + * + *

    The graph is intentionally not configurable: TODO can move to IN_PROGRESS or BLOCKED; + * IN_PROGRESS can move to DONE or BLOCKED; BLOCKED can move to TODO or IN_PROGRESS; and DONE can + * only reopen to IN_PROGRESS. + */ public enum TaskStatus { + /** Work has not started. */ TODO, + /** Work is actively progressing. */ IN_PROGRESS, + /** Work cannot currently proceed. */ BLOCKED, + /** Work is complete and may only be reopened to IN_PROGRESS. */ DONE; + /** + * Tests whether the fixed workflow permits a direct edge to {@code target}. + * + * @param target requested next state + * @return {@code true} only for one of the specified v1 edges + */ public boolean canTransitionTo(TaskStatus target) { return switch (this) { case TODO -> target == IN_PROGRESS || target == BLOCKED; diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java index 7ebbd28..a232b91 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java @@ -2,6 +2,15 @@ package com.lab.labtimesheet.feature.task.model.dto; import java.time.LocalDate; +/** + * Authenticated request to create one Task in a Project. + * + * @param projectId Project aggregate identifier + * @param assigneeMembershipId same-Project active membership identifier, not a user identifier + * @param title required Task title + * @param description optional Task description + * @param dueDate optional business date constrained by Project dates and the current global calendar + */ public record CreateTaskCommand( long projectId, long assigneeMembershipId, diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java index d67e530..a482600 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java @@ -1,3 +1,9 @@ package com.lab.labtimesheet.feature.task.model.dto; +/** + * One authorized Task assignee option for the create form. + * + * @param membershipId active same-Project membership identifier, never a user identifier + * @param displayName safe display label supplied by the Project feature + */ public record TaskAssigneeChoice(long membershipId, String displayName) {} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java index 1d3f800..b448b9f 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java @@ -2,4 +2,13 @@ package com.lab.labtimesheet.feature.task.model.dto; import java.time.Instant; +/** + * Historical append-only Task comment exposed to authorized Task readers. + * + * @param id comment identifier + * @param taskId owning Task identifier + * @param authorUserId historical author user identifier + * @param body normalized comment text + * @param createdAt persisted creation instant + */ public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java index efe9815..6aa2cde 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java @@ -6,6 +6,14 @@ import jakarta.validation.constraints.Size; import java.time.LocalDate; import org.springframework.format.annotation.DateTimeFormat; +/** + * Browser-bound Task creation fields. + * + * @param title required title, limited to 200 characters before service normalization + * @param description optional safe text retained after validation + * @param assigneeMembershipId selected same-Project membership identifier + * @param dueDate optional ISO date; service validation applies Project and calendar rules + */ public record TaskCreateForm( @NotBlank @Size(max = 200) String title, String description, diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java index d7ac141..3604350 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java @@ -2,11 +2,19 @@ package com.lab.labtimesheet.feature.task.model.dto; import java.util.List; +/** + * Role-scoped Task contribution to the shared dashboard. + * + * @param blockedTaskCount blocked Tasks in active Projects owned by a Mentor; otherwise zero + * @param assignedTaskCount current non-deleted Tasks assigned to an Intern; otherwise zero + * @param priorityTasks at most five Intern assignments ordered by due date, null last, then Task ID + */ public record TaskDashboardView( long blockedTaskCount, long assignedTaskCount, List priorityTasks) { + /** Copies the priority list so downstream UI code cannot mutate the service result. */ public TaskDashboardView { priorityTasks = List.copyOf(priorityTasks); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java index 9f9a91d..ba4e352 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java @@ -2,12 +2,21 @@ package com.lab.labtimesheet.feature.task.model.dto; import java.util.List; +/** + * Authorized Task detail view and server-derived action capabilities. + * + * @param task visible non-deleted Task + * @param comments append-only comment history in creation order + * @param canChangeStatus true only for the current assignee of an ACTIVE Project + * @param canComment true only for an eligible active member or owning Mentor before completion + */ public record TaskDetails( TaskView task, List comments, boolean canChangeStatus, boolean canComment) { + /** Copies the comment list so historical output cannot be modified by a view consumer. */ public TaskDetails { comments = List.copyOf(comments); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java index 8049554..bc6a64d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java @@ -3,8 +3,16 @@ package com.lab.labtimesheet.feature.task.model.dto; import com.lab.labtimesheet.feature.task.model.TaskProgress; import java.util.List; +/** + * Authorized current Task list with aggregate progress and create capability. + * + * @param tasks non-deleted Tasks ordered by Task identifier + * @param progress status counts derived from exactly {@code tasks}; empty lists produce N/A progress + * @param canCreate true only for an active member while the Project is PLANNED or ACTIVE + */ public record TaskListView(List tasks, TaskProgress progress, boolean canCreate) { + /** Copies the Task list so view consumers cannot alter the authorized result. */ public TaskListView { tasks = List.copyOf(tasks); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java index 30d09a7..374d555 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java @@ -3,6 +3,14 @@ package com.lab.labtimesheet.feature.task.model.dto; import com.lab.labtimesheet.feature.task.model.TaskStatus; import java.time.LocalDate; +/** + * Compact current assignment rendered on an Intern dashboard. + * + * @param title Task title + * @param projectName Project display name from the Project service boundary + * @param status current fixed workflow status + * @param dueDate optional Task due date + */ public record TaskPriorityView( String title, String projectName, diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java index a466eee..0ed999d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java @@ -4,6 +4,22 @@ import com.lab.labtimesheet.feature.task.model.TaskStatus; import java.time.Instant; import java.time.LocalDate; +/** + * Authorized current Task projection with immutable creator and assignment attribution. + * + * @param id Task identifier + * @param projectId owning Project identifier + * @param assigneeMembershipId current same-Project assignee membership identifier + * @param assigneeName current or historical assignee name supplied by the Project feature + * @param title Task title + * @param description optional description + * @param status current fixed workflow status + * @param dueDate optional due date + * @param creatorMembershipId immutable creating membership identifier + * @param assignerMembershipId membership identifier responsible for the current assignment + * @param assignedAt instant the current assignment was established + * @param createdAt immutable Task creation instant + */ public record TaskView( long id, long projectId, diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java index 59564d3..9d4c8cc 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java @@ -13,6 +13,13 @@ import jakarta.persistence.Version; import java.time.Instant; import java.time.LocalDate; +/** + * Persisted Task aggregate row with one current same-Project assignee. + * + *

    Creator attribution never changes. Assignment actor/time describe the current assignment, + * deletion is soft and historical, and the JPA version detects conflicting updates. Newly created + * Tasks always begin in TODO; status changes must follow the fixed {@link TaskStatus} graph. + */ @Entity @Table(name = "tasks") public class Task { @@ -64,8 +71,20 @@ public class Task { @Version private long version; + /** Constructor reserved for JPA materialization. */ protected Task() {} + /** + * Creates a TODO Task and records the creating membership as both creator and assigner. + * + * @param projectId owning Project identifier + * @param assigneeMembershipId active membership identifier in the same Project + * @param title normalized required title + * @param description optional normalized description + * @param dueDate optional validated business due date + * @param actorMembershipId authenticated creating membership identifier + * @param now server-controlled creation and assignment instant + */ public Task( long projectId, long assigneeMembershipId, @@ -87,6 +106,13 @@ public class Task { this.updatedAt = now; } + /** + * Applies one permitted fixed-graph status transition and advances the update timestamp. + * + * @param target next Task status + * @param now server-controlled mutation instant + * @throws IllegalArgumentException when the requested direct transition is forbidden + */ public void changeStatus(TaskStatus target, Instant now) { if (!status.canTransitionTo(target)) { throw new IllegalArgumentException("Task status transition is not allowed"); @@ -95,50 +121,110 @@ public class Task { updatedAt = now; } + /** + * Returns the persistence identity. + * + * @return Task identifier, or {@code null} before insertion + */ public Long getId() { return id; } + /** + * Returns the aggregate identity. + * + * @return owning Project identifier + */ public long getProjectId() { return projectId; } + /** + * Returns the current assignment identity. + * + * @return current same-Project assignee membership identifier + */ public long getAssigneeMembershipId() { return assigneeMembershipId; } + /** + * Returns the display title. + * + * @return normalized Task title + */ public String getTitle() { return title; } + /** + * Returns the descriptive text. + * + * @return optional normalized description + */ public String getDescription() { return description; } + /** + * Returns the workflow state. + * + * @return current fixed workflow status + */ public TaskStatus getStatus() { return status; } + /** + * Returns the business deadline. + * + * @return optional validated due date + */ public LocalDate getDueDate() { return dueDate; } + /** + * Returns current assignment timing. + * + * @return instant when the current assignment was established + */ public Instant getAssignedAt() { return assignedAt; } + /** + * Returns original creator attribution. + * + * @return immutable creating membership identifier + */ public long getCreatorMembershipId() { return creatorMembershipId; } + /** + * Returns current assignment attribution. + * + * @return membership identifier responsible for the current assignment + */ public long getAssignerMembershipId() { return assignerMembershipId; } + /** + * Returns lifecycle visibility state. + * + * @return soft-deletion instant, or {@code null} while current + */ public Instant getDeletedAt() { return deletedAt; } + /** + * Returns creation timing. + * + * @return immutable creation instant + */ public Instant getCreatedAt() { return createdAt; } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java index 6e3b15e..5de025b 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java @@ -8,6 +8,12 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +/** + * Persisted append-only Task comment. + * + *

    The author is retained as a user identifier so membership or leadership changes do not move + * historical attribution. This entity intentionally exposes no edit or delete operation. + */ @Entity @Table(name = "task_comments") public class TaskComment { @@ -28,8 +34,17 @@ public class TaskComment { @Column(name = "created_at", nullable = false) private Instant createdAt; + /** Constructor reserved for JPA materialization. */ protected TaskComment() {} + /** + * Creates an immutable comment from server-authorized values. + * + * @param taskId owning Task identifier + * @param authorUserId authenticated historical author user identifier + * @param body normalized non-blank comment text + * @param createdAt server-controlled creation instant + */ public TaskComment(long taskId, long authorUserId, String body, Instant createdAt) { this.taskId = taskId; this.authorUserId = authorUserId; @@ -37,22 +52,47 @@ public class TaskComment { this.createdAt = createdAt; } + /** + * Returns the persistence identity. + * + * @return comment identifier, or {@code null} before insertion + */ public Long getId() { return id; } + /** + * Returns the owning record identity. + * + * @return owning Task identifier + */ public long getTaskId() { return taskId; } + /** + * Returns historical authorship. + * + * @return immutable historical author user identifier + */ public long getAuthorUserId() { return authorUserId; } + /** + * Returns comment content. + * + * @return normalized comment text + */ public String getBody() { return body; } + /** + * Returns creation timing. + * + * @return immutable creation instant + */ public Instant getCreatedAt() { return createdAt; } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java index f0073fc..b9ebcb8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java @@ -4,7 +4,14 @@ import com.lab.labtimesheet.feature.task.model.entity.TaskComment; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +/** JPA persistence boundary for append-only Task comments. */ public interface TaskCommentRepository extends JpaRepository { + /** + * Loads a Task's complete comment history deterministically. + * + * @param taskId owning Task identifier + * @return comments ordered by creation instant and then identifier + */ List findAllByTaskIdOrderByCreatedAtAscIdAsc(long taskId); } diff --git a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java index 1a8708e..43cbce1 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java @@ -12,22 +12,79 @@ import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +/** + * JPA persistence and current-read queries for Task rows. + * + *

    Normal reads consistently exclude soft-deleted rows. Mutation callers acquire the owning + * Project lock before requesting the Task row lock so aggregate and Task lock order remains stable. + */ public interface TaskRepository extends JpaRepository { + /** + * Finds a current Task only when its identifier belongs to the supplied Project. + * + * @param id Task identifier + * @param projectId owning Project identifier + * @return matching non-deleted Task, if visible in that aggregate + */ Optional findByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId); + /** + * Locks one current Task for a mutation after the caller has locked its Project. + * + * @param id Task identifier + * @param projectId owning Project identifier + * @return matching non-deleted Task under a pessimistic write lock + */ @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findLockedByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId); + /** + * Lists current Tasks for one Project in deterministic identifier order. + * + * @param projectId owning Project identifier + * @return non-deleted Tasks + */ List findAllByProjectIdAndDeletedAtIsNullOrderById(long projectId); + /** + * Counts all current Tasks in one Project. + * + * @param projectId owning Project identifier + * @return non-deleted Task count + */ long countByProjectIdAndDeletedAtIsNull(long projectId); + /** + * Counts current Tasks in a status across the supplied Projects. + * + * @param projectIds authorized Project identifiers + * @param status status to count + * @return matching non-deleted Task count + */ long countByProjectIdInAndStatusAndDeletedAtIsNull(List projectIds, TaskStatus status); + /** + * Counts current Tasks assigned through the supplied Project memberships. + * + * @param projectIds authorized Project identifiers + * @param assigneeMembershipIds actor memberships scoped to those Projects + * @return matching non-deleted Task count + */ long countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull( List projectIds, List assigneeMembershipIds); + /** + * Loads the highest-priority current assignments within authorized Project/membership pairs. + * + *

    Ordering is due date ascending, null due dates last, then Task identifier ascending. The + * supplied page bounds how many rows are returned. + * + * @param projectIds authorized active Project identifiers + * @param assigneeMembershipIds actor's current memberships in those Projects + * @param pageable result limit + * @return ordered non-deleted Tasks + */ @Query(""" select task from Task task @@ -43,6 +100,17 @@ public interface TaskRepository extends JpaRepository { @Param("assigneeMembershipIds") List assigneeMembershipIds, Pageable pageable); + /** + * Counts current Tasks whose assignee is outside a non-empty active-membership set. + * + *

    The Project activation caller is responsible for holding the Project write lock through + * its decision. Empty membership sets are handled by {@code TaskQueryService} rather than this + * {@code NOT IN} query. + * + * @param projectId locked Project identifier + * @param activeMembershipIds non-empty active same-Project membership identifiers + * @return non-deleted Tasks assigned outside the supplied set + */ @Query(""" select count(task) from Task task diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java index 9540de2..3a06dd8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java @@ -15,6 +15,13 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Supplies the Task-owned portion of the shared role dashboard. + * + *

    Project visibility, role, and active membership facts come only from the Project service DTO + * boundary. Mentor counts cover blocked current Tasks in visible active Projects. Intern counts and + * priorities cover current assignments only where the actor still has an active membership. + */ @Service public class TaskDashboardService { @@ -23,11 +30,27 @@ public class TaskDashboardService { private final TaskRepository tasks; private final ProjectQueryService projects; + /** + * Creates the dashboard query service. + * + * @param tasks Task persistence boundary + * @param projects authorized Project query boundary + */ public TaskDashboardService(TaskRepository tasks, ProjectQueryService projects) { this.tasks = tasks; this.projects = projects; } + /** + * Builds the role-scoped Task dashboard for one authenticated account. + * + *

    Mentors receive the blocked count for active Projects they can see. Interns receive their + * non-deleted assignment count and at most five priority Tasks, ordered by due date ascending, + * null due dates last, and Task identifier ascending. Other roles receive zero/empty values. + * + * @param actorEmail authenticated account email + * @return immutable role-appropriate Task dashboard data + */ @Transactional(readOnly = true) public TaskDashboardView dashboard(String actorEmail) { var actor = projects.authenticatedActor(actorEmail); diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java index e469cf3..a50f814 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java @@ -5,15 +5,35 @@ import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Public Task query boundary used by other features without exposing Task entities or repositories. + */ @Service public class TaskQueryService { private final TaskRepository tasks; + /** + * Creates the cross-feature Task query service. + * + * @param tasks Task persistence boundary + */ public TaskQueryService(TaskRepository tasks) { this.tasks = tasks; } + /** + * Counts current Tasks assigned outside the supplied active membership set. + * + *

    Only non-deleted Tasks are considered. An empty set means every current Task is invalid + * and avoids an empty {@code NOT IN} predicate. This method joins an existing transaction; a + * Project lifecycle caller must acquire and retain the Project write lock before calling it so + * the assignee guard remains stable through the Project decision and commit. + * + * @param projectId Project whose current Task assignments are being validated + * @param activeMembershipIds current eligible same-Project membership identifiers + * @return number of current Tasks whose assignee is not in the supplied set + */ @Transactional(readOnly = true) public long countCurrentTasksAssignedOutside(long projectId, Set activeMembershipIds) { if (activeMembershipIds.isEmpty()) { diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java index c8a44a8..302ef27 100644 --- a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java @@ -33,6 +33,14 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Executes authorized Task creation, status, comment, and current/historical read use cases. + * + *

    Project identity, lifecycle, ownership, leadership, and membership are obtained through + * Project service DTOs. Mutations lock and re-evaluate the Project first; status/comment mutations + * then lock the Task row, preserving the Project-to-Task lock order. Access failures are translated + * to a non-disclosing Task 404, while authenticated business-rule failures use Task validation. + */ @Service public class TaskService { @@ -43,6 +51,16 @@ public class TaskService { private final CalendarApplicationService calendar; private final Clock clock; + /** + * Creates the Task application service and its feature boundaries. + * + * @param tasks Task persistence boundary + * @param comments append-only comment persistence boundary + * @param projects authorized Project read boundary + * @param projectMutations Project-first locking mutation boundary + * @param calendar authoritative global day-off query boundary + * @param clock server time source for persisted instants + */ public TaskService( TaskRepository tasks, TaskCommentRepository comments, @@ -58,6 +76,20 @@ public class TaskService { this.clock = clock; } + /** + * Creates a TODO Task in a PLANNED or ACTIVE Project. + * + *

    The Project is write-locked before membership and lifecycle checks. A current Leader may + * choose any active same-Project member; another active member may choose only themselves. + * Creator, assigner, and assignment time are stored from authenticated current context. An + * optional due date must be within Project dates and not a current global day off. + * + * @param actorEmail authenticated account email + * @param command requested Project, membership, and Task fields + * @return created Task projection + * @throws TaskNotFoundException when current authorization/context is absent + * @throws TaskValidationException when title or due date violates a business rule + */ @Transactional public TaskView create(String actorEmail, CreateTaskCommand command) { String title = requireTitle(command.title()); @@ -84,6 +116,21 @@ public class TaskService { return view(tasks.saveAndFlush(task), assignee.displayName()); } + /** + * Changes an ACTIVE Project Task through one fixed workflow edge. + * + *

    The transaction locks the Project before the Task row and permits only the current + * assignee membership to mutate status. Neither leadership nor a global role substitutes for + * assignment authority. + * + * @param actorEmail authenticated account email + * @param projectId owning Project identifier + * @param taskId Task identifier within that Project + * @param target requested next status + * @return updated Task projection + * @throws TaskNotFoundException when scope, lifecycle, assignment, or identifiers are invalid + * @throws TaskValidationException when the requested status edge is forbidden + */ @Transactional public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) { TaskAccess access = requireMutationAccess(actorEmail, projectId); @@ -103,6 +150,21 @@ public class TaskService { return view(tasks.saveAndFlush(task), actorMembership.displayName()); } + /** + * Appends a comment to a current Task before Project completion. + * + *

    The transaction locks the Project before the Task row. The owning Mentor or any active + * member may comment; the persisted author is the authenticated user so later membership or + * leadership changes do not alter history. + * + * @param actorEmail authenticated account email + * @param projectId owning Project identifier + * @param taskId Task identifier within that Project + * @param body required comment text + * @return created append-only comment projection + * @throws TaskNotFoundException when current authorization, lifecycle, or identifiers are invalid + * @throws TaskValidationException when the normalized body is empty + */ @Transactional public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) { String normalizedBody = requireCommentBody(body); @@ -123,6 +185,18 @@ public class TaskService { return view(comments.saveAndFlush(comment)); } + /** + * Lists current Tasks and progress for an authorized Project reader. + * + *

    Soft-deleted Tasks are excluded. Active members may read open Projects; former members may + * read only a completed Project in which they historically participated. Empty current Task + * sets produce empty completion percentage semantics. The create capability is server-derived. + * + * @param actorEmail authenticated account email + * @param projectId Project identifier + * @return visible Tasks, progress counts, and create capability + * @throws TaskNotFoundException when the Project is outside the actor's authorized scope + */ @Transactional(readOnly = true) public TaskListView list(String actorEmail, long projectId) { TaskAccess access = requireReadableProject(actorEmail, projectId); @@ -137,6 +211,19 @@ public class TaskService { isOpen(access.project()) && activeMembership(access) != null); } + /** + * Loads one current Task, append-only comments, and server-derived action capabilities. + * + *

    Status capability requires the ACTIVE Project's current assignee. Comment capability + * requires an active member or owning Mentor before completion. Historical completed-Project + * readers receive details with no mutation capability. + * + * @param actorEmail authenticated account email + * @param projectId owning Project identifier + * @param taskId Task identifier within that Project + * @return authorized detail projection + * @throws TaskNotFoundException when scope or identifiers are invalid + */ @Transactional(readOnly = true) public TaskDetails details(String actorEmail, long projectId, long taskId) { TaskAccess access = requireReadableProject(actorEmail, projectId); @@ -157,6 +244,18 @@ public class TaskService { return new TaskDetails(task, taskComments, canChangeStatus, canComment); } + /** + * Returns assignee options for an authorized Task create form. + * + *

    A current Leader receives every active same-Project membership; another active member + * receives only their own membership. Completed Projects and non-members are denied without + * disclosing Project membership data. + * + * @param actorEmail authenticated account email + * @param projectId Project identifier + * @return authorized membership choices + * @throws TaskNotFoundException when current authorization or lifecycle is invalid + */ @Transactional(readOnly = true) public List assignmentChoices(String actorEmail, long projectId) { TaskAccess access = requireProjectAccess(actorEmail, projectId); diff --git a/src/main/resources/templates/tasks/form.html b/src/main/resources/templates/tasks/form.html index c604e00..4df9b1a 100644 --- a/src/main/resources/templates/tasks/form.html +++ b/src/main/resources/templates/tasks/form.html @@ -29,6 +29,7 @@

    +

    Due date error

    diff --git a/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java index c3ec13e..023d1e3 100644 --- a/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java @@ -16,6 +16,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException; +import com.lab.labtimesheet.feature.task.exception.TaskValidationException; import com.lab.labtimesheet.feature.task.model.TaskProgress; import com.lab.labtimesheet.feature.task.model.TaskStatus; import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand; @@ -28,7 +29,11 @@ import com.lab.labtimesheet.feature.task.service.TaskService; import java.time.Instant; import java.time.LocalDate; import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -125,6 +130,46 @@ class TaskControllerTest { .create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class)); } + @Test + void invalidDueDateRendersFieldErrorAndRetainsSafeInput() throws Exception { + TaskAssigneeChoice assignee = new TaskAssigneeChoice(7L, "Member Name"); + given(taskService.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class))) + .willThrow(new TaskValidationException("Due date must fall within the Project dates")); + given(taskService.assignmentChoices(ACTOR_EMAIL, 10L)).willReturn(List.of(assignee)); + + mockMvc.perform(post("/projects/10/tasks") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("title", "Draft") + .param("description", "Safe notes") + .param("assigneeMembershipId", "7") + .param("dueDate", "2026-09-01")) + .andExpect(status().isOk()) + .andExpect(view().name("tasks/form")) + .andExpect(model().attributeHasFieldErrors("taskForm", "dueDate")) + .andExpect(model().attribute("assignees", List.of(assignee))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Draft"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Safe notes"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("2026-09-01"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "Due date must fall within the Project dates"))); + } + + @Test + void guessedProjectDuringCreateRemainsNotFound() throws Exception { + given(taskService.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class))) + .willThrow(new TaskNotFoundException()); + + mockMvc.perform(post("/projects/999/tasks") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("title", "Draft") + .param("assigneeMembershipId", "7")) + .andExpect(status().isNotFound()); + + verify(taskService, org.mockito.Mockito.never()).assignmentChoices(ACTOR_EMAIL, 999L); + } + @Test void statusAndCommentPostsUseAuthenticatedIdentityAndCsrf() throws Exception { given(taskService.changeStatus(ACTOR_EMAIL, 10L, 25L, TaskStatus.IN_PROGRESS)) @@ -171,10 +216,33 @@ class TaskControllerTest { .andExpect(content().string(org.hamcrest.Matchers.containsString("Add comment"))); } + @ParameterizedTest(name = "{0} exposes only {1}") + @MethodSource("allowedStatusChoices") + void taskDetailsExposeOnlyAllowedStatusTransitions(TaskStatus current, List expected) throws Exception { + given(taskService.details(ACTOR_EMAIL, 10L, 25L)) + .willReturn(new TaskDetails(task(25L, current), List.of(), true, true)); + + mockMvc.perform(get("/projects/10/tasks/25").with(user(ACTOR_EMAIL))) + .andExpect(status().isOk()) + .andExpect(model().attribute("statuses", expected)); + } + + private static Stream allowedStatusChoices() { + return Stream.of( + Arguments.of(TaskStatus.TODO, List.of(TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED)), + Arguments.of(TaskStatus.IN_PROGRESS, List.of(TaskStatus.BLOCKED, TaskStatus.DONE)), + Arguments.of(TaskStatus.BLOCKED, List.of(TaskStatus.TODO, TaskStatus.IN_PROGRESS)), + Arguments.of(TaskStatus.DONE, List.of(TaskStatus.IN_PROGRESS))); + } + private static TaskView task(long id) { + return task(id, TaskStatus.TODO); + } + + private static TaskView task(long id, TaskStatus status) { Instant instant = Instant.parse("2026-08-14T10:00:00Z"); return new TaskView( - id, 10L, 7L, "Member Name", "Draft", "Notes", TaskStatus.TODO, + id, 10L, 7L, "Member Name", "Draft", "Notes", status, LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant); } } From e38e2cdea912160b183c65398c4e8d5682c1b00e Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:19:20 +0700 Subject: [PATCH 44/62] test(task): record review fix evidence --- docs/tests/unit/task-api-documentation.md | 84 +++++++++++++++++++++++ docs/tests/web/task-pages.md | 36 +++++++--- 2 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 docs/tests/unit/task-api-documentation.md diff --git a/docs/tests/unit/task-api-documentation.md b/docs/tests/unit/task-api-documentation.md new file mode 100644 index 0000000..5d34b29 --- /dev/null +++ b/docs/tests/unit/task-api-documentation.md @@ -0,0 +1,84 @@ +# Test Evidence: Task public API documentation retrofit + +- **Test type:** Unit +- **Requirement IDs:** `TST-009` +- **Scenario IDs:** `Iteration 1 Task Javadoc retrofit` +- **Test class/method:** `Maven compiler and Javadoc doclint (no synthetic test)` +- **Implementation commit:** `fb0ed7f12c9d89235c102b67f2b13f786011c9ee` + +## Protected behavior + +Every Task-owned production type and declared public or protected API carries meaningful Javadoc for its business contract. The documented contracts include authorization and lifecycle scope, Project-first/Task-row lock order, non-disclosing HTTP behavior, fixed status transitions, empty progress, actor/history/version invariants, repository filtering and locks, DTO identifier domains and capability flags, the cross-feature activation guard, and dashboard scope/order/limit. + +## Test method + +This is prose and API documentation, so `TST-009` forbids an artificial unit test. Java 25 compilation checks source validity. The Maven Javadoc plugin runs standard doclint against only `com.lab.labtimesheet.feature.task`, making missing or malformed Task API documentation directly observable without treating unrelated feature retrofit work as Task-owned. + +## Hand-derived expected result + +The Task package contains 21 production Java types. Each type has a main description. Every declared public/protected constructor and method has a contract comment; record components document their identifier domains, null/empty meanings, and capability semantics. Task-scoped Javadoc generation completes with no warnings. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -DskipTests -Ddoclint=all -Dsubpackages=com.lab.labtimesheet.feature.task javadoc:javadoc +``` + +**Observed result** + +```text +[WARNING] Javadoc Warnings +[WARNING] Task.java: warning: no main description (12 accessors) +[WARNING] TaskComment.java: warning: no main description (5 accessors) +[WARNING] TaskStatus.java: warning: no comment (4 enum constants) +[WARNING] 21 warnings +[INFO] BUILD SUCCESS +``` + +This was a diagnostic documentation baseline rather than a failing behavioral test. The parent instruction explicitly required doclint/compile instead of a fake test. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -DskipTests -Ddoclint=all -Dsubpackages=com.lab.labtimesheet.feature.task javadoc:javadoc +``` + +**Observed result** + +```text +[INFO] --- javadoc:3.12.0:javadoc (default-cli) @ labtimesheet --- +[INFO] BUILD SUCCESS +``` + +No Task-scoped Javadoc warning was emitted. + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -DskipTests compile + +[INFO] Compiling 112 source files with javac [debug parameters release 25] to target/classes +[INFO] BUILD SUCCESS + +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw clean test + +[INFO] Tests run: 113, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +Doclint validates Javadoc structure and references, not whether prose perfectly models runtime behavior. Contract accuracy was checked by a scoped adversarial diff review against the numbered Task, authorization, Project-lifecycle, UI, and database requirements. Other feature owners retain responsibility for their own Iteration 1 Javadoc retrofits. diff --git a/docs/tests/web/task-pages.md b/docs/tests/web/task-pages.md index 0bd59ae..51cf408 100644 --- a/docs/tests/web/task-pages.md +++ b/docs/tests/web/task-pages.md @@ -1,24 +1,24 @@ # Test Evidence: Task pages and server-side request boundaries - **Test type:** Web -- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-007`, `TSK-011`, `TSK-012` -- **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-005`, `TSK-007`–`TSK-008`, `TSK-011`, `TSK-012`, `UI-014` +- **Scenario IDs:** `I1-TSK-01`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` - **Test class/method:** `com.lab.labtimesheet.feature.task.controller.TaskControllerTest` -- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` +- **Implementation commit:** `fb0ed7f12c9d89235c102b67f2b13f786011c9ee` ## Protected behavior -Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, show `N/A` for an empty Project, display assignees, and expose create/status/comment controls only when the service-provided capability permits them. +Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, show `N/A` for an empty Project, display assignees, and expose create/status/comment controls only when the service-provided capability permits them. The status form exposes only direct edges from the current fixed status graph. An authorized create request with an invalid due date returns the form with the due-date field error, retained safe input, and refreshed authorized assignees; an access failure still returns non-disclosing HTTP 404. ## Test method -Nine `@WebMvcTest` MockMvc tests render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, exception-to-status mapping, assignee output, and capability-controlled actions. Only the PostgreSQL-backed Task service is replaced at the controller boundary. +Fifteen `@WebMvcTest` MockMvc invocations render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, exception-to-status mapping, assignee output, and capability-controlled actions. A four-case parameterized test independently specifies every permitted status choice set. Dedicated create tests distinguish a due-date business validation response from a guessed-Project access response. Only the PostgreSQL-backed Task service is replaced at the controller boundary. ## Hand-derived expected result -Unauthenticated list access returns 401 under the current platform security baseline. An authorized empty list returns 200 and contains `N/A`. A denied guessed Task returns 404. A valid create request passes Project 10, assignee membership 7, the supplied fields, and the authenticated email to the service, then redirects to Task 25. Blank title stays on the form with a field error and no write. Valid status/comment posts redirect to Task 25. +Unauthenticated list access returns 401 under the current platform security baseline. An authorized empty list returns 200 and contains `N/A`. A denied guessed Task or Project returns 404. A valid create request passes Project 10, assignee membership 7, the supplied fields, and the authenticated email to the service, then redirects to Task 25. Blank title stays on the form with a field error and no write. An invalid due date returns 200 with the message attached to `dueDate`, keeps title, description, assignee, and date, and reloads the permitted choices. Valid status/comment posts redirect to Task 25. -When `canCreate`, `canChangeStatus`, or `canComment` is false, the corresponding control is absent. When true, it is rendered. Both list and detail output the assignee display name. +When `canCreate`, `canChangeStatus`, or `canComment` is false, the corresponding control is absent. When true, it is rendered. Both list and detail output the assignee display name. The hand-derived status choices are TODO to IN_PROGRESS/BLOCKED; IN_PROGRESS to BLOCKED/DONE; BLOCKED to TODO/IN_PROGRESS; and DONE to IN_PROGRESS. ## RED @@ -42,6 +42,15 @@ The first sandboxed GREEN attempt then exposed an environment boundary, not an a The later view-capability increment was observed RED at test compilation because the Task DTOs did not yet provide the required capability and assignee fields. +The review-fix increment used the same command and observed these additional production-shaped failures before the controller/form change: + +```text +[ERROR] Tests run: 14, Failures: 5, Errors: 0, Skipped: 0 +[ERROR] invalidDueDateRendersFieldErrorAndRetainsSafeInput: Status expected:<200> but was:<400> +[ERROR] taskDetailsExposeOnlyAllowedStatusTransitions: expected permitted subsets but was:<{TODO, IN_PROGRESS, BLOCKED, DONE}> for all four source states +[INFO] BUILD FAILURE +``` + ## GREEN **Command** @@ -57,7 +66,7 @@ Run with approved sandbox escalation for Mockito Java 25 self-attach. **Observed result** ```text -[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 15, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -69,9 +78,14 @@ Run with approved sandbox escalation for Mockito Java 25 self-attach. 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 test +./mvnw -Dtest=TaskDomainRulesTest,TaskPersistenceStructureTest,TaskMutationBoundaryTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskControllerTest,TaskCreationIntegrationTest test -[INFO] Tests run: 107, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 57, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS + +./mvnw clean test + +[INFO] Tests run: 113, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -79,4 +93,4 @@ The suite ran with approved escalation for OrbStack and Mockito self-attach. ## External-test boundaries -This slice test does not prove PostgreSQL state changes; those are covered by `TaskCreationIntegrationTest`. Shared shell styling/navigation remains owned by `work/reports-ui`. Browser journeys, notifications, Iteration 2 workflows, and narrow-screen behavior are outside this Iteration 1 Task evidence. +This slice test does not prove PostgreSQL state changes; those are covered by `TaskCreationIntegrationTest` in the affected/full commands. Shared shell styling/navigation remains owned by `work/reports-ui`. Browser journeys, notifications, Iteration 2 workflows, and narrow-screen behavior are outside this Iteration 1 Task evidence. From af0eb3cabb7253574da4827f7bb55fa8b9a19cdb Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:21:41 +0700 Subject: [PATCH 45/62] fix(projects): close review authorization and error gaps --- docs/tests/integration/projects-workflows.md | 27 ++- docs/tests/unit/projects-layer-structure.md | 20 ++- docs/tests/web/projects-pages.md | 26 ++- .../project/controller/ProjectController.java | 154 ++++++++++++++-- .../ProjectAccessDeniedException.java | 4 + .../exception/ProjectControllerAdvice.java | 54 +++++- .../ProjectRuleViolationException.java | 9 + .../model/ProjectInternEligibility.java | 18 ++ .../project/model/ProjectLeaderChange.java | 7 + .../feature/project/model/ProjectStatus.java | 4 + .../project/model/dto/ProjectActorView.java | 6 + .../model/dto/ProjectCreateCommand.java | 9 + .../project/model/dto/ProjectCreateForm.java | 20 +++ .../model/dto/ProjectDashboardSummary.java | 6 + .../project/model/dto/ProjectDetail.java | 13 ++ .../model/dto/ProjectLeadershipTermView.java | 8 + .../project/model/dto/ProjectMemberForm.java | 8 +- .../project/model/dto/ProjectMemberView.java | 10 ++ .../project/model/dto/ProjectSummary.java | 9 + .../project/model/dto/ProjectTaskContext.java | 23 +++ .../model/dto/ProjectTaskMemberView.java | 7 + .../project/model/entity/ProjectEntity.java | 129 ++++++++++++++ .../entity/ProjectLeadershipTermEntity.java | 42 +++++ .../model/entity/ProjectMembershipEntity.java | 37 ++++ .../project/repository/ProjectRepository.java | 32 ++++ .../project/service/ProjectQueryService.java | 84 ++++++++- .../project/service/ProjectService.java | 63 +++++++ .../resources/templates/projects/detail.html | 3 +- .../resources/templates/projects/form.html | 2 + .../templates/projects/leadership.html | 7 +- .../resources/templates/projects/members.html | 7 +- .../controller/ProjectControllerTest.java | 167 +++++++++++++++++- .../ProjectServiceIntegrationTest.java | 21 ++- .../resources/templates/error/generic.html | 10 ++ 34 files changed, 1001 insertions(+), 45 deletions(-) create mode 100644 src/test/resources/templates/error/generic.html diff --git a/docs/tests/integration/projects-workflows.md b/docs/tests/integration/projects-workflows.md index 98bf180..64964b7 100644 --- a/docs/tests/integration/projects-workflows.md +++ b/docs/tests/integration/projects-workflows.md @@ -2,9 +2,9 @@ - **Test type:** Integration - **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004`, `AUTH-011`, `DB-003`, `DB-007` -- **Scenario IDs:** `AC-AUTH-010`, `AC-PRJ-001`–`AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009` +- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-007`, `AC-AUTH-010`, `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009` - **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest` -- **Implementation commits:** `25a855e`, `dbf1202` +- **Implementation commits:** `25a855e`, `dbf1202`, `pending review-fix commit` ## Protected behavior @@ -16,7 +16,7 @@ A Spring Boot integration test uses the platform-owned PostgreSQL 18.4 Testconta ## Hand-derived expected result -Creation yields one Project, one active membership, and one current leadership term. Direct addition yields one membership per Project/Intern pair while allowing the same Intern in a second Project. Leader change yields one closed and one current term while the Task assignee ID remains unchanged. Activation persists `ACTIVE` and `activated_at` when every live Task is assigned to a current eligible membership; a live Task assigned to a closed membership leaves the Project `PLANNED` and the Task intact. Admin, owner, and historical member visibility is allowed; unrelated IDs are denied uniformly. +Creation yields one Project, one active membership, and one current leadership term. Direct addition yields one membership per Project/Intern pair while allowing the same Intern in a second Project. Leader change yields one closed and one current term while the Task assignee ID remains unchanged. Activation persists `ACTIVE` and `activated_at` when every live Task is assigned to a current eligible membership; a live Task assigned to a closed membership leaves the Project `PLANNED` and the Task intact. Admin and owner visibility is allowed. Intern visibility requires a current membership while the Project is `PLANNED` or `ACTIVE`; a closed membership becomes visible again only after the Project is `COMPLETED`. Unrelated and former-member open-Project IDs are denied uniformly. Completed detail has no current Leader and no mutation capability for Admin, owner, or former members. ## RED @@ -62,6 +62,21 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **GREEN:** after wiring the locked Project aggregate to Account eligibility and `TaskQueryService.countCurrentTasksAssignedOutside`, both focused activation tests passed. The valid Project became `ACTIVE`; the former-member assignee case threw `ProjectRuleViolationException`, retained `PLANNED`, and preserved its live Task. +## Review round 1 visibility and completed-detail regression + +**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=ProjectServiceIntegrationTest#listAndDetailQueriesEnforceRoleOwnershipAndMembershipWithoutIdDisclosure+completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader test +``` + +**Observed RED:** `Tests run: 2, Failures: 1, Errors: 1`. The former member still received the active Project in `listVisible`, and completed `detail` threw `ProjectRuleViolationException: Project has no current Leader`. + +**Observed GREEN:** the same command completed with `Tests run: 2, Failures: 0, Errors: 0, Skipped: 0` and `BUILD SUCCESS` against PostgreSQL 18.4. The test closes a real membership and, for completed history, closes all membership and leadership intervals before querying Admin, owner, and former-member detail DTOs. + ## Affected suite **Command and result** @@ -70,12 +85,12 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock 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 test +./mvnw -Dtest='Project*Test' test -[INFO] Tests run: 111, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 31, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` ## External-test boundaries -This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, a two-transaction lock race, or Iteration 2 invitations/removals/completion. Task query semantics have their own Task-owned unit evidence; this integration proves Project consumes that public service boundary atomically without importing Task persistence. +This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, or a two-transaction leadership race. In particular it does not claim `AC-PRJ-002`; that concurrency proof remains Iteration 3 scope. Iteration 2 invitations/removals/completion services are also out of scope; SQL is used only to shape the already specified completed-history fixture. Task query semantics have their own Task-owned unit evidence; this integration proves Project consumes that public service boundary atomically without importing Task persistence. diff --git a/docs/tests/unit/projects-layer-structure.md b/docs/tests/unit/projects-layer-structure.md index e27ad70..32ee21d 100644 --- a/docs/tests/unit/projects-layer-structure.md +++ b/docs/tests/unit/projects-layer-structure.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ARC-002`, `ARC-005`–`ARC-007`, `OPS-018`–`OPS-020`, `TST-001`–`TST-010` - **Scenario IDs:** `I1-PRJ-01`–`I1-PRJ-05` - **Test class/method:** `com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest#projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa` -- **Implementation commit:** `25a855e` +- **Implementation commits:** `25a855e`, `pending review-fix commit` ## Protected behavior @@ -70,6 +70,22 @@ export PATH="$JAVA_HOME/bin:$PATH" [INFO] BUILD SUCCESS ``` +## Iteration 1 Javadoc retrofit verification + +No behavioral RED was manufactured for documentation. The initial Project-scoped doclint run +reported 29 warnings for missing type comments, an implicit public advice constructor, and +accessor comments without main descriptions. After documenting every Project-owned production +type and declared public/protected API, the same scoped command passed: + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -DskipTests -Dmaven.javadoc.failOnWarnings=true -Ddoclint=all -Dsubpackages=com.lab.labtimesheet.feature.project javadoc:javadoc + +[INFO] BUILD SUCCESS +[INFO] Total time: 2.579 s +``` + ## External-test boundaries -This check does not prove database mappings, transaction behavior, MVC routing, or runtime authorization; those remain covered by PostgreSQL and MockMvc tests. +This check does not prove database mappings, transaction behavior, MVC routing, or runtime authorization; those remain covered by PostgreSQL and MockMvc tests. Whole-application fail-on-warning Javadoc remains an integration responsibility after every feature owner completes the approved Iteration 1 retrofit; this evidence deliberately scopes generation to the Project-owned package. diff --git a/docs/tests/web/projects-pages.md b/docs/tests/web/projects-pages.md index 44b9ffc..e1652af 100644 --- a/docs/tests/web/projects-pages.md +++ b/docs/tests/web/projects-pages.md @@ -4,11 +4,11 @@ - **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `PRJ-012`, `SEC-001`, `ERR-001` - **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `AC-PRJ-006`, `I1-PRJ-04`, `I1-PRJ-05` - **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest` -- **Implementation commits:** `25a855e`, `a9ee99a`, `2f25731`, `dbf1202` +- **Implementation commits:** `25a855e`, `a9ee99a`, `2f25731`, `dbf1202`, `pending review-fix commit` ## Protected behavior -Authenticated users receive only authorized Project routes; guessed IDs return a non-disclosing not-found response; valid Mentor create requests use the authenticated identity; invalid forms do not mutate; the planned-Project activation action is shown only to the owning Mentor; state changes require CSRF. +Authenticated users receive only authorized Project routes; guessed IDs return the shared non-disclosing error contract; valid Mentor create requests use the authenticated identity; binding and domain validation re-render safe forms with retained input and no mutation. Completed owner/Admin/former-member views render without a current Leader or mutation forms. The planned-Project activation action is shown only to the owning Mentor; state changes require CSRF. ## Test method @@ -16,7 +16,7 @@ MockMvc exercises the real controller, binding, Bean Validation, exception mappi ## Hand-derived expected result -An authorized list request renders `projects/list`. An unauthorized direct ID returns 404. Member and leadership routes authorize through actor plus Project ID. A valid create redirects to the created detail ID; a blank name and zero Leader ID render field errors and make no service call. An owning Mentor can submit activation and is redirected to detail; non-owners do not receive that control. POST without CSRF returns 403. +An authorized list request renders `projects/list`. Unauthorized and missing direct IDs produce the same `error/generic` view with `errorStatus`, `errorTitle`, and `errorMessage`; no exception detail is rendered. Member and leadership routes authorize through actor plus Project ID. A valid create redirects to the created detail ID; blank/date-invalid input and ineligible Leader/member selections retain safe input and render field errors without a successful mutation. An activation guard failure returns to detail with its safe rule message. Completed Project pages show no current Leader and no forms for owner, Admin, or former member. POST without CSRF returns 403. ## RED @@ -62,9 +62,9 @@ export PATH="$JAVA_HOME/bin:$PATH" 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 test +./mvnw -Dtest='Project*Test' test -[INFO] Tests run: 111, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 31, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -86,6 +86,20 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **GREEN:** after adding the CSRF-protected POST route and owner/status-conditional Thymeleaf form, the two focused tests passed; the full `ProjectControllerTest` class passed 10 tests with zero failures, errors, or skips. +## Review round 1 safe-validation, completed-page, and error-contract regression + +**RED command:** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectControllerTest test +``` + +**Observed RED:** `Tests run: 14, Failures: 5, Errors: 0`. Domain validation and activation returned blank 409 responses instead of their safe originating views; authorization/conflict responses had no `ModelAndView`; and completed detail rendered an empty Leader value instead of an explicit no-current-Leader state. + +**Observed GREEN:** the same command passed the expanded owner/Admin/former-member matrix with `Tests run: 16, Failures: 0, Errors: 0, Skipped: 0` and `BUILD SUCCESS`. The Project test resource supplies only a contract fixture for `error/generic`; Reporting/UI owns the production shared template. + ## External-test boundaries -This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, browser accessibility, or Iteration 2 invitation/exit/completion pages. Server-side activation authorization and Task-assignee atomicity are covered by Project domain and PostgreSQL integration tests. +This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, or live-browser accessibility. Reporting/UI owns the final production `error/generic` template and will consume the documented three-key model contract after merging this pin; Project deliberately does not edit that shared asset. Iteration 2 invitation/exit/completion pages remain out of scope. Server-side activation authorization and Task-assignee atomicity are covered by Project domain and PostgreSQL integration tests. diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java index 7d3aeda..e11f370 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -1,6 +1,7 @@ package com.lab.labtimesheet.feature.project.controller; 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.service.ProjectQueryService; @@ -16,6 +17,14 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +/** + * Serves authenticated, server-rendered Project pages and binds Project mutation forms. + * + *

    Project services remain the authority for ownership, membership, lifecycle, and + * transactional validation. Known rule failures are returned to the originating safe view, + * while authorization failures are left to {@code ProjectControllerAdvice} so identifiers are + * not disclosed. + */ @Controller @RequestMapping("/projects") public class ProjectController { @@ -23,11 +32,25 @@ public class ProjectController { private final ProjectQueryService pages; private final ProjectService projects; + /** + * Creates the MVC adapter for Project queries and mutations. + * + * @param pages authorized Project read operations + * @param projects transactional Project mutation operations + */ public ProjectController(ProjectQueryService pages, ProjectService projects) { this.pages = pages; this.projects = projects; } + /** + * Lists only Projects visible to the authenticated actor and exposes Project creation only + * to Mentors. + * + * @param principal authenticated user + * @param model response model + * @return the Project list view + */ @GetMapping public String list(Principal principal, Model model) { var actor = pages.authenticatedActor(principal.getName()); @@ -36,6 +59,14 @@ public class ProjectController { return "projects/list"; } + /** + * Opens the creation form for an authenticated Mentor. + * + * @param principal authenticated user + * @param model response model + * @return the Project creation view + * @throws ProjectAccessDeniedException when the actor is not an active Mentor + */ @GetMapping("/new") public String createForm(Principal principal, Model model) { if (!"MENTOR".equals(pages.authenticatedActor(principal.getName()).role())) { @@ -45,6 +76,14 @@ public class ProjectController { return "projects/form"; } + /** + * Creates a Project or re-renders the form with retained safe input when validation fails. + * + * @param principal authenticated user + * @param projectForm validated browser input + * @param bindingResult binding and domain validation results + * @return a redirect to the created Project, or the creation form on validation failure + */ @PostMapping public String create( Principal principal, @@ -53,54 +92,147 @@ public class ProjectController { if (bindingResult.hasErrors()) { return "projects/form"; } - long projectId = projects.create(actorId(principal), projectForm.toCommand()); - return "redirect:/projects/" + projectId; + try { + long projectId = projects.create(actorId(principal), projectForm.toCommand()); + return "redirect:/projects/" + projectId; + } catch (ProjectRuleViolationException exception) { + bindingResult.rejectValue( + "initialLeaderUserId", "project.initialLeader.ineligible", exception.getMessage()); + return "projects/form"; + } } + /** + * Renders an authorized Project detail without disclosing guessed identifiers. + * + * @param principal authenticated user + * @param projectId requested Project identifier + * @param model response model + * @return the Project detail view + */ @GetMapping("/{projectId}") public String detail(Principal principal, @PathVariable long projectId, Model model) { model.addAttribute("project", pages.detail(actorId(principal), projectId)); return "projects/detail"; } + /** + * Activates a planned Project or re-renders its detail with a safe lifecycle error. + * + * @param principal authenticated user + * @param projectId Project to activate + * @param model response model used when activation is rejected + * @return a detail redirect after success, or the detail view after a rule failure + */ @PostMapping("/{projectId}/activate") - public String activate(Principal principal, @PathVariable long projectId) { - projects.activate(actorId(principal), projectId); - return "redirect:/projects/" + projectId; + public String activate(Principal principal, @PathVariable long projectId, Model model) { + long actorId = actorId(principal); + try { + projects.activate(actorId, projectId); + return "redirect:/projects/" + projectId; + } catch (ProjectRuleViolationException exception) { + model.addAttribute("project", pages.detail(actorId, projectId)); + model.addAttribute("projectError", exception.getMessage()); + return "projects/detail"; + } } + /** + * Renders authorized current and historical membership intervals. + * + * @param principal authenticated user + * @param projectId requested Project identifier + * @param model response model + * @return the membership history view + */ @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)); return "projects/members"; } + /** + * Adds an eligible Intern or re-renders membership history with the submitted identifier + * and a safe validation message. + * + * @param principal authenticated user + * @param projectId owning Project identifier + * @param memberForm 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( Principal principal, @PathVariable long projectId, - @Valid @ModelAttribute ProjectMemberForm memberForm) { - projects.addMember(actorId(principal), projectId, memberForm.internUserId()); - return "redirect:/projects/" + projectId + "/members"; + @Valid @ModelAttribute("projectMemberForm") ProjectMemberForm memberForm, + BindingResult bindingResult, + Model model) { + long actorId = actorId(principal); + if (!bindingResult.hasErrors()) { + try { + projects.addMember(actorId, projectId, memberForm.internUserId()); + return "redirect:/projects/" + projectId + "/members"; + } catch (ProjectRuleViolationException exception) { + bindingResult.rejectValue("internUserId", "project.member.ineligible", exception.getMessage()); + } + } + model.addAttribute("project", pages.detail(actorId, projectId)); + model.addAttribute("members", pages.members(actorId, projectId)); + return "projects/members"; } + /** + * Renders the authorized leadership-term history and an owner-only mutation form while the + * Project is mutable. + * + * @param principal authenticated user + * @param projectId requested Project identifier + * @param model response model + * @return the leadership history view + */ @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)); + model.addAttribute("projectMemberForm", new ProjectMemberForm(null)); return "projects/leadership"; } + /** + * Appoints an eligible current member or re-renders leadership history with retained input. + * + * @param principal authenticated user + * @param projectId owning Project identifier + * @param memberForm validated replacement Leader selection + * @param bindingResult binding and domain validation results + * @param model response model used on failure + * @return a leadership redirect after success, or the leadership view on validation failure + */ @PostMapping("/{projectId}/leadership") public String changeLeader( Principal principal, @PathVariable long projectId, - @Valid @ModelAttribute ProjectMemberForm memberForm) { - projects.changeLeader(actorId(principal), projectId, memberForm.internUserId()); - return "redirect:/projects/" + projectId + "/leadership"; + @Valid @ModelAttribute("projectMemberForm") ProjectMemberForm memberForm, + BindingResult bindingResult, + Model model) { + long actorId = actorId(principal); + if (!bindingResult.hasErrors()) { + try { + projects.changeLeader(actorId, projectId, memberForm.internUserId()); + return "redirect:/projects/" + projectId + "/leadership"; + } catch (ProjectRuleViolationException exception) { + bindingResult.rejectValue("internUserId", "project.leader.ineligible", exception.getMessage()); + } + } + model.addAttribute("project", pages.detail(actorId, projectId)); + model.addAttribute("leadership", pages.leadership(actorId, projectId)); + return "projects/leadership"; } private long actorId(Principal principal) { diff --git a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java index 65ef0dd..00a4823 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectAccessDeniedException.java @@ -1,7 +1,11 @@ package com.lab.labtimesheet.feature.project.exception; +/** + * Signals a Project lookup or operation that must fail without revealing resource existence. + */ public final class ProjectAccessDeniedException extends RuntimeException { + /** Creates the internal denial signal; controllers replace its message with generic copy. */ public ProjectAccessDeniedException() { super("Project access denied"); } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java index f6c381c..3fa2f45 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java @@ -3,19 +3,57 @@ package com.lab.labtimesheet.feature.project.exception; import com.lab.labtimesheet.feature.project.controller.ProjectController; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.servlet.ModelAndView; -@RestControllerAdvice(assignableTypes = ProjectController.class) +/** + * Maps uncaught Project authorization and lifecycle failures to the shared, non-disclosing + * server-rendered error contract. + * + *

    The Reporting/UI feature supplies {@code error/generic}. Its stable model contains + * {@code errorStatus}, {@code errorTitle}, and {@code errorMessage}; none is populated from the + * exception message. + */ +@ControllerAdvice(assignableTypes = ProjectController.class) public class ProjectControllerAdvice { - @ExceptionHandler(ProjectAccessDeniedException.class) - @ResponseStatus(HttpStatus.NOT_FOUND) - public void accessDenied() { + /** Creates the stateless Project exception-to-view adapter. */ + public ProjectControllerAdvice() { } + /** + * Hides whether a requested Project or nested resource exists. + * + * @return the shared generic error view with HTTP 404 and safe copy + */ + @ExceptionHandler(ProjectAccessDeniedException.class) + public ModelAndView accessDenied() { + return genericError( + HttpStatus.NOT_FOUND, + "Project unavailable", + "The requested Project could not be found or is not available to you."); + } + + /** + * Reports an uncaught stale or invalid Project request without exposing aggregate details. + * Known form validation failures are handled by the controller before reaching this fallback. + * + * @return the shared generic error view with HTTP 409 and safe copy + */ @ExceptionHandler(ProjectRuleViolationException.class) - @ResponseStatus(HttpStatus.CONFLICT) - public void conflict() { + public ModelAndView conflict() { + return genericError( + HttpStatus.CONFLICT, + "Project request could not be completed", + "Review the Project and try again."); + } + + private static ModelAndView genericError(HttpStatus status, String title, String message) { + var error = new ModelAndView("error/generic"); + error.setStatus(status); + error.addObject("errorStatus", status.value()); + error.addObject("errorTitle", title); + error.addObject("errorMessage", message); + return error; } } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java index 86d24d6..f63f605 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectRuleViolationException.java @@ -1,7 +1,16 @@ package com.lab.labtimesheet.feature.project.exception; +/** + * Signals that a Project lifecycle, eligibility, membership, or leadership rule rejected a + * mutation without committing a partial aggregate change. + */ public final class ProjectRuleViolationException extends RuntimeException { + /** + * Creates a domain-rule failure whose message may be shown only by a known safe form flow. + * + * @param message actionable domain validation message without protected identifiers + */ public ProjectRuleViolationException(String message) { super(message); } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java index 63668ed..2325e3a 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectInternEligibility.java @@ -1,13 +1,31 @@ package com.lab.labtimesheet.feature.project.model; +/** + * Account-owned eligibility fact used by the Project aggregate without importing Account + * persistence types. + * + * @param userId Intern account identifier + * @param eligible true only when both account and internship are active for the relevant check + */ public record ProjectInternEligibility(long userId, boolean eligible) { + /** + * Rejects invalid identifiers before they enter Project membership history. + * + * @param userId Intern account identifier + * @param eligible Account-service eligibility decision + */ public ProjectInternEligibility { if (userId <= 0) { throw new IllegalArgumentException("Intern user ID must be positive"); } } + /** + * Returns the Account-service eligibility decision. + * + * @return true when the Intern may participate in the requested Project operation + */ public boolean isEligible() { return eligible; } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java index 7c1d67d..6b53363 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectLeaderChange.java @@ -3,5 +3,12 @@ package com.lab.labtimesheet.feature.project.model; import com.lab.labtimesheet.feature.project.model.entity.ProjectMembershipEntity; import java.time.Instant; +/** + * In-transaction handoff between closing the current leadership term and opening its replacement. + * It exists so the old interval can be flushed before PostgreSQL validates the new current term. + * + * @param replacement active same-Project membership appointed as Leader + * @param effectiveAt end/start instant shared by the adjacent leadership terms + */ public record ProjectLeaderChange(ProjectMembershipEntity replacement, Instant effectiveAt) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectStatus.java b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectStatus.java index 75d9e0d..d4acf9d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/ProjectStatus.java @@ -1,7 +1,11 @@ package com.lab.labtimesheet.feature.project.model; +/** Project aggregate lifecycle; completion is terminal and read-only. */ public enum ProjectStatus { + /** Preparation state in which membership, leadership, and Task definitions may change. */ PLANNED, + /** Execution state in which Project work may proceed. */ ACTIVE, + /** Terminal read-only state retaining historical membership and leadership visibility. */ COMPLETED } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java index 6139322..4afb1ce 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectActorView.java @@ -1,4 +1,10 @@ package com.lab.labtimesheet.feature.project.model.dto; +/** + * Active authenticated actor information exposed to Project web consumers. + * + * @param userId stable account identifier + * @param role immutable global role name + */ public record ProjectActorView(long userId, String role) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java index 2c2913f..3daddc9 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateCommand.java @@ -2,6 +2,15 @@ package com.lab.labtimesheet.feature.project.model.dto; import java.time.LocalDate; +/** + * Service command for atomically planning a Project with its initial Leader. + * + * @param name required Project name + * @param description optional Project description + * @param startDate inclusive Project start date + * @param endDate inclusive Project end date, not before {@code startDate} + * @param initialLeaderUserId eligible Intern appointed as the first Leader + */ public record ProjectCreateCommand( String name, String description, diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java index b95332a..eb4f125 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectCreateForm.java @@ -8,6 +8,15 @@ import jakarta.validation.constraints.Size; import java.time.LocalDate; import org.springframework.format.annotation.DateTimeFormat; +/** + * Validated browser input for planning a Project and appointing its initial Leader. + * + * @param name required Project name, limited to the persisted column length + * @param description optional description + * @param startDate inclusive Project start date + * @param endDate inclusive Project end date + * @param initialLeaderUserId positive eligible Intern user identifier + */ public record ProjectCreateForm( @NotBlank @Size(max = 160) String name, String description, @@ -15,15 +24,26 @@ public record ProjectCreateForm( @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, @NotNull @Positive Long initialLeaderUserId) { + /** Creates an empty form for the initial GET request and Thymeleaf binding. */ public ProjectCreateForm() { this(null, null, null, null, null); } + /** + * Validates the date interval only after both required dates have bound successfully. + * + * @return true when either date awaits required-field validation or end is not before start + */ @AssertTrue(message = "End date must not precede start date") public boolean isDateRangeValid() { return startDate == null || endDate == null || !endDate.isBefore(startDate); } + /** + * Converts validated browser input to the immutable service command. + * + * @return creation command preserving the submitted values + */ public ProjectCreateCommand toCommand() { return new ProjectCreateCommand(name, description, startDate, endDate, initialLeaderUserId); } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java index 8a970f0..2d781d2 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDashboardSummary.java @@ -1,4 +1,10 @@ package com.lab.labtimesheet.feature.project.model.dto; +/** + * Role-scoped current Project metrics for the dashboard. + * + * @param activeProjectCount number of active Projects visible in the actor's current scope + * @param distinctActiveMemberCount distinct eligible active members for a Mentor; zero for other roles + */ public record ProjectDashboardSummary(long activeProjectCount, long distinctActiveMemberCount) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java index 72a13a0..688cceb 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java @@ -2,6 +2,19 @@ package com.lab.labtimesheet.feature.project.model.dto; import java.time.LocalDate; +/** + * Authorized Project detail for server-rendered pages. + * + * @param id Project identifier + * @param name display name + * @param description optional description + * @param status lifecycle status + * @param startDate inclusive Project start date + * @param endDate inclusive Project end date + * @param mentorName owning Mentor display name + * @param leaderName current Leader display name, or null after completion closes leadership + * @param canManage whether the viewer is the owner and the Project remains mutable + */ public record ProjectDetail( long id, String name, diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java index a63fd92..f9a5a1b 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectLeadershipTermView.java @@ -2,6 +2,14 @@ package com.lab.labtimesheet.feature.project.model.dto; import java.time.Instant; +/** + * Historical leadership interval for an authorized Project page. + * + * @param id leadership-term identifier + * @param leaderName retained Leader display name + * @param startedAt inclusive term start instant + * @param endedAt term end instant, or null while the term is current + */ public record ProjectLeadershipTermView( long id, String leaderName, diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java index 13d483c..2af2b0d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberForm.java @@ -1,6 +1,12 @@ package com.lab.labtimesheet.feature.project.model.dto; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; -public record ProjectMemberForm(@Positive long internUserId) { +/** + * Browser form selecting an Intern for direct membership or leadership appointment. + * + * @param internUserId positive Intern user identifier; null binding is rejected before mutation + */ +public record ProjectMemberForm(@NotNull @Positive Long internUserId) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java index b799097..2356a9a 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMemberView.java @@ -2,6 +2,16 @@ package com.lab.labtimesheet.feature.project.model.dto; import java.time.Instant; +/** + * Current or historical Project membership for authorized server-rendered pages. + * + * @param membershipId stable membership-interval identifier + * @param internUserId participating Intern user identifier + * @param displayName current Account display name + * @param joinedAt inclusive membership start instant + * @param leftAt membership end instant, or null while current + * @param currentLeader true only for the current open-Project Leader membership + */ public record ProjectMemberView( long membershipId, long internUserId, diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java index 93de086..c218eec 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectSummary.java @@ -2,5 +2,14 @@ package com.lab.labtimesheet.feature.project.model.dto; import java.time.LocalDate; +/** + * Compact authorized Project row for lists. + * + * @param id Project identifier + * @param name display name + * @param status lifecycle status + * @param startDate inclusive start date + * @param endDate inclusive end date + */ public record ProjectSummary(long id, String name, String status, LocalDate startDate, LocalDate endDate) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java index ffb3570..b14a41b 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskContext.java @@ -3,6 +3,17 @@ package com.lab.labtimesheet.feature.project.model.dto; import java.time.LocalDate; import java.util.List; +/** + * DTO-only Project authorization and lifecycle context consumed by the Task feature. + * + * @param projectId Project identifier + * @param mentorUserId owning Mentor user identifier + * @param status lifecycle status + * @param startDate inclusive Project start date + * @param endDate inclusive Project end date + * @param currentLeaderMembershipId current Leader membership, or null after completion + * @param activeMembers eligible current memberships, empty after completion + */ public record ProjectTaskContext( long projectId, long mentorUserId, @@ -12,6 +23,18 @@ public record ProjectTaskContext( Long currentLeaderMembershipId, List activeMembers) { + /** + * Defensively snapshots member context so consumers cannot change authorization facts after + * they were read. + * + * @param projectId Project identifier + * @param mentorUserId owning Mentor user identifier + * @param status lifecycle status + * @param startDate inclusive Project start date + * @param endDate inclusive Project end date + * @param currentLeaderMembershipId current Leader membership, or null after completion + * @param activeMembers eligible current memberships, copied and never null + */ public ProjectTaskContext { activeMembers = List.copyOf(activeMembers); } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java index 3123f1d..cae7a22 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectTaskMemberView.java @@ -1,4 +1,11 @@ package com.lab.labtimesheet.feature.project.model.dto; +/** + * Current eligible Project member exposed to Task services without sharing Project entities. + * + * @param membershipId active membership-interval identifier used by Task foreign keys + * @param userId Intern account identifier used for actor authorization + * @param displayName current Account display name + */ public record ProjectTaskMemberView(long membershipId, long userId, String displayName) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java index 850fe03..6a3ed22 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java @@ -23,6 +23,13 @@ import java.util.List; import java.util.Objects; import java.util.Set; +/** + * JPA aggregate root for Project lifecycle, membership intervals, and leadership intervals. + * + *

    A planned or active Project owns exactly one current Leader membership. Completion is + * terminal and closes current intervals; history is retained rather than reassigned or deleted. + * Mutation methods enforce aggregate rules independently of browser control visibility. + */ @Entity @Table(name = "projects") public class ProjectEntity { @@ -68,6 +75,7 @@ public class ProjectEntity { @Version private long version; + /** Constructor reserved for JPA materialization. */ protected ProjectEntity() { } @@ -87,6 +95,19 @@ public class ProjectEntity { this.updatedAt = createdAt; } + /** + * Plans a Project with one eligible initial Leader membership and its first leadership term. + * The returned aggregate is never empty or leaderless. + * + * @param mentorUserId active Mentor who owns the Project + * @param name required Project name + * @param description optional description + * @param startDate inclusive start date + * @param endDate inclusive end date, not before {@code startDate} + * @param initialLeader eligible Intern appointed as first Leader + * @param at server mutation instant used for all initial records + * @return new unsaved planned aggregate + */ public static ProjectEntity plan( long mentorUserId, String name, @@ -119,6 +140,14 @@ public class ProjectEntity { return project; } + /** + * Adds a distinct eligible current member to a mutable Project after owner authorization. + * + * @param actorMentorUserId authenticated owning Mentor + * @param intern current Account/internship eligibility fact + * @param at server join instant + * @return newly created membership interval + */ public ProjectMembershipEntity addMember( long actorMentorUserId, ProjectInternEligibility intern, Instant at) { requireOwner(actorMentorUserId); @@ -131,6 +160,15 @@ public class ProjectEntity { return addEligibleMember(intern, actorMentorUserId, at); } + /** + * Closes the current leadership term and prepares an eligible active-member replacement. + * Callers must flush the closed interval before opening the replacement term. + * + * @param actorMentorUserId authenticated owning Mentor + * @param intern eligible replacement Intern + * @param at server effective instant + * @return replacement membership and adjacent-term effective instant + */ public ProjectLeaderChange prepareLeaderChange( long actorMentorUserId, ProjectInternEligibility intern, Instant at) { requireOwner(actorMentorUserId); @@ -148,6 +186,12 @@ public class ProjectEntity { return new ProjectLeaderChange(replacement, effectiveAt); } + /** + * Opens the replacement term after the former current term has been closed and flushed. + * + * @param actorMentorUserId authenticated owning Mentor + * @param change prepared replacement from this transaction + */ public void completeLeaderChange(long actorMentorUserId, ProjectLeaderChange change) { requireOwner(actorMentorUserId); Objects.requireNonNull(change, "change"); @@ -158,6 +202,15 @@ public class ProjectEntity { this, change.replacement(), change.effectiveAt(), actorMentorUserId)); } + /** + * Moves a planned Project to active after current member, Leader, and Task-assignee guards + * pass. The transition is one-way and records the server activation instant. + * + * @param actorMentorUserId authenticated owning Mentor + * @param activeInternUserIds currently eligible member user identifiers + * @param allTaskAssigneesAreCurrent true when every non-deleted Task points to a current membership + * @param at server activation instant + */ public void activate( long actorMentorUserId, Set activeInternUserIds, @@ -184,59 +237,135 @@ public class ProjectEntity { updatedAt = at; } + /** + * Returns the persistence identity. + * + * @return persisted identifier, or null before insertion + */ public Long id() { return id; } + /** + * Returns immutable Project ownership. + * + * @return owning Mentor user identifier + */ public long mentorUserId() { return mentorUserId; } + /** + * Returns the display name. + * + * @return normalized Project name + */ public String name() { return name; } + /** + * Returns optional descriptive copy. + * + * @return normalized optional description, or null when absent + */ public String description() { return description; } + /** + * Returns the lower business-date boundary. + * + * @return inclusive Project start date + */ public LocalDate startDate() { return startDate; } + /** + * Returns the upper business-date boundary. + * + * @return inclusive Project end date + */ public LocalDate endDate() { return endDate; } + /** + * Returns the current lifecycle state. + * + * @return current aggregate lifecycle status + */ public ProjectStatus status() { return status; } + /** + * Returns when execution began. + * + * @return server activation instant, or null while planned + */ public Instant activatedAt() { return activatedAt; } + /** + * Returns a defensive snapshot of current and historical membership intervals. + * + * @return unmodifiable membership snapshot + */ public List memberships() { return List.copyOf(memberships); } + /** + * Returns a defensive snapshot of current and historical leadership intervals. + * + * @return unmodifiable leadership-term snapshot + */ public List leadershipTerms() { return List.copyOf(leadershipTerms); } + /** + * Enforces owning-Mentor authority without revealing details to non-owners. + * + * @param actorMentorUserId authenticated Mentor identifier + * @throws ProjectAccessDeniedException when the actor does not own this Project + */ public void authorizeOwner(long actorMentorUserId) { requireOwner(actorMentorUserId); } + /** + * Checks only open membership intervals. + * + * @param internUserId Intern account identifier + * @return true when the Intern currently belongs to this Project + */ public boolean hasCurrentMember(long internUserId) { return memberships.stream() .anyMatch(membership -> membership.internUserId() == internUserId && membership.isCurrent()); } + /** + * Checks current and closed membership intervals for completed-history authorization. + * + * @param internUserId Intern account identifier + * @return true when the Intern has ever belonged to this Project + */ public boolean hasEverHadMember(long internUserId) { return memberships.stream().anyMatch(membership -> membership.internUserId() == internUserId); } + /** + * Resolves the active membership referenced by the one current leadership term. + * Completed Projects deliberately have no current Leader and callers must not use this method + * for completed-history rendering. + * + * @return current Leader membership + * @throws ProjectRuleViolationException when the open-Project Leader invariant is absent + */ public ProjectMembershipEntity currentLeader() { return currentMembership(currentLeadershipTerm().internUserId()); } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java index 62974e5..dbfd7ec 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java @@ -12,6 +12,12 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.Instant; +/** + * JPA leadership interval attached to an active same-Project membership. + * + *

    Changes close the current term and create a new term; completion closes the final term. + * Historical terms retain the appointing and ending Mentor attribution. + */ @Entity @Table(name = "project_leadership_terms") public class ProjectLeadershipTermEntity { @@ -40,6 +46,7 @@ public class ProjectLeadershipTermEntity { @Column(name = "ended_by_mentor_user_id") private Long endedByMentorUserId; + /** Constructor reserved for JPA materialization. */ protected ProjectLeadershipTermEntity() { } @@ -54,30 +61,65 @@ public class ProjectLeadershipTermEntity { this.appointedByMentorUserId = appointedByMentorUserId; } + /** + * Returns the interval identity. + * + * @return persisted leadership-term identifier, or null before insertion + */ public Long id() { return id; } + /** + * Returns the Intern who led during this interval. + * + * @return Intern account identifier obtained from the retained membership interval + */ public long internUserId() { return membership.internUserId(); } + /** + * Returns when leadership authority began. + * + * @return inclusive leadership start instant + */ public Instant startedAt() { return startedAt; } + /** + * Returns appointment provenance. + * + * @return owning Mentor account that appointed this Leader + */ public long appointedByMentorUserId() { return appointedByMentorUserId; } + /** + * Returns when leadership authority ended. + * + * @return term end instant, or null while current + */ public Instant endedAt() { return endedAt; } + /** + * Returns closure provenance. + * + * @return Mentor that closed the term, or null while current + */ public Long endedByMentorUserId() { return endedByMentorUserId; } + /** + * Indicates whether this term currently grants Leader authority. + * + * @return true while the term has no end instant + */ public boolean isCurrent() { return endedAt == null; } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java index 59f158c..3240d17 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java @@ -12,6 +12,12 @@ import jakarta.persistence.Table; import jakarta.persistence.Version; import java.time.Instant; +/** + * JPA membership interval linking one Intern to one Project. + * + *

    Leaving closes the interval; the row and its provenance remain for completed Project and + * Task history. Current membership is represented by a null {@code leftAt}. + */ @Entity @Table(name = "project_memberships") public class ProjectMembershipEntity { @@ -42,6 +48,7 @@ public class ProjectMembershipEntity { @Version private long version; + /** Constructor reserved for JPA materialization. */ protected ProjectMembershipEntity() { } @@ -53,26 +60,56 @@ public class ProjectMembershipEntity { this.updatedAt = joinedAt; } + /** + * Returns the interval identity used by Project and Task relationships. + * + * @return persisted membership identifier, or null before insertion + */ public Long id() { return id; } + /** + * Returns the participating Intern. + * + * @return participating Intern account identifier + */ public long internUserId() { return internUserId; } + /** + * Returns when membership authority began. + * + * @return inclusive membership start instant + */ public Instant joinedAt() { return joinedAt; } + /** + * Returns membership provenance. + * + * @return account identifier that directly created this interval + */ public long addedByUserId() { return addedByUserId; } + /** + * Returns when membership authority ended. + * + * @return interval end instant, or null while membership is current + */ public Instant leftAt() { return leftAt; } + /** + * Indicates whether the Intern currently belongs to the Project. + * + * @return true while the membership interval has no end instant + */ public boolean isCurrent() { return leftAt == null; } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java b/src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java index c0be117..d4defd8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/repository/ProjectRepository.java @@ -9,20 +9,52 @@ import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +/** + * Persists the Project aggregate, including its membership and leadership intervals. + * + *

    Consumers outside the Project feature use Project services and DTOs rather than this + * repository or its JPA entities. + */ public interface ProjectRepository extends JpaRepository { + /** + * Loads one Project under a pessimistic write lock for mutation-time authorization and + * invariant checks. The caller's transaction retains the lock through commit or rollback. + * + * @param id Project identifier + * @return the locked aggregate, or empty when the identifier does not exist + */ @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select project from ProjectEntity project where project.id = :id") Optional findLockedById(@Param("id") long id); + /** + * Lists all Projects for Admin read-only inspection, most recently updated first. + * + * @return ordered Projects + */ List findAllByOrderByUpdatedAtDescIdDesc(); + /** + * Lists Projects owned by one Mentor, most recently updated first. + * + * @param mentorUserId owning Mentor user identifier + * @return ordered owned Projects + */ List findByMentorUserIdOrderByUpdatedAtDescIdDesc(long mentorUserId); + /** + * Lists Projects visible to an Intern: current memberships in open Projects and historical + * memberships only after completion. + * + * @param internUserId Intern user identifier + * @return ordered visible Projects without duplicate rows + */ @Query(""" select distinct project from ProjectEntity project join project.memberships membership where membership.internUserId = :internUserId + and (membership.leftAt is null or project.status = com.lab.labtimesheet.feature.project.model.ProjectStatus.COMPLETED) order by project.updatedAt desc, project.id desc """) List findVisibleToIntern(@Param("internUserId") long internUserId); diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java index 3a3cf45..8bc861c 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -18,22 +18,49 @@ import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Provides authorization-aware, DTO-only Project reads to MVC and other features. + * + *

    Admins inspect all Projects, Mentors inspect only owned Projects, and Interns inspect open + * Projects only while currently enrolled. Completed Projects remain visible to historical + * members but expose no current Leader or active-member context. + */ @Service public class ProjectQueryService { private final ProjectRepository projects; private final AccountService accounts; + /** + * Creates the Project read service. + * + * @param projects Project aggregate repository + * @param accounts public Account identity and internship-eligibility boundary + */ public ProjectQueryService(ProjectRepository projects, AccountService accounts) { this.projects = projects; this.accounts = accounts; } + /** + * Resolves an active authenticated account to its stable user identifier. + * + * @param email authenticated email address + * @return active user identifier + * @throws ProjectAccessDeniedException when no active identity is available + */ @Transactional(readOnly = true) public long authenticatedUserId(String email) { return authenticatedActor(email).userId(); } + /** + * Resolves the active authenticated actor needed for role-aware Project navigation. + * + * @param email authenticated email address + * @return user identifier and immutable global role + * @throws ProjectAccessDeniedException when no active identity is available + */ @Transactional(readOnly = true) public ProjectActorView authenticatedActor(String email) { try { @@ -47,15 +74,32 @@ public class ProjectQueryService { } } + /** + * Lists Projects visible under the actor's current role and Project relationship. + * Historical Intern membership grants visibility only to completed Projects. + * + * @param actorUserId active actor user identifier + * @return ordered authorized summaries + */ @Transactional(readOnly = true) public List listVisible(long actorUserId) { var actor = activeActor(actorUserId); return visibleProjects(actor, actorUserId).stream().map(ProjectQueryService::summary).toList(); } + /** + * Returns one authorized Project detail. Completed Projects have no current Leader and never + * grant mutation capability, including to their owning Mentor. + * + * @param actorUserId active actor user identifier + * @param projectId requested Project identifier + * @return authorized detail + * @throws ProjectAccessDeniedException for missing and unauthorized identifiers alike + */ @Transactional(readOnly = true) public ProjectDetail detail(long actorUserId, long projectId) { var project = visibleProject(actorUserId, projectId); + var completed = project.status() == ProjectStatus.COMPLETED; return new ProjectDetail( project.id(), project.name(), @@ -64,10 +108,18 @@ public class ProjectQueryService { project.startDate(), project.endDate(), displayName(project.mentorUserId()), - displayName(project.currentLeader().internUserId()), - project.mentorUserId() == actorUserId); + completed ? null : displayName(project.currentLeader().internUserId()), + !completed && project.mentorUserId() == actorUserId); } + /** + * Returns membership interval history for an authorized Project. Completed history marks no + * membership as current Leader because completion closes the final leadership term. + * + * @param actorUserId active actor user identifier + * @param projectId requested Project identifier + * @return membership history in aggregate order + */ @Transactional(readOnly = true) public List members(long actorUserId, long projectId) { var project = visibleProject(actorUserId, projectId); @@ -87,6 +139,13 @@ public class ProjectQueryService { .toList(); } + /** + * Returns retained leadership terms for an authorized Project, newest first. + * + * @param actorUserId active actor user identifier + * @param projectId requested Project identifier + * @return leadership history, including closed terms + */ @Transactional(readOnly = true) public List leadership(long actorUserId, long projectId) { return visibleProject(actorUserId, projectId).leadershipTerms().stream() @@ -99,6 +158,15 @@ public class ProjectQueryService { .toList(); } + /** + * Returns the DTO-only Project facts needed for Task reads. Open Projects include the current + * Leader membership and active eligible members; completed Projects return a null Leader and + * an empty active-member list while remaining visible to former members. + * + * @param actorUserId active actor user identifier + * @param projectId requested Project identifier + * @return authorized Task context + */ @Transactional(readOnly = true) public ProjectTaskContext taskContext(long actorUserId, long projectId) { var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new); @@ -140,6 +208,13 @@ public class ProjectQueryService { activeMembers); } + /** + * Computes role-scoped dashboard counts from current active Project relationships. + * Historical memberships never contribute to current Intern or Mentor metrics. + * + * @param actorUserId active actor user identifier + * @return active Project count and, for Mentors, distinct eligible active-member count + */ @Transactional(readOnly = true) public ProjectDashboardSummary dashboardSummary(long actorUserId) { var actor = activeActor(actorUserId); @@ -170,7 +245,10 @@ public class ProjectQueryService { var actor = activeActor(actorUserId); var visible = "ADMIN".equals(actor.role().name()) || ("MENTOR".equals(actor.role().name()) && project.mentorUserId() == actorUserId) - || ("INTERN".equals(actor.role().name()) && project.hasEverHadMember(actorUserId)); + || ("INTERN".equals(actor.role().name()) + && (project.hasCurrentMember(actorUserId) + || (project.status() == ProjectStatus.COMPLETED + && project.hasEverHadMember(actorUserId)))); if (!visible) { throw new ProjectAccessDeniedException(); } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java index 1dcac12..8295e2e 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java @@ -14,6 +14,13 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Executes Project aggregate mutations under Spring-managed transactions. + * + *

    Existing aggregates are pessimistically locked before mutation-time authorization and + * lifecycle checks. Account and Task facts arrive through public feature services; Project never + * imports their repositories or entities. + */ @Service public class ProjectService { @@ -23,6 +30,15 @@ public class ProjectService { private final TaskQueryService taskQueries; private final Clock clock; + /** + * Creates the Project mutation service. + * + * @param projects Project aggregate repository + * @param accounts public Account identity and eligibility boundary + * @param queries DTO-only Project query boundary reused for locked Task context + * @param taskQueries public Task activation-guard boundary + * @param clock server clock supplying persisted mutation instants + */ public ProjectService( ProjectRepository projects, AccountService accounts, @@ -36,6 +52,17 @@ public class ProjectService { this.clock = clock; } + /** + * Atomically creates a planned Mentor-owned Project, eligible initial membership, and first + * leadership term. {@code saveAndFlush} exposes database invariant violations before commit. + * + * @param actorUserId authenticated active Mentor creating and owning the Project + * @param command validated creation values + * @return generated Project identifier + * @throws ProjectAccessDeniedException when the actor is not an active Mentor + * @throws com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException when + * dates, name, or initial-Leader eligibility violate the aggregate rules + */ @Transactional public long create(long actorUserId, ProjectCreateCommand command) { requireActiveMentor(actorUserId); @@ -50,6 +77,15 @@ public class ProjectService { return projects.saveAndFlush(project).id(); } + /** + * Adds one eligible Intern as a current member while holding the Project write lock. + * The same Intern may belong to other Projects, but duplicate current membership in this + * Project is rejected before flush. + * + * @param actorUserId authenticated owning Mentor + * @param projectId Project to update + * @param internUserId Intern selected for direct addition + */ @Transactional public void addMember(long actorUserId, long projectId, long internUserId) { var project = lockedProject(projectId); @@ -58,6 +94,15 @@ public class ProjectService { projects.flush(); } + /** + * Replaces the current Leader with an eligible current member in one transaction. + * The closed term is flushed before its replacement so PostgreSQL's immediate exclusion rule + * observes exactly one current term; Task assignments are not changed. + * + * @param actorUserId authenticated owning Mentor + * @param projectId Project whose Leader changes + * @param internUserId active same-Project replacement Intern + */ @Transactional public void changeLeader(long actorUserId, long projectId, long internUserId) { var project = lockedProject(projectId); @@ -71,11 +116,29 @@ public class ProjectService { projects.flush(); } + /** + * Locks the Project and re-evaluates visibility, lifecycle, current leadership, and active + * eligible memberships for a Task mutation. When called inside {@code TaskService}'s + * transaction, the pessimistic lock remains held through the outer commit or rollback. + * + * @param actorUserId authenticated Task actor + * @param projectId owning Project identifier + * @return DTO-only locked mutation context + * @throws ProjectAccessDeniedException for missing or unauthorized Projects + */ @Transactional public ProjectTaskContext taskMutationContext(long actorUserId, long projectId) { return queries.taskContext(actorUserId, lockedProject(projectId)); } + /** + * Activates a planned Project while holding its write lock. Current Account eligibility and + * Task-assignee validity are checked inside the same transaction; any failure leaves the + * Project planned and preserves Tasks and interval history. + * + * @param actorUserId authenticated owning Mentor + * @param projectId planned Project to activate + */ @Transactional public void activate(long actorUserId, long projectId) { var project = lockedProject(projectId); diff --git a/src/main/resources/templates/projects/detail.html b/src/main/resources/templates/projects/detail.html index 9588814..225fab9 100644 --- a/src/main/resources/templates/projects/detail.html +++ b/src/main/resources/templates/projects/detail.html @@ -5,7 +5,8 @@

    Project

    -
    Status
    Mentor
    Leader
    +

    +
    Status
    Mentor
    Leader
    diff --git a/src/main/resources/templates/projects/form.html b/src/main/resources/templates/projects/form.html index 2834cfa..006d9ec 100644 --- a/src/main/resources/templates/projects/form.html +++ b/src/main/resources/templates/projects/form.html @@ -5,11 +5,13 @@

    Create Project

    +

    Please correct the highlighted Project details.

    +

    diff --git a/src/main/resources/templates/projects/leadership.html b/src/main/resources/templates/projects/leadership.html index abe41e9..5399b04 100644 --- a/src/main/resources/templates/projects/leadership.html +++ b/src/main/resources/templates/projects/leadership.html @@ -7,7 +7,12 @@
    Leadership history
    LeaderStartedEnded
    -
    +
    +

    Please correct the Leader selection.

    + +

    + +
    diff --git a/src/main/resources/templates/projects/members.html b/src/main/resources/templates/projects/members.html index 8ee0e9f..b8401dc 100644 --- a/src/main/resources/templates/projects/members.html +++ b/src/main/resources/templates/projects/members.html @@ -7,7 +7,12 @@
    Membership history
    InternJoinedLeftRole
    -
    +
    +

    Please correct the member selection.

    + +

    + +
    diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java index b4682b1..59f100d 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -1,11 +1,13 @@ package com.lab.labtimesheet.feature.project.controller; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.not; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; @@ -14,15 +16,21 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; 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; import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary; +import com.lab.labtimesheet.feature.project.model.dto.ProjectLeadershipTermView; +import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView; import com.lab.labtimesheet.feature.project.service.ProjectQueryService; import com.lab.labtimesheet.feature.project.service.ProjectService; +import java.time.Instant; import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.security.test.context.support.WithMockUser; @@ -83,7 +91,11 @@ class ProjectControllerTest { when(pages.detail(20L, 999L)).thenThrow(new ProjectAccessDeniedException()); mvc.perform(get("/projects/999")) - .andExpect(status().isNotFound()); + .andExpect(status().isNotFound()) + .andExpect(view().name("error/generic")) + .andExpect(model().attribute("errorStatus", 404)) + .andExpect(model().attribute("errorTitle", "Project unavailable")) + .andExpect(model().attributeExists("errorMessage")); } @Test @@ -210,15 +222,166 @@ class ProjectControllerTest { .andExpect(status().isOk()) .andExpect(view().name("projects/form")) .andExpect(model().attributeHasFieldErrors( - "projectForm", "name", "initialLeaderUserId")); + "projectForm", "name", "initialLeaderUserId")) + .andExpect(model().attributeHasErrors("projectForm")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("2026-09-30"))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("2026-08-15"))); verify(projects, never()).create(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any()); } + @Test + @WithMockUser(username = "mentor@example.test") + void domainValidationErrorsStayOnTheirSafeFormsWithRetainedInput() 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))); + when(pages.leadership(10L, 30L)).thenReturn(List.of(new ProjectLeadershipTermView( + 50L, "Current Leader", Instant.parse("2026-08-15T00:00:00Z"), null))); + when(projects.create( + 10L, + new ProjectCreateCommand( + "Retained name", + "Retained description", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + 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); + doThrow(new ProjectRuleViolationException("Selected Intern is already the current Leader")) + .when(projects).changeLeader(10L, 30L, 20L); + + mvc.perform(post("/projects") + .with(csrf()) + .param("name", "Retained name") + .param("description", "Retained description") + .param("startDate", "2026-08-15") + .param("endDate", "2026-09-30") + .param("initialLeaderUserId", "99")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/form")) + .andExpect(model().attributeHasFieldErrors("projectForm", "initialLeaderUserId")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("Retained name"))); + + mvc.perform(post("/projects/30/members") + .with(csrf()) + .param("internUserId", "20")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/members")) + .andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("value=\"20\""))); + + mvc.perform(post("/projects/30/leadership") + .with(csrf()) + .param("internUserId", "20")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/leadership")) + .andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("value=\"20\""))); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void activationRuleErrorReturnsToDetailWithoutLosingSafeContext() throws Exception { + when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail()); + doThrow(new ProjectRuleViolationException("Every current Task assignee must be an active Project member")) + .when(projects).activate(10L, 30L); + + mvc.perform(post("/projects/30/activate").with(csrf())) + .andExpect(status().isOk()) + .andExpect(view().name("projects/detail")) + .andExpect(model().attribute("projectError", + "Every current Task assignee must be an active Project member")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("Every current Task assignee must be an active Project member"))); + } + + @Test + @WithMockUser(username = "member@example.test") + void uncaughtRuleConflictUsesGenericNonDisclosingErrorContract() throws Exception { + when(pages.authenticatedUserId("member@example.test")).thenReturn(20L); + when(pages.detail(20L, 30L)) + .thenThrow(new ProjectRuleViolationException("sensitive aggregate detail")); + + mvc.perform(get("/projects/30")) + .andExpect(status().isConflict()) + .andExpect(view().name("error/generic")) + .andExpect(model().attribute("errorStatus", 409)) + .andExpect(model().attribute("errorTitle", "Project request could not be completed")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString("sensitive aggregate detail")))); + } + + @ParameterizedTest + @ValueSource(strings = {"owner@example.test", "admin@example.test", "former@example.test"}) + void completedProjectPagesRenderForAuthorizedRolesWithoutCurrentLeaderOrMutationForms(String email) + throws Exception { + long actorId = switch (email) { + case "owner@example.test" -> 10L; + case "admin@example.test" -> 11L; + default -> 20L; + }; + when(pages.authenticatedUserId(email)).thenReturn(actorId); + when(pages.detail(actorId, 30L)).thenReturn(new ProjectDetail( + 30L, + "Completed Project", + null, + "COMPLETED", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + "Mentor", + null, + false)); + when(pages.members(actorId, 30L)).thenReturn(List.of()); + when(pages.leadership(actorId, 30L)).thenReturn(List.of(new ProjectLeadershipTermView( + 50L, + "Former Leader", + Instant.parse("2026-08-15T00:00:00Z"), + Instant.parse("2026-09-30T00:00:00Z")))); + + mvc.perform(get("/projects/30").with(user(email))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("No current Leader"))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString(">Activate<")))); + mvc.perform(get("/projects/30/members").with(user(email))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString("Add member")))); + mvc.perform(get("/projects/30/leadership").with(user(email))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("Former Leader"))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString("Change Leader")))); + } + @Test @WithMockUser(username = "mentor@example.test") void stateChangingRoutesRequireCsrf() throws Exception { mvc.perform(post("/projects")) .andExpect(status().isForbidden()); } + + private static ProjectDetail plannedOwnerDetail() { + return new ProjectDetail( + 30L, + "Intern Portal Refresh", + null, + "PLANNED", + LocalDate.of(2026, 8, 15), + LocalDate.of(2026, 9, 30), + "Mentor", + "Current Leader", + true); + } } diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java index a860e9a..f4a12ff 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java @@ -1,6 +1,7 @@ package com.lab.labtimesheet.feature.project.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -172,15 +173,17 @@ class ProjectServiceIntegrationTest { """, dbTime(NOW.plusSeconds(60)), mentorId, projectId, memberId); entityManager.clear(); - assertEquals(projectId, projectPages.detail(memberId, projectId).id()); - assertTrue(projectService.taskMutationContext(memberId, projectId).activeMembers().stream() - .noneMatch(member -> member.userId() == memberId)); + assertEquals(List.of(), projectPages.listVisible(memberId)); + assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(memberId, projectId)); + assertThrows(ProjectAccessDeniedException.class, + () -> projectService.taskMutationContext(memberId, projectId)); assertEquals(0, projectPages.dashboardSummary(memberId).activeProjectCount()); assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount()); } @Test void completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader() { + long adminId = user("admin-history@example.test", "ADMIN"); long mentorId = user("mentor-history@example.test", "MENTOR"); long leaderId = intern("leader-history@example.test", "I012"); long memberId = intern("member-history@example.test", "I013"); @@ -205,6 +208,18 @@ class ProjectServiceIntegrationTest { """, activatedAt, completedAt, completedAt, projectId); entityManager.clear(); + assertEquals(List.of(projectId), projectPages.listVisible(memberId).stream() + .map(summary -> summary.id()) + .toList()); + var ownerDetail = projectPages.detail(mentorId, projectId); + var adminDetail = projectPages.detail(adminId, projectId); + var formerMemberDetail = projectPages.detail(memberId, projectId); + assertNull(ownerDetail.leaderName()); + assertNull(adminDetail.leaderName()); + assertNull(formerMemberDetail.leaderName()); + assertFalse(ownerDetail.canManage()); + assertFalse(adminDetail.canManage()); + assertFalse(formerMemberDetail.canManage()); var taskContext = projectPages.taskContext(memberId, projectId); assertEquals("COMPLETED", taskContext.status()); assertNull(taskContext.currentLeaderMembershipId()); diff --git a/src/test/resources/templates/error/generic.html b/src/test/resources/templates/error/generic.html new file mode 100644 index 0000000..1ea6437 --- /dev/null +++ b/src/test/resources/templates/error/generic.html @@ -0,0 +1,10 @@ + + +Request unavailable + +
    +

    Request unavailable

    +

    The request could not be completed.

    +
    + + From baa0695c60153bc997ebf8b11adcfbdd2cbc1962 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:23:27 +0700 Subject: [PATCH 46/62] docs(projects): record review-fix evidence --- docs/tests/integration/projects-workflows.md | 2 +- docs/tests/unit/projects-layer-structure.md | 2 +- docs/tests/web/projects-pages.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tests/integration/projects-workflows.md b/docs/tests/integration/projects-workflows.md index 64964b7..f4b6339 100644 --- a/docs/tests/integration/projects-workflows.md +++ b/docs/tests/integration/projects-workflows.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004`, `AUTH-011`, `DB-003`, `DB-007` - **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-007`, `AC-AUTH-010`, `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009` - **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest` -- **Implementation commits:** `25a855e`, `dbf1202`, `pending review-fix commit` +- **Implementation commits:** `25a855e`, `dbf1202`, `af0eb3c` ## Protected behavior diff --git a/docs/tests/unit/projects-layer-structure.md b/docs/tests/unit/projects-layer-structure.md index 32ee21d..8bbcbfa 100644 --- a/docs/tests/unit/projects-layer-structure.md +++ b/docs/tests/unit/projects-layer-structure.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ARC-002`, `ARC-005`–`ARC-007`, `OPS-018`–`OPS-020`, `TST-001`–`TST-010` - **Scenario IDs:** `I1-PRJ-01`–`I1-PRJ-05` - **Test class/method:** `com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest#projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa` -- **Implementation commits:** `25a855e`, `pending review-fix commit` +- **Implementation commits:** `25a855e`, `af0eb3c` ## Protected behavior diff --git a/docs/tests/web/projects-pages.md b/docs/tests/web/projects-pages.md index e1652af..edb2c43 100644 --- a/docs/tests/web/projects-pages.md +++ b/docs/tests/web/projects-pages.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `PRJ-012`, `SEC-001`, `ERR-001` - **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `AC-PRJ-006`, `I1-PRJ-04`, `I1-PRJ-05` - **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest` -- **Implementation commits:** `25a855e`, `a9ee99a`, `2f25731`, `dbf1202`, `pending review-fix commit` +- **Implementation commits:** `25a855e`, `a9ee99a`, `2f25731`, `dbf1202`, `af0eb3c` ## Protected behavior From 4c39df70e1f901e232669e9090ff5d21393519f0 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:25:13 +0700 Subject: [PATCH 47/62] fix(attendance): preserve frozen attendance boundaries --- .../attendance-frozen-leave-concurrency.md | 96 ++++++++++++++ .../integration/attendance-persistence.md | 4 +- .../unit/attendance-checkout-eligibility.md | 92 ++++++++++++++ docs/tests/unit/attendance-current-state.md | 4 +- .../unit/attendance-feature-structure.md | 4 +- docs/tests/unit/attendance-policy.md | 4 +- .../tests/unit/attendance-punch-boundaries.md | 4 +- docs/tests/web/attendance-web.md | 30 ++++- .../controller/AttendanceController.java | 36 ++++++ .../controller/CalendarController.java | 32 +++++ .../exception/AttendanceException.java | 14 +++ .../exception/AttendanceRejection.java | 11 ++ .../exception/CalendarException.java | 8 ++ .../attendance/model/AttendanceActor.java | 9 ++ .../model/AttendanceDayContext.java | 8 ++ .../attendance/model/AttendancePolicy.java | 43 ++++--- .../attendance/model/AttendanceRecord.java | 27 ++++ .../attendance/model/AttendanceRole.java | 6 + .../model/AttendanceViolations.java | 8 ++ .../model/dto/AttendanceCurrentState.java | 6 + .../model/dto/AttendanceHistoryItem.java | 94 +++++++++++++- .../model/dto/GlobalCalendarEvent.java | 9 ++ .../model/entity/AttendancePolicyEntity.java | 11 ++ .../model/entity/AttendanceRecordEntity.java | 30 +++++ .../entity/GlobalCalendarEventEntity.java | 37 ++++++ .../model/entity/LeaveRequestDayEntity.java | 54 ++++++++ .../model/entity/LeaveRequestDayId.java | 50 ++++++++ .../model/entity/LeaveRequestEntity.java | 53 ++++++++ .../AttendancePolicyRepository.java | 8 ++ .../repository/AttendanceQueryRepository.java | 24 +++- .../AttendanceRecordRepository.java | 18 +++ .../GlobalCalendarEventRepository.java | 16 +++ .../service/AttendanceApplicationService.java | 70 +++++++++-- .../service/AttendanceCurrentUserService.java | 9 ++ .../service/AttendancePolicyTimeline.java | 20 +++ .../attendance/service/AttendanceService.java | 23 ++++ .../service/CalendarApplicationService.java | 38 ++++++ .../templates/attendance/history.html | 10 +- .../controller/AttendanceControllerTest.java | 29 ++++- .../model/AttendancePolicyFixtures.java | 32 +++++ .../model/AttendancePolicyTest.java | 2 +- .../model/entity/LeaveEntityFixtures.java | 36 ++++++ .../AttendanceApplicationServiceTest.java | 47 ++++++- .../AttendanceConcurrencyIntegrationTest.java | 119 ++++++++++++++++++ .../AttendancePersistenceIntegrationTest.java | 40 ++++++ .../service/AttendanceServiceTest.java | 3 +- 46 files changed, 1270 insertions(+), 58 deletions(-) create mode 100644 docs/tests/integration/attendance-frozen-leave-concurrency.md create mode 100644 docs/tests/unit/attendance-checkout-eligibility.md create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayEntity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayId.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyFixtures.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveEntityFixtures.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceConcurrencyIntegrationTest.java diff --git a/docs/tests/integration/attendance-frozen-leave-concurrency.md b/docs/tests/integration/attendance-frozen-leave-concurrency.md new file mode 100644 index 0000000..f978770 --- /dev/null +++ b/docs/tests/integration/attendance-frozen-leave-concurrency.md @@ -0,0 +1,96 @@ +# Test Evidence: Frozen leave dates and concurrent punch outcomes + +- **Test type:** Integration +- **Requirement IDs:** `ATT-005`, `ATT-006`, `ATT-007`, `ATT-008`, `ATT-010`, `LEV-003`, `LEV-011` +- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-LEV-001`, `AC-LEV-005` +- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates`, `com.lab.labtimesheet.feature.attendance.service.AttendanceConcurrencyIntegrationTest#concurrentDuplicatePunchesReturnStableDomainOutcomes` +- **Implementation commit:** `pending` + +## Protected behavior + +Approved leave blocks check-in only on exact immutable `leave_request_days`, not +every calendar date inside the request range. Concurrent duplicate punches return +stable attendance rejection codes while preserving a single raw check-in and checkout. + +## Test method + +Spring Boot migrates PostgreSQL 18.4, creates an active Intern only through public +Account and SMTP services, and persists an Attendance-owned approved leave request +plus one frozen allocation through JPA. A separate non-transactional test releases +two threads simultaneously against each transactional punch endpoint. + +## Hand-derived expected result + +For an approved 14–17 August range with only 17 August allocated, check-in on +14 August succeeds and 17 August returns `APPROVED_LEAVE`. Two simultaneous +check-ins produce one success and one `ALREADY_CHECKED_IN`; two simultaneous +checkouts produce one success and one `ALREADY_CHECKED_OUT`. + +## 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=AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates test +``` + +**Observed result** + +```text +AttendanceException: APPROVED_LEAVE at AttendanceApplicationService.checkIn for +the unallocated 2026-08-14 range date. +Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 +BUILD FAILURE +Process exited 1 because the query used the whole leave request range. +``` + +The repository-exception unit regressions separately failed because raw +`DataIntegrityViolationException` and `ObjectOptimisticLockingFailureException` +escaped the application boundary. + +## 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=AttendanceConcurrencyIntegrationTest test +./mvnw -Dtest=AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates test +``` + +**Observed result** + +```text +AttendanceConcurrencyIntegrationTest: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +PostgreSQL reported SQLSTATE 23505 on uq_attendance_records_intern_date; the caller +received ALREADY_CHECKED_IN. The checkout race returned ALREADY_CHECKED_OUT. +AttendancePersistenceIntegrationTest focused allocation test: Tests run: 1, +Failures: 0, Errors: 0, Skipped: 0. +BUILD SUCCESS +Process exited 0. +``` + +## 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='*Attendance*Test' test +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +The tests do not implement the later leave workflow or account terminal-state +transitions. They prove the current read/query boundary, exact PostgreSQL 18.4 +allocation semantics, and duplicate-punch conflict translation. diff --git a/docs/tests/integration/attendance-persistence.md b/docs/tests/integration/attendance-persistence.md index 9d1ef55..2bf6c3a 100644 --- a/docs/tests/integration/attendance-persistence.md +++ b/docs/tests/integration/attendance-persistence.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ATT-002`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-010`, `CAL-001`, `CAL-006`, `CAL-007`, `CAL-009`, `AUTH-003`, `RPT-004` - **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-CAL-003`, `AC-CAL-004` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendancePersistenceIntegrationTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc` ## Protected behavior @@ -87,7 +87,7 @@ 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='*Attendance*Test' test -Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/unit/attendance-checkout-eligibility.md b/docs/tests/unit/attendance-checkout-eligibility.md new file mode 100644 index 0000000..a534fc7 --- /dev/null +++ b/docs/tests/unit/attendance-checkout-eligibility.md @@ -0,0 +1,92 @@ +# Test Evidence: Checkout eligibility and stable conflict outcomes + +- **Test type:** Unit +- **Requirement IDs:** `ATT-007`, `ATT-008`, `ATT-010`, `ATT-012` +- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004` +- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServiceTest#rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate`, `#translatesConcurrentCheckInUniqueConflictToStableDuplicateRejection`, `#translatesConcurrentCheckoutVersionConflictToStableDuplicateRejection` +- **Implementation commit:** `pending` + +## Protected behavior + +Checkout revalidates active internship eligibility for the attendance row's +persisted work date. A terminal Intern cannot checkout after checking in. +Database uniqueness and optimistic-lock races are translated to stable duplicate +punch rejection codes instead of leaking persistence exceptions. + +## Test method + +Plain JUnit and Mockito drive the production transactional application service +with a fixed Clock, attached policy, persisted row, AccountService eligibility, +and repository exceptions. Account state remains behind its public service API. + +## Hand-derived expected result + +False date-aware eligibility returns `INACTIVE_INTERN` before raw checkout is +saved. A check-in uniqueness race returns `ALREADY_CHECKED_IN`; a checkout +version race returns `ALREADY_CHECKED_OUT`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceApplicationServiceTest#rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate test +``` + +**Observed result** + +```text +Expected AttendanceException(INACTIVE_INTERN) but was NullPointerException after +the service continued to save checkout without calling AccountService eligibility. +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +BUILD FAILURE +Process exited 1. +``` + +The conflict regressions were also observed RED in the combined focused run: + +```text +DataIntegrityViolationException: concurrent unique conflict +ObjectOptimisticLockingFailureException: optimistic locking failed +Both escaped AttendanceApplicationService instead of stable AttendanceException values. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceApplicationServiceTest test +``` + +**Observed result** + +```text +Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## 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='*Attendance*Test' test +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +The account platform has no Iteration 1 terminal-state mutation API, so the +completed/withdrawn state is represented through its public date-aware eligibility +result. The companion PostgreSQL concurrency test proves the real unique conflict. diff --git a/docs/tests/unit/attendance-current-state.md b/docs/tests/unit/attendance-current-state.md index 2e089a8..3f45621 100644 --- a/docs/tests/unit/attendance-current-state.md +++ b/docs/tests/unit/attendance-current-state.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ATT-005`, `I1-UI-03` - **Scenario IDs:** `I1-ATT-03`, `I1-ATT-04` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServiceTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc` ## Protected behavior @@ -70,7 +70,7 @@ 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='*Attendance*Test' test -Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/unit/attendance-feature-structure.md b/docs/tests/unit/attendance-feature-structure.md index 6ea1119..1ab64d6 100644 --- a/docs/tests/unit/attendance-feature-structure.md +++ b/docs/tests/unit/attendance-feature-structure.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ARC-005`, `OPS-020` - **Scenario IDs:** `I1-ATT-01` through `I1-ATT-05` structural gate - **Test class/method:** `com.lab.labtimesheet.architecture.AttendanceLayerStructureTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc` ## Protected behavior @@ -90,7 +90,7 @@ 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='*Attendance*Test' test -Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/unit/attendance-policy.md b/docs/tests/unit/attendance-policy.md index edfab27..b0ac641 100644 --- a/docs/tests/unit/attendance-policy.md +++ b/docs/tests/unit/attendance-policy.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004` - **Scenario IDs:** `AC-ATT-001` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.model.AttendancePolicyTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `71901d1670f633a1b594bdce3348efebe73fc175` ## Protected behavior @@ -66,7 +66,7 @@ Process exited 0. ```text ./mvnw -Dtest='*Attendance*Test' test -Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/unit/attendance-punch-boundaries.md b/docs/tests/unit/attendance-punch-boundaries.md index fb99a25..93a9344 100644 --- a/docs/tests/unit/attendance-punch-boundaries.md +++ b/docs/tests/unit/attendance-punch-boundaries.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `GOV-011`, `GOV-012`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-009`, `ATT-010`, `ATT-011`, `ATT-012`, `ATT-016` - **Scenario IDs:** `AC-ATT-002`, `AC-ATT-003`, `AC-ATT-004`, `AC-ATT-005` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceServiceTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `71901d1670f633a1b594bdce3348efebe73fc175` ## Protected behavior @@ -71,7 +71,7 @@ Process exited 0. ```text ./mvnw -Dtest='*Attendance*Test' test -Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/web/attendance-web.md b/docs/tests/web/attendance-web.md index 3578f80..609557a 100644 --- a/docs/tests/web/attendance-web.md +++ b/docs/tests/web/attendance-web.md @@ -1,29 +1,35 @@ # Test Evidence: Attendance and global-calendar web authorization - **Test type:** Web -- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-003`, `ATT-007`, `ATT-010`, `CAL-001`, `CAL-007`, `RPT-004` +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-003`, `ATT-007`, `ATT-010`, `ATT-016`, `CAL-001`, `CAL-007`, `RPT-004`, `UI-013` - **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-CAL-004` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.AttendanceControllerTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc` ## Protected behavior Authenticated Intern punch routes use the server-resolved user ID, own history renders attached policy details, Mentor inspection routes preserve the target scope, and calendar management rejects non-Admin access. Calendar updates carry -the submitted optimistic version. +the submitted optimistic version. History renders policy-local 24-hour times, +`dd/MM/yyyy` dates, and every simultaneous violation; `On time` appears only +when no violation applies. ## Test method `@WebMvcTest` runs Spring Security filters, CSRF protection, MVC binding, route selection, controller authorization, Thymeleaf rendering, and service-call arguments while mocking only application-service and current-user boundaries. +The presentation regression supplies a row that is both late and early and +asserts the attached Asia/Ho_Chi_Minh timezone conversion. ## Hand-derived expected result An Intern authenticated as user 42 can punch only ID 42. A Mentor can inspect target 42 but receives HTTP 403 for Admin calendar management. Attached policy grace renders as `30 min`. An event form with version 3 calls update with 3. +`2026-08-14T02:00:00.001Z` renders as local `09:00`, and a 15:00 local checkout +on that late row renders both `Late` and `Early departure`, never `On time`. ## RED @@ -46,6 +52,20 @@ export PATH="$JAVA_HOME/bin:$PATH" Process exited 1 because the required authenticated web endpoints did not exist. ``` +The review presentation regression was separately observed RED: + +```text +./mvnw -Dtest=AttendanceApplicationServiceTest,AttendanceControllerTest test +AttendanceControllerTest.historyRendersPolicyLocalDisplayValuesAndEveryViolation: +Expected a string containing "14/08/2026" but rendered "2026-08-14"; +the same row rendered one nested-ternary result, "Early departure", and raw UTC instants. +Tests run: 13, Failures: 3, Errors: 1, Skipped: 0 +BUILD FAILURE +Process exited 1. The eligible behavioral failures were the missing local presentation +values and simultaneous violation output; the checkout fixture error was corrected +before its own focused RED and is not claimed as behavioral evidence. +``` + ## GREEN **Command** @@ -59,7 +79,7 @@ export PATH="$JAVA_HOME/bin:$PATH" **Observed result** ```text -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` @@ -73,7 +93,7 @@ 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='*Attendance*Test' test -Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java index 74159f8..aed3970 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java @@ -17,6 +17,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.mvc.support.RedirectAttributes; +/** + * Server-rendered attendance routes for Intern punches and role-scoped historical inspection. + */ @Controller @RequestMapping("/attendance") public class AttendanceController { @@ -30,6 +33,15 @@ public class AttendanceController { this.currentUsers = currentUsers; } + /** + * Renders the authenticated Intern's inclusive attendance history, defaulting to the current month. + * + * @param principal authenticated user + * @param from optional inclusive local start date + * @param to optional inclusive local end date + * @param model Thymeleaf model + * @return attendance history view name + */ @GetMapping public String ownHistory( Principal principal, @@ -40,6 +52,16 @@ public class AttendanceController { return history(actor, actor.userId(), from, to, model); } + /** + * Renders a target Intern's history for an authenticated Mentor or Admin. + * + * @param principal authenticated inspecting user + * @param internId target Intern account identifier + * @param from optional inclusive local start date + * @param to optional inclusive local end date + * @param model Thymeleaf model + * @return attendance history view name + */ @GetMapping("/interns/{internId}") public String inspectHistory( Principal principal, @@ -54,6 +76,13 @@ public class AttendanceController { return history(actor, internId, from, to, model); } + /** + * Checks in the authenticated Intern using server time and redirects with stable feedback. + * + * @param principal authenticated Intern + * @param redirectAttributes flash-message destination + * @return redirect to own attendance history + */ @PostMapping("/check-in") public String checkIn(Principal principal, RedirectAttributes redirectAttributes) { AttendanceActor actor = requireIntern(currentUsers.actor(principal)); @@ -66,6 +95,13 @@ public class AttendanceController { return "redirect:/attendance"; } + /** + * Checks out the authenticated Intern using server time and redirects with stable feedback. + * + * @param principal authenticated Intern + * @param redirectAttributes flash-message destination + * @return redirect to own attendance history + */ @PostMapping("/check-out") public String checkOut(Principal principal, RedirectAttributes redirectAttributes) { AttendanceActor actor = requireIntern(currentUsers.actor(principal)); diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java index cde6cd7..6babad1 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java @@ -18,6 +18,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.mvc.support.RedirectAttributes; +/** + * Admin-only server-rendered routes for manual global calendar management. + */ @Controller @RequestMapping("/attendance/calendar") public class CalendarController { @@ -35,6 +38,13 @@ public class CalendarController { this.currentUsers = currentUsers; } + /** + * Renders the next year of locally stored calendar events for an authenticated Admin. + * + * @param principal authenticated Admin + * @param model Thymeleaf model + * @return calendar management view name + */ @GetMapping public String calendar(Principal principal, Model model) { requireAdmin(currentUsers.actor(principal)); @@ -44,6 +54,16 @@ public class CalendarController { return "attendance/calendar"; } + /** + * Creates a custom future event using the authenticated Admin identity. + * + * @param principal authenticated Admin + * @param date local event date + * @param name non-blank display name + * @param dayOff authoritative day-off choice + * @param redirectAttributes flash-message destination + * @return redirect to calendar management + */ @PostMapping public String create( Principal principal, @@ -57,6 +77,18 @@ public class CalendarController { return "redirect:/attendance/calendar"; } + /** + * Updates a future event using the submitted optimistic version and authenticated Admin identity. + * + * @param principal authenticated Admin + * @param eventId event identifier + * @param version expected optimistic version + * @param date replacement local date + * @param name replacement display name + * @param dayOff replacement day-off choice + * @param redirectAttributes flash-message destination + * @return redirect to calendar management + */ @PostMapping("/{eventId}") public String update( Principal principal, diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java index 5afc11e..cc421ea 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java @@ -1,14 +1,28 @@ package com.lab.labtimesheet.feature.attendance.exception; +/** + * Signals a rejected attendance punch or state lookup with a stable domain reason. + */ public final class AttendanceException extends RuntimeException { + /** Stable reason preserved for controller and service consumers. */ private final AttendanceRejection rejection; + /** + * Creates an exception for the rejection that callers may safely translate to UI feedback. + * + * @param rejection stable reason for refusing the attendance operation + */ public AttendanceException(AttendanceRejection rejection) { super(rejection.name()); this.rejection = rejection; } + /** + * Returns the stable rejection reason without exposing persistence failures. + * + * @return attendance rejection reason + */ public AttendanceRejection rejection() { return rejection; } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java index a860d9b..f322a01 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java @@ -1,12 +1,23 @@ package com.lab.labtimesheet.feature.attendance.exception; +/** + * Stable business outcomes for attendance operations, including idempotency and eligibility failures. + */ public enum AttendanceRejection { + /** The account or internship is not active for the work date. */ INACTIVE_INTERN, + /** The attached policy does not configure the date's weekday for attendance. */ NON_WORKDAY, + /** The authoritative global calendar exempts the date. */ GLOBAL_DAY_OFF, + /** An approved leave request has a frozen allocation for the exact date. */ APPROVED_LEAVE, + /** A row already exists for the Intern and work date. */ ALREADY_CHECKED_IN, + /** No row exists for the current work date. */ NO_ATTENDANCE_RECORD, + /** The row already contains its first raw checkout. */ ALREADY_CHECKED_OUT, + /** The attached-policy inclusive checkout cutoff has passed. */ CHECKOUT_CUTOFF_PASSED } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java index abd0c84..4443ea4 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java @@ -1,7 +1,15 @@ package com.lab.labtimesheet.feature.attendance.exception; +/** + * Signals a rejected global-calendar mutation, including immutable-history and optimistic conflicts. + */ public final class CalendarException extends RuntimeException { + /** + * Creates a calendar rejection with operator-facing context. + * + * @param message explanation of the rejected mutation + */ public CalendarException(String message) { super(message); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java index 08907c7..03e9302 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java @@ -2,8 +2,17 @@ package com.lab.labtimesheet.feature.attendance.model; import java.util.Objects; +/** + * Attendance authorization context resolved from the authenticated account service identity. + * + * @param userId authoritative application user identifier + * @param role immutable global role used for attendance route and history scope checks + */ public record AttendanceActor(long userId, AttendanceRole role) { + /** + * Rejects an actor without a resolved global role. + */ public AttendanceActor { Objects.requireNonNull(role, "role"); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java index 460a3c0..632bdf5 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java @@ -1,3 +1,11 @@ package com.lab.labtimesheet.feature.attendance.model; +/** + * Date-specific eligibility facts supplied to check-in without exposing account or calendar persistence. + * Approved leave means an approved request has a frozen allocation for the exact work date. + * + * @param activeIntern whether the account service considers the Intern active for the date + * @param globalDayOff whether the authoritative local calendar exempts the date + * @param approvedLeave whether a frozen approved leave allocation covers the date + */ public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java index 5646aa8..884dc0d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java @@ -8,6 +8,21 @@ import java.time.ZoneId; import java.util.Objects; import java.util.Set; +/** + * Immutable effective-dated attendance rules interpreted in their configured business timezone. + * Grace boundaries are inclusive and the checkout cutoff must remain before the next local midnight. + * + * @param id persistent policy version identifier attached permanently to attendance rows + * @param effectiveFrom first local business date governed by this version + * @param zoneId timezone used to derive work dates and schedule instants + * @param scheduledStart expected local start time + * @param scheduledEnd expected local end time + * @param checkInGraceMinutes allowed minutes after scheduled start, from 0 through 720 + * @param checkoutGraceMinutes allowed minutes after scheduled end, from 0 through 720 + * @param monthlyLeaveQuota quota snapshot source for newly submitted leave allocations + * @param violationPenalty penalty applied per applicable attendance violation + * @param workdays configured ISO weekdays that normally require attendance + */ public record AttendancePolicy( long id, LocalDate effectiveFrom, @@ -23,6 +38,9 @@ public record AttendancePolicy( private static final int MAX_GRACE_MINUTES = 720; private static final int SECONDS_PER_DAY = 86_400; + /** + * Validates schedule and grace invariants and defensively snapshots the configured workdays. + */ public AttendancePolicy { Objects.requireNonNull(effectiveFrom, "effectiveFrom"); Objects.requireNonNull(zoneId, "zoneId"); @@ -41,25 +59,12 @@ public record AttendancePolicy( } } - public static AttendancePolicy seeded(long id) { - return new AttendancePolicy( - id, - LocalDate.of(1970, 1, 1), - ZoneId.of("Asia/Ho_Chi_Minh"), - LocalTime.of(8, 30), - LocalTime.of(15, 30), - 30, - 30, - 3, - new BigDecimal("0.25"), - Set.of( - DayOfWeek.MONDAY, - DayOfWeek.TUESDAY, - DayOfWeek.WEDNESDAY, - DayOfWeek.THURSDAY, - DayOfWeek.FRIDAY)); - } - + /** + * Reports whether the local date is a configured workday under this version. + * + * @param date local date interpreted by this policy + * @return {@code true} when the weekday is configured for attendance + */ public boolean isWorkday(LocalDate date) { return workdays.contains(date.getDayOfWeek()); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java index 724900e..20f2e76 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java @@ -8,6 +8,16 @@ import java.time.LocalDate; import java.time.ZonedDateTime; import java.util.Objects; +/** + * One Intern's immutable raw check-in and optional raw checkout for a local work date. + * The attached policy version permanently determines schedule boundaries and violation interpretation. + * + * @param internId Intern account identifier + * @param workDate policy-local date derived when check-in was accepted + * @param policy historical policy version attached at check-in + * @param checkInAt uneditable raw server check-in instant + * @param checkOutAt uneditable raw server checkout instant, or {@code null} until accepted + */ public record AttendanceRecord( long internId, LocalDate workDate, @@ -15,12 +25,22 @@ public record AttendanceRecord( Instant checkInAt, Instant checkOutAt) { + /** + * Validates required historical fields while preserving a nullable raw checkout. + */ public AttendanceRecord { Objects.requireNonNull(workDate, "workDate"); Objects.requireNonNull(policy, "policy"); Objects.requireNonNull(checkInAt, "checkInAt"); } + /** + * Returns a copy with the first raw checkout when it is at or before the attached-policy cutoff. + * Repeated or post-cutoff attempts are rejected without changing the original record. + * + * @param at authoritative server instant + * @return record containing the accepted raw checkout + */ public AttendanceRecord checkOut(Instant at) { Objects.requireNonNull(at, "at"); if (checkOutAt != null) { @@ -32,6 +52,13 @@ public record AttendanceRecord( return new AttendanceRecord(internId, workDate, policy, checkInAt, at); } + /** + * Classifies violations using the attached policy and an authoritative observation instant. + * A missing checkout appears only after the inclusive cutoff and never implies early departure. + * + * @param observedAt instant at which missing-checkout status is evaluated + * @return independent violation flags for presentation and reporting + */ public AttendanceViolations violations(Instant observedAt) { boolean late = checkInAt.isAfter(scheduledStart().plusSeconds(policy.checkInGraceMinutes() * 60L)); boolean missingCheckout = checkOutAt == null && observedAt.isAfter(checkoutCutoff()); diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java index bcea83e..3d99c95 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java @@ -1,7 +1,13 @@ package com.lab.labtimesheet.feature.attendance.model; +/** + * Global account roles recognized by attendance authorization rules. + */ public enum AttendanceRole { + /** Global system administrator. */ ADMIN, + /** Global laboratory Mentor. */ MENTOR, + /** Internship participant. */ INTERN } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java index ee72e91..07317a8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java @@ -1,3 +1,11 @@ package com.lab.labtimesheet.feature.attendance.model; +/** + * Independent attendance violations for a row. Late may coexist with early departure or missing checkout; + * missing checkout and early departure are mutually exclusive because the latter requires an effective checkout. + * + * @param late check-in occurred strictly after the inclusive grace boundary + * @param earlyDeparture effective checkout occurred before scheduled end + * @param missingCheckout no effective checkout existed after the inclusive checkout cutoff passed + */ public record AttendanceViolations(boolean late, boolean earlyDeparture, boolean missingCheckout) {} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java index 2fd7033..f4c2664 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java @@ -1,7 +1,13 @@ package com.lab.labtimesheet.feature.attendance.model.dto; +/** + * Presentation-safe current business-date punch state exposed across feature boundaries. + */ public enum AttendanceCurrentState { + /** No attendance row exists for the current business date. */ NOT_CHECKED_IN, + /** A row exists without raw checkout. */ CHECKED_IN, + /** A row exists with its accepted raw checkout. */ CHECKED_OUT } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java index b406b90..b001a10 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java @@ -5,10 +5,102 @@ import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; import java.time.Instant; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +/** + * Presentation and reporting DTO for one historical attendance row. + * Raw instants remain available for precise consumers while display accessors consistently render + * {@code dd/MM/yyyy} and policy-local 24-hour {@code HH:mm} values from the attached policy timezone. + * + * @param workDate immutable policy-local work date + * @param checkInAt raw server check-in instant + * @param checkOutAt raw server checkout instant, or {@code null} when absent + * @param policy historical policy version attached to the row + * @param violations all applicable violations at query time + */ public record AttendanceHistoryItem( LocalDate workDate, Instant checkInAt, Instant checkOutAt, AttendancePolicy policy, - AttendanceViolations violations) {} + AttendanceViolations violations) { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/uuuu"); + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); + + /** + * Formats the business date as {@code dd/MM/yyyy}. + * + * @return presentation-ready work date + */ + public String workDateDisplay() { + return DATE_FORMAT.format(workDate); + } + + /** + * Formats raw check-in in the attached policy timezone as 24-hour {@code HH:mm}. + * + * @return presentation-ready local check-in time + */ + public String checkInTimeDisplay() { + return TIME_FORMAT.format(checkInAt.atZone(policy.zoneId())); + } + + /** + * Formats raw checkout in the attached policy timezone or reports {@code Missing} when absent. + * + * @return presentation-ready local checkout value + */ + public String checkOutTimeDisplay() { + return checkOutAt == null ? "Missing" : TIME_FORMAT.format(checkOutAt.atZone(policy.zoneId())); + } + + /** + * Formats the attached policy's local scheduled start as {@code HH:mm}. + * + * @return presentation-ready scheduled start + */ + public String scheduledStartDisplay() { + return TIME_FORMAT.format(policy.scheduledStart()); + } + + /** + * Formats the attached policy's local scheduled end as {@code HH:mm}. + * + * @return presentation-ready scheduled end + */ + public String scheduledEndDisplay() { + return TIME_FORMAT.format(policy.scheduledEnd()); + } + + /** + * Returns every applicable violation in stable presentation order, or only {@code On time} + * when no violation applies. + * + * @return immutable, non-empty presentation labels + */ + public List violationLabels() { + List labels = new ArrayList<>(3); + if (violations.late()) { + labels.add("Late"); + } + if (violations.earlyDeparture()) { + labels.add("Early departure"); + } + if (violations.missingCheckout()) { + labels.add("Missing checkout"); + } + return labels.isEmpty() ? List.of("On time") : List.copyOf(labels); + } + + /** + * Joins every applicable violation for table and export cells. + * + * @return comma-separated violation labels, or {@code On time} + */ + public String resultDisplay() { + return String.join(", ", violationLabels()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java index 6875491..89962c8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java @@ -2,4 +2,13 @@ package com.lab.labtimesheet.feature.attendance.model.dto; import java.time.LocalDate; +/** + * Persistence-free global calendar event returned to controllers and feature consumers. + * + * @param id stable event identifier + * @param date local business date of the event + * @param name operator-provided display name + * @param dayOff whether this event makes the date globally exempt + * @param version optimistic version required by update requests + */ public record GlobalCalendarEvent(long id, LocalDate date, String name, boolean dayOff, long version) {} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java index e341613..59e98b9 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java @@ -20,6 +20,9 @@ import java.time.ZoneId; import java.util.Set; import java.util.stream.Collectors; +/** + * JPA mapping of an immutable-on-effective attendance policy version and its configured workdays. + */ @Entity @Table(name = "attendance_policy_versions") public class AttendancePolicyEntity { @@ -62,8 +65,16 @@ public class AttendancePolicyEntity { @Version private long version; + /** + * Required by JPA; application code resolves existing effective-dated versions instead of constructing them here. + */ protected AttendancePolicyEntity() {} + /** + * Converts the persisted version to the immutable policy used for historical boundary calculations. + * + * @return domain policy including its persisted identifier and timezone + */ public AttendancePolicy toDomain() { Set workdays = isoWeekdays.stream() .map(day -> DayOfWeek.of(day.intValue())) diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java index dcf01ab..73302d9 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java @@ -14,6 +14,9 @@ import jakarta.persistence.Version; import java.time.Instant; import java.time.LocalDate; +/** + * JPA persistence model for one Intern/work-date punch row with its permanently attached policy version. + */ @Entity @Table(name = "attendance_records") public class AttendanceRecordEntity { @@ -41,8 +44,20 @@ public class AttendanceRecordEntity { @Version private long version; + /** + * Required by JPA. + */ protected AttendanceRecordEntity() {} + /** + * Creates a new persistence row from server-authoritative raw punch values. + * + * @param internUserId scalar account identifier; account data remains owned by the account feature + * @param workDate attached-policy local work date + * @param policy persisted policy version fixed at check-in + * @param checkInAt raw server check-in instant + * @param checkOutAt raw server checkout instant, normally {@code null} for a new row + */ public AttendanceRecordEntity( long internUserId, LocalDate workDate, @@ -56,14 +71,29 @@ public class AttendanceRecordEntity { this.checkOutAt = checkOutAt; } + /** + * Rehydrates the immutable domain record without replacing the historical policy. + * + * @return attendance domain record + */ public AttendanceRecord toDomain() { return new AttendanceRecord(internUserId, workDate, policy.toDomain(), checkInAt, checkOutAt); } + /** + * Stores the first accepted raw checkout; callers must enforce cutoff and single-write rules transactionally. + * + * @param checkOutAt accepted server checkout instant + */ public void setCheckOutAt(Instant checkOutAt) { this.checkOutAt = checkOutAt; } + /** + * Returns the immutable local date used for account eligibility revalidation at checkout. + * + * @return persisted work date + */ public LocalDate workDate() { return workDate; } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java index 3bd5224..c70abd6 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java @@ -11,6 +11,9 @@ import jakarta.persistence.Table; import jakarta.persistence.Version; import java.time.LocalDate; +/** + * JPA model for the locally authoritative global calendar decision. + */ @Entity @Table(name = "global_calendar_events") public class GlobalCalendarEventEntity { @@ -40,8 +43,19 @@ public class GlobalCalendarEventEntity { @Version private long version; + /** + * Required by JPA. + */ protected GlobalCalendarEventEntity() {} + /** + * Creates a custom calendar event attributed to the Admin actor. + * + * @param date local business date + * @param name display name validated by the application service + * @param dayOff whether the event exempts attendance and date validation + * @param actorUserId Admin who created the local decision + */ public GlobalCalendarEventEntity(LocalDate date, String name, boolean dayOff, long actorUserId) { this.calendarDate = date; this.name = name; @@ -51,6 +65,14 @@ public class GlobalCalendarEventEntity { this.updatedByUserId = actorUserId; } + /** + * Applies an authorized future-event edit while preserving creator attribution. + * + * @param date replacement local date + * @param name replacement display name + * @param dayOff replacement authoritative day-off choice + * @param actorUserId Admin performing the update + */ public void update(LocalDate date, String name, boolean dayOff, long actorUserId) { this.calendarDate = date; this.name = name; @@ -58,14 +80,29 @@ public class GlobalCalendarEventEntity { this.updatedByUserId = actorUserId; } + /** + * Returns a persistence-free event including the optimistic version needed for edits. + * + * @return calendar event DTO + */ public GlobalCalendarEvent toDomain() { return new GlobalCalendarEvent(id, calendarDate, name, dayOff, version); } + /** + * Returns the date whose mutability is governed by the current policy-local business date. + * + * @return event calendar date + */ public LocalDate calendarDate() { return calendarDate; } + /** + * Returns the optimistic version expected by a subsequent update. + * + * @return current version + */ public long version() { return version; } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayEntity.java new file mode 100644 index 0000000..94cdf04 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayEntity.java @@ -0,0 +1,54 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import java.time.LocalDate; + +/** + * JPA mapping of an immutable leave-day allocation whose exact date, policy, and quota snapshot remain historical. + */ +@Entity +@Table(name = "leave_request_days") +public class LeaveRequestDayEntity { + + @EmbeddedId + private LeaveRequestDayId id; + + @MapsId("leaveRequestId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "leave_request_id", nullable = false) + private LeaveRequestEntity request; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "policy_version_id", nullable = false) + private AttendancePolicyEntity policy; + + @Column(name = "quota_month", nullable = false) + private LocalDate quotaMonth; + + @Column(name = "monthly_quota_snapshot", nullable = false) + private int monthlyQuotaSnapshot; + + /** + * Required by JPA. + */ + protected LeaveRequestDayEntity() {} + + LeaveRequestDayEntity( + LeaveRequestEntity request, + LocalDate leaveDate, + AttendancePolicyEntity policy, + int monthlyQuotaSnapshot) { + this.request = request; + this.id = new LeaveRequestDayId(request.id(), leaveDate); + this.policy = policy; + this.quotaMonth = leaveDate.withDayOfMonth(1); + this.monthlyQuotaSnapshot = monthlyQuotaSnapshot; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayId.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayId.java new file mode 100644 index 0000000..a4d59dc --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayId.java @@ -0,0 +1,50 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Objects; + +/** + * Composite identifier of one frozen quota-consuming date within a leave request. + */ +@Embeddable +public class LeaveRequestDayId implements Serializable { + + /** Parent request identity used by the composite primary key. */ + @Column(name = "leave_request_id", nullable = false) + private long leaveRequestId; + + /** Exact frozen allocation date used by the composite primary key. */ + @Column(name = "leave_date", nullable = false) + private LocalDate leaveDate; + + /** + * Required by JPA. + */ + protected LeaveRequestDayId() {} + + /** + * Creates the identity for an already-persisted request and its exact allocated date. + * + * @param leaveRequestId persisted leave request identifier + * @param leaveDate frozen quota-consuming date + */ + public LeaveRequestDayId(long leaveRequestId, LocalDate leaveDate) { + this.leaveRequestId = leaveRequestId; + this.leaveDate = Objects.requireNonNull(leaveDate, "leaveDate"); + } + + @Override + public boolean equals(Object candidate) { + return candidate instanceof LeaveRequestDayId other + && leaveRequestId == other.leaveRequestId + && leaveDate.equals(other.leaveDate); + } + + @Override + public int hashCode() { + return Objects.hash(leaveRequestId, leaveDate); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java index 91fa7e1..e40e4ed 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java @@ -6,8 +6,13 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; import java.time.LocalDate; +/** + * Minimal Attendance-owned JPA mapping of leave request state used when evaluating frozen leave-day allocations. + */ @Entity @Table(name = "leave_requests") public class LeaveRequestEntity { @@ -25,8 +30,56 @@ public class LeaveRequestEntity { @Column(name = "end_date", nullable = false) private LocalDate endDate; + @Column(nullable = false) + private String reason; + @Column(nullable = false) private String status; + @Column(name = "submitted_at", nullable = false) + private Instant submittedAt; + + @Column(name = "first_counted_start_at", nullable = false) + private Instant firstCountedStartAt; + + @Column(name = "decided_by_mentor_user_id") + private Long decidedByMentorUserId; + + @Column(name = "decided_at") + private Instant decidedAt; + + @Version + private long version; + + /** + * Required by JPA. + */ protected LeaveRequestEntity() {} + + LeaveRequestEntity( + long internUserId, + LocalDate startDate, + LocalDate endDate, + String reason, + Instant submittedAt, + Instant firstCountedStartAt, + long decidedByMentorUserId, + Instant decidedAt) { + this.internUserId = internUserId; + this.startDate = startDate; + this.endDate = endDate; + this.reason = reason; + this.status = "APPROVED"; + this.submittedAt = submittedAt; + this.firstCountedStartAt = firstCountedStartAt; + this.decidedByMentorUserId = decidedByMentorUserId; + this.decidedAt = decidedAt; + } + + long id() { + if (id == null) { + throw new IllegalStateException("Leave request has not been persisted"); + } + return id; + } } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java index bdeba3e..dd70c31 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java @@ -4,7 +4,15 @@ import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEnti import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +/** + * Spring Data access to the effective-dated policy timeline owned by Attendance. + */ public interface AttendancePolicyRepository extends JpaRepository { + /** + * Loads the complete timeline in effective-date order for deterministic local-date resolution. + * + * @return ascending policy versions, including the 1970 seed + */ List findAllByOrderByEffectiveFromAsc(); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java index 086984e..d4c737a 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java @@ -1,18 +1,32 @@ package com.lab.labtimesheet.feature.attendance.repository; -import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestDayEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestDayId; import java.time.LocalDate; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; -public interface AttendanceQueryRepository extends Repository { +/** + * Narrow Attendance read repository for date-specific leave eligibility facts. + */ +public interface AttendanceQueryRepository extends Repository { + /** + * Checks whether an approved request owns a frozen allocation for the exact date. + * Request range membership alone is intentionally insufficient because non-workdays and holidays are excluded + * when leave is materialized. + * + * @param internId Intern account identifier + * @param workDate exact policy-local date being evaluated + * @return {@code true} only for an approved frozen allocation + */ @Query(""" - select count(request) > 0 - from LeaveRequestEntity request + select count(day) > 0 + from LeaveRequestDayEntity day + join day.request request where request.internUserId = :internId and request.status = 'APPROVED' - and :workDate between request.startDate and request.endDate + and day.id.leaveDate = :workDate """) boolean hasApprovedLeave( @Param("internId") long internId, @Param("workDate") LocalDate workDate); diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java index e2954c3..cecf18e 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java @@ -6,10 +6,28 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +/** + * Spring Data access to raw attendance rows and attached historical policy versions. + */ public interface AttendanceRecordRepository extends JpaRepository { + /** + * Finds the unique row protected by the database's Intern/work-date constraint. + * + * @param internUserId Intern account identifier + * @param workDate policy-local work date + * @return row when the Intern has checked in on that date + */ Optional findByInternUserIdAndWorkDate(long internUserId, LocalDate workDate); + /** + * Loads an Intern's inclusive history newest-first; each entity carries its attached policy. + * + * @param internUserId Intern account identifier + * @param from inclusive first local date + * @param to inclusive last local date + * @return matching attendance rows newest-first + */ List findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc( long internUserId, LocalDate from, LocalDate to); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java index 2d86432..db46831 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java @@ -5,10 +5,26 @@ import java.time.LocalDate; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +/** + * Spring Data access to locally authoritative global calendar events. + */ public interface GlobalCalendarEventRepository extends JpaRepository { + /** + * Reports whether any stored event makes the exact local date a global day off. + * + * @param date local business date + * @return {@code true} when at least one authoritative day-off decision exists + */ boolean existsByCalendarDateAndDayOffTrue(LocalDate date); + /** + * Lists events across an inclusive local-date range in deterministic order. + * + * @param from inclusive first date + * @param to inclusive last date + * @return events ordered by date then identifier + */ List findByCalendarDateBetweenOrderByCalendarDateAscIdAsc( LocalDate from, LocalDate to); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java index 423d91d..0882159 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java @@ -21,9 +21,15 @@ import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.springframework.security.access.AccessDeniedException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Transactional Attendance application boundary for punches, current state, and authorized history reads. + * Account eligibility is obtained only through {@link AccountService}; raw rows retain their attached policy. + */ @Service public class AttendanceApplicationService { @@ -52,6 +58,14 @@ public class AttendanceApplicationService { this.attendance = attendance; } + /** + * Records the sole server-time check-in for the effective policy-local date. + * Eligibility, workday, calendar, and exact frozen leave allocation are evaluated in the transaction; + * a concurrent unique conflict is returned as {@link AttendanceRejection#ALREADY_CHECKED_IN}. + * + * @param internId Intern account identifier + * @return persisted raw attendance record + */ @Transactional public AttendanceRecord checkIn(long internId) { Instant now = clock.instant(); @@ -62,15 +76,27 @@ public class AttendanceApplicationService { .map(AttendanceRecordEntity::toDomain); AttendanceRecord record = attendance.checkIn( internId, now, policy, dayContext(internId, workDate), existing); - return recordEntities.saveAndFlush(new AttendanceRecordEntity( - record.internId(), - record.workDate(), - policyEntities.getReferenceById(record.policy().id()), - record.checkInAt(), - record.checkOutAt())) - .toDomain(); + try { + return recordEntities.saveAndFlush(new AttendanceRecordEntity( + record.internId(), + record.workDate(), + policyEntities.getReferenceById(record.policy().id()), + record.checkInAt(), + record.checkOutAt())) + .toDomain(); + } catch (DataIntegrityViolationException conflict) { + throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN); + } } + /** + * Records the first server-time checkout for today's open row under its attached policy cutoff. + * Account eligibility is revalidated for the persisted work date, and an optimistic race is returned as + * {@link AttendanceRejection#ALREADY_CHECKED_OUT}; rejected attempts do not replace raw checkout. + * + * @param internId Intern account identifier + * @return persisted checked-out record + */ @Transactional public AttendanceRecord checkOut(long internId) { Instant now = clock.instant(); @@ -79,10 +105,23 @@ public class AttendanceApplicationService { Optional entity = recordEntities.findByInternUserIdAndWorkDate(internId, workDate); AttendanceRecord checkedOut = attendance.checkOut(entity.map(AttendanceRecordEntity::toDomain), now); AttendanceRecordEntity persisted = entity.orElseThrow(); + if (!accounts.isEligibleIntern(internId, persisted.workDate())) { + throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN); + } persisted.setCheckOutAt(checkedOut.checkOutAt()); - return recordEntities.saveAndFlush(persisted).toDomain(); + try { + return recordEntities.saveAndFlush(persisted).toDomain(); + } catch (ObjectOptimisticLockingFailureException conflict) { + throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_OUT); + } } + /** + * Reports an eligible Intern's current policy-local business-date punch state. + * + * @param internId Intern account identifier + * @return presentation-safe current state + */ @Transactional(readOnly = true) public AttendanceCurrentState currentState(long internId) { Instant now = clock.instant(); @@ -99,6 +138,16 @@ public class AttendanceApplicationService { .orElse(AttendanceCurrentState.NOT_CHECKED_IN); } + /** + * Returns inclusive historical rows newest-first, allowing Interns only their own history while Mentor and Admin + * actors may inspect another Intern. DTOs retain raw instants and provide attached-policy local display values. + * + * @param actor authenticated Attendance authorization context + * @param internId target Intern account identifier + * @param from inclusive first local date + * @param to inclusive last local date + * @return immutable presentation/reporting history items + */ @Transactional(readOnly = true) public List history( AttendanceActor actor, long internId, LocalDate from, LocalDate to) { @@ -120,6 +169,11 @@ public class AttendanceApplicationService { .toList(); } + /** + * Resolves the current business date in the effective policy timezone. + * + * @return current policy-local date from the injected server clock + */ @Transactional(readOnly = true) public LocalDate currentBusinessDate() { AttendancePolicy policy = timeline().resolve(clock.instant()); diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java index 47e7aeb..1572f94 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java @@ -9,6 +9,9 @@ import java.security.Principal; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; +/** + * Converts Spring Security principals into active Attendance authorization contexts through AccountService DTOs. + */ @Service public class AttendanceCurrentUserService { @@ -18,6 +21,12 @@ public class AttendanceCurrentUserService { this.accounts = accounts; } + /** + * Resolves the authenticated email through the Account feature and rejects missing or inactive identities. + * + * @param principal authenticated server principal + * @return Attendance actor containing only the user ID and global role needed by this feature + */ public AttendanceActor actor(Principal principal) { if (principal == null || principal.getName() == null) { throw new AccessDeniedException("Authentication is required"); diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java index 1f45b3c..c49d003 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java @@ -8,16 +8,30 @@ import java.util.Comparator; import java.util.List; import java.util.Objects; +/** + * Deterministically resolves immutable attendance policy versions for local dates or server instants. + */ public final class AttendancePolicyTimeline { private final List policies; + /** + * Snapshots and orders the supplied versions by effective date. + * + * @param policies available policy versions, normally including the 1970 seed + */ public AttendancePolicyTimeline(Collection policies) { this.policies = policies.stream() .sorted(Comparator.comparing(AttendancePolicy::effectiveFrom)) .toList(); } + /** + * Resolves the latest version effective on or before a local business date. + * + * @param date local business date + * @return governing policy version + */ public AttendancePolicy resolve(LocalDate date) { Objects.requireNonNull(date, "date"); return policies.stream() @@ -26,6 +40,12 @@ public final class AttendancePolicyTimeline { .orElseThrow(() -> new IllegalArgumentException("no attendance policy applies on " + date)); } + /** + * Resolves an instant against each version's own timezone and effective date. + * + * @param instant authoritative server instant + * @return governing policy version + */ public AttendancePolicy resolve(Instant instant) { Objects.requireNonNull(instant, "instant"); return policies.stream() diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java index 4e285bd..cca61f6 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java @@ -10,9 +10,25 @@ import java.time.LocalDate; import java.util.Optional; import org.springframework.stereotype.Service; +/** + * Pure attendance punch rules over immutable policy, date context, and raw record values. + */ @Service public final class AttendanceService { + AttendanceService() {} + + /** + * Creates the sole raw check-in for an eligible Intern/date using the supplied server instant. + * Equality at the grace boundary is accepted; violation classification remains attached-policy based. + * + * @param internId Intern account identifier + * @param now authoritative server instant + * @param policy effective policy at check-in + * @param context date-specific account, calendar, and frozen-leave facts + * @param existingRecord existing row for the same Intern/date, if any + * @return new raw attendance record + */ public AttendanceRecord checkIn( long internId, Instant now, @@ -27,6 +43,13 @@ public final class AttendanceService { return new AttendanceRecord(internId, workDate, policy, now, null); } + /** + * Applies the first raw checkout through the attached-policy inclusive cutoff. + * + * @param record open attendance row, if one exists + * @param now authoritative server instant + * @return checked-out record preserving its original check-in and attached policy + */ public AttendanceRecord checkOut(Optional record, Instant now) { return record .orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD)) diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java index 525fa8c..72953b1 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java @@ -16,6 +16,9 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Transactional boundary for the locally authoritative global calendar and its cross-feature day-off decision. + */ @Service public class CalendarApplicationService { @@ -32,6 +35,15 @@ public class CalendarApplicationService { this.events = events; } + /** + * Creates an Admin-authored custom event on a non-past policy-local date. + * + * @param actor authenticated Attendance actor; must be Admin + * @param date local event date + * @param name non-blank display name + * @param dayOff authoritative attendance/due-date exemption choice + * @return persisted event DTO including optimistic version + */ @Transactional public GlobalCalendarEvent createManual( AttendanceActor actor, LocalDate date, String name, boolean dayOff) { @@ -41,6 +53,18 @@ public class CalendarApplicationService { .toDomain(); } + /** + * Updates a future custom event when the submitted optimistic version still matches. + * Past event dates and attempts to move an event into the past are rejected. + * + * @param actor authenticated Attendance actor; must be Admin + * @param eventId event identifier + * @param expectedVersion version rendered to the editor + * @param date replacement local event date + * @param name replacement non-blank display name + * @param dayOff replacement authoritative day-off choice + * @return updated event DTO and advanced version + */ @Transactional public GlobalCalendarEvent updateManual( AttendanceActor actor, @@ -61,6 +85,13 @@ public class CalendarApplicationService { return events.saveAndFlush(event).toDomain(); } + /** + * Lists locally stored events over an inclusive date range. + * + * @param from inclusive first local date + * @param to inclusive last local date + * @return events ordered by date and identifier + */ @Transactional(readOnly = true) public List list(LocalDate from, LocalDate to) { if (from.isAfter(to)) { @@ -72,6 +103,13 @@ public class CalendarApplicationService { .toList(); } + /** + * Answers whether the exact local date has any authoritative stored day-off event. + * This is the public cross-feature calendar API; it performs no live HolidayAPI call. + * + * @param date local business date to inspect + * @return {@code true} when at least one local event is marked as a day off + */ @Transactional(readOnly = true) public boolean isGlobalDayOff(LocalDate date) { return events.existsByCalendarDateAndDayOffTrue(date); diff --git a/src/main/resources/templates/attendance/history.html b/src/main/resources/templates/attendance/history.html index 5cd6782..67bc934 100644 --- a/src/main/resources/templates/attendance/history.html +++ b/src/main/resources/templates/attendance/history.html @@ -41,12 +41,12 @@ - - - - + + + + - + diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java index 0a7c787..72cd1bb 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java @@ -13,9 +13,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures; import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; @@ -69,7 +72,7 @@ class AttendanceControllerTest { LocalDate.of(2026, 8, 14), Instant.parse("2026-08-14T02:00:00Z"), Instant.parse("2026-08-14T09:00:00Z"), - AttendancePolicy.seeded(1L), + AttendancePolicyFixtures.seeded(1L), new AttendanceViolations(false, false, false)))); mockMvc.perform(get("/attendance") @@ -82,6 +85,30 @@ class AttendanceControllerTest { .andExpect(content().string(org.hamcrest.Matchers.containsString("30 min"))); } + @Test + void historyRendersPolicyLocalDisplayValuesAndEveryViolation() throws Exception { + AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN); + when(currentUsers.actor(any())).thenReturn(actor); + when(attendance.history(eq(actor), eq(42L), any(), any())).thenReturn(List.of(new AttendanceHistoryItem( + LocalDate.of(2026, 8, 14), + Instant.parse("2026-08-14T02:00:00.001Z"), + Instant.parse("2026-08-14T08:00:00Z"), + AttendancePolicyFixtures.seeded(1L), + new AttendanceViolations(true, true, false)))); + + mockMvc.perform(get("/attendance") + .with(user("intern@example.test").roles("INTERN")) + .param("from", "2026-08-01") + .param("to", "2026-08-31")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("14/08/2026"))) + .andExpect(content().string(containsString("09:00"))) + .andExpect(content().string(containsString("15:00"))) + .andExpect(content().string(containsString("Late"))) + .andExpect(content().string(containsString("Early departure"))) + .andExpect(content().string(not(containsString("On time")))); + } + @Test void mentorCanInspectInternHistory() throws Exception { AttendanceActor mentor = new AttendanceActor(7L, AttendanceRole.MENTOR); diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyFixtures.java b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyFixtures.java new file mode 100644 index 0000000..1430173 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyFixtures.java @@ -0,0 +1,32 @@ +package com.lab.labtimesheet.feature.attendance.model; + +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Set; + +public final class AttendancePolicyFixtures { + + private AttendancePolicyFixtures() {} + + public static AttendancePolicy seeded(long id) { + return new AttendancePolicy( + id, + LocalDate.of(1970, 1, 1), + ZoneId.of("Asia/Ho_Chi_Minh"), + LocalTime.of(8, 30), + LocalTime.of(15, 30), + 30, + 30, + 3, + new BigDecimal("0.25"), + Set.of( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY)); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java index 103f81f..badcd94 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java @@ -16,7 +16,7 @@ class AttendancePolicyTest { @Test void resolvesSeedPolicyForHistoricalAndCurrentDates() { - AttendancePolicy seeded = AttendancePolicy.seeded(1L); + AttendancePolicy seeded = AttendancePolicyFixtures.seeded(1L); AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded)); assertEquals(seeded, timeline.resolve(LocalDate.of(1970, 1, 1))); diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveEntityFixtures.java b/src/test/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveEntityFixtures.java new file mode 100644 index 0000000..a57fc38 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveEntityFixtures.java @@ -0,0 +1,36 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import java.time.Instant; +import java.time.LocalDate; + +public final class LeaveEntityFixtures { + + private LeaveEntityFixtures() {} + + public static LeaveRequestEntity approvedRequest( + long internUserId, + LocalDate startDate, + LocalDate endDate, + Instant submittedAt, + Instant firstCountedStartAt, + long decidedByMentorUserId, + Instant decidedAt) { + return new LeaveRequestEntity( + internUserId, + startDate, + endDate, + "Attendance integration fixture", + submittedAt, + firstCountedStartAt, + decidedByMentorUserId, + decidedAt); + } + + public static LeaveRequestDayEntity allocatedDay( + LeaveRequestEntity request, + LocalDate leaveDate, + AttendancePolicyEntity policy, + int monthlyQuotaSnapshot) { + return new LeaveRequestDayEntity(request, leaveDate, policy, monthlyQuotaSnapshot); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java index aff3d25..d755753 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java @@ -2,6 +2,7 @@ package com.lab.labtimesheet.feature.attendance.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -9,6 +10,7 @@ import com.lab.labtimesheet.feature.account.service.AccountService; import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures; import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; @@ -24,6 +26,8 @@ import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; class AttendanceApplicationServiceTest { @@ -39,7 +43,7 @@ class AttendanceApplicationServiceTest { @BeforeEach void setUp() { AttendancePolicyEntity policyEntity = mock(AttendancePolicyEntity.class); - when(policyEntity.toDomain()).thenReturn(AttendancePolicy.seeded(1L)); + when(policyEntity.toDomain()).thenReturn(AttendancePolicyFixtures.seeded(1L)); when(policies.findAllByOrderByEffectiveFromAsc()).thenReturn(List.of(policyEntity)); when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(true); attendance = new AttendanceApplicationService( @@ -74,14 +78,53 @@ class AttendanceApplicationServiceTest { .isEqualTo(AttendanceRejection.INACTIVE_INTERN)); } + @Test + void rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate() { + AttendanceRecordEntity entity = entityFor(null); + when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)) + .thenReturn(Optional.of(entity)); + when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(false); + + assertThatThrownBy(() -> attendance.checkOut(INTERN_ID)) + .isInstanceOfSatisfying(AttendanceException.class, + exception -> assertThat(exception.rejection()) + .isEqualTo(AttendanceRejection.INACTIVE_INTERN)); + } + + @Test + void translatesConcurrentCheckInUniqueConflictToStableDuplicateRejection() { + when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)).thenReturn(Optional.empty()); + when(records.saveAndFlush(any(AttendanceRecordEntity.class))) + .thenThrow(new DataIntegrityViolationException("concurrent unique conflict")); + + assertThatThrownBy(() -> attendance.checkIn(INTERN_ID)) + .isInstanceOfSatisfying(AttendanceException.class, + exception -> assertThat(exception.rejection()) + .isEqualTo(AttendanceRejection.ALREADY_CHECKED_IN)); + } + + @Test + void translatesConcurrentCheckoutVersionConflictToStableDuplicateRejection() { + AttendanceRecordEntity entity = entityFor(null); + when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)).thenReturn(Optional.of(entity)); + when(records.saveAndFlush(entity)) + .thenThrow(new ObjectOptimisticLockingFailureException(AttendanceRecordEntity.class, 1L)); + + assertThatThrownBy(() -> attendance.checkOut(INTERN_ID)) + .isInstanceOfSatisfying(AttendanceException.class, + exception -> assertThat(exception.rejection()) + .isEqualTo(AttendanceRejection.ALREADY_CHECKED_OUT)); + } + private static AttendanceRecordEntity entityFor(Instant checkOutAt) { AttendanceRecordEntity entity = mock(AttendanceRecordEntity.class); when(entity.toDomain()).thenReturn(new AttendanceRecord( INTERN_ID, WORK_DATE, - AttendancePolicy.seeded(1L), + AttendancePolicyFixtures.seeded(1L), NOW, checkOutAt)); + when(entity.workDate()).thenReturn(WORK_DATE); return entity; } } diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceConcurrencyIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceConcurrencyIntegrationTest.java new file mode 100644 index 0000000..ebf14c9 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceConcurrencyIntegrationTest.java @@ -0,0 +1,119 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import static org.assertj.core.api.Assertions.assertThat; + +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.account.service.BootstrapService; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; +import com.lab.labtimesheet.feature.integration.model.SecurityMode; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +@Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class) +@SpringBootTest +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AttendanceConcurrencyIntegrationTest { + + @Autowired + private AttendanceApplicationService attendance; + + @Autowired + private BootstrapService bootstrap; + + @Autowired + private AccountService accounts; + + @Autowired + private SmtpConfigurationService smtp; + + @Autowired + private AttendancePersistenceIntegrationTest.RecordingSmtpProbe mail; + + @Autowired + private AttendancePersistenceIntegrationTest.MutableClock clock; + + @Test + void concurrentDuplicatePunchesReturnStableDomainOutcomes() throws Exception { + long internId = createActiveIntern(); + + clock.set(Instant.parse("2026-08-14T02:00:00Z")); + assertThat(runConcurrently(() -> punchOutcome(() -> attendance.checkIn(internId)))) + .containsExactlyInAnyOrder("SUCCESS", AttendanceRejection.ALREADY_CHECKED_IN.name()); + + clock.set(Instant.parse("2026-08-14T09:00:00Z")); + assertThat(runConcurrently(() -> punchOutcome(() -> attendance.checkOut(internId)))) + .containsExactlyInAnyOrder("SUCCESS", AttendanceRejection.ALREADY_CHECKED_OUT.name()); + } + + private long createActiveIntern() { + bootstrap.bootstrap("concurrency-admin@example.test", "Admin", "correct horse battery staple"); + long adminId = accounts.requireActiveAdminId("concurrency-admin@example.test"); + long draftId = smtp.saveDraft(adminId, new SmtpDraft( + "mailpit", + 1025, + SecurityMode.NONE, + null, + null, + "concurrency-admin@example.test", + "Lab Timesheet")); + smtp.testDraft(draftId, adminId, "concurrency-admin@example.test"); + smtp.activate(draftId, adminId); + mail.clear(); + + var creation = accounts.create(new CreateAccountCommand( + "concurrency-intern@example.test", + "Concurrent Intern", + GlobalRole.INTERN, + "INT-CONCURRENT", + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 12, 31)), adminId); + assertThat(creation.deliverySucceeded()).isTrue(); + assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue(); + accounts.activateInternship(creation.userId(), adminId); + return creation.userId(); + } + + private static String punchOutcome(Runnable punch) { + try { + punch.run(); + return "SUCCESS"; + } catch (AttendanceException rejection) { + return rejection.rejection().name(); + } + } + + private static List runConcurrently(Callable action) throws Exception { + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + Callable synchronizedAction = () -> { + ready.countDown(); + start.await(); + return action.call(); + }; + Future first = executor.submit(synchronizedAction); + Future second = executor.submit(synchronizedAction); + ready.await(); + start.countDown(); + return List.of(first.get(), second.get()); + } + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java index 347882d..71a232e 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java @@ -13,6 +13,8 @@ import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity; import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository; import com.lab.labtimesheet.feature.integration.model.SecurityMode; import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; @@ -26,6 +28,7 @@ import java.time.ZoneId; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -40,6 +43,8 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; +import static com.lab.labtimesheet.feature.attendance.model.entity.LeaveEntityFixtures.allocatedDay; +import static com.lab.labtimesheet.feature.attendance.model.entity.LeaveEntityFixtures.approvedRequest; @Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class) @SpringBootTest @@ -71,6 +76,9 @@ class AttendancePersistenceIntegrationTest { @Autowired private AttendanceRecordRepository records; + @Autowired + private EntityManager entityManager; + @Autowired private MutableClock clock; @@ -141,6 +149,38 @@ class AttendancePersistenceIntegrationTest { assertThat(item.violations().missingCheckout()).isFalse(); } + @Test + void approvedLeaveBlocksOnlyItsFrozenAllocatedDates() { + LocalDate unallocatedDate = LocalDate.of(2026, 8, 14); + LocalDate allocatedDate = LocalDate.of(2026, 8, 17); + LeaveRequestEntity request = approvedRequest( + internId, + unallocatedDate, + allocatedDate, + Instant.parse("2026-08-13T00:00:00Z"), + Instant.parse("2026-08-14T01:30:00Z"), + adminId, + Instant.parse("2026-08-13T00:30:00Z")); + entityManager.persist(request); + entityManager.flush(); + entityManager.persist(allocatedDay( + request, + allocatedDate, + entityManager.getReference(AttendancePolicyEntity.class, 1L), + 3)); + entityManager.flush(); + + clock.set(Instant.parse("2026-08-14T02:00:00Z")); + attendance.checkIn(internId); + assertThat(records.findByInternUserIdAndWorkDate(internId, unallocatedDate)).isPresent(); + + clock.set(Instant.parse("2026-08-17T02:00:00Z")); + assertThatThrownBy(() -> attendance.checkIn(internId)) + .isInstanceOfSatisfying(AttendanceException.class, + exception -> assertThat(exception.rejection()) + .isEqualTo(com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.APPROVED_LEAVE)); + } + @Test void calendarDayOffBlocksCheckInAndPastEventsAreImmutable() { AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN); diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java index 537eceb..8654717 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java @@ -17,6 +17,7 @@ import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext; import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures; import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; import java.math.BigDecimal; @@ -158,7 +159,7 @@ class AttendanceServiceTest { } private static AttendancePolicy seededPolicy() { - return AttendancePolicy.seeded(1L); + return AttendancePolicyFixtures.seeded(1L); } private static AttendancePolicy policy(int checkoutGraceMinutes, Set workdays) { From 01b8095e9459417e2cf5bd1079c796d4f01ec549 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:26:39 +0700 Subject: [PATCH 48/62] docs(attendance): finalize review evidence --- docs/tests/integration/attendance-frozen-leave-concurrency.md | 2 +- docs/tests/unit/attendance-checkout-eligibility.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tests/integration/attendance-frozen-leave-concurrency.md b/docs/tests/integration/attendance-frozen-leave-concurrency.md index f978770..28853d4 100644 --- a/docs/tests/integration/attendance-frozen-leave-concurrency.md +++ b/docs/tests/integration/attendance-frozen-leave-concurrency.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ATT-005`, `ATT-006`, `ATT-007`, `ATT-008`, `ATT-010`, `LEV-003`, `LEV-011` - **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-LEV-001`, `AC-LEV-005` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates`, `com.lab.labtimesheet.feature.attendance.service.AttendanceConcurrencyIntegrationTest#concurrentDuplicatePunchesReturnStableDomainOutcomes` -- **Implementation commit:** `pending` +- **Implementation commit:** `4c39df70e1f901e232669e9090ff5d21393519f0` ## Protected behavior diff --git a/docs/tests/unit/attendance-checkout-eligibility.md b/docs/tests/unit/attendance-checkout-eligibility.md index a534fc7..d3036a4 100644 --- a/docs/tests/unit/attendance-checkout-eligibility.md +++ b/docs/tests/unit/attendance-checkout-eligibility.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ATT-007`, `ATT-008`, `ATT-010`, `ATT-012` - **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServiceTest#rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate`, `#translatesConcurrentCheckInUniqueConflictToStableDuplicateRejection`, `#translatesConcurrentCheckoutVersionConflictToStableDuplicateRejection` -- **Implementation commit:** `pending` +- **Implementation commit:** `4c39df70e1f901e232669e9090ff5d21393519f0` ## Protected behavior From 17fa25bb0921718f780037cd8c55a956bbdf6b19 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:29:41 +0700 Subject: [PATCH 49/62] fix account and SMTP onboarding workflows --- .../config/SecurityConfiguration.java | 2 +- .../account/controller/AccountController.java | 69 ++++---- .../controller/BootstrapController.java | 26 ++- .../account/model/dto/ActivationForm.java | 43 +++++ .../account/model/dto/BootstrapForm.java | 48 ++++++ .../account/model/dto/CreateAccountForm.java | 82 +++++++++ .../controller/SmtpController.java | 125 ++++++++++++-- .../controller/SmtpWarningAdvice.java | 23 +++ .../integration/model/dto/SmtpActionForm.java | 19 +++ .../integration/model/dto/SmtpForm.java | 113 ++++++++++++ .../model/dto/SmtpSetupStatus.java | 21 +++ .../service/SmtpConfigurationService.java | 25 +++ .../templates/accounts/activate.html | 10 +- .../resources/templates/accounts/new.html | 23 ++- .../resources/templates/bootstrap/form.html | 13 +- src/main/resources/templates/smtp/defer.html | 22 +++ src/main/resources/templates/smtp/form.html | 39 ++++- .../controller/AccountWebIntegrationTest.java | 69 +++++++- ...BootstrapOnboardingWebIntegrationTest.java | 140 +++++++++++++++ .../SmtpOnboardingWebIntegrationTest.java | 161 ++++++++++++++++++ 20 files changed, 988 insertions(+), 85 deletions(-) create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/ActivationForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/BootstrapForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpWarningAdvice.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpActionForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpSetupStatus.java create mode 100644 src/main/resources/templates/smtp/defer.html create mode 100644 src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java diff --git a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java index 5fde489..e5f6f85 100644 --- a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java @@ -35,7 +35,7 @@ class SecurityConfiguration { .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .headers(headers -> headers.referrerPolicy(policy -> policy.policy(ReferrerPolicy.NO_REFERRER))) - .formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", true)) + .formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", false)) .logout(logout -> logout.logoutSuccessUrl("/login?logout")) .addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class) .build(); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java index 621eccf..4518475 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java @@ -1,17 +1,18 @@ package com.lab.labtimesheet.feature.account.controller; import java.security.Principal; -import java.time.LocalDate; -import com.lab.labtimesheet.feature.account.model.GlobalRole; -import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand; +import com.lab.labtimesheet.feature.account.model.dto.ActivationForm; +import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm; import com.lab.labtimesheet.feature.account.service.AccountService; -import org.springframework.format.annotation.DateTimeFormat; +import jakarta.validation.Valid; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestParam; @Controller class AccountController { @@ -22,64 +23,56 @@ class AccountController { } @GetMapping("/admin/accounts/new") - String newAccount() { + String newAccount(Model model) { + if (!model.containsAttribute("accountForm")) { + model.addAttribute("accountForm", new CreateAccountForm()); + } return "accounts/new"; } @PostMapping("/admin/accounts") - String create( - @RequestParam String email, - @RequestParam String displayName, - @RequestParam GlobalRole role, - @RequestParam(required = false) String studentCode, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipStart, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipEnd, - Principal principal, - Model model) { + String create(@Valid @ModelAttribute("accountForm") CreateAccountForm form, BindingResult bindingResult, + Principal principal) { + if (bindingResult.hasErrors()) { + return "accounts/new"; + } try { - var result = accounts.create( - new CreateAccountCommand( - email, displayName, role, clean(studentCode), internshipStart, internshipEnd), - accounts.requireActiveAdminId(principal.getName())); + var result = accounts.create(form.toCommand(), accounts.requireActiveAdminId(principal.getName())); return result.deliverySucceeded() ? "redirect:/admin/accounts/new?created" : "redirect:/admin/accounts/new?deliveryFailed"; + } catch (DataIntegrityViolationException duplicate) { + bindingResult.rejectValue("email", "account.email.duplicate", "An account with this email already exists"); + return "accounts/new"; } catch (IllegalArgumentException | IllegalStateException exception) { - model.addAttribute("error", exception.getMessage()); + bindingResult.reject("account.invalid", exception.getMessage()); return "accounts/new"; } } - private static String clean(String value) { - return value == null || value.isBlank() ? null : value.trim(); - } - @GetMapping("/activate") - String activationForm(@RequestParam String token, Model model) { - model.addAttribute("token", token); + String activationForm(@ModelAttribute("activationForm") ActivationForm form, Model model) { + if (form.getToken() == null || form.getToken().isBlank()) { + model.addAttribute("error", "This activation link is invalid or no longer usable"); + } return "accounts/activate"; } @PostMapping("/activate") - String activate( - @RequestParam String token, - @RequestParam String password, - @RequestParam String confirmPassword, - Model model) { - if (!password.equals(confirmPassword)) { - model.addAttribute("token", token); - model.addAttribute("error", "Passwords do not match"); + String activate(@Valid @ModelAttribute("activationForm") ActivationForm form, BindingResult bindingResult) { + if (bindingResult.hasErrors()) { + form.clearPasswords(); return "accounts/activate"; } try { - if (accounts.activate(token, password)) { + if (accounts.activate(form.getToken(), form.getPassword())) { return "redirect:/login?activated"; } - model.addAttribute("error", "This activation link is invalid or no longer usable"); + bindingResult.reject("activation.invalid", "This activation link is invalid or no longer usable"); } catch (IllegalArgumentException exception) { - model.addAttribute("error", exception.getMessage()); + bindingResult.reject("activation.invalid", exception.getMessage()); } - model.addAttribute("token", token); + form.clearPasswords(); return "accounts/activate"; } } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java index 0daa0dc..e7bd134 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java @@ -1,13 +1,16 @@ package com.lab.labtimesheet.feature.account.controller; +import com.lab.labtimesheet.feature.account.model.dto.BootstrapForm; import com.lab.labtimesheet.feature.account.service.BootstrapService; +import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.server.ResponseStatusException; @Controller @@ -20,21 +23,30 @@ class BootstrapController { } @GetMapping - String form() { + String form(Model model) { requireOpen(); + if (!model.containsAttribute("bootstrapForm")) { + model.addAttribute("bootstrapForm", new BootstrapForm()); + } return "bootstrap/form"; } @PostMapping - String create(@RequestParam String email, @RequestParam String displayName, @RequestParam String password, - Model model) { + String create(@Valid @ModelAttribute("bootstrapForm") BootstrapForm form, BindingResult bindingResult) { + requireOpen(); + if (bindingResult.hasErrors()) { + form.setPassword(null); + return "bootstrap/form"; + } try { - if (bootstrap.bootstrap(email, displayName, password) == BootstrapService.BootstrapOutcome.CREATED) { - return "redirect:/login"; + if (bootstrap.bootstrap(form.getEmail(), form.getDisplayName(), form.getPassword()) + == BootstrapService.BootstrapOutcome.CREATED) { + return "redirect:/admin/smtp?onboarding"; } throw new ResponseStatusException(HttpStatus.NOT_FOUND); } catch (IllegalArgumentException validation) { - model.addAttribute("error", validation.getMessage()); + bindingResult.reject("bootstrap.invalid", validation.getMessage()); + form.setPassword(null); return "bootstrap/form"; } } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/ActivationForm.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/ActivationForm.java new file mode 100644 index 0000000..9026242 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/ActivationForm.java @@ -0,0 +1,43 @@ +package com.lab.labtimesheet.feature.account.model.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Validated activation submission. Password fields remain request-local and are never repopulated by the view. + */ +public class ActivationForm { + @NotBlank(message = "This activation link is invalid or no longer usable") + private String token; + + @NotBlank(message = "Password is required") + @Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters") + private String password; + + @NotBlank(message = "Password confirmation is required") + private String confirmPassword; + + /** + * Confirms both password entries agree without exposing either value. + * + * @return {@code true} when confirmation matches + */ + @AssertTrue(message = "Passwords do not match") + public boolean isPasswordConfirmed() { + return password != null && password.equals(confirmPassword); + } + + /** Clears both cleartext password values before rendering an error response. */ + public void clearPasswords() { + password = null; + confirmPassword = null; + } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getConfirmPassword() { return confirmPassword; } + public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/BootstrapForm.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/BootstrapForm.java new file mode 100644 index 0000000..ffe1344 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/BootstrapForm.java @@ -0,0 +1,48 @@ +package com.lab.labtimesheet.feature.account.model.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Validated browser input for creating the first administrator. + * The password is deliberately never copied into redirected state or repopulated after validation failure. + */ +public class BootstrapForm { + @NotBlank(message = "Email is required") + @Email(message = "Enter a valid email address") + @Size(max = 320, message = "Email must contain at most 320 characters") + private String email; + + @NotBlank(message = "Display name is required") + @Size(max = 120, message = "Display name must contain at most 120 characters") + private String displayName; + + @NotBlank(message = "Password is required") + @Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters") + private String password; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email == null ? null : email.trim(); + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName == null ? null : displayName.trim(); + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountForm.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountForm.java new file mode 100644 index 0000000..b010d00 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountForm.java @@ -0,0 +1,82 @@ +package com.lab.labtimesheet.feature.account.model.dto; + +import java.time.LocalDate; + +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.format.annotation.DateTimeFormat; + +/** Validated, non-secret Admin input for creating an immutable-role account. */ +public class CreateAccountForm { + @NotBlank(message = "Email is required") + @Email(message = "Enter a valid email address") + @Size(max = 320, message = "Email must contain at most 320 characters") + private String email; + + @NotBlank(message = "Display name is required") + @Size(max = 120, message = "Display name must contain at most 120 characters") + private String displayName; + + @NotNull(message = "Role is required") + private GlobalRole role; + + @Size(max = 64, message = "Student code must contain at most 64 characters") + private String studentCode; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate internshipStart; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate internshipEnd; + + /** + * Validates the role-dependent internship fields and their inclusive date ordering. + * + * @return {@code true} when Intern details are complete, or absent for non-Intern roles + */ + @AssertTrue(message = "Intern details are required for Intern accounts and must use a valid date range") + public boolean isInternDetailsValid() { + if (role == null) { + return true; + } + if (role != GlobalRole.INTERN) { + return !hasText(studentCode) && internshipStart == null && internshipEnd == null; + } + return hasText(studentCode) && internshipStart != null && internshipEnd != null + && !internshipEnd.isBefore(internshipStart); + } + + /** + * Converts validated browser input to the account service command. + * + * @return normalized service command + */ + public CreateAccountCommand toCommand() { + return new CreateAccountCommand(email, displayName, role, clean(studentCode), internshipStart, internshipEnd); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static String clean(String value) { + return hasText(value) ? value.trim() : null; + } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email == null ? null : email.trim(); } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName == null ? null : displayName.trim(); } + public GlobalRole getRole() { return role; } + public void setRole(GlobalRole role) { this.role = role; } + public String getStudentCode() { return studentCode; } + public void setStudentCode(String studentCode) { this.studentCode = studentCode; } + public LocalDate getInternshipStart() { return internshipStart; } + public void setInternshipStart(LocalDate internshipStart) { this.internshipStart = internshipStart; } + public LocalDate getInternshipEnd() { return internshipEnd; } + public void setInternshipEnd(LocalDate internshipEnd) { this.internshipEnd = internshipEnd; } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java index 436a746..c802868 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java @@ -1,20 +1,33 @@ package com.lab.labtimesheet.feature.integration.controller; import java.security.Principal; +import java.util.List; import com.lab.labtimesheet.feature.account.service.AccountService; -import com.lab.labtimesheet.feature.integration.model.SecurityMode; -import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpActionForm; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpForm; import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; @Controller @RequestMapping("/admin/smtp") class SmtpController { + private static final String DEFERRAL_STEP = SmtpController.class.getName() + ".deferralStep"; + private static final List DEFERRAL_WARNINGS = List.of( + "Account onboarding is disabled until SMTP is active.", + "Activation resend is disabled until SMTP is active.", + "Password recovery is disabled until SMTP is active.", + "Workflow email delivery is less immediate until SMTP is active.", + "I acknowledge this installation remains restricted until SMTP is active."); + private final SmtpConfigurationService smtp; private final AccountService accounts; @@ -24,29 +37,107 @@ class SmtpController { } @GetMapping - String form() { - return "smtp/form"; + String form(Model model) { + return renderForm(model, null); } @PostMapping("/draft") - String saveDraft(@RequestParam String host, @RequestParam int port, @RequestParam SecurityMode securityMode, - @RequestParam(required = false) String username, @RequestParam(required = false) String password, - @RequestParam String fromAddress, @RequestParam String fromName, Principal principal) { - smtp.saveDraft(adminId(principal), - new SmtpDraft(host, port, securityMode, username, password, fromAddress, fromName)); - return "redirect:/admin/smtp"; + String saveDraft(@Valid @ModelAttribute("smtpForm") SmtpForm form, BindingResult bindingResult, + Principal principal, Model model) { + if (bindingResult.hasErrors()) { + form.clearPassword(); + return renderForm(model, form); + } + try { + smtp.saveDraft(adminId(principal), form.toDraft()); + return "redirect:/admin/smtp?saved"; + } catch (IllegalArgumentException | IllegalStateException validation) { + bindingResult.reject("smtp.invalid", validation.getMessage()); + form.clearPassword(); + return renderForm(model, form); + } } @PostMapping("/test") - String test(@RequestParam long draftId, Principal principal) { - smtp.testDraft(draftId, adminId(principal), principal.getName()); - return "redirect:/admin/smtp"; + String test(@Valid @ModelAttribute("smtpAction") SmtpActionForm action, BindingResult bindingResult, + Principal principal, Model model) { + if (bindingResult.hasErrors()) { + return renderActionError(model, bindingResult); + } + try { + smtp.testDraft(action.getDraftId(), adminId(principal), principal.getName()); + return "redirect:/admin/smtp?tested"; + } catch (IllegalArgumentException | IllegalStateException failure) { + bindingResult.reject("smtp.test.failed", failure.getMessage()); + return renderActionError(model, bindingResult); + } } @PostMapping("/activate") - String activate(@RequestParam long draftId, Principal principal) { - smtp.activate(draftId, adminId(principal)); - return "redirect:/admin/smtp"; + String activate(@Valid @ModelAttribute("smtpAction") SmtpActionForm action, BindingResult bindingResult, + Principal principal, Model model) { + if (bindingResult.hasErrors()) { + return renderActionError(model, bindingResult); + } + try { + smtp.activate(action.getDraftId(), adminId(principal)); + return "redirect:/admin/smtp?activated"; + } catch (IllegalArgumentException | IllegalStateException failure) { + bindingResult.reject("smtp.activate.failed", failure.getMessage()); + return renderActionError(model, bindingResult); + } + } + + @GetMapping("/defer") + String deferral(HttpSession session, Model model) { + if (smtp.hasActiveConfiguration()) { + return "redirect:/admin/smtp"; + } + int step = deferralStep(session); + model.addAttribute("deferralStep", step); + model.addAttribute("deferralWarning", DEFERRAL_WARNINGS.get(step - 1)); + return "smtp/defer"; + } + + @PostMapping("/defer/next") + String nextDeferral(HttpSession session) { + session.setAttribute(DEFERRAL_STEP, Math.min(5, deferralStep(session) + 1)); + return "redirect:/admin/smtp/defer"; + } + + @PostMapping("/defer/back") + String previousDeferral(HttpSession session) { + session.setAttribute(DEFERRAL_STEP, Math.max(1, deferralStep(session) - 1)); + return "redirect:/admin/smtp/defer"; + } + + @PostMapping("/defer/finish") + String finishDeferral(HttpSession session) { + if (deferralStep(session) != 5) { + return "redirect:/admin/smtp/defer"; + } + session.removeAttribute(DEFERRAL_STEP); + return "redirect:/dashboard"; + } + + private String renderForm(Model model, SmtpForm submittedForm) { + var status = smtp.setupStatus(); + model.addAttribute("smtpStatus", status); + model.addAttribute("smtpAction", new SmtpActionForm()); + if (submittedForm == null) { + model.addAttribute("smtpForm", SmtpForm.from(status)); + } + return "smtp/form"; + } + + private String renderActionError(Model model, BindingResult bindingResult) { + model.addAttribute(BindingResult.MODEL_KEY_PREFIX + "smtpAction", bindingResult); + return renderForm(model, null); + } + + private static int deferralStep(HttpSession session) { + Object value = session.getAttribute(DEFERRAL_STEP); + return value instanceof Integer step && step >= 1 && step <= 5 ? step : 1; } private long adminId(Principal principal) { diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpWarningAdvice.java b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpWarningAdvice.java new file mode 100644 index 0000000..aaf0d9e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpWarningAdvice.java @@ -0,0 +1,23 @@ +package com.lab.labtimesheet.feature.integration.controller; + +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +/** + * Supplies the persistent restricted-installation warning state to server-rendered views until a tested SMTP + * configuration is active. + */ +@ControllerAdvice +class SmtpWarningAdvice { + private final SmtpConfigurationService smtp; + + SmtpWarningAdvice(SmtpConfigurationService smtp) { + this.smtp = smtp; + } + + @ModelAttribute("smtpRestricted") + boolean smtpRestricted() { + return !smtp.hasActiveConfiguration(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpActionForm.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpActionForm.java new file mode 100644 index 0000000..d0dad6b --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpActionForm.java @@ -0,0 +1,19 @@ +package com.lab.labtimesheet.feature.integration.model.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +/** Validated identifier submitted by the SMTP test and activation forms. */ +public class SmtpActionForm { + @NotNull(message = "SMTP draft is required") + @Positive(message = "SMTP draft is invalid") + private Long draftId; + + public Long getDraftId() { + return draftId; + } + + public void setDraftId(Long draftId) { + this.draftId = draftId; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpForm.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpForm.java new file mode 100644 index 0000000..5810f35 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpForm.java @@ -0,0 +1,113 @@ +package com.lab.labtimesheet.feature.integration.model.dto; + +import com.lab.labtimesheet.feature.integration.model.SecurityMode; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * Validated Admin input for an SMTP draft. The cleartext password exists only for the current request and is + * cleared before the form is rendered again. + */ +public class SmtpForm { + @NotBlank(message = "Host is required") + @Size(max = 255, message = "Host must contain at most 255 characters") + private String host; + + @Min(value = 1, message = "Port must be between 1 and 65535") + @Max(value = 65535, message = "Port must be between 1 and 65535") + private int port = 587; + + @NotNull(message = "Security mode is required") + private SecurityMode securityMode = SecurityMode.STARTTLS; + + @Size(max = 320, message = "Username must contain at most 320 characters") + private String username; + + @Size(max = 1024, message = "Password is too long") + private String password; + + @NotBlank(message = "From address is required") + @Email(message = "Enter a valid email address") + @Size(max = 320, message = "From address must contain at most 320 characters") + private String fromAddress; + + @NotBlank(message = "From name is required") + @Size(max = 120, message = "From name must contain at most 120 characters") + private String fromName; + + /** + * Ensures SMTP authentication is either fully configured or completely absent. + * + * @return {@code true} when username and password presence agree + */ + @AssertTrue(message = "SMTP username and password must be supplied together") + public boolean isAuthenticationComplete() { + return hasText(username) == hasText(password); + } + + /** + * Converts validated browser input into the service command. The password remains request-local until the + * service encrypts it. + * + * @return SMTP draft command + */ + public SmtpDraft toDraft() { + return new SmtpDraft(host, port, securityMode, clean(username), emptyToNull(password), fromAddress, fromName); + } + + /** + * Builds a safe form representation of an existing draft without decrypting or exposing its password. + * + * @param status current non-secret setup status + * @return form populated only with non-secret values + */ + public static SmtpForm from(SmtpSetupStatus status) { + SmtpForm form = new SmtpForm(); + if (status.draftId() != null) { + form.host = status.host(); + form.port = status.port(); + form.securityMode = status.securityMode(); + form.username = status.username(); + form.fromAddress = status.fromAddress(); + form.fromName = status.fromName(); + } + return form; + } + + /** Clears the request-local cleartext password before rendering. */ + public void clearPassword() { + password = null; + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static String clean(String value) { + return hasText(value) ? value.trim() : null; + } + + private static String emptyToNull(String value) { + return value == null || value.isEmpty() ? null : value; + } + + public String getHost() { return host; } + public void setHost(String host) { this.host = host; } + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + public SecurityMode getSecurityMode() { return securityMode; } + public void setSecurityMode(SecurityMode securityMode) { this.securityMode = securityMode; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getFromAddress() { return fromAddress; } + public void setFromAddress(String fromAddress) { this.fromAddress = fromAddress; } + public String getFromName() { return fromName; } + public void setFromName(String fromName) { this.fromName = fromName; } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpSetupStatus.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpSetupStatus.java new file mode 100644 index 0000000..57e8062 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpSetupStatus.java @@ -0,0 +1,21 @@ +package com.lab.labtimesheet.feature.integration.model.dto; + +import com.lab.labtimesheet.feature.integration.model.SecurityMode; + +/** + * Non-secret snapshot used by Admin setup views. No encrypted or cleartext credential material crosses this + * service boundary. + * + * @param active whether a tested SMTP configuration is active + * @param draftId editable draft identifier, or {@code null} when no draft exists + * @param tested whether the current draft most recently passed its connection test + * @param host draft host + * @param port draft port + * @param securityMode draft transport security + * @param username draft username, or {@code null} + * @param fromAddress draft sender address + * @param fromName draft sender display name + */ +public record SmtpSetupStatus(boolean active, Long draftId, boolean tested, String host, int port, + SecurityMode securityMode, String username, String fromAddress, String fromName) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java index dc584c9..c46acb8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java @@ -8,6 +8,7 @@ import com.lab.labtimesheet.feature.integration.model.SmtpStatus; import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret; import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpSetupStatus; import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration; import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository; import org.springframework.core.env.Environment; @@ -78,6 +79,30 @@ public class SmtpConfigurationService { return mailDelivery.isAvailable(); } + /** + * Returns the non-secret SMTP state needed by the Admin setup page. + * Password ciphertext, nonce, and decrypted credentials are never included. + * + * @return current active flag and editable draft metadata + */ + @Transactional(readOnly = true) + public SmtpSetupStatus setupStatus() { + boolean active = configurations.existsByStatus(SmtpStatus.ACTIVE); + return configurations.findByStatus(SmtpStatus.DRAFT) + .map(draft -> new SmtpSetupStatus( + active, + draft.getId(), + draft.getTestedAt() != null, + draft.getHost(), + draft.getPort(), + draft.getSecurityMode(), + draft.getUsername(), + draft.getFromAddress(), + draft.getFromName())) + .orElseGet(() -> new SmtpSetupStatus(active, null, false, null, 587, + SecurityMode.STARTTLS, null, null, null)); + } + @Transactional(readOnly = true) public SmtpConnection activeConnection() { return mailDelivery.activeConnection(); diff --git a/src/main/resources/templates/accounts/activate.html b/src/main/resources/templates/accounts/activate.html index 69c785f..3dbad3b 100644 --- a/src/main/resources/templates/accounts/activate.html +++ b/src/main/resources/templates/accounts/activate.html @@ -5,10 +5,16 @@

    Choose your password

    -
    - + +
    +

    +
    + +

    +

    +

    diff --git a/src/main/resources/templates/accounts/new.html b/src/main/resources/templates/accounts/new.html index 8595aa5..2208861 100644 --- a/src/main/resources/templates/accounts/new.html +++ b/src/main/resources/templates/accounts/new.html @@ -4,25 +4,32 @@

    Create account

    +

    This is a restricted installation until tested SMTP is active.

    Account created and activation email sent.

    Account created, but activation delivery failed.

    -

    -
    - - + +
    +

    +
    + +

    + +

    +

    Intern details - - - + + +
    +

    diff --git a/src/main/resources/templates/bootstrap/form.html b/src/main/resources/templates/bootstrap/form.html index cbc74a0..82d4777 100644 --- a/src/main/resources/templates/bootstrap/form.html +++ b/src/main/resources/templates/bootstrap/form.html @@ -4,11 +4,16 @@

    Create the first administrator

    -

    -
    - - + +
    +

    +
    + +

    + +

    +

    diff --git a/src/main/resources/templates/smtp/defer.html b/src/main/resources/templates/smtp/defer.html new file mode 100644 index 0000000..9f38ab8 --- /dev/null +++ b/src/main/resources/templates/smtp/defer.html @@ -0,0 +1,22 @@ + + +Defer SMTP configuration + +
    +

    Defer SMTP configuration

    +

    +

    +
    Configure SMTP +
    + +
    +

    Back

    +
    + +
    +
    + +
    +
    + + diff --git a/src/main/resources/templates/smtp/form.html b/src/main/resources/templates/smtp/form.html index 8117d4b..d34459f 100644 --- a/src/main/resources/templates/smtp/form.html +++ b/src/main/resources/templates/smtp/form.html @@ -4,16 +4,41 @@

    SMTP configuration

    -
    - - - - +

    This is a restricted installation until tested SMTP is active.

    +

    Configure SMTP now to enable account onboarding and recovery.

    +

    SMTP is active.

    +

    Draft saved.

    +

    Test passed.

    + +
    +

    +
    + +

    + +

    + +

    + - - +

    + +

    + +

    +
    + + +
    +
    + + +
    +

    + Defer SMTP +

    diff --git a/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java index ba040d3..9afb100 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java @@ -43,7 +43,7 @@ import org.springframework.test.web.servlet.MockMvc; @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class AccountWebIntegrationTest { @Autowired private MockMvc mockMvc; @@ -139,6 +139,73 @@ class AccountWebIntegrationTest { .andExpect(unauthenticated()); } + @Test + void invalidAndDuplicateAccountFormsReturnActionableErrorsWithoutCreatingAnotherAccount() throws Exception { + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", "not-an-email") + .param("displayName", "Safe display name") + .param("role", "INTERN") + .param("studentCode", "") + .param("internshipStart", "") + .param("internshipEnd", "")) + .andExpect(status().isOk()) + .andExpect(view().name("accounts/new")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("valid email address"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Intern details are required"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Safe display name"))); + + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", "mentor@example.com") + .param("displayName", "Mentor One") + .param("role", "MENTOR")) + .andExpect(status().is3xxRedirection()); + + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", " MENTOR@EXAMPLE.COM ") + .param("displayName", "Duplicate Mentor") + .param("role", "MENTOR")) + .andExpect(status().isOk()) + .andExpect(view().name("accounts/new")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("already exists"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Duplicate Mentor"))); + } + + @Test + void additionalAdminActivatesAndAuthenticatesWithoutChangingTheFirstAdmin() throws Exception { + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", "second-admin@example.com") + .param("displayName", "Second Admin") + .param("role", "ADMIN")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/accounts/new?created")); + + String rawToken = mail.activationTokenFor("second-admin@example.com"); + mockMvc.perform(post("/activate") + .with(csrf()) + .param("token", rawToken) + .param("password", "new secure admin password") + .param("confirmPassword", "new secure admin password")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?activated")); + + mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "second-admin@example.com") + .param("password", "new secure admin password")) + .andExpect(status().is3xxRedirection()) + .andExpect(authenticated().withRoles("ADMIN")); + assertThat(accounts.requireIdentityByEmail("admin@example.com").status()).isEqualTo(AccountStatus.ACTIVE); + assertThat(accounts.requireIdentityByEmail("admin@example.com").role()).isEqualTo(GlobalRole.ADMIN); + } + @TestConfiguration(proxyBeanMethods = false) static class MailProbeConfiguration { @Bean diff --git a/src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java new file mode 100644 index 0000000..49de963 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java @@ -0,0 +1,140 @@ +package com.lab.labtimesheet.feature.account.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.request.SecurityMockMvcRequestPostProcessors.user; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import com.lab.labtimesheet.config.TestcontainersConfiguration; +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.annotation.Import; +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) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class BootstrapOnboardingWebIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Test + void bootstrapOffersSmtpAfterTheFirstAdminSignsIn() throws Exception { + MockHttpSession session = new MockHttpSession(); + var bootstrapResult = mockMvc.perform(post("/bootstrap") + .session(session) + .with(csrf()) + .param("email", " ADMIN@EXAMPLE.COM ") + .param("displayName", "First Admin") + .param("password", "correct horse battery staple")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/smtp?onboarding")) + .andReturn(); + assertThat(bootstrapResult.getRequest().getSession(false)).isSameAs(session); + + mockMvc.perform(get("/admin/smtp?onboarding").session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login")); + + mockMvc.perform(post("/login") + .session(session) + .with(csrf()) + .param("username", " ADMIN@EXAMPLE.COM ") + .param("password", "correct horse battery staple")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", org.hamcrest.Matchers.containsString( + "/admin/smtp?onboarding"))); + + mockMvc.perform(get("/admin/smtp?onboarding").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Configure SMTP now"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Defer SMTP"))); + } + + @Test + void fiveDistinctDeferralConfirmationsAreSequentialAndOnlyTheLastCanFinish() throws Exception { + initializeAdmin(); + var first = mockMvc.perform(get("/admin/smtp/defer") + .with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(view().name("smtp/defer")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Account onboarding is disabled"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Back"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Configure SMTP"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("Finish without SMTP")))) + .andReturn(); + MockHttpSession session = (MockHttpSession) first.getRequest().getSession(false); + assertThat(session).isNotNull(); + + assertStep(session, "Activation resend is disabled", false); + assertStep(session, "Password recovery is disabled", false); + assertStep(session, "Workflow email delivery is less immediate", false); + assertStep(session, "I acknowledge this installation remains restricted", true); + + mockMvc.perform(post("/admin/smtp/defer/finish") + .session(session) + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dashboard")); + } + + @Test + void bootstrapValidationRetainsSafeFieldsButNeverThePassword() throws Exception { + mockMvc.perform(post("/bootstrap") + .with(csrf()) + .param("email", "not-an-email") + .param("displayName", "Safe Admin Name") + .param("password", "must-not-be-rendered")) + .andExpect(status().isOk()) + .andExpect(view().name("bootstrap/form")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("valid email address"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Safe Admin Name"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("must-not-be-rendered")))); + } + + private void initializeAdmin() throws Exception { + mockMvc.perform(post("/bootstrap") + .with(csrf()) + .param("email", "admin@example.com") + .param("displayName", "Admin") + .param("password", "correct horse battery staple")) + .andExpect(status().is3xxRedirection()); + } + + private void assertStep(MockHttpSession session, String warning, boolean finishVisible) throws Exception { + mockMvc.perform(post("/admin/smtp/defer/next") + .session(session) + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/smtp/defer")); + + var matcher = finishVisible + ? org.hamcrest.Matchers.containsString("Finish without SMTP") + : org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("Finish without SMTP")); + mockMvc.perform(get("/admin/smtp/defer") + .session(session) + .with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString(warning))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Back"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Configure SMTP"))) + .andExpect(content().string(matcher)); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java new file mode 100644 index 0000000..53e57f3 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java @@ -0,0 +1,161 @@ +package com.lab.labtimesheet.feature.integration.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.request.SecurityMockMvcRequestPostProcessors.user; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import java.util.ArrayList; +import java.util.List; + +import com.lab.labtimesheet.config.TestcontainersConfiguration; +import com.lab.labtimesheet.feature.account.service.BootstrapService; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; +import com.lab.labtimesheet.feature.integration.service.SmtpProbe; +import org.junit.jupiter.api.BeforeEach; +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.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@Import({TestcontainersConfiguration.class, SmtpOnboardingWebIntegrationTest.ProbeConfiguration.class}) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class SmtpOnboardingWebIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private BootstrapService bootstrap; + + @Autowired + private RecordingProbe probe; + + @BeforeEach + void initializeAdmin() { + bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); + } + + @Test + void adminCanSaveTestAndActivateSmtpWithVisibleStatus() throws Exception { + mockMvc.perform(post("/admin/smtp/draft") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("host", "mailpit") + .param("port", "1025") + .param("securityMode", "NONE") + .param("username", "smtp-user") + .param("password", "smtp-secret") + .param("fromAddress", "notifications@example.com") + .param("fromName", "Lab Timesheet")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/smtp?saved")); + + var draftPage = mockMvc.perform(get("/admin/smtp").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Draft saved"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Test connection"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("Activate SMTP")))) + .andReturn(); + String html = draftPage.getResponse().getContentAsString(); + String draftId = html.replaceAll("(?s).*name=\"draftId\" value=\"([0-9]+)\".*", "$1"); + assertThat(draftId).matches("[0-9]+"); + + mockMvc.perform(post("/admin/smtp/test") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("draftId", draftId)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/smtp?tested")); + assertThat(probe.recipients).contains("admin@example.com"); + + mockMvc.perform(get("/admin/smtp").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Test passed"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Activate SMTP"))); + + mockMvc.perform(post("/admin/smtp/activate") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("draftId", draftId)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/smtp?activated")); + + mockMvc.perform(get("/admin/smtp").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("SMTP is active"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("restricted installation")))); + } + + @Test + void invalidDraftRetainsOnlySafeFieldsAndRendersValidationErrors() throws Exception { + mockMvc.perform(post("/admin/smtp/draft") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("host", "") + .param("port", "70000") + .param("securityMode", "STARTTLS") + .param("username", "smtp-user") + .param("password", "must-not-be-rendered") + .param("fromAddress", "not-an-email") + .param("fromName", "Safe sender name")) + .andExpect(status().isOk()) + .andExpect(view().name("smtp/form")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Host is required"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Port must be between"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("valid email address"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Safe sender name"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("must-not-be-rendered")))); + } + + @Test + void restrictedWarningPersistsOnAdminPagesUntilActivationAndMutationsRequireCsrf() throws Exception { + mockMvc.perform(get("/admin/accounts/new").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("restricted installation"))); + + mockMvc.perform(post("/admin/smtp/draft") + .with(user("admin@example.com").roles("ADMIN")) + .param("host", "mailpit") + .param("port", "1025") + .param("securityMode", "NONE") + .param("fromAddress", "admin@example.com") + .param("fromName", "Lab Timesheet")) + .andExpect(status().isForbidden()); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ProbeConfiguration { + @Bean + @Primary + RecordingProbe recordingProbe() { + return new RecordingProbe(); + } + } + + static final class RecordingProbe implements SmtpProbe { + private final List recipients = new ArrayList<>(); + + @Override + public void send(SmtpConnection connection, String recipient, String subject, String body) { + recipients.add(recipient); + } + } +} From 8ff6ee3d873db909b1ce9df690f7a3abb2c3c79d Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:43:01 +0700 Subject: [PATCH 50/62] address platform review findings and document APIs --- .../labtimesheet/LabtimesheetApplication.java | 6 ++ .../lab/labtimesheet/ServletInitializer.java | 7 ++ .../config/SecurityConfiguration.java | 4 + .../config/SecurityProperties.java | 7 ++ .../config/TimeConfiguration.java | 1 + .../account/controller/AccountController.java | 1 + .../controller/AuthenticationController.java | 1 + .../controller/BootstrapAccessFilter.java | 18 +++++ .../controller/BootstrapController.java | 1 + .../account/controller/HomeController.java | 1 + .../feature/account/model/AccountStatus.java | 1 + .../feature/account/model/GlobalRole.java | 1 + .../account/model/InternshipStatus.java | 1 + .../feature/account/model/TokenPurpose.java | 1 + .../account/model/dto/AccountCreation.java | 6 ++ .../account/model/dto/AccountIdentity.java | 9 +++ .../account/model/dto/AccountSummary.java | 7 ++ .../model/dto/CreateAccountCommand.java | 10 +++ .../feature/account/model/entity/AppUser.java | 31 ++++++++ .../account/model/entity/InternProfile.java | 21 ++++++ .../account/model/entity/SystemState.java | 9 +++ .../account/model/entity/UserActionToken.java | 38 ++++++++++ .../account/repository/AppUserRepository.java | 15 ++++ .../repository/InternProfileRepository.java | 10 +++ .../repository/SystemStateRepository.java | 6 ++ .../repository/UserActionTokenRepository.java | 14 ++++ .../account/service/AccountService.java | 75 +++++++++++++++++++ .../account/service/BootstrapService.java | 30 ++++++++ .../service/DatabaseUserDetailsService.java | 8 ++ .../controller/SmtpController.java | 6 +- .../integration/model/SecurityMode.java | 1 + .../feature/integration/model/SmtpStatus.java | 1 + .../model/dto/EncryptedSecret.java | 9 +++ .../integration/model/dto/SmtpConnection.java | 12 +++ .../integration/model/dto/SmtpDraft.java | 12 +++ .../model/entity/SmtpConfiguration.java | 45 +++++++++++ .../SmtpConfigurationRepository.java | 10 +++ .../service/MailDeliveryService.java | 23 ++++++ .../integration/service/SecretCipher.java | 1 + .../service/SmtpConfigurationService.java | 38 ++++++++++ .../integration/service/SmtpProbe.java | 9 +++ src/main/resources/templates/smtp/form.html | 1 + .../service/BootstrapIntegrationTest.java | 32 ++++++++ .../SmtpOnboardingWebIntegrationTest.java | 34 +++++++++ 44 files changed, 573 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java index 851a85c..cacb346 100644 --- a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java +++ b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java @@ -6,10 +6,16 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import com.lab.labtimesheet.config.SecurityProperties; +/** Application entry point and root component-scan boundary for Lab Timesheet. */ @SpringBootApplication @EnableConfigurationProperties(SecurityProperties.class) public class LabtimesheetApplication { + /** + * Starts the standalone Spring Boot process. + * + * @param args command-line arguments forwarded to Spring Boot + */ public static void main(String[] args) { SpringApplication.run(LabtimesheetApplication.class, args); } diff --git a/src/main/java/com/lab/labtimesheet/ServletInitializer.java b/src/main/java/com/lab/labtimesheet/ServletInitializer.java index 816ecea..512f617 100644 --- a/src/main/java/com/lab/labtimesheet/ServletInitializer.java +++ b/src/main/java/com/lab/labtimesheet/ServletInitializer.java @@ -3,8 +3,15 @@ package com.lab.labtimesheet; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; +/** Configures the application when deployed as a traditional servlet-container WAR. */ public class ServletInitializer extends SpringBootServletInitializer { + /** + * Registers the same application source used by the standalone launcher. + * + * @param application servlet-container application builder + * @return builder configured with the Lab Timesheet application source + */ @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { return application.sources(LabtimesheetApplication.class); diff --git a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java index e5f6f85..28f7f66 100644 --- a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java @@ -11,6 +11,10 @@ import org.springframework.security.web.SecurityFilterChain; 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. + */ @Configuration(proxyBeanMethods = false) class SecurityConfiguration { @Bean diff --git a/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java b/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java index 9b00897..7d19cb5 100644 --- a/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java @@ -4,6 +4,7 @@ import java.util.Base64; import org.springframework.boot.context.properties.ConfigurationProperties; +/** Security material used to encrypt integration credentials at rest. */ @ConfigurationProperties("lab.security") public class SecurityProperties { private String masterKey; @@ -16,6 +17,12 @@ public class SecurityProperties { this.masterKey = masterKey; } + /** + * Decodes and validates the configured AES-256 master key. + * + * @return a newly decoded 32-byte key + * @throws IllegalStateException when the property is absent or does not decode to exactly 256 bits + */ public byte[] decodedMasterKey() { if (masterKey == null || masterKey.isBlank()) { throw new IllegalStateException("lab.security.master-key is required"); diff --git a/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java b/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java index aba4a15..a942085 100644 --- a/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java @@ -5,6 +5,7 @@ import java.time.Clock; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** Provides the injectable UTC clock used for server-authoritative business time. */ @Configuration(proxyBeanMethods = false) class TimeConfiguration { @Bean diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java index 4518475..3039e37 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; +/** Handles Admin account creation and single-use account activation browser flows. */ @Controller class AccountController { private final AccountService accounts; diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/AuthenticationController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/AuthenticationController.java index b1d8e9c..487a54f 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/AuthenticationController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/AuthenticationController.java @@ -3,6 +3,7 @@ package com.lab.labtimesheet.feature.account.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +/** Renders the project-owned form-login page used by Spring Security. */ @Controller class AuthenticationController { @GetMapping("/login") diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java index c9472bc..1360e56 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java @@ -9,13 +9,31 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.filter.OncePerRequestFilter; +/** + * Hides all non-bootstrap application routes until durable first-Admin initialization completes. + * Only bootstrap pages, health, public assets, and error rendering remain reachable beforehand. + */ public class BootstrapAccessFilter extends OncePerRequestFilter { private final BootstrapService bootstrap; + /** + * Creates the pre-bootstrap access guard. + * + * @param bootstrap durable installation-state service + */ public BootstrapAccessFilter(BootstrapService bootstrap) { this.bootstrap = bootstrap; } + /** + * Returns HTTP 404 for hidden routes before bootstrap so no authentication surface is exposed prematurely. + * + * @param request current HTTP request + * @param response current HTTP response + * @param chain remaining filter chain + * @throws ServletException when downstream servlet processing fails + * @throws IOException when response or downstream I/O fails + */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java index e7bd134..1fc3a1a 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.server.ResponseStatusException; +/** Renders and processes the one-time first-Admin installation form. */ @Controller @RequestMapping("/bootstrap") class BootstrapController { diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java index 053a38f..4fd9ee5 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java @@ -3,6 +3,7 @@ package com.lab.labtimesheet.feature.account.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +/** Maps the authenticated application root to the shared role-aware dashboard. */ @Controller class HomeController { @GetMapping("/") diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java b/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java index a62ff21..558f455 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java @@ -1,5 +1,6 @@ package com.lab.labtimesheet.feature.account.model; +/** Durable authentication lifecycle of a global account. */ public enum AccountStatus { PENDING_ACTIVATION, ACTIVE, diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java b/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java index defdc1c..9d331cf 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java @@ -1,5 +1,6 @@ package com.lab.labtimesheet.feature.account.model; +/** Immutable system-wide role assigned when an account is created. */ public enum GlobalRole { ADMIN, MENTOR, diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java b/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java index fa86482..3c2df23 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java @@ -1,5 +1,6 @@ package com.lab.labtimesheet.feature.account.model; +/** Durable lifecycle of an Intern's internship independently of account activation. */ public enum InternshipStatus { NOT_STARTED, ACTIVE, diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java b/src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java index a1a9f5f..4ff0628 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java @@ -1,5 +1,6 @@ package com.lab.labtimesheet.feature.account.model; +/** Purpose discriminator preventing one bearer-token class from serving another workflow. */ public enum TokenPurpose { ACTIVATION, PASSWORD_RESET diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java index 1797583..babe402 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java @@ -1,4 +1,10 @@ package com.lab.labtimesheet.feature.account.model.dto; +/** + * Result of creating a pending account and attempting its immediate activation delivery. + * + * @param userId created account identifier + * @param deliverySucceeded whether the initial activation email was accepted by the configured SMTP boundary + */ public record AccountCreation(long userId, boolean deliverySucceeded) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java index 66110f5..9976162 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java @@ -3,6 +3,15 @@ package com.lab.labtimesheet.feature.account.model.dto; import com.lab.labtimesheet.feature.account.model.AccountStatus; import com.lab.labtimesheet.feature.account.model.GlobalRole; +/** + * Non-secret account identity exposed to other features without leaking JPA entities. + * + * @param id account identifier + * @param email normalized email address + * @param displayName user-facing name + * @param role immutable global role + * @param status current authentication lifecycle state + */ public record AccountIdentity( long id, String email, diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java index 946f03e..4a89886 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java @@ -1,4 +1,11 @@ package com.lab.labtimesheet.feature.account.model.dto; +/** + * Current account metrics exposed to reporting without persistence coupling. + * + * @param activeAccounts accounts able to authenticate + * @param pendingActivations accounts awaiting first-password activation + * @param activeInternships Intern profiles in the active lifecycle state + */ public record AccountSummary(long activeAccounts, long pendingActivations, long activeInternships) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java index 8e0b384..18a0cba 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java @@ -4,6 +4,16 @@ import java.time.LocalDate; import com.lab.labtimesheet.feature.account.model.GlobalRole; +/** + * Account-service creation input; internship fields are required only for the Intern role. + * + * @param email account email + * @param displayName user-facing name + * @param role immutable global role + * @param studentCode Intern student code, otherwise {@code null} + * @param internshipStart inclusive Intern start date, otherwise {@code null} + * @param internshipEnd inclusive Intern end date, otherwise {@code null} + */ public record CreateAccountCommand( String email, String displayName, diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java index 656dd47..26e0c54 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java @@ -17,6 +17,10 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.Version; +/** + * Persistent global account with immutable role, authentication lifecycle, creator attribution, and optimistic + * locking. Password hashes are absent until a pending account consumes its activation token. + */ @Entity @Table(name = "app_users") public class AppUser { @@ -57,6 +61,7 @@ public class AppUser { @Version private long version; + /** Required by JPA; domain instances are created through named factories. */ protected AppUser() { } @@ -73,16 +78,42 @@ public class AppUser { this.updatedAt = now; } + /** + * Creates the first already-active Admin used to initialize an installation. + * + * @param email normalized email + * @param displayName user-facing name + * @param passwordHash encoded password + * @param now server timestamp + * @return new active Admin entity without a creator + */ public static AppUser bootstrapAdmin(String email, String displayName, String passwordHash, Instant now) { return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now); } + /** + * Creates a role-bearing account that cannot authenticate until activation assigns its password hash. + * + * @param email normalized email + * @param displayName user-facing name + * @param globalRole immutable global role + * @param createdBy Admin creating the account + * @param now server timestamp + * @return new pending account entity + */ public static AppUser pending( String email, String displayName, GlobalRole globalRole, AppUser createdBy, Instant now) { return new AppUser( email, displayName, null, globalRole, AccountStatus.PENDING_ACTIVATION, null, createdBy, now); } + /** + * Transitions a pending account to active and records its encoded first password atomically. + * + * @param encodedPassword password-encoder output, never cleartext + * @param now server activation timestamp + * @throws IllegalStateException when the account is not pending activation + */ public void activate(String encodedPassword, Instant now) { if (accountStatus != AccountStatus.PENDING_ACTIVATION) { throw new IllegalStateException("Only a pending account can activate"); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java index c2011dc..4d4452c 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java @@ -12,6 +12,10 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import jakarta.persistence.Version; +/** + * Persistent internship lifecycle and inclusive eligibility dates for an Intern account. + * The shared primary key is the owning account identifier without a cross-feature entity relationship. + */ @Entity @Table(name = "intern_profiles") public class InternProfile { @@ -56,6 +60,7 @@ public class InternProfile { @Version private long version; + /** Required by JPA; domain instances are created through {@link #notStarted}. */ protected InternProfile() { } @@ -70,11 +75,27 @@ public class InternProfile { this.updatedAt = now; } + /** + * Creates an internship awaiting its separately authorized start transition. + * + * @param userId owning Intern account identifier + * @param studentCode university student code + * @param internshipStartDate inclusive eligibility start date + * @param internshipEndDate inclusive eligibility end date + * @param now server timestamp + * @return new not-started internship profile + */ public static InternProfile notStarted( long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) { return new InternProfile(userId, studentCode, internshipStartDate, internshipEndDate, now); } + /** + * Transitions a not-started internship to active. + * + * @param now server activation timestamp + * @throws IllegalStateException when the internship already left the not-started state + */ public void activate(Instant now) { if (internshipStatus != InternshipStatus.NOT_STARTED) { throw new IllegalStateException("Only a not-started internship can activate"); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java index e1ca36b..d01c219 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java @@ -11,6 +11,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.Version; +/** Durable singleton installation state used to serialize and remember first-Admin bootstrap. */ @Entity @Table(name = "system_state") public class SystemState { @@ -37,6 +38,7 @@ public class SystemState { @Version private long version; + /** Required by JPA; Flyway creates the singleton row. */ protected SystemState() { } @@ -44,6 +46,13 @@ public class SystemState { return initialized; } + /** + * Marks the installation initialized and retains the first Admin attribution. + * + * @param admin first active Admin + * @param now server initialization timestamp + * @throws IllegalStateException when initialization already completed + */ public void initialize(AppUser admin, Instant now) { if (initialized) { throw new IllegalStateException("Bootstrap is already complete"); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java index 3e4d6fd..98a6fd2 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java @@ -13,6 +13,10 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +/** + * Persistent one-time user-action token state. Only a defensive copy of the SHA-256 token hash is stored; raw + * bearer tokens never enter this entity. + */ @Entity @Table(name = "user_action_tokens") public class UserActionToken { @@ -45,6 +49,7 @@ public class UserActionToken { @Column(name = "created_at", nullable = false) private Instant createdAt; + /** Required by JPA; domain instances are created through named factories. */ protected UserActionToken() { } @@ -57,15 +62,37 @@ public class UserActionToken { this.createdAt = now; } + /** + * Creates an unused activation-token record from a cryptographic hash. + * + * @param userId account being activated + * @param tokenHash 32-byte SHA-256 hash of the raw bearer token + * @param expiresAt exclusive expiry instant + * @param issuedByUserId Admin issuing the token + * @param now server creation timestamp + * @return new activation-token entity + */ public static UserActionToken activation( long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) { return new UserActionToken(userId, tokenHash, expiresAt, issuedByUserId, now); } + /** + * Checks single-use and exclusive-expiry state at a server timestamp. + * + * @param now server timestamp + * @return {@code true} only before expiry and before use or invalidation + */ public boolean isUsableAt(Instant now) { return usedAt == null && invalidatedAt == null && now.isBefore(expiresAt); } + /** + * Consumes the token once. + * + * @param now server consumption timestamp + * @throws IllegalStateException when expired, invalidated, or already used + */ public void markUsed(Instant now) { if (!isUsableAt(now)) { throw new IllegalStateException("Activation token is not usable"); @@ -73,6 +100,12 @@ public class UserActionToken { usedAt = now; } + /** + * Invalidates an unused token, idempotently, after its delivery fails. + * + * @param now server invalidation timestamp + * @throws IllegalStateException when the token was already consumed + */ public void invalidate(Instant now) { if (usedAt != null) { throw new IllegalStateException("A used token cannot be invalidated"); @@ -94,6 +127,11 @@ public class UserActionToken { return purpose; } + /** + * Returns a defensive copy of the persisted token hash. + * + * @return copied SHA-256 hash bytes + */ public byte[] getTokenHash() { return Arrays.copyOf(tokenHash, tokenHash.length); } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java index 7d13a81..3b5bca4 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java @@ -11,15 +11,30 @@ import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +/** Account-feature persistence boundary for global users. */ public interface AppUserRepository extends JpaRepository { + /** + * Finds an account by its canonical lower-case, trimmed email. + * + * @param email normalized email + * @return matching account, if present + */ @Query("select u from AppUser u where lower(trim(u.email)) = :email") Optional findByNormalizedEmail(@Param("email") String email); + /** + * Locks an account row for a lifecycle mutation until the current transaction completes. + * + * @param id account identifier + * @return locked account, if present + */ @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select u from AppUser u where u.id = :id") Optional findForUpdateById(@Param("id") Long id); + /** Counts accounts matching an immutable role and lifecycle state. */ long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status); + /** Counts accounts in a lifecycle state. */ long countByAccountStatus(AccountStatus status); } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java index 25aeffc..781e259 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java @@ -10,14 +10,24 @@ import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +/** Account-feature persistence boundary for Intern lifecycle and eligibility. */ public interface InternProfileRepository extends JpaRepository { + /** Returns whether an Intern profile has the requested lifecycle state. */ boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status); + /** Returns whether an Intern is in the requested state throughout the supplied inclusive date point. */ boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual( Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate); + /** Counts Intern profiles in a lifecycle state. */ long countByInternshipStatus(InternshipStatus status); + /** + * Locks an Intern profile for lifecycle mutation until the current transaction completes. + * + * @param userId owning account identifier + * @return locked profile, if present + */ @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select p from InternProfile p where p.userId = :userId") java.util.Optional findForUpdateByUserId(@Param("userId") Long userId); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java index 294ee7c..d5539ff 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java @@ -8,7 +8,13 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; +/** Persistence boundary for the single durable installation-state row. */ public interface SystemStateRepository extends JpaRepository { + /** + * Locks the singleton row so concurrent bootstrap attempts cannot both create a first Admin. + * + * @return locked installation state + */ @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select s from SystemState s where s.singletonId = 1") Optional findSingletonForUpdate(); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java index ab932bb..1b473de 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java @@ -10,12 +10,26 @@ import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +/** Persistence boundary for hashed, one-time account-action tokens. */ public interface UserActionTokenRepository extends JpaRepository { + /** + * Locks a token selected by hash and purpose for atomic single-use consumption. + * + * @param hash SHA-256 hash of the supplied raw bearer token + * @param purpose expected workflow purpose + * @return locked matching token, if present + */ @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select t from UserActionToken t where t.tokenHash = :hash and t.purpose = :purpose") Optional findForUpdateByHashAndPurpose( @Param("hash") byte[] hash, @Param("purpose") TokenPurpose purpose); + /** + * Locks a token by identifier for delivery-failure invalidation. + * + * @param id token identifier + * @return locked token, if present + */ @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select t from UserActionToken t where t.id = :id") Optional findForUpdateById(@Param("id") Long id); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java index 4a806bc..81b7ba8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java @@ -30,6 +30,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +/** + * Owns account creation, activation, identity lookup, and Intern eligibility boundaries. + * Mutations use JPA transactions and expose DTOs rather than account entities to other features. + */ @Service public class AccountService { private static final Duration ACTIVATION_LIFETIME = Duration.ofHours(24); @@ -63,6 +67,15 @@ public class AccountService { this.publicOrigin = normalizeOrigin(publicOrigin); } + /** + * Creates a pending immutable-role account and sends its one-time activation link immediately. + * Only the SHA-256 token hash is persisted; the raw token remains in memory for this delivery call. If delivery + * fails, the token is invalidated in a separate transaction and the pending account remains for audit history. + * + * @param command validated account details + * @param adminId active Admin creating the account + * @return created account identifier and whether activation delivery succeeded + */ public AccountCreation create(CreateAccountCommand command, long adminId) { ValidatedAccount account = validate(command); if (!mailDelivery.isAvailable()) { @@ -90,6 +103,14 @@ public class AccountService { } } + /** + * Consumes a valid, unexpired activation bearer token once and assigns the first encoded password. + * The token and user rows are locked in the surrounding transaction. + * + * @param rawToken raw token received from the activation link + * @param password first password, containing 12 through 128 characters + * @return {@code true} when activation completed; {@code false} for an invalid, expired, used, or stale token + */ @Transactional public boolean activate(String rawToken, String password) { BootstrapService.requirePassword(password); @@ -140,6 +161,11 @@ public class AccountService { profile.activate(clock.instant()); } + /** + * Summarizes current account and internship state for dashboard consumers. + * + * @return active account, pending activation, and active internship counts + */ @Transactional(readOnly = true) public AccountSummary summary() { return new AccountSummary( @@ -148,18 +174,38 @@ public class AccountService { internProfiles.countByInternshipStatus(InternshipStatus.ACTIVE)); } + /** + * Resolves an account boundary DTO by database identifier regardless of lifecycle state. + * + * @param userId account identifier + * @return non-secret identity and lifecycle state + * @throws IllegalArgumentException when the account does not exist + */ @Transactional(readOnly = true) public AccountIdentity requireIdentityById(long userId) { return users.findById(userId).map(AccountService::identity) .orElseThrow(() -> new IllegalArgumentException("Account not found")); } + /** + * Resolves an account boundary DTO by normalized email regardless of lifecycle state. + * + * @param email email address, normalized by trimming and lower-casing + * @return non-secret identity and lifecycle state + * @throws IllegalArgumentException when the account does not exist + */ @Transactional(readOnly = true) public AccountIdentity requireIdentityByEmail(String email) { return users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)).map(AccountService::identity) .orElseThrow(() -> new IllegalArgumentException("Account not found")); } + /** + * Checks whether the account and its internship are both currently active. + * + * @param userId account identifier + * @return {@code true} only for an active Intern with an active internship + */ @Transactional(readOnly = true) public boolean isEligibleIntern(long userId) { return users.findById(userId) @@ -170,6 +216,14 @@ public class AccountService { .isPresent(); } + /** + * Checks active Intern eligibility on an inclusive internship date range. + * + * @param userId account identifier + * @param workDate server-derived business date being authorized + * @return {@code true} only when account and internship are active and the date is within the internship + * @throws IllegalArgumentException when {@code workDate} is {@code null} + */ @Transactional(readOnly = true) public boolean isEligibleIntern(long userId, LocalDate workDate) { if (workDate == null) { @@ -184,6 +238,13 @@ public class AccountService { .isPresent(); } + /** + * Resolves the cross-feature identity of a currently eligible Intern. + * + * @param userId account identifier + * @return non-secret account identity + * @throws IllegalArgumentException when the account or internship is not active + */ @Transactional(readOnly = true) public AccountIdentity requireEligibleIntern(long userId) { if (!isEligibleIntern(userId)) { @@ -192,6 +253,13 @@ public class AccountService { return requireIdentityById(userId); } + /** + * Resolves an authenticated active Admin by normalized email. + * + * @param email authenticated principal name + * @return Admin account identifier + * @throws IllegalArgumentException when the account is not an active Admin + */ @Transactional(readOnly = true) public long requireActiveAdminId(String email) { AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)) @@ -199,6 +267,13 @@ public class AccountService { return requireActiveAdmin(user); } + /** + * Requires the identified account to be an active Admin. + * + * @param userId account identifier + * @return the same identifier after authorization + * @throws IllegalArgumentException when the account is missing or not an active Admin + */ @Transactional(readOnly = true) public long requireActiveAdminId(long userId) { AppUser user = users.findById(userId) diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java index 288ca68..9ea29aa 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java @@ -11,6 +11,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Performs the one-time installation bootstrap guarded by the locked singleton system-state row. + * Successful creation persists the first active Admin and initialization marker atomically. + */ @Service public class BootstrapService { private final SystemStateRepository systemStates; @@ -26,6 +30,14 @@ public class BootstrapService { this.clock = clock; } + /** + * Creates the first active Admin exactly once. + * + * @param email first Admin email, normalized by trimming and lower-casing + * @param displayName first Admin display name + * @param password first Admin password, containing 12 through 128 characters + * @return {@link BootstrapOutcome#CREATED} or {@link BootstrapOutcome#ALREADY_INITIALIZED} + */ @Transactional public BootstrapOutcome bootstrap(String email, String displayName, String password) { String normalizedEmail = normalizeEmail(email); @@ -44,15 +56,32 @@ public class BootstrapService { return BootstrapOutcome.CREATED; } + /** + * Reads the durable installation state. + * + * @return {@code true} after the first Admin has been committed + */ @Transactional(readOnly = true) public boolean isInitialized() { return systemStates.findById((short) 1).map(SystemState::isInitialized).orElse(false); } + /** + * Produces the canonical account lookup form of an email address. + * + * @param email email supplied at a trust boundary + * @return trimmed, locale-independent lower-case email + */ public static String normalizeEmail(String email) { return requireText(email, "Email").toLowerCase(Locale.ROOT); } + /** + * Enforces the shared account password length boundary. + * + * @param password cleartext request value + * @throws IllegalArgumentException when outside 12 through 128 characters + */ public static void requirePassword(String password) { if (password == null || password.length() < 12 || password.length() > 128) { throw new IllegalArgumentException("Password must contain 12 through 128 characters"); @@ -66,6 +95,7 @@ public class BootstrapService { return value.trim(); } + /** Result of attempting the single allowed installation bootstrap. */ public enum BootstrapOutcome { CREATED, ALREADY_INITIALIZED diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java index 0a78533..d3633af 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java @@ -9,6 +9,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** Adapts persisted account credentials and lifecycle state to Spring Security authentication. */ @Service class DatabaseUserDetailsService implements UserDetailsService { private final AppUserRepository users; @@ -17,6 +18,13 @@ class DatabaseUserDetailsService implements UserDetailsService { this.users = users; } + /** + * Loads the normalized account and disables authentication unless its lifecycle state is active. + * + * @param username submitted email address + * @return Spring Security user details with the immutable global role + * @throws UsernameNotFoundException when no account has that normalized email + */ @Override @Transactional(readOnly = true) public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java index c802868..d44a800 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java @@ -17,6 +17,10 @@ import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +/** + * Runs the Admin SMTP draft, connection-test, activation, and ordered setup-deferral browser workflows. + * Cleartext passwords remain request-local and are cleared before any error view is rendered. + */ @Controller @RequestMapping("/admin/smtp") class SmtpController { @@ -131,7 +135,7 @@ class SmtpController { } private String renderActionError(Model model, BindingResult bindingResult) { - model.addAttribute(BindingResult.MODEL_KEY_PREFIX + "smtpAction", bindingResult); + model.addAttribute("smtpActionError", bindingResult.getAllErrors().getFirst().getDefaultMessage()); return renderForm(model, null); } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java index 0356e10..97ba27a 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java @@ -1,5 +1,6 @@ package com.lab.labtimesheet.feature.integration.model; +/** Transport security mode used when opening an SMTP connection. */ public enum SecurityMode { NONE, STARTTLS, diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java index 104b501..6902619 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java @@ -1,5 +1,6 @@ package com.lab.labtimesheet.feature.integration.model; +/** Lifecycle state of a versioned SMTP configuration. */ public enum SmtpStatus { DRAFT, ACTIVE, diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java index 48cf6d6..238d986 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java @@ -1,16 +1,25 @@ package com.lab.labtimesheet.feature.integration.model.dto; +/** + * AES-GCM output persisted for an integration credential; arrays are defensively copied at every boundary. + * + * @param ciphertext encrypted credential including the authentication tag + * @param nonce unique 96-bit nonce used for this encryption + * @param keyVersion key-rotation identifier + */ public record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) { public EncryptedSecret { ciphertext = ciphertext.clone(); nonce = nonce.clone(); } + /** @return a defensive copy of the encrypted credential bytes */ @Override public byte[] ciphertext() { return ciphertext.clone(); } + /** @return a defensive copy of the AES-GCM nonce */ @Override public byte[] nonce() { return nonce.clone(); diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java index e5659cc..27822ad 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java @@ -2,6 +2,18 @@ package com.lab.labtimesheet.feature.integration.model.dto; import com.lab.labtimesheet.feature.integration.model.SecurityMode; +/** + * Complete request-local SMTP connection material passed only to the delivery adapter. + * The cleartext password must never be persisted, logged, or exposed to views. + * + * @param host SMTP host + * @param port SMTP port + * @param securityMode transport security mode + * @param username optional authentication username + * @param password optional decrypted password, scoped to the immediate call + * @param fromAddress envelope From address + * @param fromName human-readable From name + */ public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password, String fromAddress, String fromName) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java index 3df5bb5..efb765c 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java @@ -2,6 +2,18 @@ package com.lab.labtimesheet.feature.integration.model.dto; import com.lab.labtimesheet.feature.integration.model.SecurityMode; +/** + * Admin SMTP draft command. Its optional cleartext password is request-local and is encrypted by the service before + * persistence. + * + * @param host SMTP host + * @param port SMTP port + * @param securityMode transport security mode + * @param username optional authentication username + * @param password optional cleartext password for immediate encryption + * @param fromAddress envelope From address + * @param fromName human-readable From name + */ public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password, String fromAddress, String fromName) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java index 738ea1f..989e81c 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java @@ -16,6 +16,10 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import jakarta.persistence.Version; +/** + * Versioned SMTP configuration entity whose credentials remain AES-GCM encrypted at rest. + * Draft edits clear test status; only a tested draft can activate; replaced active revisions are retained as retired. + */ @Entity @Table(name = "smtp_configurations") public class SmtpConfiguration { @@ -85,9 +89,19 @@ public class SmtpConfiguration { @Version private long version; + /** Required by JPA; revisions are created through {@link #draft}. */ protected SmtpConfiguration() { } + /** + * Creates an editable SMTP revision with encrypted credential material. + * + * @param draft validated SMTP settings + * @param password encrypted password, or {@code null} for unauthenticated SMTP + * @param adminId active Admin creating the revision + * @param now server timestamp + * @return new draft revision + */ public static SmtpConfiguration draft(SmtpDraft draft, EncryptedSecret password, long adminId, Instant now) { SmtpConfiguration configuration = new SmtpConfiguration(); configuration.status = SmtpStatus.DRAFT; @@ -97,6 +111,14 @@ public class SmtpConfiguration { return configuration; } + /** + * Replaces editable settings and clears any previous successful-test marker. + * + * @param draft validated SMTP settings + * @param password encrypted password, or {@code null} + * @param now server update timestamp + * @throws IllegalStateException when this revision is no longer a draft + */ public void updateDraft(SmtpDraft draft, EncryptedSecret password, Instant now) { if (status != SmtpStatus.DRAFT) { throw new IllegalStateException("Only an SMTP draft can be edited"); @@ -115,6 +137,13 @@ public class SmtpConfiguration { updatedAt = now; } + /** + * Records a successful external probe after its delivery adapter returns. + * + * @param adminId active Admin who performed the test + * @param now server success timestamp + * @throws IllegalStateException when this revision is no longer a draft + */ public void markTested(long adminId, Instant now) { if (status != SmtpStatus.DRAFT) { throw new IllegalStateException("SMTP draft is no longer available"); @@ -124,6 +153,13 @@ public class SmtpConfiguration { updatedAt = now; } + /** + * Promotes a tested draft to the active delivery configuration. + * + * @param adminId active Admin authorizing activation + * @param now server activation timestamp + * @throws IllegalStateException when the draft has not passed a test + */ public void activate(long adminId, Instant now) { if (status != SmtpStatus.DRAFT || testedAt == null) { throw new IllegalStateException("SMTP draft must pass a test before activation"); @@ -134,6 +170,13 @@ public class SmtpConfiguration { updatedAt = now; } + /** + * Retains but disables a replaced active revision. + * + * @param adminId active Admin activating its successor + * @param now server retirement timestamp + * @throws IllegalStateException when this revision is not active + */ public void retire(long adminId, Instant now) { if (status != SmtpStatus.ACTIVE) { throw new IllegalStateException("Only active SMTP can be retired"); @@ -172,10 +215,12 @@ public class SmtpConfiguration { return username; } + /** @return a defensive copy of encrypted password bytes, or {@code null} */ public byte[] getPasswordCiphertext() { return passwordCiphertext == null ? null : passwordCiphertext.clone(); } + /** @return a defensive copy of the AES-GCM nonce, or {@code null} */ public byte[] getPasswordNonce() { return passwordNonce == null ? null : passwordNonce.clone(); } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java b/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java index 9805e59..fba1872 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java @@ -8,11 +8,21 @@ import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +/** Integration-feature persistence boundary for retained SMTP revisions. */ public interface SmtpConfigurationRepository extends JpaRepository { + /** Finds the single revision in a given lifecycle state. */ Optional findByStatus(SmtpStatus status); + /** Returns whether a revision exists in a lifecycle state. */ boolean existsByStatus(SmtpStatus status); + /** + * Locks the identified revision in the expected state for atomic activation. + * + * @param id SMTP revision identifier + * @param status required current lifecycle state + * @return locked revision, if present + */ @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findWithLockByIdAndStatus(Long id, SmtpStatus status); } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java index 36a0ac7..7075fd0 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java @@ -7,6 +7,10 @@ import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepo import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Cross-feature email delivery boundary backed by the single active SMTP revision. + * Stored credentials are decrypted only while constructing the immediate adapter call. + */ @Service public class MailDeliveryService { private final SmtpConfigurationRepository configurations; @@ -19,15 +23,34 @@ public class MailDeliveryService { this.probe = probe; } + /** + * Reports whether workflows may emit required email. + * + * @return {@code true} when an active tested SMTP revision exists + */ @Transactional(readOnly = true) public boolean isAvailable() { return configurations.existsByStatus(SmtpStatus.ACTIVE); } + /** + * Sends one immediate message through the active configuration. + * + * @param recipient destination email address + * @param subject message subject + * @param body plain-text message body + * @throws IllegalStateException when no active configuration exists or delivery fails + */ public void send(String recipient, String subject, String body) { probe.send(activeConnection(), recipient, subject, body); } + /** + * Resolves request-local connection material from the active encrypted configuration. + * + * @return complete connection values, including the transient decrypted password + * @throws IllegalStateException when SMTP is not active + */ @Transactional(readOnly = true) public SmtpConnection activeConnection() { return configurations.findByStatus(SmtpStatus.ACTIVE) diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java index 033d22f..e4ab18d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java @@ -11,6 +11,7 @@ import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import org.springframework.stereotype.Component; +/** Encrypts SMTP credentials with AES-256-GCM using a fresh nonce per stored revision. */ @Component public class SecretCipher { private static final int NONCE_BYTES = 12; diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java index c46acb8..60fabd1 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java @@ -16,6 +16,10 @@ import org.springframework.core.env.Profiles; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Owns the Admin SMTP revision workflow: save an encrypted draft, test it, then atomically activate it. + * A changed draft loses prior test status, and an active revision is retired when its tested successor activates. + */ @Service public class SmtpConfigurationService { private final SmtpConfigurationRepository configurations; @@ -38,6 +42,14 @@ public class SmtpConfigurationService { this.mailDelivery = mailDelivery; } + /** + * Creates or replaces the editable draft after validating Admin authority and environment transport rules. + * Any supplied password is encrypted before persistence and prior test status is cleared. + * + * @param adminId active Admin saving the draft + * @param draft SMTP settings and optional request-local password + * @return persisted draft identifier + */ @Transactional public long saveDraft(long adminId, SmtpDraft draft) { validate(draft); @@ -53,6 +65,13 @@ public class SmtpConfigurationService { return configurations.save(configuration).getId(); } + /** + * Sends a real probe using a draft and records success only after the adapter returns successfully. + * + * @param draftId draft revision to test + * @param adminId active Admin performing the test + * @param recipient Admin email receiving the test message + */ public void testDraft(long draftId, long adminId, String recipient) { SmtpConfiguration draft = configurations.findById(draftId) .filter(configuration -> configuration.getStatus() == SmtpStatus.DRAFT) @@ -63,6 +82,12 @@ public class SmtpConfigurationService { configurations.save(draft); } + /** + * Activates a previously tested draft under a pessimistic lock and retires the prior active revision. + * + * @param draftId tested draft revision + * @param adminId active Admin authorizing activation + */ @Transactional public void activate(long draftId, long adminId) { SmtpConfiguration draft = configurations.findWithLockByIdAndStatus(draftId, SmtpStatus.DRAFT) @@ -74,6 +99,7 @@ public class SmtpConfigurationService { draft.activate(verifiedAdminId, now); } + /** @return {@code true} when a tested SMTP revision is currently active */ @Transactional(readOnly = true) public boolean hasActiveConfiguration() { return mailDelivery.isAvailable(); @@ -103,11 +129,23 @@ public class SmtpConfigurationService { SecurityMode.STARTTLS, null, null, null)); } + /** + * Resolves the active SMTP connection for an immediate integration call. + * + * @return transient connection values, including a decrypted password when configured + */ @Transactional(readOnly = true) public SmtpConnection activeConnection() { return mailDelivery.activeConnection(); } + /** + * Sends a plain-text message through the active SMTP revision. + * + * @param recipient destination email address + * @param subject message subject + * @param body message body + */ public void sendWithActiveConfiguration(String recipient, String subject, String body) { mailDelivery.send(recipient, subject, body); } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java index c96308e..7a2874d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java @@ -2,7 +2,16 @@ package com.lab.labtimesheet.feature.integration.service; import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; +/** External SMTP adapter boundary used by setup tests and application email delivery. */ @FunctionalInterface public interface SmtpProbe { + /** + * Sends one immediate plain-text message using the supplied request-local connection values. + * + * @param connection complete SMTP connection material + * @param recipient destination email address + * @param subject message subject + * @param body message body + */ void send(SmtpConnection connection, String recipient, String subject, String body); } diff --git a/src/main/resources/templates/smtp/form.html b/src/main/resources/templates/smtp/form.html index d34459f..6b25946 100644 --- a/src/main/resources/templates/smtp/form.html +++ b/src/main/resources/templates/smtp/form.html @@ -9,6 +9,7 @@

    SMTP is active.

    Draft saved.

    Test passed.

    +

    diff --git a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java index d2edb50..f17b3b0 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java @@ -11,13 +11,19 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import javax.sql.DataSource; + +import com.lab.labtimesheet.LabtimesheetApplication; import com.lab.labtimesheet.config.TestcontainersConfiguration; import com.lab.labtimesheet.feature.account.model.AccountStatus; import com.lab.labtimesheet.feature.account.model.GlobalRole; import com.lab.labtimesheet.feature.account.repository.AppUserRepository; import com.lab.labtimesheet.feature.account.repository.SystemStateRepository; +import com.zaxxer.hikari.HikariDataSource; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.context.annotation.Import; @@ -47,6 +53,9 @@ class BootstrapIntegrationTest { @Autowired private SystemStateRepository systemStates; + @Autowired + private DataSource dataSource; + @Test void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception { mockMvc.perform(get("/bootstrap")).andExpect(status().isOk()); @@ -87,6 +96,29 @@ class BootstrapIntegrationTest { assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue(); } + @Test + void bootstrapRemainsClosedInAnIndependentApplicationContext() { + bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple"); + HikariDataSource currentDataSource = (HikariDataSource) dataSource; + + try (var restarted = new SpringApplicationBuilder(LabtimesheetApplication.class) + .profiles("test") + .web(WebApplicationType.SERVLET) + .properties( + "server.port=0", + "spring.main.register-shutdown-hook=false", + "spring.datasource.url=" + currentDataSource.getJdbcUrl(), + "spring.datasource.username=" + currentDataSource.getUsername(), + "spring.datasource.password=" + currentDataSource.getPassword()) + .run()) { + BootstrapService restartedBootstrap = restarted.getBean(BootstrapService.class); + assertThat(restartedBootstrap.isInitialized()).isTrue(); + assertThat(restartedBootstrap.bootstrap( + "another@example.com", "Another", "correct horse battery staple")) + .isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); + } + } + @Test void exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes() { bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple"); diff --git a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java index 53e57f3..abd88a2 100644 --- a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java @@ -141,6 +141,36 @@ class SmtpOnboardingWebIntegrationTest { .andExpect(status().isForbidden()); } + @Test + void failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft() throws Exception { + mockMvc.perform(post("/admin/smtp/draft") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("host", "mailpit") + .param("port", "1025") + .param("securityMode", "NONE") + .param("fromAddress", "notifications@example.com") + .param("fromName", "Lab Timesheet")) + .andExpect(status().is3xxRedirection()); + + String html = mockMvc.perform(get("/admin/smtp") + .with(user("admin@example.com").roles("ADMIN"))) + .andReturn().getResponse().getContentAsString(); + String draftId = html.replaceAll("(?s).*name=\"draftId\" value=\"([0-9]+)\".*", "$1"); + probe.failureMessage = "Connection refused by the configured SMTP server"; + + mockMvc.perform(post("/admin/smtp/test") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("draftId", draftId)) + .andExpect(status().isOk()) + .andExpect(view().name("smtp/form")) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "Connection refused by the configured SMTP server"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("Activate SMTP")))); + } + @TestConfiguration(proxyBeanMethods = false) static class ProbeConfiguration { @Bean @@ -152,9 +182,13 @@ class SmtpOnboardingWebIntegrationTest { static final class RecordingProbe implements SmtpProbe { private final List recipients = new ArrayList<>(); + private String failureMessage; @Override public void send(SmtpConnection connection, String recipient, String subject, String body) { + if (failureMessage != null) { + throw new IllegalStateException(failureMessage); + } recipients.add(recipient); } } From 98688dec7e897595de80f0da9c5f675c368a6f30 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:50:27 +0700 Subject: [PATCH 51/62] correct platform verification evidence --- docs/tests/integration/account-activation.md | 8 +- docs/tests/integration/account-boundary.md | 4 +- .../integration/first-admin-bootstrap.md | 17 ++-- .../integration/internship-start-guard.md | 72 ++++++++++++++ docs/tests/integration/platform-foundation.md | 2 +- docs/tests/integration/smtp-onboarding.md | 10 +- .../unit/package-by-feature-structure.md | 4 +- docs/tests/unit/platform-javadoc-retrofit.md | 79 ++++++++++++++++ docs/tests/unit/smtp-transport-boundaries.md | 69 ++++++++++++++ docs/tests/web/account-activation-flow.md | 8 +- .../web/authenticated-dashboard-landing.md | 2 +- docs/tests/web/platform-onboarding-forms.md | 93 +++++++++++++++++++ docs/tests/web/platform-security-responses.md | 73 +++++++++++++++ docs/tests/web/project-login-flow.md | 4 +- .../model/dto/EncryptedSecret.java | 1 + 15 files changed, 418 insertions(+), 28 deletions(-) create mode 100644 docs/tests/integration/internship-start-guard.md create mode 100644 docs/tests/unit/platform-javadoc-retrofit.md create mode 100644 docs/tests/unit/smtp-transport-boundaries.md create mode 100644 docs/tests/web/platform-onboarding-forms.md create mode 100644 docs/tests/web/platform-security-responses.md diff --git a/docs/tests/integration/account-activation.md b/docs/tests/integration/account-activation.md index 951613e..22bf289 100644 --- a/docs/tests/integration/account-activation.md +++ b/docs/tests/integration/account-activation.md @@ -1,10 +1,10 @@ # Test Evidence: SMTP-gated account creation and activation - **Test type:** Integration -- **Requirement IDs:** `ACC-008`–`ACC-014`, `ACC-019`, `ACC-020`, `NOT-008`, `SEC-005`, `SEC-007` -- **Scenario IDs:** `AC-ACC-001`, `AC-ACC-002`, `AC-ACC-003`, `AC-ACC-007` +- **Requirement IDs:** `ACC-008`–`ACC-012`, `ACC-014`, `ACC-019`, `ACC-020`, `NOT-008`, `SEC-002`–`SEC-004` +- **Scenario IDs:** `AC-ACC-004` (Mentor path), `AC-ACC-005` (Mentor/Intern paths), `AC-ACC-006` (initial delivery failure only) - **Test class/method:** `com.lab.labtimesheet.feature.account.service.AccountActivationIntegrationTest#smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory` -- **Implementation commit:** `this milestone commit` +- **Implementation commit:** `98a52a1ac23591fa1cd30b7b175da81ec607e521`; start-date guard added in `6181984cf85f184be39513d6313f9cbe8267add5` ## Protected behavior @@ -65,4 +65,4 @@ BUILD SUCCESS ## External-test boundaries -The recording SMTP boundary proves the exact in-memory handoff but not Mailpit/network delivery. MVC creation, activation, login, role denial, and logout are covered separately by `AccountWebIntegrationTest`; resend, password reset, session invalidation after credential/state changes, lock/deactivation, and production origin/readiness hardening remain separate slices. +The recording SMTP boundary proves the exact in-memory handoff but not Mailpit/network delivery. MVC creation, activation, login, role denial, logout, and the additional-Admin path are covered separately by `AccountWebIntegrationTest`. This test covers only the Mentor path of SMTP gating and the Mentor/Intern paths of hash-only creation; it does not claim all-role coverage for AC-ACC-004/005. It covers the initial failure/invalidation part of AC-ACC-006, not resend. Resend, password reset, session invalidation after credential/state changes, lock/deactivation, and production origin/readiness hardening remain separate slices. diff --git a/docs/tests/integration/account-boundary.md b/docs/tests/integration/account-boundary.md index 8fe5c41..016a90d 100644 --- a/docs/tests/integration/account-boundary.md +++ b/docs/tests/integration/account-boundary.md @@ -2,9 +2,9 @@ - **Test type:** Integration - **Requirement IDs:** `ACC-002, ACC-014, ACC-020–ACC-021, PRJ-017, ATT-007` -- **Scenario IDs:** `AC-ACC-002, AC-ATT-001` +- **Scenario IDs:** No direct acceptance-scenario mapping (cross-feature API regression) - **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest.exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes` -- **Implementation commit:** `this milestone commit` +- **Implementation commit:** `1235204bf1298599264a07943ca1167432556bd2` ## Protected behavior diff --git a/docs/tests/integration/first-admin-bootstrap.md b/docs/tests/integration/first-admin-bootstrap.md index a94a6bf..9eba892 100644 --- a/docs/tests/integration/first-admin-bootstrap.md +++ b/docs/tests/integration/first-admin-bootstrap.md @@ -1,18 +1,18 @@ # Test Evidence: Atomic first administrator bootstrap - **Test type:** Integration -- **Requirement IDs:** `ACC-001–ACC-004, SEC-001–SEC-002, GOV-013` -- **Scenario IDs:** `AC-ACC-001, AC-ACC-002, AC-SEC-001` +- **Requirement IDs:** `ACC-001–ACC-003, ACC-009, SEC-001` +- **Scenario IDs:** `AC-ACC-001, AC-ACC-002` - **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest` -- **Implementation commit:** `this milestone commit` +- **Implementation commit:** `bc70db1d0d8eaa68bb8e22db44e38af27b0fa945`; restart characterization added in `8ff6ee3d873db909b1ce9df690f7a3abb2c3c79d` ## Protected behavior -Before initialization only bootstrap and health are reachable. Concurrent valid submissions create exactly one active Admin, atomically persist initialization, and permanently close bootstrap. The public account service resolves the winning Admin by normalized email or ID without exposing JPA entities or repositories. +Before initialization only bootstrap, bootstrap assets, health, and error rendering are reachable. Concurrent valid submissions create exactly one active Admin, atomically persist initialization, and permanently close bootstrap. A separately started Spring application context connected to the same PostgreSQL database observes the initialized state and cannot create another Admin. ## Test method -A PostgreSQL 18.4 integration test releases two Java 25 virtual-thread-safe requests onto the same service concurrently and asserts the row-locked outcomes and database state through Spring Data JPA. MockMvc checks pre/post-bootstrap route exposure, and the account API is checked against the actual concurrent winner. +A PostgreSQL 18.4 integration test releases two Java 25 tasks onto the same service concurrently and asserts the row-locked outcomes and database state through Spring Data JPA. MockMvc checks pre/post-bootstrap route exposure. A characterization method then starts and closes an independent servlet application context against the same container datasource and verifies the durable state through the public bootstrap service. ## Hand-derived expected result @@ -47,16 +47,19 @@ The public bootstrap behavior did not exist. export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock -./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test +./mvnw -Dtest=BootstrapIntegrationTest test ``` **Observed result** ```text -Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` +The independent-context restart assertion was added as characterization coverage for an evidence gap. No +retrospective RED is claimed because the persisted implementation already satisfied it when the test was added. + ## Affected suite **Command and result** diff --git a/docs/tests/integration/internship-start-guard.md b/docs/tests/integration/internship-start-guard.md new file mode 100644 index 0000000..1558499 --- /dev/null +++ b/docs/tests/integration/internship-start-guard.md @@ -0,0 +1,72 @@ +# Test Evidence: Internship cannot activate before its business start date + +- **Test type:** Integration +- **Requirement IDs:** `ACC-019`, `ACC-020` +- **Scenario IDs:** `AC-ACC-010` (start-date transition only) +- **Test class/method:** `com.lab.labtimesheet.feature.account.service.AccountActivationIntegrationTest#internshipCannotActivateBeforeItsBusinessStartDate` +- **Implementation commit:** `6181984cf85f184be39513d6313f9cbe8267add5` + +## Protected behavior + +An active Intern account cannot move its separately stored internship from `NOT_STARTED` to `ACTIVE` before the +configured inclusive start date in the application's injected business timezone. + +## Test method + +The PostgreSQL 18.4 test creates and activates an Intern account through the production SMTP/account services. Its +internship starts one business day after the fixed test clock. The Admin attempts the lifecycle transition and the +test reloads the profile through the owning feature repository. + +## Hand-derived expected result + +The service throws an actionable start-date error and the persisted internship remains `NOT_STARTED`. + +## 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=AccountActivationIntegrationTest#internshipCannotActivateBeforeItsBusinessStartDate test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +Expected code to raise a throwable, but the internship activated before its start date. +BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +./mvnw -Dtest=AccountActivationIntegrationTest#internshipCannotActivateBeforeItsBusinessStartDate test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest=BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test +Tests run: 20, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## External-test boundaries + +This covers only the early-activation guard. It does not claim the later scheduler, completion, withdrawal, transfer, +or session-lifecycle portions of AC-ACC-010. diff --git a/docs/tests/integration/platform-foundation.md b/docs/tests/integration/platform-foundation.md index ec1b8cc..ff30d21 100644 --- a/docs/tests/integration/platform-foundation.md +++ b/docs/tests/integration/platform-foundation.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ARC-001–ARC-008, DB-003–DB-012, OPS-003, TST-001–TST-010` - **Scenario IDs:** `AC-DB-001, AC-OPS-002, AC-TST-001` - **Test class/method:** `com.lab.labtimesheet.config.PlatformFoundationTest.flywayCreatesApprovedPostgresCatalog`, `com.lab.labtimesheet.config.PlatformFoundationTest.testClockIsDeterministic` -- **Implementation commit:** `this milestone commit` +- **Implementation commit:** `4b37f8fd05804d2d76e11cec1afce52919f2eb59` ## Protected behavior diff --git a/docs/tests/integration/smtp-onboarding.md b/docs/tests/integration/smtp-onboarding.md index 8b7fe4b..703c3e8 100644 --- a/docs/tests/integration/smtp-onboarding.md +++ b/docs/tests/integration/smtp-onboarding.md @@ -2,9 +2,9 @@ - **Test type:** Integration - **Requirement IDs:** `INT-001–INT-008, ACC-011, SEC-001` -- **Scenario IDs:** `AC-INT-001, AC-INT-002, AC-ACC-004` +- **Scenario IDs:** `AC-INT-001, AC-INT-002` - **Test class/method:** `com.lab.labtimesheet.feature.integration.service.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted` -- **Implementation commit:** `this milestone commit` +- **Implementation commit:** `bc70db1d0d8eaa68bb8e22db44e38af27b0fa945` ## Protected behavior @@ -32,8 +32,8 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -SmtpAccountIntegrationTest.java: cannot find symbol class SmtpConfigurationService -SmtpAccountIntegrationTest.java: cannot find symbol class SmtpProbe +The pre-refactor RED test source, then named SmtpAccountIntegrationTest.java, reported missing +SmtpConfigurationService and SmtpProbe symbols. 17 compilation errors BUILD FAILURE ``` @@ -48,7 +48,7 @@ The SMTP revision and controllable delivery boundaries were absent. export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock -./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test +./mvnw -Dtest=SmtpIntegrationTest test ``` **Observed result** diff --git a/docs/tests/unit/package-by-feature-structure.md b/docs/tests/unit/package-by-feature-structure.md index b8acc9a..38d3b3c 100644 --- a/docs/tests/unit/package-by-feature-structure.md +++ b/docs/tests/unit/package-by-feature-structure.md @@ -2,9 +2,9 @@ - **Test type:** Unit - **Requirement IDs:** `ARC-001–ARC-008` -- **Scenario IDs:** `AC-ARC-001` +- **Scenario IDs:** No direct acceptance-scenario mapping (architecture regression) - **Test class/method:** `com.lab.labtimesheet.config.LayerStructureTest.applicationUsesOnlyApprovedPackageByFeatureStructure` -- **Implementation commit:** `this milestone commit` +- **Implementation commit:** `1235204bf1298599264a07943ca1167432556bd2` ## Protected behavior diff --git a/docs/tests/unit/platform-javadoc-retrofit.md b/docs/tests/unit/platform-javadoc-retrofit.md new file mode 100644 index 0000000..1e43ebf --- /dev/null +++ b/docs/tests/unit/platform-javadoc-retrofit.md @@ -0,0 +1,79 @@ +# Test Evidence: Platform production API Javadocs + +- **Test type:** Unit (documentation/static verification) +- **Requirement IDs:** Repository Javadoc implementation standard; Iteration 1 retrofit exception +- **Scenario IDs:** No runtime acceptance-scenario mapping +- **Test class/method:** Maven Javadoc Plugin 3.12.0 over Platform production sources +- **Implementation commit:** `8ff6ee3d873db909b1ce9df690f7a3abb2c3c79d` + +## Protected behavior + +Platform-owned production types and declared public/protected non-trivial APIs under the root application package, +`config`, `feature.account`, and `feature.integration` describe their business purpose and important authorization, +transaction, state-transition, time, persistence, encryption, and raw-token boundaries. Trivial form/entity accessors +remain intentionally undocumented as permitted by the repository standard. + +## Test method + +The Maven Javadoc Plugin generates protected/public API documentation using Java 25 with doclint enabled. The +`missing` category is disabled because the repository explicitly exempts trivial accessors and generated methods; +all structural HTML/reference/syntax categories remain enabled. Compilation and the full runtime suite separately +verify the documented sources. + +## Hand-derived expected result + +Documentation generation completes without doclint errors or warnings for the selected categories, and Java +compilation plus all Platform tests remain green. + +## RED + +**Command** + +```text +Not applicable: this is the approved Iteration 1 documentation retrofit. No runtime RED was invented. +``` + +**Observed result** + +```text +Before the retrofit, manual source audit found missing type and non-trivial API Javadocs throughout Platform-owned +config, account, and integration code. This is review evidence, not a claimed executable RED. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -DskipTests -Dshow=protected -Ddoclint=all,-missing javadoc:javadoc +``` + +**Observed result** + +```text +Maven Javadoc Plugin 3.12.0 +BUILD SUCCESS +No Javadoc warnings were emitted. +``` + +## 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 test +Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## External-test boundaries + +Generated Javadocs validate documentation syntax and references, not whether every statement is behaviorally true. +The focused and full production-shaped tests provide that separate runtime evidence. Private fields/helpers and +trivial accessors are outside the retrofit contract. diff --git a/docs/tests/unit/smtp-transport-boundaries.md b/docs/tests/unit/smtp-transport-boundaries.md new file mode 100644 index 0000000..a30bfa9 --- /dev/null +++ b/docs/tests/unit/smtp-transport-boundaries.md @@ -0,0 +1,69 @@ +# Test Evidence: Bounded SMTP transport and configured sender name + +- **Test type:** Unit +- **Requirement IDs:** `INT-005`, `INT-007`, `NOT-008` +- **Scenario IDs:** No direct acceptance-scenario mapping (transport-adapter regression) +- **Test class/method:** `com.lab.labtimesheet.feature.integration.service.JavaMailSmtpProbeTest` +- **Implementation commit:** `6181984cf85f184be39513d6313f9cbe8267add5` + +## Protected behavior + +Immediate SMTP calls configure finite connection, read, and write timeouts for SMTP and SMTPS, and apply both the +configured From address and human-readable From name to the MIME message. + +## Test method + +The test injects a local JavaMail sender factory, exercises both STARTTLS and TLS connections, and inspects the +resulting JavaMail properties and MIME From header without opening a network connection or exposing a real secret. + +## Hand-derived expected result + +STARTTLS uses `mail.smtp.*` timeout properties; TLS uses `mail.smtps.*`. Each timeout is 5000 milliseconds and the +encoded From header contains the configured address and display name. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=JavaMailSmtpProbeTest test +``` + +**Observed result** + +```text +BUILD FAILURE during test compilation: JavaMailSmtpProbe had no injectable sender-factory constructor needed to +inspect production message construction without network I/O. +``` + +## GREEN + +**Command** + +```text +./mvnw -Dtest=JavaMailSmtpProbeTest test +``` + +**Observed result** + +```text +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest=BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test +Tests run: 20, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## External-test boundaries + +This is a network-free adapter construction test. It does not prove DNS, TLS negotiation, authentication, Mailpit, +or production SMTP interoperability. Test values are non-secret fixtures. diff --git a/docs/tests/web/account-activation-flow.md b/docs/tests/web/account-activation-flow.md index 924e103..7883c5f 100644 --- a/docs/tests/web/account-activation-flow.md +++ b/docs/tests/web/account-activation-flow.md @@ -2,9 +2,9 @@ - **Test type:** Web - **Requirement IDs:** `ACC-008–ACC-011, ACC-014, ACC-019, AUTH-001–AUTH-002, SEC-002–SEC-004` -- **Scenario IDs:** `AC-ACC-005, AC-ACC-007, AC-AUTH-001` +- **Scenario IDs:** `AC-ACC-005` (Mentor/Intern browser paths), `AC-ACC-007` - **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AccountWebIntegrationTest.adminCreatesMentorAndInternThenMentorActivatesAuthenticatesAndLogsOut` -- **Implementation commit:** `this milestone commit` +- **Implementation commit:** `8e786ba37ba7fcff09cf88d5951acb21fbb36ea8`; validation/additional-Admin coverage added in `17fa25bb0921718f780037cd8c55a956bbdf6b19` ## Protected behavior @@ -62,7 +62,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` @@ -82,4 +82,4 @@ BUILD SUCCESS ## External-test boundaries -This test does not contact Mailpit or an external SMTP server and is not a real browser/accessibility test. It does not cover activation resend, password reset, account lock/deactivation, session invalidation after credential/state changes, production origin configuration, containerization, CI, or deployment. +This test does not contact Mailpit or an external SMTP server and is not a real browser/accessibility test. Hash-only persistence and exact expiry are covered by the integration test. It does not cover activation resend, password reset, account lock/deactivation, session invalidation after credential/state changes, production origin configuration, containerization, CI, or deployment. diff --git a/docs/tests/web/authenticated-dashboard-landing.md b/docs/tests/web/authenticated-dashboard-landing.md index 31c4a27..3a08a65 100644 --- a/docs/tests/web/authenticated-dashboard-landing.md +++ b/docs/tests/web/authenticated-dashboard-landing.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `I1-UI-03, I1-UI-04` - **Scenario IDs:** `I1-UI-04 authentication integration follow-up` - **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AuthenticationWebIntegrationTest.projectLoginPageSupportsFailureNormalizedSuccessAndLogout` -- **Implementation commit:** `this milestone commit` +- **Implementation commit:** `c4656a88806a92cb59b2e588035a4124854feb92` ## Protected behavior diff --git a/docs/tests/web/platform-onboarding-forms.md b/docs/tests/web/platform-onboarding-forms.md new file mode 100644 index 0000000..2caea33 --- /dev/null +++ b/docs/tests/web/platform-onboarding-forms.md @@ -0,0 +1,93 @@ +# Test Evidence: Validated bootstrap, SMTP, and account onboarding + +- **Test type:** Web +- **Requirement IDs:** `ACC-005–ACC-012`, `INT-004`, `INT-006–INT-008`, `SEC-001` +- **Scenario IDs:** `AC-ACC-003`; `AC-INT-002` (Admin browser boundary) +- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.BootstrapOnboardingWebIntegrationTest`, `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest`, `com.lab.labtimesheet.feature.account.controller.AccountWebIntegrationTest#invalidAndDuplicateAccountFormsReturnActionableErrorsWithoutCreatingAnotherAccount` +- **Implementation commit:** `17fa25bb0921718f780037cd8c55a956bbdf6b19`; SMTP failure feedback added in `8ff6ee3d873db909b1ce9df690f7a3abb2c3c79d` + +## Protected behavior + +Bootstrap offers SMTP setup after creating the first Admin. The Admin can save a validated draft, test it, and +activate only a successful test; or traverse five distinct ordered deferral acknowledgements before finishing. +Restricted-installation warnings persist until activation. Invalid bootstrap/account/SMTP forms retain only safe +non-secret values and show actionable errors. All state-changing browser operations require CSRF. + +## Test method + +MockMvc drives the production controllers, Bean Validation, Thymeleaf rendering, Spring Security filter chain, JPA +services, and PostgreSQL 18.4. SMTP is replaced only at its network adapter. The tests inspect rendered status, +buttons, warnings, validation messages, password non-retention, CSRF denial, ordered deferral navigation, and the +failed-probe response while verifying that activation remains unavailable. + +## Hand-derived expected result + +Successful bootstrap lands on `/admin/smtp?onboarding`. A saved draft shows Test but not Activate; a successful test +shows Activate; activation clears the restricted warning. Deferral exposes warnings one through five in order, Back +and Configure on every screen, and Finish only on screen five. Invalid data returns HTTP 200 with field/global errors +and no submitted password. A failed SMTP probe displays its safe error and leaves the draft untested. + +## 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=BootstrapOnboardingWebIntegrationTest,SmtpOnboardingWebIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 6, Failures: 5, Errors: 1, Skipped: 0 +Bootstrap redirected to /login instead of SMTP onboarding; deferral returned 404; SMTP status and warning were +absent; invalid form input raised a validation exception. +BUILD FAILURE +``` + +The later failure-feedback regression used this focused command: + +```text +./mvnw -Dtest=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +Expected the configured connection-refusal message, but smtp/form omitted it. +BUILD FAILURE +``` + +## 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=BootstrapOnboardingWebIntegrationTest,SmtpOnboardingWebIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest=BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test +Tests run: 20, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## External-test boundaries + +The SMTP adapter is in-memory here, so this does not prove external Mailpit/server interoperability. MockMvc is not a +real browser or accessibility run. The test exposes only the adapter's safe failure message fixture and never a raw +password, integration secret, or activation bearer token. diff --git a/docs/tests/web/platform-security-responses.md b/docs/tests/web/platform-security-responses.md new file mode 100644 index 0000000..f70b4f7 --- /dev/null +++ b/docs/tests/web/platform-security-responses.md @@ -0,0 +1,73 @@ +# Test Evidence: Public assets and activation-safe response headers + +- **Test type:** Web +- **Requirement IDs:** `ACC-001`, `SEC-001`, `SEC-003`, `SEC-009` +- **Scenario IDs:** No direct acceptance-scenario mapping (response-security regression) +- **Test class/method:** `com.lab.labtimesheet.config.SecurityResponseIntegrationTest` +- **Implementation commit:** `6181984cf85f184be39513d6313f9cbe8267add5` + +## Protected behavior + +Public `/assets/**` requests remain reachable before bootstrap in both the Spring Security chain and bootstrap access +filter. Responses use `Referrer-Policy: no-referrer` so an activation URL bearer token cannot be forwarded in a +same-origin Referer header when a user follows another link. + +## Test method + +MockMvc starts the production filter chain against PostgreSQL 18.4 before initialization. It requests a known static +test asset and the activation page, asserting successful resource delivery and the exact global response header. + +## Hand-derived expected result + +The known asset returns HTTP 200 before bootstrap. The activation response contains exactly +`Referrer-Policy: no-referrer`. + +## 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=SecurityResponseIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 2, Failures: 2, Errors: 0, Skipped: 0 +The asset request returned 404 and the activation response Referrer-Policy header was null. +BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +./mvnw -Dtest=SecurityResponseIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest=BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test +Tests run: 20, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## External-test boundaries + +This verifies server response behavior through MockMvc, not browser enforcement of Referrer-Policy or Reporting's +integrated asset graph. It does not place a real activation token in logs, evidence, or request fixtures. diff --git a/docs/tests/web/project-login-flow.md b/docs/tests/web/project-login-flow.md index 3adcf83..2296de2 100644 --- a/docs/tests/web/project-login-flow.md +++ b/docs/tests/web/project-login-flow.md @@ -1,10 +1,10 @@ # Test Evidence: Project-owned login flow - **Test type:** Web -- **Requirement IDs:** `ACC-009, SEC-001, SEC-005, I1-UI-04` +- **Requirement IDs:** `ACC-009, SEC-001, I1-UI-04` - **Scenario IDs:** `I1-UI-04 authentication integration follow-up` - **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AuthenticationWebIntegrationTest.projectLoginPageSupportsFailureNormalizedSuccessAndLogout` -- **Implementation commit:** `this milestone commit` +- **Implementation commit:** `a18d8e1d3dd02c8978033f09563d2ec9341926c7` ## Protected behavior diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java index 238d986..8aa21df 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java @@ -8,6 +8,7 @@ package com.lab.labtimesheet.feature.integration.model.dto; * @param keyVersion key-rotation identifier */ public record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) { + /** Defensively copies both byte arrays before this value can cross the encryption boundary. */ public EncryptedSecret { ciphertext = ciphertext.clone(); nonce = nonce.clone(); From 06dba4fb13eed675cc08ff8c00fe3e3650468c3b Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:13:12 +0700 Subject: [PATCH 52/62] fix(platform): address round 2 review findings --- docs/tests/unit/vietnam-business-clock.md | 72 ++++++++++++++++++ docs/tests/web/account-uniqueness-feedback.md | 75 +++++++++++++++++++ docs/tests/web/platform-onboarding-forms.md | 9 ++- docs/tests/web/smtp-error-sanitization.md | 75 +++++++++++++++++++ .../config/TimeConfiguration.java | 7 +- .../account/controller/AccountController.java | 33 +++++++- .../controller/SmtpController.java | 11 ++- .../resources/templates/accounts/new.html | 1 + .../config/TimeConfigurationTest.java | 21 ++++++ .../controller/AccountWebIntegrationTest.java | 31 ++++++++ .../SmtpOnboardingWebIntegrationTest.java | 7 +- 11 files changed, 329 insertions(+), 13 deletions(-) create mode 100644 docs/tests/unit/vietnam-business-clock.md create mode 100644 docs/tests/web/account-uniqueness-feedback.md create mode 100644 docs/tests/web/smtp-error-sanitization.md create mode 100644 src/test/java/com/lab/labtimesheet/config/TimeConfigurationTest.java diff --git a/docs/tests/unit/vietnam-business-clock.md b/docs/tests/unit/vietnam-business-clock.md new file mode 100644 index 0000000..4ae1dd6 --- /dev/null +++ b/docs/tests/unit/vietnam-business-clock.md @@ -0,0 +1,72 @@ +# Test Evidence: Vietnam business-date clock boundary + +- **Test type:** Unit +- **Requirement IDs:** `ACC-019`, `ACC-020` +- **Scenario IDs:** `AC-ACC-010` (business-date boundary only) +- **Test class/method:** `com.lab.labtimesheet.config.TimeConfigurationTest#utcInstantAtVietnamMidnightUsesTheNewLocalBusinessDate` +- **Implementation commit:** `pending` + +## Protected behavior + +The production application clock uses `Asia/Ho_Chi_Minh`, so account lifecycle decisions based on `LocalDate.now` +advance at Vietnam midnight rather than seven hours later at UTC midnight. + +## Test method + +The test obtains the real production clock configuration, fixes its configured zone at the UTC instant +`2026-08-14T17:00:00Z`, and derives the local business date. No Spring context or database is needed because the +contract under test is the clock bean's zone. + +## Hand-derived expected result + +Vietnam is UTC+07:00, so `2026-08-14T17:00:00Z` is `2026-08-15T00:00:00+07:00` and the business date is +`2026-08-15`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TimeConfigurationTest test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +Expected 2026-08-15 but was 2026-08-14 because the production clock used UTC. +BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +./mvnw -Dtest=TimeConfigurationTest test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest=TimeConfigurationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test +Tests run: 22, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## External-test boundaries + +This verifies the production clock zone and its midnight boundary. It does not exercise later scheduler behavior or +attendance-policy timezone versioning. diff --git a/docs/tests/web/account-uniqueness-feedback.md b/docs/tests/web/account-uniqueness-feedback.md new file mode 100644 index 0000000..9f53d19 --- /dev/null +++ b/docs/tests/web/account-uniqueness-feedback.md @@ -0,0 +1,75 @@ +# Test Evidence: Constraint-specific account uniqueness feedback + +- **Test type:** Web +- **Requirement IDs:** `ACC-019`, `DB-003` +- **Scenario IDs:** `AC-ACC-005` (Intern creation uniqueness boundary) +- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AccountWebIntegrationTest#duplicateNormalizedStudentCodeIsReportedOnStudentCodeRatherThanEmail` +- **Implementation commit:** `pending` + +## Protected behavior + +A case- and whitespace-normalized duplicate Intern student code is reported on the student-code field. A distinct +email is not falsely labeled as duplicate, and unknown uniqueness constraints fall back to a non-specific conflict. + +## Test method + +MockMvc creates one Intern through the authenticated CSRF-protected production form and then submits a second Intern +with a distinct email and the same student code in different case with surrounding whitespace. PostgreSQL 18.4 +enforces the real Flyway expression index; the controller maps Hibernate's known constraint name to the form field. + +## Hand-derived expected result + +The second request returns HTTP 200 on `accounts/new`, retains the safe display name, shows the student-code conflict, +and does not claim that the distinct email already exists. + +## 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=AccountWebIntegrationTest#duplicateNormalizedStudentCodeIsReportedOnStudentCodeRatherThanEmail test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +PostgreSQL reported uq_intern_profiles_student_code_ci, but the form displayed "this email already exists". +BUILD FAILURE +PostgreSQL: 18.4 +``` + +## GREEN + +**Command** + +```text +./mvnw -Dtest=AccountWebIntegrationTest#duplicateNormalizedStudentCodeIsReportedOnStudentCodeRatherThanEmail test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest=TimeConfigurationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test +Tests run: 22, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## External-test boundaries + +The test covers the two Platform-owned normalized identity constraints. It does not enumerate later-iteration feature +constraints or perform a real-browser accessibility pass. diff --git a/docs/tests/web/platform-onboarding-forms.md b/docs/tests/web/platform-onboarding-forms.md index 2caea33..3d8b693 100644 --- a/docs/tests/web/platform-onboarding-forms.md +++ b/docs/tests/web/platform-onboarding-forms.md @@ -18,14 +18,15 @@ non-secret values and show actionable errors. All state-changing browser operati MockMvc drives the production controllers, Bean Validation, Thymeleaf rendering, Spring Security filter chain, JPA services, and PostgreSQL 18.4. SMTP is replaced only at its network adapter. The tests inspect rendered status, buttons, warnings, validation messages, password non-retention, CSRF denial, ordered deferral navigation, and the -failed-probe response while verifying that activation remains unavailable. +failed-probe response while verifying that activation remains unavailable and raw adapter diagnostics are absent. ## Hand-derived expected result Successful bootstrap lands on `/admin/smtp?onboarding`. A saved draft shows Test but not Activate; a successful test shows Activate; activation clears the restricted warning. Deferral exposes warnings one through five in order, Back and Configure on every screen, and Finish only on screen five. Invalid data returns HTTP 200 with field/global errors -and no submitted password. A failed SMTP probe displays its safe error and leaves the draft untested. +and no submitted password. A failed SMTP probe displays fixed operator guidance and leaves the draft untested without +rendering the adapter's diagnostic. ## RED @@ -89,5 +90,5 @@ PostgreSQL: 18.4 ## External-test boundaries The SMTP adapter is in-memory here, so this does not prove external Mailpit/server interoperability. MockMvc is not a -real browser or accessibility run. The test exposes only the adapter's safe failure message fixture and never a raw -password, integration secret, or activation bearer token. +real browser or accessibility run. The test uses a non-secret diagnostic fixture only to prove that raw adapter text +is absent; it never exposes a password, integration secret, or activation bearer token. diff --git a/docs/tests/web/smtp-error-sanitization.md b/docs/tests/web/smtp-error-sanitization.md new file mode 100644 index 0000000..832bcc2 --- /dev/null +++ b/docs/tests/web/smtp-error-sanitization.md @@ -0,0 +1,75 @@ +# Test Evidence: Sanitized SMTP failure feedback + +- **Test type:** Web +- **Requirement IDs:** `INT-005`, `INT-008` +- **Scenario IDs:** `AC-INT-002` (failed-draft browser boundary) +- **Test class/method:** `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft` +- **Implementation commit:** `pending` + +## Protected behavior + +An SMTP test failure renders fixed actionable guidance but never renders the external adapter's arbitrary diagnostic. +The failed draft remains untested and cannot be activated. + +## Test method + +MockMvc saves a valid SMTP draft, configures the in-memory network adapter to throw a distinctive non-secret raw +diagnostic, and submits the authenticated CSRF-protected test action. It checks the production controller and +Thymeleaf response for the fixed message, absence of the raw diagnostic, and absence of the activation action. + +## Hand-derived expected result + +The response is HTTP 200 on `smtp/form`, contains the fixed operator message, omits the adapter diagnostic, and does +not offer Activate SMTP. + +## 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=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +The fixed guidance was absent and the rendered smtpActionError contained the adapter's distinctive diagnostic. +BUILD FAILURE +PostgreSQL: 18.4 +``` + +## GREEN + +**Command** + +```text +./mvnw -Dtest=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest=TimeConfigurationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test +Tests run: 22, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## External-test boundaries + +The SMTP adapter is in-memory, so this does not prove live server interoperability. The diagnostic is a deterministic +non-secret fixture; no password, credential, or activation token is logged or recorded. diff --git a/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java b/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java index a942085..426a443 100644 --- a/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java @@ -1,15 +1,18 @@ package com.lab.labtimesheet.config; import java.time.Clock; +import java.time.ZoneId; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -/** Provides the injectable UTC clock used for server-authoritative business time. */ +/** Provides the injectable Vietnam-zone clock used for server-authoritative business dates and time. */ @Configuration(proxyBeanMethods = false) class TimeConfiguration { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Ho_Chi_Minh"); + @Bean Clock applicationClock() { - return Clock.systemUTC(); + return Clock.system(BUSINESS_ZONE); } } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java index 3039e37..7f1eb31 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java @@ -6,6 +6,7 @@ import com.lab.labtimesheet.feature.account.model.dto.ActivationForm; import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm; import com.lab.labtimesheet.feature.account.service.AccountService; import jakarta.validation.Valid; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -14,7 +15,10 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; -/** Handles Admin account creation and single-use account activation browser flows. */ +/** + * Handles Admin account creation and single-use account activation browser flows. Known database uniqueness + * constraints are mapped to their owning form fields without exposing persistence diagnostics. + */ @Controller class AccountController { private final AccountService accounts; @@ -43,7 +47,7 @@ class AccountController { ? "redirect:/admin/accounts/new?created" : "redirect:/admin/accounts/new?deliveryFailed"; } catch (DataIntegrityViolationException duplicate) { - bindingResult.rejectValue("email", "account.email.duplicate", "An account with this email already exists"); + rejectUniquenessViolation(bindingResult, duplicate); return "accounts/new"; } catch (IllegalArgumentException | IllegalStateException exception) { bindingResult.reject("account.invalid", exception.getMessage()); @@ -76,4 +80,29 @@ class AccountController { form.clearPasswords(); return "accounts/activate"; } + + private static void rejectUniquenessViolation(BindingResult bindingResult, + DataIntegrityViolationException violation) { + String constraintName = constraintName(violation); + if ("uq_app_users_email_ci".equals(constraintName)) { + bindingResult.rejectValue( + "email", "account.email.duplicate", "An account with this email already exists"); + } else if ("uq_intern_profiles_student_code_ci".equals(constraintName)) { + bindingResult.rejectValue("studentCode", "account.studentCode.duplicate", + "An Intern with this student code already exists"); + } else { + bindingResult.reject("account.unique", "Account details conflict with an existing account"); + } + } + + private static String constraintName(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof ConstraintViolationException violation) { + return violation.getConstraintName(); + } + current = current.getCause(); + } + return null; + } } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java index d44a800..79954a5 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java @@ -19,11 +19,16 @@ import org.springframework.web.bind.annotation.RequestMapping; /** * Runs the Admin SMTP draft, connection-test, activation, and ordered setup-deferral browser workflows. - * Cleartext passwords remain request-local and are cleared before any error view is rendered. + * Cleartext passwords remain request-local and are cleared before any error view is rendered. Failures crossing the + * SMTP adapter boundary are represented by fixed operator guidance rather than raw provider diagnostics. */ @Controller @RequestMapping("/admin/smtp") class SmtpController { + private static final String TEST_FAILURE_MESSAGE = + "SMTP test failed. Verify the draft settings and server availability, then try again."; + private static final String ACTIVATION_FAILURE_MESSAGE = + "SMTP activation failed. Test the current draft again before activating it."; private static final String DEFERRAL_STEP = SmtpController.class.getName() + ".deferralStep"; private static final List DEFERRAL_WARNINGS = List.of( "Account onboarding is disabled until SMTP is active.", @@ -72,7 +77,7 @@ class SmtpController { smtp.testDraft(action.getDraftId(), adminId(principal), principal.getName()); return "redirect:/admin/smtp?tested"; } catch (IllegalArgumentException | IllegalStateException failure) { - bindingResult.reject("smtp.test.failed", failure.getMessage()); + bindingResult.reject("smtp.test.failed", TEST_FAILURE_MESSAGE); return renderActionError(model, bindingResult); } } @@ -87,7 +92,7 @@ class SmtpController { smtp.activate(action.getDraftId(), adminId(principal)); return "redirect:/admin/smtp?activated"; } catch (IllegalArgumentException | IllegalStateException failure) { - bindingResult.reject("smtp.activate.failed", failure.getMessage()); + bindingResult.reject("smtp.activate.failed", ACTIVATION_FAILURE_MESSAGE); return renderActionError(model, bindingResult); } } diff --git a/src/main/resources/templates/accounts/new.html b/src/main/resources/templates/accounts/new.html index 2208861..a17015e 100644 --- a/src/main/resources/templates/accounts/new.html +++ b/src/main/resources/templates/accounts/new.html @@ -26,6 +26,7 @@
    Intern details +

    diff --git a/src/test/java/com/lab/labtimesheet/config/TimeConfigurationTest.java b/src/test/java/com/lab/labtimesheet/config/TimeConfigurationTest.java new file mode 100644 index 0000000..7983d85 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/config/TimeConfigurationTest.java @@ -0,0 +1,21 @@ +package com.lab.labtimesheet.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; + +import org.junit.jupiter.api.Test; + +class TimeConfigurationTest { + @Test + void utcInstantAtVietnamMidnightUsesTheNewLocalBusinessDate() { + Clock applicationClock = new TimeConfiguration().applicationClock(); + Instant vietnamMidnight = Instant.parse("2026-08-14T17:00:00Z"); + + LocalDate businessDate = LocalDate.now(Clock.fixed(vietnamMidnight, applicationClock.getZone())); + + assertThat(businessDate).isEqualTo(LocalDate.of(2026, 8, 15)); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java index 9afb100..7730dee 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java @@ -176,6 +176,37 @@ class AccountWebIntegrationTest { .andExpect(content().string(org.hamcrest.Matchers.containsString("Duplicate Mentor"))); } + @Test + void duplicateNormalizedStudentCodeIsReportedOnStudentCodeRatherThanEmail() throws Exception { + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", "first-intern@example.com") + .param("displayName", "First Intern") + .param("role", "INTERN") + .param("studentCode", "STU-ROUND-2") + .param("internshipStart", "2026-08-01") + .param("internshipEnd", "2026-12-31")) + .andExpect(status().is3xxRedirection()); + + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", "second-intern@example.com") + .param("displayName", "Second Intern") + .param("role", "INTERN") + .param("studentCode", " stu-round-2 ") + .param("internshipStart", "2026-08-01") + .param("internshipEnd", "2026-12-31")) + .andExpect(status().isOk()) + .andExpect(view().name("accounts/new")) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "An Intern with this student code already exists"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("this email already exists")))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Second Intern"))); + } + @Test void additionalAdminActivatesAndAuthenticatesWithoutChangingTheFirstAdmin() throws Exception { mockMvc.perform(post("/admin/accounts") diff --git a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java index abd88a2..8e8c115 100644 --- a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java @@ -157,7 +157,8 @@ class SmtpOnboardingWebIntegrationTest { .with(user("admin@example.com").roles("ADMIN"))) .andReturn().getResponse().getContentAsString(); String draftId = html.replaceAll("(?s).*name=\"draftId\" value=\"([0-9]+)\".*", "$1"); - probe.failureMessage = "Connection refused by the configured SMTP server"; + String rawDiagnostic = "AUTH rejected for smtp-secret-raw-diagnostic"; + probe.failureMessage = rawDiagnostic; mockMvc.perform(post("/admin/smtp/test") .with(user("admin@example.com").roles("ADMIN")) @@ -166,7 +167,9 @@ class SmtpOnboardingWebIntegrationTest { .andExpect(status().isOk()) .andExpect(view().name("smtp/form")) .andExpect(content().string(org.hamcrest.Matchers.containsString( - "Connection refused by the configured SMTP server"))) + "SMTP test failed. Verify the draft settings and server availability, then try again."))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString(rawDiagnostic)))) .andExpect(content().string(org.hamcrest.Matchers.not( org.hamcrest.Matchers.containsString("Activate SMTP")))); } From 62a21132e290454bb0c087c76758dbdf0d15e266 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:13:57 +0700 Subject: [PATCH 53/62] docs(platform): pin round 2 evidence --- docs/tests/unit/vietnam-business-clock.md | 2 +- docs/tests/web/account-uniqueness-feedback.md | 2 +- docs/tests/web/smtp-error-sanitization.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tests/unit/vietnam-business-clock.md b/docs/tests/unit/vietnam-business-clock.md index 4ae1dd6..8530b72 100644 --- a/docs/tests/unit/vietnam-business-clock.md +++ b/docs/tests/unit/vietnam-business-clock.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ACC-019`, `ACC-020` - **Scenario IDs:** `AC-ACC-010` (business-date boundary only) - **Test class/method:** `com.lab.labtimesheet.config.TimeConfigurationTest#utcInstantAtVietnamMidnightUsesTheNewLocalBusinessDate` -- **Implementation commit:** `pending` +- **Implementation commit:** `06dba4fb13eed675cc08ff8c00fe3e3650468c3b` ## Protected behavior diff --git a/docs/tests/web/account-uniqueness-feedback.md b/docs/tests/web/account-uniqueness-feedback.md index 9f53d19..7361b0f 100644 --- a/docs/tests/web/account-uniqueness-feedback.md +++ b/docs/tests/web/account-uniqueness-feedback.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ACC-019`, `DB-003` - **Scenario IDs:** `AC-ACC-005` (Intern creation uniqueness boundary) - **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AccountWebIntegrationTest#duplicateNormalizedStudentCodeIsReportedOnStudentCodeRatherThanEmail` -- **Implementation commit:** `pending` +- **Implementation commit:** `06dba4fb13eed675cc08ff8c00fe3e3650468c3b` ## Protected behavior diff --git a/docs/tests/web/smtp-error-sanitization.md b/docs/tests/web/smtp-error-sanitization.md index 832bcc2..bceedff 100644 --- a/docs/tests/web/smtp-error-sanitization.md +++ b/docs/tests/web/smtp-error-sanitization.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `INT-005`, `INT-008` - **Scenario IDs:** `AC-INT-002` (failed-draft browser boundary) - **Test class/method:** `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft` -- **Implementation commit:** `pending` +- **Implementation commit:** `06dba4fb13eed675cc08ff8c00fe3e3650468c3b` ## Protected behavior From bf6f9af78b42151f2c26ef206978e3a55f75594a Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:24:59 +0700 Subject: [PATCH 54/62] fix(platform): sanitize Spring mail test failures --- .../web/smtp-mail-exception-sanitization.md | 76 +++++++++++++++++++ .../controller/SmtpController.java | 3 +- .../SmtpOnboardingWebIntegrationTest.java | 9 ++- 3 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 docs/tests/web/smtp-mail-exception-sanitization.md diff --git a/docs/tests/web/smtp-mail-exception-sanitization.md b/docs/tests/web/smtp-mail-exception-sanitization.md new file mode 100644 index 0000000..f7dcb88 --- /dev/null +++ b/docs/tests/web/smtp-mail-exception-sanitization.md @@ -0,0 +1,76 @@ +# Test Evidence: Sanitized production mail exception feedback + +- **Test type:** Web +- **Requirement IDs:** `INT-005`, `INT-008` +- **Scenario IDs:** `AC-INT-002` (production mail-failure boundary) +- **Test class/method:** `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft` +- **Implementation commit:** `pending` + +## Protected behavior + +Spring Mail delivery failures from the production SMTP adapter return the fixed Admin guidance instead of escaping +the MVC request or exposing provider diagnostics. A failed probe does not mark the draft tested or enable activation. + +## Test method + +MockMvc saves a valid draft, then the test SMTP boundary throws Spring's production-shaped `MailSendException` with a +distinctive deterministic diagnostic. The authenticated CSRF-protected request crosses the real controller and SMTP +configuration service, and the rendered Thymeleaf response is inspected for the fixed message, raw-text absence, and +absence of the activation action. + +## Hand-derived expected result + +The response is HTTP 200 on `smtp/form`, contains the fixed operator guidance, omits the exception diagnostic, and +does not offer Activate SMTP because `markTested` was never reached. + +## 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=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 +MailSendException escaped as ServletException with the distinctive diagnostic instead of rendering smtp/form. +BUILD FAILURE +PostgreSQL: 18.4 +``` + +## GREEN + +**Command** + +```text +./mvnw -Dtest=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest=TimeConfigurationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test +Tests run: 22, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## External-test boundaries + +The test exercises the production Spring Mail exception type without contacting an external SMTP server. It does not +prove live Mailpit/provider interoperability and contains no real credential or activation token. diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java index 79954a5..b3fb507 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java @@ -9,6 +9,7 @@ import com.lab.labtimesheet.feature.integration.model.dto.SmtpForm; import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; +import org.springframework.mail.MailException; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; @@ -76,7 +77,7 @@ class SmtpController { try { smtp.testDraft(action.getDraftId(), adminId(principal), principal.getName()); return "redirect:/admin/smtp?tested"; - } catch (IllegalArgumentException | IllegalStateException failure) { + } catch (IllegalArgumentException | IllegalStateException | MailException failure) { bindingResult.reject("smtp.test.failed", TEST_FAILURE_MESSAGE); return renderActionError(model, bindingResult); } diff --git a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java index 8e8c115..607b318 100644 --- a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java @@ -26,6 +26,7 @@ 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.mail.MailSendException; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -158,7 +159,7 @@ class SmtpOnboardingWebIntegrationTest { .andReturn().getResponse().getContentAsString(); String draftId = html.replaceAll("(?s).*name=\"draftId\" value=\"([0-9]+)\".*", "$1"); String rawDiagnostic = "AUTH rejected for smtp-secret-raw-diagnostic"; - probe.failureMessage = rawDiagnostic; + probe.failure = new MailSendException(rawDiagnostic); mockMvc.perform(post("/admin/smtp/test") .with(user("admin@example.com").roles("ADMIN")) @@ -185,12 +186,12 @@ class SmtpOnboardingWebIntegrationTest { static final class RecordingProbe implements SmtpProbe { private final List recipients = new ArrayList<>(); - private String failureMessage; + private MailSendException failure; @Override public void send(SmtpConnection connection, String recipient, String subject, String body) { - if (failureMessage != null) { - throw new IllegalStateException(failureMessage); + if (failure != null) { + throw failure; } recipients.add(recipient); } From 692b23e9b9891d360882671d8247965b44920b2f Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:25:48 +0700 Subject: [PATCH 55/62] docs(platform): pin round 3 SMTP evidence --- docs/tests/web/smtp-mail-exception-sanitization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tests/web/smtp-mail-exception-sanitization.md b/docs/tests/web/smtp-mail-exception-sanitization.md index f7dcb88..ffcef2f 100644 --- a/docs/tests/web/smtp-mail-exception-sanitization.md +++ b/docs/tests/web/smtp-mail-exception-sanitization.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `INT-005`, `INT-008` - **Scenario IDs:** `AC-INT-002` (production mail-failure boundary) - **Test class/method:** `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft` -- **Implementation commit:** `pending` +- **Implementation commit:** `bf6f9af78b42151f2c26ef206978e3a55f75594a` ## Protected behavior From c81c0dfe659385ec36c3fa29e055635307c2de28 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:03:19 +0700 Subject: [PATCH 56/62] fix(ui): complete reviewed desktop integration --- docs/tests/web/review-round-1-shared-ui.md | 36 ++++++++++++------- src/main/frontend/app.css | 20 +++++++++++ src/main/resources/static/assets/app.css | 2 +- src/main/resources/static/assets/app.js | 11 ++++-- .../templates/fragments/auth-layout.html | 1 + .../resources/templates/fragments/layout.html | 19 +++++----- src/main/resources/templates/smtp/defer.html | 2 +- src/main/resources/templates/smtp/form.html | 2 +- .../controller/AttendanceControllerTest.java | 4 +++ .../controller/ProjectControllerTest.java | 4 +++ .../AccountTemplateIntegrationTest.java | 18 ++++++++-- .../AttendanceTemplateIntegrationTest.java | 11 ++++-- .../DashboardControllerWebTest.java | 4 +++ .../controller/DashboardTemplateWebTest.java | 5 +++ .../ProjectTaskFormAccessibilityWebTest.java | 4 +++ .../SharedErrorTemplateWebTest.java | 5 +++ .../task/controller/TaskControllerTest.java | 4 +++ .../labtimesheet/ui/UiContractWebTest.java | 29 +++++++++++++++ .../resources/templates/error/generic.html | 10 ------ 19 files changed, 148 insertions(+), 43 deletions(-) delete mode 100644 src/test/resources/templates/error/generic.html diff --git a/docs/tests/web/review-round-1-shared-ui.md b/docs/tests/web/review-round-1-shared-ui.md index 40f7121..044a977 100644 --- a/docs/tests/web/review-round-1-shared-ui.md +++ b/docs/tests/web/review-round-1-shared-ui.md @@ -4,19 +4,19 @@ - **Requirement IDs:** `AUTH-002`, `UI-003`, `UI-004`, `UI-010`, `UI-013`, `UI-014`, `ERR-001`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04` - **Scenario IDs:** `AC-AUTH-001`, `AC-UI-002`, `AC-UI-003`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest#populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation`, `com.lab.labtimesheet.feature.reporting.controller.SharedErrorTemplateWebTest`, `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskFormAccessibilityWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest#mentorAndInternDashboardsRenderRealScopedProjectTaskAndAttendanceData` -- **Implementation commit:** `8388b4c` +- **Implementation commit:** `pending final review-fix commit` ## Protected behavior -The authenticated shell exposes only reachable role-authorized links. Intern attendance uses `/attendance`; Mentor attendance, profile, and notification links remain hidden until their authorized destination flows exist. Every rendered role-navigation link resolves through an actual authenticated GET. Attendance history uses the row's attached policy timezone for 24-hour times, formats business dates as `dd/MM/yyyy`, and renders every simultaneous violation. Project and Task field errors have stable IDs associated to invalid controls. Generic 404 and 409 pages use the shared shell and safe caller-supplied copy without rendering exception details. +The authenticated shell exposes only reachable role-authorized links. Intern attendance uses `/attendance`; Mentor attendance, profile, and notification links remain hidden until their authorized destination flows exist. Every rendered role-navigation link resolves through an actual authenticated GET. Attendance history uses the row's attached policy timezone for 24-hour times, formats business dates as `dd/MM/yyyy`, and renders every simultaneous violation. Project and Task field errors have stable IDs associated to invalid controls. Generic 404 and 409 pages use the shared shell and safe caller-supplied copy without rendering exception details. The collapsed desktop sidebar exposes its current state, keeps every control within its rail, and gives icon-only navigation a visible keyboard-focus tooltip. Both shared shells explicitly reference a local favicon so browser console checks do not depend on an unmapped `/favicon.ico` request. ## Test method -MockMvc renders the production shell and templates with real Spring Security principals and production-shaped Attendance DTOs. Project and Task invalid POSTs pass through their real controllers and validation, with only feature services replaced at the slice boundary. The full Spring/PostgreSQL role journey creates accounts, internship, Project, and Task through public services, renders each role's real dashboard, extracts every visible shell link, and performs an authenticated GET against each extracted path. +MockMvc renders the production shell and templates with real Spring Security principals and production-shaped Attendance DTOs. Project and Task invalid POSTs pass through their real controllers and validation, with only feature services replaced at the slice boundary. The full Spring/PostgreSQL role journey creates accounts, internship, Project, and Task through public services, renders each role's real dashboard, extracts every visible shell link, and performs an authenticated GET against each extracted path. A desktop browser then exercises the real local Java process at 1365x900 for all three roles, inspecting focus, tooltip pseudo-content, runtime `aria-expanded`, theme persistence/head ordering, console output, and document overflow. ## Hand-derived expected result -Mentor navigation contains only overview and owned Projects; Intern navigation contains overview, `/attendance`, and Projects; Admin navigation contains overview, account creation, and global calendar. No role receives `/attendance/me`, `/profile`, `/notifications`, or a selector-less Mentor attendance destination. `2026-08-14T02:05:00Z` under `Asia/Ho_Chi_Minh` renders as `14/08/2026 09:05`; `09:00:00Z` renders as `16:00`. Late plus early-departure and late plus missing-checkout labels are both retained. Every rendered validation message has a stable referenced ID. Error pages expose only status and generic copy. +Mentor navigation contains only overview and owned Projects; Intern navigation contains overview, `/attendance`, and Projects; Admin navigation contains overview, account creation, and global calendar. No role receives `/attendance/me`, `/profile`, `/notifications`, or a selector-less Mentor attendance destination. `2026-08-14T02:05:00Z` under `Asia/Ho_Chi_Minh` renders as `14/08/2026 09:05`; `09:00:00Z` renders as `16:00`. Late plus early-departure and late plus missing-checkout labels are both retained. Every rendered validation message has a stable referenced ID. Error pages expose only status and generic copy. At 1365x900, root and body scroll widths remain 1365, the collapsed toggle reports `aria-expanded=false`, expanding reports `true`, and keyboard focus exposes the corresponding control name without horizontal overflow. ## RED @@ -28,6 +28,9 @@ export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest test ./mvnw -Dtest=RoleDashboardWebIntegrationTest test +./mvnw -Dtest=ProjectControllerTest,AttendanceControllerTest,TaskControllerTest test +./mvnw -Dtest=UiContractWebTest#collapsedSidebarExposesStateAndKeyboardVisibleControlNames test +./mvnw -Dtest=UiContractWebTest#mentorShellRendersOnlyReachableAuthorizedNavigation test ``` **Observed result** @@ -41,10 +44,14 @@ Invalid controls had no aria-describedby and inline errors had no stable IDs. PostgreSQL 18.4 role journey: Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 Following the Admin shell's visible /profile link returned 404 instead of 200. + +After the four reviewed producer pins were merged, the three producer WebMvc slices ran 39 tests with 39 context errors. The merged Platform SmtpWarningAdvice required SmtpConfigurationService, which was absent only from those narrow slice fixtures; no behavior assertion ran. + +The collapsed-sidebar regression failed 1/1 at the missing aria-expanded assertion. The favicon regression failed 1/1 because the rendered shared shell had no explicit local icon link; the real browser independently logged /favicon.ico as 404. BUILD FAILURE ``` -The failures occurred after real template rendering and controller validation; they identify the missing reviewed behavior rather than fixture or environment failure. +The failures occurred after real template rendering and controller validation, or at an exact missing merged slice dependency; they identify the missing reviewed behavior or fixture boundary rather than an unrelated environment failure. ## GREEN @@ -54,15 +61,21 @@ The failures occurred after real template rendering and controller validation; t export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock -./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest test +./mvnw clean -Dtest=AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,UiContractWebTest test ./mvnw -Dtest=RoleDashboardWebIntegrationTest test +./mvnw -Dtest=ProjectControllerTest,AttendanceControllerTest,TaskControllerTest test +./mvnw -Dtest=UiContractWebTest#collapsedSidebarExposesStateAndKeyboardVisibleControlNames test +./mvnw -Dtest=UiContractWebTest#mentorShellRendersOnlyReachableAuthorizedNavigation test ``` **Observed result** ```text -Focused templates: Tests run: 13, Failures: 0, Errors: 0, Skipped: 0 +Post-merge clean Reporting/UI slices: Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 PostgreSQL 18.4 role journey: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +Producer WebMvc slices: Tests run: 39, Failures: 0, Errors: 0, Skipped: 0 +Collapsed sidebar: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +Local favicon contract: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` @@ -75,14 +88,13 @@ export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock npm run build -./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,AttendanceControllerTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest,ProjectControllerTest,TaskControllerTest,RoleDashboardWebIntegrationTest test +./mvnw -Dtest=SecurityResponseIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountWebIntegrationTest,SmtpOnboardingWebIntegrationTest,RoleDashboardWebIntegrationTest,UiContractWebTest,AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,ProjectControllerTest,TaskControllerTest,AttendanceControllerTest test ./mvnw -DskipTests compile ./mvnw -DskipTests -Ddoclint=all javadoc:javadoc Node v24.19.0; npm 11.17.0 -Tailwind CSS v4.3.3: Done in 68ms -PostgreSQL 18.4 via Testcontainers -Tests run: 40, Failures: 0, Errors: 0, Skipped: 0 +Tailwind CSS v4.3.3: Done +Merged affected web suite on PostgreSQL 18.4: Tests run: 79, Failures: 0, Errors: 0, Skipped: 0 Compile: success Javadoc/doclint: success BUILD SUCCESS @@ -90,4 +102,4 @@ BUILD SUCCESS ## External-test boundaries -The automated checks prove rendering, controller validation, role-scoped navigation targets, attached-policy formatting, and generic error copy. They do not prove first-paint timing, keyboard focus/tooltips, runtime `aria-expanded` synchronization, or viewport overflow; those remain mandatory live desktop browser gates after the corrected producer pins are merged. +The automated checks prove rendering, controller validation, role-scoped navigation targets, attached-policy formatting, generic error copy, public local assets, safe Referrer-Policy, and retained safe form fields. Edge/Chromium desktop checks against the real local Java/PostgreSQL process covered Admin dashboard, Mentor dashboard/Projects, and Intern dashboard/attendance: every representative page had `documentElement.scrollWidth == body.scrollWidth == innerWidth == 1365`; keyboard focus showed a solid focus ring and tooltip; collapse/expand synchronized `aria-expanded`; the theme bootstrap preceded CSS and survived reload; console checks were empty after the explicit local favicon link. A human-observed no-flash check is inherently practical rather than deterministic, and mobile remains outside Iteration 1 scope. diff --git a/src/main/frontend/app.css b/src/main/frontend/app.css index b5a677c..100a415 100644 --- a/src/main/frontend/app.css +++ b/src/main/frontend/app.css @@ -81,10 +81,30 @@ .nav-list { display: grid; gap: .2rem; margin: 0; padding: 0; list-style: none; } .nav-link { display: flex; min-height: 2.5rem; align-items: center; gap: .7rem; border-radius: .55rem; padding: .55rem .7rem; color: var(--muted); font-weight: 600; text-decoration: none; } .nav-link:hover, .nav-link[aria-current="page"] { background: var(--panel); color: var(--ink); box-shadow: 0 1px 2px rgb(20 25 35 / .08); } + .nav-link[data-tooltip] { position: relative; } + [data-sidebar-collapsed="true"] .nav-link[data-tooltip]:hover::after, + [data-sidebar-collapsed="true"] .nav-link[data-tooltip]:focus-visible::after { + position: absolute; + z-index: 20; + top: 50%; + left: calc(100% + .75rem); + padding: .38rem .55rem; + border: 1px solid var(--border-strong); + border-radius: .4rem; + background: var(--ink); + color: var(--panel); + content: attr(data-tooltip); + font-size: .75rem; + line-height: 1; + pointer-events: none; + transform: translateY(-50%); + white-space: nowrap; + } .nav-icon { width: 1.05rem; height: 1.05rem; flex: 0 0 auto; } .sidebar-footer { display: grid; gap: .7rem; margin-top: auto; } .theme-field { display: grid; gap: .25rem; } .theme-field select { min-height: 2.4rem; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .35rem .55rem; } + [data-sidebar-collapsed="true"] .theme-field select { width: 2.5rem; padding-inline: .25rem; font-size: 0; } .logout-form button { width: 100%; border: 0; background: transparent; text-align: left; } .app-column { min-width: 0; } .app-header { display: flex; min-height: 3.75rem; align-items: center; gap: .8rem; border-bottom: 1px solid var(--border); padding: 0 1.5rem; } diff --git a/src/main/resources/static/assets/app.css b/src/main/resources/static/assets/app.css index 450c03d..9b446c9 100644 --- a/src/main/resources/static/assets/app.css +++ b/src/main/resources/static/assets/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.hidden{display:none}.inline{display:inline}.table{display:table}.border{border-style:var(--tw-border-style);border-width:1px}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-link[data-tooltip]{position:relative}[data-sidebar-collapsed=true] .nav-link[data-tooltip]:hover:after,[data-sidebar-collapsed=true] .nav-link[data-tooltip]:focus-visible:after{z-index:20;border:1px solid var(--border-strong);background:var(--ink);color:var(--panel);content:attr(data-tooltip);pointer-events:none;white-space:nowrap;border-radius:.4rem;padding:.38rem .55rem;font-size:.75rem;line-height:1;position:absolute;top:50%;left:calc(100% + .75rem);transform:translateY(-50%)}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}[data-sidebar-collapsed=true] .theme-field select{width:2.5rem;padding-inline:.25rem;font-size:0}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.block{display:block}.hidden{display:none}.inline{display:inline}.table{display:table}.border{border-style:var(--tw-border-style);border-width:1px}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/src/main/resources/static/assets/app.js b/src/main/resources/static/assets/app.js index 649cad1..b443b1b 100644 --- a/src/main/resources/static/assets/app.js +++ b/src/main/resources/static/assets/app.js @@ -25,10 +25,15 @@ document.addEventListener('DOMContentLoaded', () => { let collapsed = false; try { collapsed = localStorage.getItem('labtimesheet-sidebar') === 'collapsed'; } catch (_) { /* Use the expanded default. */ } - root.dataset.sidebarCollapsed = String(collapsed); - document.querySelector('[data-sidebar-toggle]')?.addEventListener('click', () => { - collapsed = !collapsed; + const sidebarToggle = document.querySelector('[data-sidebar-toggle]'); + const applySidebarState = () => { root.dataset.sidebarCollapsed = String(collapsed); + sidebarToggle?.setAttribute('aria-expanded', String(!collapsed)); + }; + applySidebarState(); + sidebarToggle?.addEventListener('click', () => { + collapsed = !collapsed; + applySidebarState(); try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); } catch (_) { /* Collapse still works for this page. */ } }); diff --git a/src/main/resources/templates/fragments/auth-layout.html b/src/main/resources/templates/fragments/auth-layout.html index 711bec1..5aa161d 100644 --- a/src/main/resources/templates/fragments/auth-layout.html +++ b/src/main/resources/templates/fragments/auth-layout.html @@ -5,6 +5,7 @@ Lab Timesheet + diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 3e46c19..4de10be 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -5,6 +5,7 @@ Lab Timesheet + @@ -19,31 +20,31 @@
    - +
    Section / Page
    diff --git a/src/main/resources/templates/smtp/defer.html b/src/main/resources/templates/smtp/defer.html index 9f38ab8..17abefa 100644 --- a/src/main/resources/templates/smtp/defer.html +++ b/src/main/resources/templates/smtp/defer.html @@ -1,6 +1,6 @@ -Defer SMTP configuration +Defer SMTP configuration

    Defer SMTP configuration

    diff --git a/src/main/resources/templates/smtp/form.html b/src/main/resources/templates/smtp/form.html index 6b25946..ddb16c8 100644 --- a/src/main/resources/templates/smtp/form.html +++ b/src/main/resources/templates/smtp/form.html @@ -1,6 +1,6 @@ -SMTP configuration +SMTP configuration

    SMTP configuration

    diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java index 72cd1bb..600ba77 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java @@ -26,6 +26,7 @@ import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent; import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService; import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService; import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import java.time.Instant; import java.time.LocalDate; import java.util.List; @@ -50,6 +51,9 @@ class AttendanceControllerTest { @MockitoBean private AttendanceCurrentUserService currentUsers; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test void internPunchesOnlyForAuthenticatedSelf() throws Exception { AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN); diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java index 59f100d..3cfc1e7 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -25,6 +25,7 @@ import com.lab.labtimesheet.feature.project.model.dto.ProjectLeadershipTermView; import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView; 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.LocalDate; import java.util.List; @@ -49,6 +50,9 @@ class ProjectControllerTest { @MockitoBean private ProjectService projects; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test @WithMockUser(username = "mentor@example.test") void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception { diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AccountTemplateIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AccountTemplateIntegrationTest.java index 87864d2..72fd0d6 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AccountTemplateIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AccountTemplateIntegrationTest.java @@ -6,11 +6,16 @@ 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.model.dto.ActivationForm; +import com.lab.labtimesheet.feature.account.model.dto.BootstrapForm; +import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.stereotype.Controller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -21,6 +26,9 @@ class AccountTemplateIntegrationTest { private final MockMvc mvc; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Autowired AccountTemplateIntegrationTest(MockMvc mvc) { this.mvc = mvc; @@ -80,13 +88,16 @@ class AccountTemplateIntegrationTest { public static class TemplateController { @GetMapping("/template-contract/accounts/new") - String accountCreation() { + String accountCreation(Model model) { + model.addAttribute("accountForm", new CreateAccountForm()); return "accounts/new"; } @GetMapping("/template-contract/accounts/activate") String activation(Model model) { - model.addAttribute("token", "raw-token"); + ActivationForm form = new ActivationForm(); + form.setToken("raw-token"); + model.addAttribute("activationForm", form); return "accounts/activate"; } @@ -96,7 +107,8 @@ class AccountTemplateIntegrationTest { } @GetMapping("/template-contract/bootstrap") - String bootstrap() { + String bootstrap(Model model) { + model.addAttribute("bootstrapForm", new BootstrapForm()); return "bootstrap/form"; } } diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java index e3566e1..e1b9c59 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java @@ -6,9 +6,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures; import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import java.time.Instant; import java.time.LocalDate; import java.util.List; @@ -17,6 +18,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.stereotype.Controller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -27,6 +29,9 @@ class AttendanceTemplateIntegrationTest { private final MockMvc mvc; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Autowired AttendanceTemplateIntegrationTest(MockMvc mvc) { this.mvc = mvc; @@ -91,13 +96,13 @@ class AttendanceTemplateIntegrationTest { LocalDate.of(2026, 8, 14), Instant.parse("2026-08-14T02:05:00Z"), Instant.parse("2026-08-14T09:00:00Z"), - AttendancePolicy.seeded(1L), + AttendancePolicyFixtures.seeded(1L), new AttendanceViolations(true, true, false)), new AttendanceHistoryItem( LocalDate.of(2026, 8, 13), Instant.parse("2026-08-13T01:30:00Z"), null, - AttendancePolicy.seeded(1L), + AttendancePolicyFixtures.seeded(1L), new AttendanceViolations(true, false, true)))); return "attendance/history"; } diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java index 7df5aac..bc5176c 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java @@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView; import com.lab.labtimesheet.feature.reporting.service.DashboardService; import java.util.List; @@ -27,6 +28,9 @@ class DashboardControllerWebTest { @MockitoBean private DashboardService dashboards; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test void adminRendersAdminDashboardForAuthenticatedIdentity() throws Exception { var dashboard = new DashboardView.Admin(2, 1, 1, 3); diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java index a5282d6..85cf24a 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java @@ -6,6 +6,7 @@ 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.integration.service.SmtpConfigurationService; import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView; import java.time.LocalDate; import java.util.List; @@ -15,6 +16,7 @@ import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.stereotype.Controller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -25,6 +27,9 @@ class DashboardTemplateWebTest { private final MockMvc mvc; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Autowired DashboardTemplateWebTest(MockMvc mvc) { this.mvc = mvc; diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java index c07381c..97d40fb 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java @@ -8,6 +8,7 @@ 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.integration.service.SmtpConfigurationService; import com.lab.labtimesheet.feature.project.controller.ProjectController; import com.lab.labtimesheet.feature.project.service.ProjectQueryService; import com.lab.labtimesheet.feature.project.service.ProjectService; @@ -36,6 +37,9 @@ class ProjectTaskFormAccessibilityWebTest { @MockitoBean private TaskService tasks; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test void projectFieldErrorsHaveStableIdsAndInputAssociations() throws Exception { mvc.perform(post("/projects") diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java index 86c0468..75e32c3 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java @@ -6,6 +6,7 @@ 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.integration.service.SmtpConfigurationService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -13,6 +14,7 @@ import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.stereotype.Controller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -25,6 +27,9 @@ class SharedErrorTemplateWebTest { @Autowired private MockMvc mvc; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test @WithMockUser(username = "intern@example.test", roles = "INTERN") void notFoundPageUsesSharedShellWithoutDisclosingRecordDetails() throws Exception { diff --git a/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java index 023d1e3..6311a04 100644 --- a/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java @@ -26,6 +26,7 @@ import com.lab.labtimesheet.feature.task.model.dto.TaskDetails; import com.lab.labtimesheet.feature.task.model.dto.TaskListView; import com.lab.labtimesheet.feature.task.model.dto.TaskView; import com.lab.labtimesheet.feature.task.service.TaskService; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import java.time.Instant; import java.time.LocalDate; import java.util.List; @@ -51,6 +52,9 @@ class TaskControllerTest { @MockitoBean private TaskService taskService; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test void taskListRequiresAuthentication() throws Exception { mockMvc.perform(get("/projects/10/tasks")) diff --git a/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java b/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java index 876bea8..9e2c61e 100644 --- a/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java +++ b/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import java.nio.charset.StandardCharsets; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -15,6 +16,7 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.context.annotation.Import; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.stereotype.Controller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.web.bind.annotation.GetMapping; @@ -25,6 +27,9 @@ class UiContractWebTest { private final MockMvc mvc; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Autowired UiContractWebTest(MockMvc mvc) { this.mvc = mvc; @@ -49,6 +54,7 @@ class UiContractWebTest { assertFalse(html.contains("href=\"/profile\"")); assertFalse(html.contains("href=\"/notifications\"")); assertTrue(html.indexOf("/assets/theme.js") < html.indexOf("/assets/app.css")); + assertTrue(html.contains("rel=\"icon\" href=\"/assets/icons.svg\"")); assertTrue(html.contains("href=\"/assets/icons.svg#panel-left\"")); } @@ -91,6 +97,29 @@ class UiContractWebTest { assertTrue(themeBootstrap.contains("matchMedia('(prefers-color-scheme: dark)')")); } + @Test + @WithMockUser(username = "admin@example.test", roles = "ADMIN") + void collapsedSidebarExposesStateAndKeyboardVisibleControlNames() throws Exception { + String html = mvc.perform(get("/ui-contract")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + String script = new ClassPathResource("static/assets/app.js") + .getContentAsString(StandardCharsets.UTF_8); + String css = new ClassPathResource("static/assets/app.css") + .getContentAsString(StandardCharsets.UTF_8); + + assertTrue(html.contains("data-sidebar-toggle aria-expanded=\"true\"")); + assertTrue(html.contains("data-tooltip=\"Overview\"")); + assertTrue(html.contains("data-tooltip=\"Accounts\"")); + assertTrue(html.contains("data-tooltip=\"Global calendar\"")); + assertTrue(html.contains("data-tooltip=\"Logout\"")); + assertTrue(html.contains("title=\"Theme preference\"")); + assertTrue(script.contains("setAttribute('aria-expanded', String(!collapsed))")); + assertTrue(css.contains("content:attr(data-tooltip)")); + } + @Test void themeTokensMeetTextFocusAndMeaningfulBoundaryContrast() throws Exception { String css = new ClassPathResource("static/assets/app.css") diff --git a/src/test/resources/templates/error/generic.html b/src/test/resources/templates/error/generic.html deleted file mode 100644 index 1ea6437..0000000 --- a/src/test/resources/templates/error/generic.html +++ /dev/null @@ -1,10 +0,0 @@ - - -Request unavailable - -
    -

    Request unavailable

    -

    The request could not be completed.

    -
    - - From cb76bea18ec723fa335384e3fb16da09c22e0980 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:08:16 +0700 Subject: [PATCH 57/62] fix(ui): make asset build source deterministic --- docs/tests/web/review-round-1-shared-ui.md | 6 +++++- src/main/frontend/app.css | 2 +- src/main/resources/static/assets/app.css | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/tests/web/review-round-1-shared-ui.md b/docs/tests/web/review-round-1-shared-ui.md index 044a977..65f5618 100644 --- a/docs/tests/web/review-round-1-shared-ui.md +++ b/docs/tests/web/review-round-1-shared-ui.md @@ -48,6 +48,8 @@ Following the Admin shell's visible /profile link returned 404 instead of 200. After the four reviewed producer pins were merged, the three producer WebMvc slices ran 39 tests with 39 context errors. The merged Platform SmtpWarningAdvice required SmtpConfigurationService, which was absent only from those narrow slice fixtures; no behavior assertion ran. The collapsed-sidebar regression failed 1/1 at the missing aria-expanded assertion. The favicon regression failed 1/1 because the rendered shared shell had no explicit local icon link; the real browser independently logged /favicon.ico as 404. + +The first post-Maven deterministic asset check changed app.css because Tailwind automatic source discovery included generated target output; a generated ring token changed the production bundle without any source-template change. BUILD FAILURE ``` @@ -94,9 +96,11 @@ npm run build Node v24.19.0; npm 11.17.0 Tailwind CSS v4.3.3: Done +Two consecutive builds produced app.css SHA-256 7bd351d0f2cae97af70532e8ee0e0248265782b508d378fd7c277cbb4f373946 and icons.svg SHA-256 001f72c93967f816fdd56f3f9b34cb5e5831b8b8c572d051669c6a3aae2c3cda. Merged affected web suite on PostgreSQL 18.4: Tests run: 79, Failures: 0, Errors: 0, Skipped: 0 +Full PostgreSQL 18.4 suite: Tests run: 195, Failures: 0, Errors: 0, Skipped: 0 Compile: success -Javadoc/doclint: success +Full Javadoc/doclint: success (producer-owned missing-comment warnings remain non-fatal) BUILD SUCCESS ``` diff --git a/src/main/frontend/app.css b/src/main/frontend/app.css index 100a415..e804c96 100644 --- a/src/main/frontend/app.css +++ b/src/main/frontend/app.css @@ -1,4 +1,4 @@ -@import "tailwindcss"; +@import "tailwindcss" source(none); @source "../resources/templates/**/*.html"; @theme { diff --git a/src/main/resources/static/assets/app.css b/src/main/resources/static/assets/app.css index 9b446c9..ad91f4e 100644 --- a/src/main/resources/static/assets/app.css +++ b/src/main/resources/static/assets/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-link[data-tooltip]{position:relative}[data-sidebar-collapsed=true] .nav-link[data-tooltip]:hover:after,[data-sidebar-collapsed=true] .nav-link[data-tooltip]:focus-visible:after{z-index:20;border:1px solid var(--border-strong);background:var(--ink);color:var(--panel);content:attr(data-tooltip);pointer-events:none;white-space:nowrap;border-radius:.4rem;padding:.38rem .55rem;font-size:.75rem;line-height:1;position:absolute;top:50%;left:calc(100% + .75rem);transform:translateY(-50%)}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}[data-sidebar-collapsed=true] .theme-field select{width:2.5rem;padding-inline:.25rem;font-size:0}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.block{display:block}.hidden{display:none}.inline{display:inline}.table{display:table}.border{border-style:var(--tw-border-style);border-width:1px}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-link[data-tooltip]{position:relative}[data-sidebar-collapsed=true] .nav-link[data-tooltip]:hover:after,[data-sidebar-collapsed=true] .nav-link[data-tooltip]:focus-visible:after{z-index:20;border:1px solid var(--border-strong);background:var(--ink);color:var(--panel);content:attr(data-tooltip);pointer-events:none;white-space:nowrap;border-radius:.4rem;padding:.38rem .55rem;font-size:.75rem;line-height:1;position:absolute;top:50%;left:calc(100% + .75rem);transform:translateY(-50%)}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}[data-sidebar-collapsed=true] .theme-field select{width:2.5rem;padding-inline:.25rem;font-size:0}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.hidden{display:none}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file From ec9958639345baa494ecd78465cae6692c8fd836 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:09:57 +0700 Subject: [PATCH 58/62] docs(ui): finalize review verification evidence --- docs/tests/web/review-round-1-shared-ui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tests/web/review-round-1-shared-ui.md b/docs/tests/web/review-round-1-shared-ui.md index 65f5618..3790811 100644 --- a/docs/tests/web/review-round-1-shared-ui.md +++ b/docs/tests/web/review-round-1-shared-ui.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-002`, `UI-003`, `UI-004`, `UI-010`, `UI-013`, `UI-014`, `ERR-001`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04` - **Scenario IDs:** `AC-AUTH-001`, `AC-UI-002`, `AC-UI-003`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest#populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation`, `com.lab.labtimesheet.feature.reporting.controller.SharedErrorTemplateWebTest`, `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskFormAccessibilityWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest#mentorAndInternDashboardsRenderRealScopedProjectTaskAndAttendanceData` -- **Implementation commit:** `pending final review-fix commit` +- **Implementation commit:** `c81c0df` with deterministic asset follow-up `cb76bea` ## Protected behavior From ddf688a5336421762ff970499bafb09505474fca Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:33:37 +0700 Subject: [PATCH 59/62] fix(ui): persist SMTP restriction in shared shell --- docs/tests/web/review-round-2-smtp-shell.md | 87 +++++++++++++ src/main/frontend/app.css | 2 + src/main/resources/static/assets/app.css | 2 +- .../resources/templates/accounts/new.html | 1 - .../resources/templates/fragments/layout.html | 5 + src/main/resources/templates/smtp/defer.html | 41 +++--- src/main/resources/templates/smtp/form.html | 118 ++++++++++++------ ...BootstrapOnboardingWebIntegrationTest.java | 16 +++ .../SmtpOnboardingWebIntegrationTest.java | 69 +++++++++- .../DashboardControllerWebTest.java | 23 +++- 10 files changed, 303 insertions(+), 61 deletions(-) create mode 100644 docs/tests/web/review-round-2-smtp-shell.md diff --git a/docs/tests/web/review-round-2-smtp-shell.md b/docs/tests/web/review-round-2-smtp-shell.md new file mode 100644 index 0000000..570dd75 --- /dev/null +++ b/docs/tests/web/review-round-2-smtp-shell.md @@ -0,0 +1,87 @@ +# Test Evidence: persistent SMTP warning and accessible onboarding shell + +- **Test type:** Web +- **Requirement IDs:** `ACC-005`, `ACC-006`, `ACC-007`, `INT-007`, `UI-004`, `UI-007`, `UI-010`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04` +- **Scenario IDs:** `AC-ACC-003`, `AC-UI-001`, `AC-UI-002` +- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest`, `com.lab.labtimesheet.feature.account.controller.BootstrapOnboardingWebIntegrationTest#fiveDistinctDeferralConfirmationsAreSequentialAndOnlyTheLastCanFinish` +- **Implementation commit:** `pending` + +## Protected behavior + +An Admin without active SMTP sees a persistent, actionable restricted-installation warning on every shared-shell page, including the dashboard reached after the fifth deferral confirmation. The warning is absent for non-Admins and after SMTP activation. SMTP configuration and deferral reuse the authenticated desktop shell and its pre-paint theme, focus, local assets, navigation, and logout behavior. Invalid SMTP fields expose a single accessible error summary plus stable field-error IDs referenced by the corresponding controls, while safe fields are retained and the submitted password is never rendered. + +## Test method + +The reporting MVC slice renders the production Admin and Mentor dashboard templates with real Spring Security principals and only the dashboard and SMTP services mocked at their public boundaries. PostgreSQL 18.4 integration tests bootstrap a real Admin, traverse all five server-owned deferral steps, finish onto the real dashboard, and render a representative account page. The SMTP integration test submits every supported invalid field combination through the real controller, Jakarta Validation, Thymeleaf binding, and production template. A separate invalid request proves safe-value retention and request-local password clearing. + +## Hand-derived expected result + +With no active SMTP, an Admin dashboard and account page contain the exact warning and an `/admin/smtp` action. A Mentor dashboard never contains that warning, and an Admin page after activation does not contain it. The first through fourth deferral steps do not expose Finish; the fifth does; Finish redirects to `/dashboard`, where the warning persists. Host, port, security mode, authentication completeness, From address, and From name each render a unique error ID and the associated invalid control references that ID through `aria-describedby`. The global summary is labeled, safe username/sender values remain, and the submitted password is absent. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=DashboardControllerWebTest,SmtpOnboardingWebIntegrationTest,BootstrapOnboardingWebIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 13, Failures: 3, Errors: 0, Skipped: 0 +DashboardControllerWebTest: Admin dashboard did not contain the persistent restricted-installation warning or SMTP action. +SmtpOnboardingWebIntegrationTest: SMTP form did not load /assets/theme.js because it was still standalone. +BootstrapOnboardingWebIntegrationTest: SMTP deferral did not load /assets/theme.js because it was still standalone. +BUILD FAILURE +``` + +The initial XPath assertion attempt was discarded before implementation because the HTML5 doctype is not XML-parseable by MockMvc's XML XPath matcher. The corrected string-based run above is the recorded behavior RED. + +A follow-up focused RED for the authentication-pair error ran one PostgreSQL-backed method and failed 1/1 because the password referenced `smtp-authentication-error` but the paired username did not. Associating both controls made the identical command pass 1/1. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=DashboardControllerWebTest,SmtpOnboardingWebIntegrationTest,BootstrapOnboardingWebIntegrationTest test +``` + +**Observed result** + +```text +PostgreSQL 18.4 via Testcontainers +Tests run: 14, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 30.179 s +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +npm run build +./mvnw -Dtest=SecurityResponseIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountWebIntegrationTest,SmtpOnboardingWebIntegrationTest,RoleDashboardWebIntegrationTest,UiContractWebTest,AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,ProjectControllerTest,TaskControllerTest,AttendanceControllerTest test + +Node v24.19.0; npm 11.17.0; Tailwind CSS v4.3.3 +Two consecutive builds produced app.css SHA-256 f0a4abbffaf66581ee7e17952743e591b8957e0cbcd19099e234d13827700e4c and icons.svg SHA-256 001f72c93967f816fdd56f3f9b34cb5e5831b8b8c572d051669c6a3aae2c3cda. +PostgreSQL 18.4 via Testcontainers +Tests run: 81, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 50.418 s +``` + +## External-test boundaries + +MockMvc verifies rendered security visibility, form binding, CSRF-generated forms, exact deferral ordering, safe retained values, and accessibility associations. It does not prove viewport overflow, keyboard focus rendering, collapse behavior, or visually observable theme flash; the separate real-browser evidence covers those boundaries. SMTP transport remains represented by the existing test probe and no real mail server is required. diff --git a/src/main/frontend/app.css b/src/main/frontend/app.css index e804c96..7fd935b 100644 --- a/src/main/frontend/app.css +++ b/src/main/frontend/app.css @@ -152,6 +152,8 @@ .badge-warning { color: var(--warning); } .badge-danger { color: var(--danger); } .alert { margin: .75rem 0; border: 1px solid var(--border); border-radius: .6rem; padding: .75rem .9rem; } + .alert-warning { border-color: color-mix(in srgb, var(--warning), transparent 55%); color: var(--warning); } + .alert-action { margin-left: .6rem; font-weight: 700; } .alert-error { border-color: color-mix(in srgb, var(--danger), transparent 60%); color: var(--danger); } .empty-state { padding: 2.5rem 1rem; text-align: center; } .empty-state p { margin: .3rem auto 0; color: var(--muted); } diff --git a/src/main/resources/static/assets/app.css b/src/main/resources/static/assets/app.css index ad91f4e..9bf6130 100644 --- a/src/main/resources/static/assets/app.css +++ b/src/main/resources/static/assets/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-link[data-tooltip]{position:relative}[data-sidebar-collapsed=true] .nav-link[data-tooltip]:hover:after,[data-sidebar-collapsed=true] .nav-link[data-tooltip]:focus-visible:after{z-index:20;border:1px solid var(--border-strong);background:var(--ink);color:var(--panel);content:attr(data-tooltip);pointer-events:none;white-space:nowrap;border-radius:.4rem;padding:.38rem .55rem;font-size:.75rem;line-height:1;position:absolute;top:50%;left:calc(100% + .75rem);transform:translateY(-50%)}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}[data-sidebar-collapsed=true] .theme-field select{width:2.5rem;padding-inline:.25rem;font-size:0}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.hidden{display:none}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-link[data-tooltip]{position:relative}[data-sidebar-collapsed=true] .nav-link[data-tooltip]:hover:after,[data-sidebar-collapsed=true] .nav-link[data-tooltip]:focus-visible:after{z-index:20;border:1px solid var(--border-strong);background:var(--ink);color:var(--panel);content:attr(data-tooltip);pointer-events:none;white-space:nowrap;border-radius:.4rem;padding:.38rem .55rem;font-size:.75rem;line-height:1;position:absolute;top:50%;left:calc(100% + .75rem);transform:translateY(-50%)}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}[data-sidebar-collapsed=true] .theme-field select{width:2.5rem;padding-inline:.25rem;font-size:0}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-warning{border-color:var(--warning)}@supports (color:color-mix(in lab, red, red)){.alert-warning{border-color:color-mix(in srgb, var(--warning), transparent 55%)}}.alert-warning{color:var(--warning)}.alert-action{margin-left:.6rem;font-weight:700}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.hidden{display:none}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/src/main/resources/templates/accounts/new.html b/src/main/resources/templates/accounts/new.html index 1e79c38..3e88074 100644 --- a/src/main/resources/templates/accounts/new.html +++ b/src/main/resources/templates/accounts/new.html @@ -10,7 +10,6 @@ Back to overview

    Create a role-specific account and send its one-time activation link.

    -

    Account created and activation email sent.

    diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 4de10be..37bae1a 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -52,6 +52,11 @@

    Page

    +
    diff --git a/src/main/resources/templates/smtp/defer.html b/src/main/resources/templates/smtp/defer.html index 17abefa..2f43c1c 100644 --- a/src/main/resources/templates/smtp/defer.html +++ b/src/main/resources/templates/smtp/defer.html @@ -1,22 +1,31 @@ - -Defer SMTP configuration + +Configure SMTP
    -

    Defer SMTP configuration

    -

    -

    - Configure SMTP - - - -

    Back

    -
    - -
    -
    - -
    +

    Review each consequence before continuing without an active mail service.

    +
    + +

    +
    +
    + +
    + Back +
    + +
    +
    + +
    +
    +
    diff --git a/src/main/resources/templates/smtp/form.html b/src/main/resources/templates/smtp/form.html index ddb16c8..bfeff6f 100644 --- a/src/main/resources/templates/smtp/form.html +++ b/src/main/resources/templates/smtp/form.html @@ -1,45 +1,89 @@ - -SMTP configuration + +Back to overview
    -

    SMTP configuration

    -

    This is a restricted installation until tested SMTP is active.

    -

    Configure SMTP now to enable account onboarding and recovery.

    -

    SMTP is active.

    -

    Draft saved.

    -

    Test passed.

    -

    -
    -
    -

    +

    Configure SMTP now to enable account onboarding and recovery.

    +

    SMTP is active.

    +

    Draft saved.

    +

    Test passed.

    + + + + - -

    - -

    - -

    - - -

    - -

    - -

    - +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    +
    +
    + + +
    +
    + + + +
    +
    + + + +
    +
    +
    + + + +
    +
    -
    - - -
    -
    - - -
    -

    - Defer SMTP -

    + +
    +
    + + +
    +
    + + +
    + Defer SMTP +
    diff --git a/src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java index 49de963..a37d169 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java @@ -71,6 +71,8 @@ class BootstrapOnboardingWebIntegrationTest { .with(user("admin@example.com").roles("ADMIN"))) .andExpect(status().isOk()) .andExpect(view().name("smtp/defer")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("/assets/theme.js"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("data-sidebar-toggle"))) .andExpect(content().string(org.hamcrest.Matchers.containsString("Account onboarding is disabled"))) .andExpect(content().string(org.hamcrest.Matchers.containsString("Back"))) .andExpect(content().string(org.hamcrest.Matchers.containsString("Configure SMTP"))) @@ -91,6 +93,20 @@ class BootstrapOnboardingWebIntegrationTest { .with(csrf())) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/dashboard")); + + mockMvc.perform(get("/dashboard") + .with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .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\""))); + + mockMvc.perform(get("/admin/accounts/new") + .with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .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\""))); } @Test diff --git a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java index 607b318..7aae0a6 100644 --- a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java @@ -102,6 +102,11 @@ class SmtpOnboardingWebIntegrationTest { .andExpect(content().string(org.hamcrest.Matchers.containsString("SMTP is active"))) .andExpect(content().string(org.hamcrest.Matchers.not( org.hamcrest.Matchers.containsString("restricted installation")))); + + mockMvc.perform(get("/admin/accounts/new").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("installation remains restricted")))); } @Test @@ -111,16 +116,71 @@ class SmtpOnboardingWebIntegrationTest { .with(csrf()) .param("host", "") .param("port", "70000") - .param("securityMode", "STARTTLS") + .param("securityMode", "") .param("username", "smtp-user") - .param("password", "must-not-be-rendered") + .param("password", "") .param("fromAddress", "not-an-email") - .param("fromName", "Safe sender name")) + .param("fromName", "")) .andExpect(status().isOk()) .andExpect(view().name("smtp/form")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("/assets/theme.js"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-form-error-summary\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "aria-labelledby=\"smtp-form-error-summary-title\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-host\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "aria-describedby=\"smtp-host-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-host-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-port\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "aria-describedby=\"smtp-port-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-port-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-security-mode\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "aria-describedby=\"smtp-security-mode-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "id=\"smtp-security-mode-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-username\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-password\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "aria-describedby=\"smtp-authentication-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "id=\"smtp-username\" autocomplete=\"username\" aria-invalid=\"true\"" + + " aria-describedby=\"smtp-authentication-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "id=\"smtp-authentication-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-from-address\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "aria-describedby=\"smtp-from-address-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "id=\"smtp-from-address-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-from-name\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "aria-describedby=\"smtp-from-name-error\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "id=\"smtp-from-name-error\""))) .andExpect(content().string(org.hamcrest.Matchers.containsString("Host is required"))) .andExpect(content().string(org.hamcrest.Matchers.containsString("Port must be between"))) .andExpect(content().string(org.hamcrest.Matchers.containsString("valid email address"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("value=\"smtp-secret\"")))); + } + + @Test + void invalidDraftRetainsSafeValuesButNeverTheSubmittedPassword() throws Exception { + mockMvc.perform(post("/admin/smtp/draft") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("host", "") + .param("port", "1025") + .param("securityMode", "NONE") + .param("username", "safe-smtp-user") + .param("password", "must-not-be-rendered") + .param("fromAddress", "notifications@example.com") + .param("fromName", "Safe sender name")) + .andExpect(status().isOk()) + .andExpect(view().name("smtp/form")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("safe-smtp-user"))) .andExpect(content().string(org.hamcrest.Matchers.containsString("Safe sender name"))) .andExpect(content().string(org.hamcrest.Matchers.not( org.hamcrest.Matchers.containsString("must-not-be-rendered")))); @@ -130,7 +190,8 @@ class SmtpOnboardingWebIntegrationTest { void restrictedWarningPersistsOnAdminPagesUntilActivationAndMutationsRequireCsrf() throws Exception { mockMvc.perform(get("/admin/accounts/new").with(user("admin@example.com").roles("ADMIN"))) .andExpect(status().isOk()) - .andExpect(content().string(org.hamcrest.Matchers.containsString("restricted installation"))); + .andExpect(content().string(org.hamcrest.Matchers.containsString( + "installation remains restricted"))); mockMvc.perform(post("/admin/smtp/draft") .with(user("admin@example.com").roles("ADMIN")) diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java index bc5176c..39b5329 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; @@ -35,15 +36,31 @@ class DashboardControllerWebTest { void adminRendersAdminDashboardForAuthenticatedIdentity() throws Exception { var dashboard = new DashboardView.Admin(2, 1, 1, 3); given(dashboards.admin("admin@example.test")).willReturn(dashboard); + given(smtpConfiguration.hasActiveConfiguration()).willReturn(false); mvc.perform(get("/dashboard").with(user("admin@example.test").roles("ADMIN"))) .andExpect(status().isOk()) .andExpect(view().name("dashboard/admin")) - .andExpect(model().attribute("dashboard", dashboard)); + .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\""))); verify(dashboards).admin("admin@example.test"); } + @Test + void activeSmtpKeepsAdminDashboardFreeOfTheRestrictedInstallationWarning() throws Exception { + var dashboard = new DashboardView.Admin(2, 1, 1, 3); + given(dashboards.admin("admin@example.test")).willReturn(dashboard); + given(smtpConfiguration.hasActiveConfiguration()).willReturn(true); + + 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.")))); + } + @Test void mentorRendersMentorDashboardForAuthenticatedIdentity() throws Exception { var dashboard = new DashboardView.Mentor("Mentor", 2, 4, 1); @@ -52,7 +69,9 @@ class DashboardControllerWebTest { 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(model().attribute("dashboard", dashboard)) + .andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString( + "This installation remains restricted until tested SMTP is active.")))); verify(dashboards).mentor("mentor@example.test"); } From 039fe25c7c2622a015c8962892dd99ff58be321d Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:44:25 +0700 Subject: [PATCH 60/62] docs(test): record SMTP onboarding browser gate --- .../e2e/review-round-2-smtp-onboarding.md | 100 ++++++++++++++++++ docs/tests/web/review-round-2-smtp-shell.md | 24 ++++- 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 docs/tests/e2e/review-round-2-smtp-onboarding.md diff --git a/docs/tests/e2e/review-round-2-smtp-onboarding.md b/docs/tests/e2e/review-round-2-smtp-onboarding.md new file mode 100644 index 0000000..e640c00 --- /dev/null +++ b/docs/tests/e2e/review-round-2-smtp-onboarding.md @@ -0,0 +1,100 @@ +# Test Evidence: Edge SMTP onboarding and persistent restriction journey + +- **Test type:** E2E +- **Requirement IDs:** `ACC-005`, `ACC-006`, `ACC-007`, `UI-002`, `UI-004`, `UI-007`, `UI-010`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04` +- **Scenario IDs:** `AC-ACC-003`, `AC-UI-001`, `AC-UI-002`, `AC-UI-003` +- **Test class/method:** `Manual Edge journey: bootstrap -> login -> SMTP -> five deferrals -> dashboard warning -> configure SMTP` +- **Implementation commit:** `ddf688a5336421762ff970499bafb09505474fca` + +## Protected behavior + +A first Admin can bootstrap and authenticate, then defer SMTP only after five sequential warnings. The fifth Finish returns to the Admin dashboard without hiding the restricted-installation state, and the persistent warning provides a working path back to SMTP configuration. SMTP and deferral pages use the same authenticated desktop shell, pre-paint theme, keyboard focus, collapsed-sidebar tooltip, local assets, and overflow containment as other Admin pages. + +## Test method + +A disposable `postgres:18.4` container exposed an empty `labtimesheet_round2` database on local port `55433`. The real Java 25 Spring process connected to that database, applied Flyway V1, and listened on local port `8080`. Microsoft Edge with the Chromium extension used an explicit 1365x900 viewport. The browser created a non-production test Admin through `/bootstrap`, signed in, opened SMTP onboarding, exercised keyboard/theme/sidebar behavior, traversed all five server-owned deferral POSTs, finished to `/dashboard`, and followed the persistent warning action back to `/admin/smtp`. Browser DOM, computed styles, URLs, scroll widths, and console logs were inspected directly. The browser viewport override was reset, its test tab finalized, the Java process gracefully stopped, and the disposable PostgreSQL container removed. + +## Hand-derived expected result + +The warning sequence is account onboarding, activation resend, password recovery, reduced workflow-email immediacy, and restricted-installation acknowledgement. Every step has Back and Configure SMTP; steps one through four have no Finish, and step five has exactly one Finish. The resulting dashboard warning links to `/admin/smtp`. At 1365x900, `documentElement.scrollWidth` and `body.scrollWidth` equal `innerWidth`; keyboard focus has a 3px solid indicator; collapsed navigation exposes tooltip text and `aria-expanded=false`, then returns to `true`; the saved dark theme is present after reload with `theme.js` before `app.css`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=DashboardControllerWebTest,SmtpOnboardingWebIntegrationTest,BootstrapOnboardingWebIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 13, Failures: 3, Errors: 0, Skipped: 0 +The rendered Admin dashboard omitted the persistent warning/action. +The rendered SMTP form and deferral pages omitted /assets/theme.js because they were standalone pages. +BUILD FAILURE +``` + +No pre-change Edge journey was executed; the production-shaped MockMvc RED above was the intentional failing gate before implementation. The pre-change templates were also manually inspected and contained standalone ``/`` documents rather than the shared shell. This record does not relabel those observations as a browser run. + +## GREEN + +**Command** + +```text +docker run -d --rm --name labtimesheet-ui-round2-pg -e POSTGRES_DB=labtimesheet_round2 -e POSTGRES_USER=lab_ui_round2 -e POSTGRES_PASSWORD= -p 55433:5432 postgres:18.4 +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export LAB_DB_URL=jdbc:postgresql://localhost:55433/labtimesheet_round2 +export LAB_DB_USERNAME=lab_ui_round2 +export LAB_DB_PASSWORD= +./mvnw spring-boot:run +``` + +**Observed result** + +```text +Edge/Chromium explicit viewport: 1365x900 +Java: 25.0.4 +PostgreSQL: 18.4; Flyway V1 applied to an empty disposable database + +Bootstrap created the first Admin and redirected to /login. +Login redirected to /admin/smtp?onboarding&continue. +SMTP onboarding rendered the shared Admin shell and persistent warning. +Steps 1-5 displayed all five required warnings in order; every step exposed Back and two visible Configure SMTP links (page action plus persistent warning); Finish counts were 0,0,0,0,1. +Finish navigated to /dashboard. The warning remained visible and its href was /admin/smtp. +Following the warning navigated to /admin/smtp with the warning still visible. + +Every sampled SMTP, deferral, and dashboard page reported innerWidth=1365 and documentElement.scrollWidth=body.scrollWidth=1365. +Keyboard focus rendered outline 3px solid rgb(49, 87, 231). +Collapsed sidebar reported aria-expanded=false and exposed tooltip content "Overview" on keyboard focus; expanding restored aria-expanded=true. +Dark theme persisted across reload with data-theme=dark and body background rgb(11, 12, 14); theme.js head index 4 preceded app.css index 5. No flash was practically observed during the reload. +Edge console error/warning log: [] + +The Spring process ended through graceful shutdown with BUILD SUCCESS. The disposable PostgreSQL container stopped and was removed; ports 8080 and 55433 were no longer listening. +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=SecurityResponseIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountWebIntegrationTest,SmtpOnboardingWebIntegrationTest,RoleDashboardWebIntegrationTest,UiContractWebTest,AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,ProjectControllerTest,TaskControllerTest,AttendanceControllerTest test + +PostgreSQL 18.4 via Testcontainers +Tests run: 81, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 50.418 s +``` + +The final integrated PostgreSQL 18.4 suite also passed 197/197 tests with no failures, errors, or skips in 01:24. Java compile and full Javadoc/doclint each completed with `BUILD SUCCESS`. + +## External-test boundaries + +The real browser run proves the specified local Edge/Chromium desktop journey and observable shell behavior against Java and PostgreSQL. The practical theme-flash observation is not a frame-by-frame measurement. It does not test mobile/tablet layouts, real SMTP transport, production TLS configuration, or non-Edge engines. Automated integration tests separately prove active-SMTP suppression and non-Admin warning suppression without creating additional browser fixture accounts. diff --git a/docs/tests/web/review-round-2-smtp-shell.md b/docs/tests/web/review-round-2-smtp-shell.md index 570dd75..f69c732 100644 --- a/docs/tests/web/review-round-2-smtp-shell.md +++ b/docs/tests/web/review-round-2-smtp-shell.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ACC-005`, `ACC-006`, `ACC-007`, `INT-007`, `UI-004`, `UI-007`, `UI-010`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04` - **Scenario IDs:** `AC-ACC-003`, `AC-UI-001`, `AC-UI-002` - **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest`, `com.lab.labtimesheet.feature.account.controller.BootstrapOnboardingWebIntegrationTest#fiveDistinctDeferralConfirmationsAreSequentialAndOnlyTheLastCanFinish` -- **Implementation commit:** `pending` +- **Implementation commit:** `ddf688a` ## Protected behavior @@ -82,6 +82,28 @@ BUILD SUCCESS Total time: 50.418 s ``` +## Full verification + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +PostgreSQL 18.4 via Testcontainers +Tests run: 197, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 01:24 min + +./mvnw -DskipTests compile +BUILD SUCCESS +Total time: 0.764 s + +./mvnw -DskipTests -Ddoclint=all javadoc:javadoc +BUILD SUCCESS +Total time: 0.924 s +``` + ## External-test boundaries MockMvc verifies rendered security visibility, form binding, CSRF-generated forms, exact deferral ordering, safe retained values, and accessibility associations. It does not prove viewport overflow, keyboard focus rendering, collapse behavior, or visually observable theme flash; the separate real-browser evidence covers those boundaries. SMTP transport remains represented by the existing test probe and no real mail server is required. From 4212e9cbc2791e0c733929af62503df26097431e Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:02:27 +0700 Subject: [PATCH 61/62] docs(dev): add iteration 1 setup and reports --- .env.example | 14 + .gitignore | 1 + DEVELOPMENT.md | 233 +++++++++++++++++ README.md | 126 ++++++++- TESTING.md | 243 ++++++++++++++++++ .../development-environment-configuration.md | 68 +++++ src/main/resources/application-dev.properties | 35 +++ src/main/resources/application-dev.yaml | 13 - 8 files changed, 711 insertions(+), 22 deletions(-) create mode 100644 .env.example create mode 100644 DEVELOPMENT.md create mode 100644 TESTING.md create mode 100644 docs/tests/integration/development-environment-configuration.md create mode 100644 src/main/resources/application-dev.properties delete mode 100644 src/main/resources/application-dev.yaml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..83c30bb --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Copy to .env, replace every placeholder, then load it into the IDE or shell. +SPRING_PROFILES_ACTIVE=dev +LAB_SERVER_PORT=8080 +LAB_FORWARD_HEADERS_STRATEGY=NONE + +LAB_DB_URL=jdbc:postgresql://localhost:55432/labtimesheet +LAB_DB_USERNAME=labtimesheet +LAB_DB_PASSWORD=replace-with-local-database-password + +LAB_SMTP_HOST=localhost +LAB_SMTP_PORT=1025 + +LAB_PUBLIC_ORIGIN=http://localhost:8080 +LAB_SECURITY_MASTER_KEY=replace-with-base64-encoded-32-byte-key diff --git a/.gitignore b/.gitignore index 50451b5..19d0ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ node_modules/ +/.env ### VS Code ### .vscode/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..bb52d30 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,233 @@ +# Development Guide + +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. + +## 1. Install the required tools + +Install: + +- Java 25 +- Docker Desktop or OrbStack +- Node.js 24 and npm 11 +- Git +- IntelliJ IDEA, if you want to run the application from the IDE + +Confirm the tools are available: + +```bash +java -version +docker version +node --version +npm --version +``` + +On macOS, select an installed Java 25 JDK with: + +```bash +export JAVA_HOME=$(/usr/libexec/java_home -v 25) +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +``` + +Java 25 is the supported project baseline. A newer local JDK can compile the +project but is not the shared team baseline. + +## 2. Prepare the project + +Clone the repository, open a terminal in its root directory, then create your +local environment file: + +```bash +cp .env.example .env +``` + +Edit `.env` and replace the database password placeholder. Generate the +encryption master key with: + +```bash +openssl rand -base64 32 +``` + +Copy that output into `LAB_SECURITY_MASTER_KEY`. Never commit `.env` or share a +real key in chat, screenshots, test evidence, or documentation. + +Install and build the local frontend assets: + +```bash +npm ci +npm run build +``` + +## 3. Start the development containers + +### PostgreSQL 18.4 + +Create a named volume once. The volume keeps your development data when the +container is stopped or replaced. + +```bash +docker volume create labtimesheet-postgres-data +``` + +Start PostgreSQL: + +```bash +docker run -d \ + --name labtimesheet-postgres \ + --restart unless-stopped \ + -e POSTGRES_DB=labtimesheet \ + -e POSTGRES_USER=labtimesheet \ + -e POSTGRES_PASSWORD=replace-with-same-password-as-env \ + -p 127.0.0.1:55432:5432 \ + -v labtimesheet-postgres-data:/var/lib/postgresql \ + postgres:18.4 +``` + +Use the same password for `POSTGRES_PASSWORD` and `LAB_DB_PASSWORD` in `.env`. +PostgreSQL stores the original password in the volume. Changing only `.env` +later will not change the database password. + +### Mailpit + +Mailpit receives development email without sending it to real people. + +```bash +docker run -d \ + --name labtimesheet-mailpit \ + --restart unless-stopped \ + -p 127.0.0.1:1025:1025 \ + -p 127.0.0.1:8025:8025 \ + axllent/mailpit:v1.27.4 +``` + +Confirm both containers are running: + +```bash +docker ps +``` + +Useful container commands: + +```bash +docker logs labtimesheet-postgres +docker logs labtimesheet-mailpit +docker stop labtimesheet-postgres labtimesheet-mailpit +docker start labtimesheet-postgres labtimesheet-mailpit +``` + +Stopping the containers keeps the database volume. Do not remove the volume +unless you intentionally want to discard your local development data. + +## 4. Run from a terminal + +Load the environment file in the same terminal that will run Spring Boot: + +```bash +set -a +source .env +set +a +./mvnw spring-boot:run +``` + +Open: + +- First-Admin setup: `http://localhost:8080/bootstrap` +- Login: `http://localhost:8080/login` +- Mailpit inbox: `http://localhost:8025` + +At first setup, configure SMTP through the Admin console with: + +| Setting | Development value | +|---|---| +| Host | `localhost` | +| Port | `1025` | +| Security | `NONE` | +| Username | leave empty | +| Password | leave empty | +| From address | a local address such as `labtimesheet@example.test` | +| From name | `Lab Timesheet` | + +Test the draft before activating it. Mailpit's web inbox shows activation and +other development messages. + +Stop the application with `Control+C`. + +## 5. Run with IntelliJ IDEA + +### Open the project + +1. Open IntelliJ IDEA. +2. Choose **Open** and select the repository root. +3. Allow IntelliJ to import the Maven project. +4. Open **File > Project Structure > Project**. +5. Select a Java 25 SDK. Add the JDK installation if it is not listed. + +### Create the run configuration + +1. Open **Run > Edit Configurations**. +2. Select **+**, then **Spring Boot**. +3. Use the name `Lab Timesheet (dev)`. +4. Set **Main class** to + `com.lab.labtimesheet.LabtimesheetApplication`. +5. Set **Use classpath of module** to the main `labtimesheet` module. +6. Set **JRE** to Java 25. +7. Set **Active profiles** to `dev`. +8. Set **Working directory** to the repository root. +9. Open the **Environment variables** editor and add every variable from your + local `.env` file. +10. Apply the configuration and run it. + +Some IntelliJ editions can load variables from an environment file directly. +If that option is available, select the local `.env`; otherwise use the +environment-variable table. Do not store real secrets in a shared or committed +run configuration. + +Run `npm ci` and `npm run build` in IntelliJ's terminal before the first launch +and after changing Tailwind or icon sources. + +## 6. Common problems + +### The application cannot connect to PostgreSQL + +Run: + +```bash +docker ps +docker logs labtimesheet-postgres +``` + +Check that `.env` uses port `55432`, database `labtimesheet`, user +`labtimesheet`, and the password used when the PostgreSQL volume was first +created. + +### Port 8080, 55432, 1025, or 8025 is already in use + +Stop the other program or container using that port. Keep `.env` and the Docker +port mapping consistent if you intentionally select another development port. + +### Mail does not appear in Mailpit + +Check that Mailpit is running and that the active Admin SMTP configuration uses +host `localhost`, port `1025`, and security `NONE`. A container health warning +does not by itself prove that SMTP is unavailable; use the Admin SMTP test. + +### IntelliJ uses the wrong Java version + +Check both **Project SDK** and the run configuration's **JRE**. They should both +be Java 25. + +### Styles or icons are missing + +Run: + +```bash +npm ci +npm run build +``` + +For test setup, commands, TDD, and test evidence rules, read +[TESTING.md](TESTING.md). diff --git a/README.md b/README.md index 1c0acd9..ece02c9 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,132 @@ # Lab Timesheet +Server-rendered Spring Boot application for managing laboratory internships, +Projects, Tasks, and attendance. Iteration 1 is complete and was verified on +15 August 2026. + +## Iteration 1: working now + +### Accounts and onboarding + +- Atomic first-Admin bootstrap that remains closed after initialization and restart. +- Optional SMTP onboarding with five distinct deferral warnings and a persistent restricted-state notice. +- Admin SMTP draft, connection test, and activation against Mailpit or another configured server. +- Admin creation of Admin, Mentor, and Intern accounts through single-use email activation. +- Password setup, form login, logout, global roles, and role-protected Admin routes. + +### Projects + +- Owning Mentors create `PLANNED` Projects with an eligible initial Leader. +- Mentor-controlled direct membership with historical membership and leadership intervals. +- Leader reassignment and guarded `PLANNED` to `ACTIVE` activation. +- Role-correct Project lists, details, member views, and guessed-ID concealment. + +### Tasks + +- One current assignee per Task. +- Active members create self-assigned Tasks; the current Leader may assign another active member. +- Due dates are checked against Project dates and current global days off. +- The fixed `TODO`, `IN_PROGRESS`, `BLOCKED`, and `DONE` transition graph is enforced. +- Authorized comments, Task lists/details, assignee display, status counts, and completion progress. + +### Attendance and calendar + +- Effective attendance-policy resolution with Vietnam business time, configured workdays, and separate 30-minute check-in and checkout grace defaults. +- Admin-managed manual global calendar days off. +- Server-time check-in and checkout with duplicate, off-day, leave-day, lifecycle, and cutoff rejection. +- `MISSING_CHECKOUT` classification without a second early-departure violation. +- Intern history plus authorized Mentor/Admin attendance inspection using the historical applied policy. + +### Desktop UI + +- Shared Thymeleaf/Tailwind shell with role-aware navigation and dashboards. +- Light, dark, and system themes applied before paint. +- Collapsible desktop sidebar, accessible forms/errors, tables, badges, empty states, and local Lucide icons. +- Bootstrap, authentication, SMTP, Project, Task, calendar, and attendance pages integrated into the same shell. + +## Deliberately not implemented yet + +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. +- Mobile layouts are best-effort. Desktop is the supported interface target. + +## Architecture and versions + +- Java 25, Spring Boot 4.1.0, Maven, Spring MVC/Security/Data JPA/Validation, Thymeleaf, Flyway, and PostgreSQL 18.4. +- Node 24/npm 11, Tailwind CSS 4.3.3, and `lucide-static` 1.27.0 for local assets. +- Package-by-feature modular monolith under `com.lab.labtimesheet.feature`. +- Cross-feature access through public services and DTOs; no cross-feature repositories, shadow entities, or business SQL. +- Flyway owns the schema; Hibernate validates it with `ddl-auto=validate`. + ## Local development -The application targets Java 25 and expects PostgreSQL on -`localhost:55432` when the default `dev` profile is active. Override any local -value with `LAB_DB_URL`, `LAB_DB_USERNAME`, `LAB_DB_PASSWORD`, -`LAB_SMTP_HOST`, or `LAB_SMTP_PORT`. +Follow [DEVELOPMENT.md](DEVELOPMENT.md) for the complete beginner-friendly +setup, PostgreSQL and Mailpit container commands, terminal launch steps, and an +IntelliJ IDEA run-configuration walkthrough. + +The committed [`.env.example`](.env.example) contains placeholders only. Real +database passwords and the AES-256 master key belong in an untracked `.env`. +Product SMTP and HolidayAPI credentials are configured through the Admin +console, not environment variables. ```bash +cp .env.example .env +# Edit .env. Generate LAB_SECURITY_MASTER_KEY with: openssl rand -base64 32 +set -a +source .env +set +a + export JAVA_HOME=/opt/homebrew/opt/openjdk@25 -export PATH="$JAVA_HOME/bin:$PATH" +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" + +npm ci +npm run build ./mvnw spring-boot:run ``` -Tests use PostgreSQL 18.4 through Testcontainers and do not use the developer -database: +Development defaults to the application on port `8080`, PostgreSQL on `55432`, +and Mailpit SMTP on `1025`. The exact Spring settings are in +[`application-dev.properties`](src/main/resources/application-dev.properties). + +On first launch, open `http://localhost:8080/bootstrap`, create the first Admin, +then configure and test SMTP or complete all five explicit deferral warnings. + +## Verification status + +The final Iteration 1 integration gate recorded: + +- 197 Maven tests passed with PostgreSQL 18.4 Testcontainers. +- Flyway replay produced exactly 23 application tables and 56 foreign keys. +- Java compilation and full Javadoc/doclint passed. +- Two consecutive Node/Tailwind/Lucide builds produced identical assets. +- A real Java process completed bootstrap, login, SMTP deferral, persistent warning recovery, and a separate Mailpit draft/test/activate flow with health `UP`. +- Independent reviews of all five work branches closed with no remaining Critical, Important, or Minor findings. + +Tests require Docker for PostgreSQL Testcontainers: ```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export DOCKER_HOST=unix:///Users/your-name/.orbstack/run/docker.sock # only when using OrbStack ./mvnw test ``` -Every feature test must have a companion Markdown evidence record under -[`docs/tests`](docs/tests/README.md). +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). + +## Branch ownership + +| Branch | Primary area | +|---|---| +| `work/platform` | Application baseline, schema, accounts, security, integrations | +| `work/projects` | Projects, membership, leadership, lifecycle | +| `work/tasks` | Tasks, comments, status, progress | +| `work/attendance` | Policy, calendar, attendance workflows | +| `work/reports-ui` | Shared UI, dashboards, reporting presentation | + +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. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..94b260e --- /dev/null +++ b/TESTING.md @@ -0,0 +1,243 @@ +# Testing Guide + +This guide explains how to prepare the test environment, run each type of test, +and follow the project's required test-driven development workflow. + +## 1. What you need + +Install these tools before running tests: + +- Java 25 +- Docker Desktop or OrbStack +- Node.js 24 and npm 11 +- Git + +Confirm the tools are available: + +```bash +java -version +docker version +node --version +npm --version +``` + +On macOS with Homebrew, the project normally uses: + +```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +``` + +If you use OrbStack and Testcontainers cannot find Docker, set: + +```bash +export DOCKER_HOST=unix:///Users/your-name/.orbstack/run/docker.sock +``` + +Replace `your-name` with your macOS account name. Docker Desktop users normally +do not need this setting. + +Tests use temporary PostgreSQL 18.4 containers. They do not use the development +database, Mailpit, or the local `.env` file. + +## 2. First test run + +From the repository root, run: + +```bash +./mvnw test +``` + +The first run may take longer because Docker downloads PostgreSQL and +Testcontainers support images. A successful run ends with `BUILD SUCCESS`. + +Frontend assets have a separate check: + +```bash +npm ci +npm run build +``` + +## 3. Test types used by this project + +### Unit tests + +Unit tests check a small rule or calculation without starting the full +application. Examples include Task status transitions and progress calculations. + +Run one class: + +```bash +./mvnw -Dtest=TaskDomainRulesTest test +``` + +### Integration tests + +Integration tests check real Spring services, Flyway migrations, JPA mappings, +transactions, and PostgreSQL constraints. Docker must be running. + +Run one integration class: + +```bash +./mvnw -Dtest=AttendancePersistenceIntegrationTest test +``` + +### Web tests + +Web tests send requests through Spring MVC and check security, validation, +Thymeleaf pages, redirects, and error messages without opening a browser. + +```bash +./mvnw -Dtest=TaskControllerTest test +``` + +### End-to-end checks + +End-to-end checks use the running application in a real desktop browser. They +cover complete journeys such as bootstrap, login, SMTP setup, Projects, Tasks, +and attendance. + +Current end-to-end checks are guided manual checks: + +1. Prepare `.env` by following the main README. +2. Start PostgreSQL 18.4 and Mailpit. +3. Run `./mvnw spring-boot:run`. +4. Follow the scenario written in `docs/tests/e2e/`. +5. Record the browser, viewport, result, and any boundary that was not tested. + +Do not record a real browser journey as a web test. Use `docs/tests/e2e/`. + +### Structure and configuration checks + +Structure tests protect package boundaries and prevent one feature from reading +another feature's repositories or database entities. + +```bash +./mvnw -Dtest=LayerStructureTest test +``` + +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. + +## 4. Useful commands + +Run one test method: + +```bash +./mvnw '-Dtest=TaskControllerTest#validCreateFormUsesAuthenticatedIdentityAndRedirectsToCreatedTask' test +``` + +Run tests for one feature by name: + +```bash +./mvnw -Dtest='*Attendance*Test' test +``` + +Run the complete backend suite: + +```bash +./mvnw test +``` + +Check compilation and Javadoc: + +```bash +./mvnw -DskipTests compile +./mvnw -DskipTests -Ddoclint=all javadoc:javadoc +``` + +Check whitespace and patch formatting: + +```bash +git diff --check +``` + +Maven test reports are written to `target/surefire-reports/`. + +## 5. Required TDD workflow + +TDD means writing the test before writing the production behavior. + +1. Choose the requirement and acceptance-scenario IDs. +2. Copy the matching template from `docs/tests/unit`, `integration`, `web`, or `e2e`. +3. Write the smallest test that proves the missing behavior. +4. Run that test and confirm it fails for the expected reason. This is **RED**. +5. Record the exact command and useful failure output in the evidence file. +6. Write the minimum production code and its Javadoc. Do not add unrelated work. +7. Run the same test again. It must pass. This is **GREEN**. +8. Run the affected feature tests, then the full suite when the milestone is complete. +9. Refactor only while the tests stay green. +10. Update the evidence file and commit the complete milestone. + +If the first test fails because Docker is stopped, a class name is wrong, or the +test setup is broken, that is not a valid RED. Fix the environment or test first. + +## 6. Evidence records + +Every behavior test needs one Markdown record in the matching directory: + +```text +docs/tests/unit/ +docs/tests/integration/ +docs/tests/web/ +docs/tests/e2e/ +``` + +Keep every heading from `_TEMPLATE.md`. Record: + +- requirement and scenario IDs; +- the behavior being protected; +- how the expected result was calculated; +- exact RED and GREEN commands and results; +- the affected-suite result; +- anything the test did not prove. + +One record may cover a closely related parameterized scenario set. A written +claim never replaces a test command and result. + +## 7. Testing best practices + +- Test user-visible behavior and stored results, not private method details. +- Use PostgreSQL 18.4 for persistence tests. Do not replace it with H2. +- Test allowed actions and denied actions, including guessed IDs and wrong roles. +- Include boundary values for dates, times, grace periods, passwords, and status transitions. +- Use the project's injectable `Clock`; do not make tests depend on the real current time. +- Keep each test independent. Do not rely on another test running first. +- Use real Spring and database components at the boundary being tested. Mock only external services such as SMTP or HolidayAPI when appropriate. +- Never put real passwords, API keys, activation links, or reset links in test code or evidence. +- Do not remove assertions, catch errors, or disable security simply to make a test pass. +- Run the focused test first so feedback is fast, then run the broader suite before committing. +- 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. + +## 8. Common problems + +### Testcontainers cannot find Docker + +Start Docker Desktop or OrbStack. Run `docker version`. OrbStack users should +also check the `DOCKER_HOST` command shown in Section 1. + +### The wrong Java version is used + +Run `java -version` and `./mvnw -version`. Both should report Java 25. Set +`JAVA_HOME` again if Maven uses another JDK. + +### The application cannot start for a manual browser check + +Confirm `.env` was loaded, PostgreSQL is reachable, and +`LAB_SECURITY_MASTER_KEY` decodes from Base64 to 32 bytes. Automated tests do +not need this local file. + +### A test passes alone but fails in the full suite + +Check for shared state, fixed ports, assumptions about test order, or data that +was not created by the test itself. Do not hide the failure with retries. + +### Build output looks stale + +Use this only after confirming the ordinary command is using stale compiled output: + +```bash +./mvnw clean test +``` diff --git a/docs/tests/integration/development-environment-configuration.md b/docs/tests/integration/development-environment-configuration.md new file mode 100644 index 0000000..aeb4ab6 --- /dev/null +++ b/docs/tests/integration/development-environment-configuration.md @@ -0,0 +1,68 @@ +# Test Evidence: Development environment configuration + +- **Test type:** Integration +- **Requirement IDs:** `OPS-001`, `OPS-004`, `SEC-013` +- **Scenario IDs:** `AC-OPS-001`, `AC-SEC-005` +- **Test class/method:** Shell configuration contract plus the full Spring Boot Maven suite +- **Implementation commit:** `pending` + +## Protected behavior + +Development starts from environment-backed datasource, encryption, public-origin, server, proxy, and local Mailpit settings without committing a real `.env` or weakening application security controls. + +## Test method + +A shell contract verifies that the committed placeholder and development properties exist, the superseded YAML is absent, the real `.env` is ignored, every required environment key is represented, and every application placeholder resolves after loading the local file. The full Maven suite then exercises Spring configuration binding, Flyway, JPA validation, security, and PostgreSQL behavior. + +## Hand-derived expected result + +The committed tree contains `.env.example` and `application-dev.properties`, never tracks `.env`, and exposes exactly the environment inputs needed by the current application. Loading the local file gives Spring a `dev` profile, PostgreSQL connection, 32-byte Base64 encryption key, public origin, local Mailpit endpoint, server port, and explicit no-forwarded-header policy. + +## RED + +**Command** + +```text +required_files=(.env.example src/main/resources/application-dev.properties); failed=0; for file in $required_files; do if [ ! -f "$file" ]; then echo "MISSING $file"; failed=1; fi; done; if [ -f src/main/resources/application-dev.yaml ]; then echo 'STALE src/main/resources/application-dev.yaml'; failed=1; fi; if ! grep -qx '/.env' .gitignore; then echo 'MISSING /.env ignore rule'; failed=1; fi; exit "$failed" +``` + +**Observed result** + +```text +MISSING .env.example +MISSING src/main/resources/application-dev.properties +STALE src/main/resources/application-dev.yaml +MISSING /.env ignore rule +exit 1 +``` + +## GREEN + +**Command** + +```text +required_files=(.env.example src/main/resources/application-dev.properties); required_env=(SPRING_PROFILES_ACTIVE LAB_SERVER_PORT LAB_FORWARD_HEADERS_STRATEGY LAB_DB_URL LAB_DB_USERNAME LAB_DB_PASSWORD LAB_SMTP_HOST LAB_SMTP_PORT LAB_PUBLIC_ORIGIN LAB_SECURITY_MASTER_KEY); required_props=(server.port server.forward-headers-strategy spring.datasource.url spring.datasource.username spring.datasource.password spring.jpa.hibernate.ddl-auto spring.jpa.open-in-view spring.flyway.enabled spring.mail.host spring.mail.port lab.public-origin lab.security.master-key); failed=0; for file in $required_files; do if [ ! -f "$file" ]; then echo "MISSING $file"; failed=1; fi; done; if [ -f src/main/resources/application-dev.yaml ]; then echo 'STALE src/main/resources/application-dev.yaml'; failed=1; fi; if ! grep -qx '/.env' .gitignore; then echo 'MISSING /.env ignore rule'; failed=1; fi; for key in $required_env; do if ! grep -q "^${key}=" .env.example; then echo "MISSING example $key"; failed=1; fi; if ! grep -q "^${key}=" .env; then echo "MISSING local $key"; failed=1; fi; done; for property in $required_props; do if ! grep -q "^${property}=" src/main/resources/application-dev.properties; then echo "MISSING property $property"; failed=1; fi; done; set -a; source .env; set +a; decoded_bytes=$(printf '%s' "$LAB_SECURITY_MASTER_KEY" | base64 -d | wc -c | tr -d ' '); if [ "$decoded_bytes" != 32 ]; then echo "INVALID master key bytes=$decoded_bytes"; failed=1; fi; if ! git check-ignore -q .env; then echo 'LOCAL .env is not ignored'; failed=1; fi; if git ls-files --error-unmatch .env >/dev/null 2>&1; then echo 'LOCAL .env is tracked'; failed=1; fi; if [ "$failed" -eq 0 ]; then echo 'development configuration contract: PASS'; fi; exit "$failed" +``` + +**Observed result** + +```text +development configuration contract: PASS + +A real Java 25 process loaded `.env` and `application-dev.properties`, connected to PostgreSQL 18.4, validated Flyway/JPA, and started on the environment-overridden port 18081. With temporary Mailpit on the configured SMTP port, `/actuator/health` returned HTTP 200 with `UP`, and `/login` returned HTTP 200. The process shut down and the temporary Mailpit container was removed. +``` + +## Affected suite + +**Command and result** + +```text +env -u SPRING_PROFILES_ACTIVE -u LAB_SERVER_PORT -u LAB_FORWARD_HEADERS_STRATEGY -u LAB_DB_URL -u LAB_DB_USERNAME -u LAB_DB_PASSWORD -u LAB_SMTP_HOST -u LAB_SMTP_PORT -u LAB_PUBLIC_ORIGIN -u LAB_SECURITY_MASTER_KEY /bin/zsh -lc 'export JAVA_HOME=/opt/homebrew/opt/openjdk@25; export PATH=/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH; export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock; ./mvnw test' + +Tests run: 197, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS in 01:33 using PostgreSQL 18.4 Testcontainers. No development environment value was present. +``` + +## External-test boundaries + +The committed example cannot prove another developer's local credentials. Product SMTP and HolidayAPI revisions remain Admin-console configuration and are intentionally absent from `.env`. diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..f859fc1 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,35 @@ +# Development profile. Values that differ between machines come from an untracked .env file. +server.port=${LAB_SERVER_PORT} +server.forward-headers-strategy=${LAB_FORWARD_HEADERS_STRATEGY} +server.servlet.session.cookie.http-only=true +server.servlet.session.cookie.secure=false +server.servlet.session.cookie.same-site=lax +server.error.include-message=never +server.error.include-stacktrace=never + +spring.datasource.url=${LAB_DB_URL} +spring.datasource.username=${LAB_DB_USERNAME} +spring.datasource.password=${LAB_DB_PASSWORD} +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.open-in-view=false +spring.jpa.properties.hibernate.jdbc.time_zone=UTC +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +spring.docker.compose.enabled=false + +# Mailpit keeps Spring's mail health check local. User-facing SMTP credentials remain Admin-console data. +spring.mail.host=${LAB_SMTP_HOST} +spring.mail.port=${LAB_SMTP_PORT} +spring.mail.protocol=smtp +spring.mail.test-connection=false +spring.mail.properties.mail.smtp.auth=false +spring.mail.properties.mail.smtp.starttls.enable=false +spring.mail.properties.mail.smtp.connectiontimeout=5000 +spring.mail.properties.mail.smtp.timeout=5000 +spring.mail.properties.mail.smtp.writetimeout=5000 + +management.endpoints.web.exposure.include=health,info +management.endpoint.health.show-details=when_authorized + +lab.public-origin=${LAB_PUBLIC_ORIGIN} +lab.security.master-key=${LAB_SECURITY_MASTER_KEY} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml deleted file mode 100644 index 63f3551..0000000 --- a/src/main/resources/application-dev.yaml +++ /dev/null @@ -1,13 +0,0 @@ -spring: - datasource: - url: ${LAB_DB_URL:jdbc:postgresql://localhost:55432/labtimesheet} - username: ${LAB_DB_USERNAME:labtimesheet} - password: ${LAB_DB_PASSWORD:labtimesheet_dev} - mail: - host: ${LAB_SMTP_HOST:localhost} - port: ${LAB_SMTP_PORT:1025} -lab: - public-origin: ${LAB_PUBLIC_ORIGIN:http://localhost:8080} - security: - # Explicit non-production key; production must supply its own 256-bit key. - master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= From 6df04c14053b586c42c4d9c8e363698b5f527836 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:02:53 +0700 Subject: [PATCH 62/62] docs(test): pin development setup evidence --- docs/tests/integration/development-environment-configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tests/integration/development-environment-configuration.md b/docs/tests/integration/development-environment-configuration.md index aeb4ab6..eeb3379 100644 --- a/docs/tests/integration/development-environment-configuration.md +++ b/docs/tests/integration/development-environment-configuration.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `OPS-001`, `OPS-004`, `SEC-013` - **Scenario IDs:** `AC-OPS-001`, `AC-SEC-005` - **Test class/method:** Shell configuration contract plus the full Spring Boot Maven suite -- **Implementation commit:** `pending` +- **Implementation commit:** `4212e9cbc2791e0c733929af62503df26097431e` ## Protected behavior