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/unit/vietnam-business-clock.md b/docs/tests/unit/vietnam-business-clock.md new file mode 100644 index 0000000..8530b72 --- /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:** `06dba4fb13eed675cc08ff8c00fe3e3650468c3b` + +## 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-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/account-uniqueness-feedback.md b/docs/tests/web/account-uniqueness-feedback.md new file mode 100644 index 0000000..7361b0f --- /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:** `06dba4fb13eed675cc08ff8c00fe3e3650468c3b` + +## 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/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..3d8b693 --- /dev/null +++ b/docs/tests/web/platform-onboarding-forms.md @@ -0,0 +1,94 @@ +# 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 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 fixed operator guidance and leaves the draft untested without +rendering the adapter's diagnostic. + +## 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 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/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/docs/tests/web/smtp-error-sanitization.md b/docs/tests/web/smtp-error-sanitization.md new file mode 100644 index 0000000..bceedff --- /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:** `06dba4fb13eed675cc08ff8c00fe3e3650468c3b` + +## 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/docs/tests/web/smtp-mail-exception-sanitization.md b/docs/tests/web/smtp-mail-exception-sanitization.md new file mode 100644 index 0000000..ffcef2f --- /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:** `bf6f9af78b42151f2c26ef206978e3a55f75594a` + +## 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/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 b4a4361..28f7f66 100644 --- a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java @@ -9,7 +9,12 @@ 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; +/** + * 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 @@ -27,11 +32,14 @@ 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()) - .formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", true)) + .headers(headers -> headers.referrerPolicy(policy -> policy.policy(ReferrerPolicy.NO_REFERRER))) + .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/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..426a443 100644 --- a/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java @@ -1,14 +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 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 621eccf..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 @@ -1,18 +1,24 @@ 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.hibernate.exception.ConstraintViolationException; +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; +/** + * 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; @@ -22,64 +28,81 @@ 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) { + rejectUniquenessViolation(bindingResult, duplicate); + 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"; } + + 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/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 a7afe85..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 { @@ -29,7 +47,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/controller/BootstrapController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java index 0daa0dc..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 @@ -1,15 +1,19 @@ 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; +/** Renders and processes the one-time first-Admin installation form. */ @Controller @RequestMapping("/bootstrap") class BootstrapController { @@ -20,21 +24,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/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/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/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/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/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 4c776a9..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); @@ -113,6 +134,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,11 +153,19 @@ 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()); } + /** + * 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( @@ -137,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) @@ -159,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) { @@ -173,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)) { @@ -181,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)) @@ -188,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 436a746..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 @@ -1,20 +1,43 @@ 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.mail.MailException; 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; +/** + * 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. 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.", + "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 +47,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 | MailException failure) { + bindingResult.reject("smtp.test.failed", TEST_FAILURE_MESSAGE); + 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", ACTIVATION_FAILURE_MESSAGE); + 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("smtpActionError", bindingResult.getAllErrors().getFirst().getDefaultMessage()); + 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/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..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 @@ -1,16 +1,26 @@ 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) { + /** Defensively copies both byte arrays before this value can cross the encryption boundary. */ 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/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/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/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/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/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/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 dc584c9..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 @@ -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; @@ -15,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; @@ -37,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); @@ -52,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) @@ -62,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) @@ -73,16 +99,53 @@ 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(); } + /** + * 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)); + } + + /** + * 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/accounts/activate.html b/src/main/resources/templates/accounts/activate.html index 0a36661..b49846d 100644 --- a/src/main/resources/templates/accounts/activate.html +++ b/src/main/resources/templates/accounts/activate.html @@ -8,10 +8,22 @@

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 7b4475b..1e79c38 100644 --- a/src/main/resources/templates/accounts/new.html +++ b/src/main/resources/templates/accounts/new.html @@ -10,27 +10,42 @@ Back to overview

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

+

Account created and activation email sent.

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

Required only when the selected role is Intern.

-
-
-
+
+
+
+
Cancel
diff --git a/src/main/resources/templates/bootstrap/form.html b/src/main/resources/templates/bootstrap/form.html index dc5a38d..501d709 100644 --- a/src/main/resources/templates/bootstrap/form.html +++ b/src/main/resources/templates/bootstrap/form.html @@ -8,10 +8,26 @@

Initialize this installation once. Later accounts are created by an active Admin.

-
-
-
-
+ + +
+ + + +
+
+ + + +
+
+ + + +
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..6b25946 100644 --- a/src/main/resources/templates/smtp/form.html +++ b/src/main/resources/templates/smtp/form.html @@ -4,16 +4,42 @@

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/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/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 ba040d3..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 @@ -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,104 @@ 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 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") + .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/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/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 new file mode 100644 index 0000000..607b318 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java @@ -0,0 +1,199 @@ +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.mail.MailSendException; +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()); + } + + @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"); + String rawDiagnostic = "AUTH rejected for smtp-secret-raw-diagnostic"; + probe.failure = new MailSendException(rawDiagnostic); + + 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( + "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")))); + } + + @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<>(); + private MailSendException failure; + + @Override + public void send(SmtpConnection connection, String recipient, String subject, String body) { + if (failure != null) { + throw failure; + } + recipients.add(recipient); + } + } +} 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